Skip to content

WebSocket protocol

The agent facade serves a JSON-over-WebSocket protocol on /ws. The machine-readable definition is api/websocket/asyncapi.yaml. The Go types are in internal/facade/protocol.go.

ws://host:port/ws?agent=<agent-name>&namespace=<namespace>&binary=<true|false>
ParameterRequiredDescription
agentYesName of the AgentRuntime. Required unless the facade pod sets OMNIA_AGENT_NAME, which the facade uses when the parameter is absent.
namespaceNoNamespace. When absent the facade uses OMNIA_NAMESPACE, and default when that is unset.
binaryNoEnable binary WebSocket frame support (defaults to false)
resumeNoSession ID of a parked realtime session to re-attach to. connected.resumed is true when the session was found and false when the facade opened a new one.
memory_facetNoThe container (a project, client or matter) the connection’s agent memory is scoped to. See Memory tools. 1 to 128 ASCII letters, digits and . _ : / -, starting with a letter or digit; a malformed or empty value, or the parameter given twice, refuses the upgrade with 400. When the AgentRuntime sets spec.memory.facet, omitting it uses the agent’s container and naming another refuses the upgrade with 403. Echoed as connected.memory_facet.
Terminal window
websocat "ws://localhost:8080/ws?agent=my-agent&namespace=production"

Messages sent from client to server.

Send a user message to the agent:

{
"type": "message",
"content": "Hello, how are you?",
"session_id": "optional-session-id",
"metadata": {
"user_id": "user-123"
}
}
FieldTypeRequiredDescription
typestringYesMust be "message"
contentstringNoUser message content (text-only)
partsarrayNoMulti-modal content parts (see below)
session_idstringNoOmitted, or the ID from connected: the message joins the connection’s session. Another ID resumes that session (see Session handling)
metadataobjectNoString key-value pairs, forwarded unchanged to the runtime. The model sees them only when the agent’s Provider sets messageMetadataTemplate (see Message metadata)
declareobjectNoWhat the session needs, such as {"sandboxes": {"<handle>": {"mounts": [{"role": "project", "id": "<project-id>"}]}}}. Read only on the message that opens the session and ignored on later messages. At most 16 KiB; a larger value is answered with INVALID_MESSAGE. A refused declaration is answered with SESSION_DECLARATION_REFUSED. A sandboxes key is <sandbox>/<instance>, or <sandbox> for the default instance, and <sandbox> is a sandbox in the agent’s ToolRegistry spec.sandboxes. The facade forwards the value to the runtime unread. Only the WebSocket facade carries a declaration: the A2A, REST and MCP facades send none.

Provide content or parts. The runtime uses the text parts of parts, joined in order and separated by a blank line, when any part carries text; otherwise it uses content. Media parts are sent to the model in addition.

The runtime forwards metadata to the model only when the agent’s default Provider sets messageMetadataTemplate. The template is rendered from the metadata and the text is appended to the message in the request. Without a template the model does not see the metadata and nothing records it.

RuleValue
Reserved keysmock_scenario, content_type and any key beginning omnia.. The runtime reads them itself and never forwards them
KeysAt most 32, each 1 to 64 characters of letters, digits, _, . or -
ValuesAt most 8 KiB each
TotalAt most 32 KiB, keys and values together

A message over a limit fails with an INVALID_MESSAGE error that names the limit, and does not reach the model.

The forwarded metadata is recorded with the session as a message.metadata runtime event. The recorded user message holds the text the client sent, without the rendered template. Session privacy treats the event’s values as content, as it treats message text: PII redaction rewrites them, an opt-out suppresses them, and at-rest encryption covers them. The metadata stays on its message in the conversation context, so the model sees it with that message on every later turn. Only text messages forward metadata; a duplex audio session forwards none.

Send a message with images or other media:

{
"type": "message",
"session_id": "sess-abc123",
"parts": [
{
"type": "text",
"text": "What's in this image?"
},
{
"type": "image",
"media": {
"url": "https://example.com/photo.jpg",
"mime_type": "image/jpeg"
}
}
]
}
TypeDescription
textPlain text content
imageImage (JPEG, PNG, GIF, WebP)
audioAudio file (MP3, WAV, OGG)
videoVideo file (MP4, WebM)
fileGeneric file attachment
interface ContentPart {
type: "text" | "image" | "audio" | "video" | "file"
text?: string // For type: "text"
media?: MediaContent // For media types
}
interface MediaContent {
// Data source (exactly one required)
data?: string // Base64-encoded (< 256KB recommended)
url?: string // HTTP/HTTPS URL
storage_ref?: string // Backend storage reference
// Required
mime_type: string // e.g., "image/jpeg", "audio/mp3"
// Optional metadata
filename?: string
size_bytes?: number
// Image-specific
width?: number
height?: number
detail?: "low" | "high" | "auto" // Vision model hint
// Audio/Video-specific
duration_ms?: number
sample_rate?: number // Audio: Hz
channels?: number // Audio: 1=mono, 2=stereo
}
{
"type": "message",
"parts": [
{ "type": "text", "text": "Describe this image" },
{
"type": "image",
"media": {
"data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR...",
"mime_type": "image/png"
}
}
]
}

Request an upload URL for a file (requires facade media storage to be enabled):

{
"type": "upload_request",
"session_id": "sess-abc123",
"upload_request": {
"filename": "photo.jpg",
"mime_type": "image/jpeg",
"size_bytes": 102400
}
}
FieldTypeRequiredDescription
typestringYesMust be "upload_request"
session_idstringNoResume existing session
upload_request.filenamestringYesOriginal filename
upload_request.mime_typestringYesMIME type of the file
upload_request.size_bytesnumberYesFile size in bytes

The server responds with an upload_ready message containing the upload URL. See Upload media to an agent for the full procedure.

Answers a tool_call for a client-side tool:

{
"type": "tool_result",
"tool_result": {
"call_id": "tc-123",
"result": {"temperature": "72F"}
}
}
FieldTypeRequiredDescription
typestringYesMust be "tool_result"
tool_result.call_idstringYesThe tool_call.id being answered
tool_result.resultanyNoTool result (any JSON value)
tool_result.errorstringNoSet when the client’s tool failed. The call is recorded as error. To decline a call, send tool_call_nack instead

tool_call_ack tells the server the client received a tool_call and is working on it. tool_call_nack declines it; the server delivers the rejection to the runtime carrying reason, and the session records the call as denied.

A call the client does not acknowledge within the ack timeout, or does not answer within clientToolTimeout after acknowledging it, is also recorded as denied. The server sends the client a tool_result with an error for that call, so the client stops waiting on it, and the turn continues.

FieldTypeRequiredDescription
typestringYes"tool_call_ack" or "tool_call_nack"
tool_call_ack.call_idstringYesThe tool_call.id (on tool_call_ack)
tool_call_nack.call_idstringYesThe tool_call.id (on tool_call_nack)
tool_call_nack.reasonstringYesWhy the client cannot or will not handle the call

{"type": "hangup"} signals an intentional end of a realtime audio session, so the facade does not park it for re-attachment.

{"type": "disclosure_ack"} asserts that the client rendered the AI disclosure it received in connected.ai_disclosure. The facade records it on the session trace as ai.disclosure.displayed with assertedBy: client. The message is optional, and its absence means unacknowledged.

{"type": "credential_refresh", "token": "<jwt>"} replaces the credential of a management-plane or OIDC connection. On a management-plane connection the dashboard proxy sends it, and the facade accepts it only when the token names the same end user, agent and workspace as the credential it replaces. On an OIDC connection the client sends it with a new token from its identity provider, and the facade accepts it only when the agent’s OIDC validator admits the token for the same end user and, with externalAuth.oidc.sessionClaim set, the same session-binding claim value. The new token must also expire later than the current one. The facade ignores any other token, including a repeat of the current one. It sends no reply and does not forward the frame to the runtime.

Messages sent from server to client.

Sent once per connection, as the first frame after the socket opens, and never again on it:

{
"type": "connected",
"session_id": "sess-abc123",
"connected": {
"capabilities": {
"binary_frames": false,
"max_payload_size": 16777216,
"protocol_version": 1
}
}
}

The connected message always carries connected.capabilities. binary_frames is true only when the URL has binary=true; max_payload_size and protocol_version are always set.

FieldTypeDescription
connected.capabilities.binary_framesbooleanServer supports binary WebSocket frames
connected.capabilities.max_payload_sizenumberMaximum payload size in bytes
connected.capabilities.protocol_versionnumberBinary protocol version
connected.resumedbooleantrue when the connection re-attached to a parked realtime session
connected.ai_disclosurebooleanThe PromptPack’s requires_ai_disclosure declaration. Absent when the pack declares nothing; an absent field is not false.

Streaming response chunk:

{
"type": "chunk",
"content": "Hello! I'm doing"
}
FieldTypeDescription
contentstringStreamed text
rolestring"user" for the caller’s transcribed speech on a realtime audio session; absent for assistant text

Final response completion:

{
"type": "done",
"content": "Hello! I'm doing great, thank you for asking!"
}

For responses containing media (e.g., generated images), the server uses the parts array:

{
"type": "done",
"session_id": "sess-abc123",
"parts": [
{
"type": "text",
"text": "Here's the image you requested:"
},
{
"type": "image",
"media": {
"url": "https://storage.example.com/generated/img-123.png",
"mime_type": "image/png",
"width": 1024,
"height": 1024
}
}
]
}

When parts is present, it takes precedence over content. For backward compatibility, text-only responses may use either format.

The agent is calling a client-side tool. The runtime executes server-side tools and never forwards them, so tool_call is the only tool request in the stream.

{
"type": "tool_call",
"tool_call": {
"id": "tc-123",
"name": "weather",
"arguments": {
"location": "San Francisco"
}
}
}
FieldTypeDescription
tool_call.idstringUnique identifier of the call
tool_call.namestringTool name
tool_call.argumentsobjectTool arguments
tool_call.consent_messagestringHuman-readable consent prompt
tool_call.categoriesarray of stringSemantic consent categories, such as location or filesystem

The facade sends a server tool_result message only when it runs the demo handler (handler mode demo). The runtime handler never sends it: server-side tool results stay in the runtime. For a client-side tool, the client sends tool_result with tool_result.call_id and tool_result.result or tool_result.error.

{
"type": "tool_result",
"tool_result": {
"id": "tc-123",
"result": "72°F, Sunny"
}
}
FieldTypeDescription
tool_result.idstringThe tool_call.id this result is for
tool_result.resultanyTool result (any JSON value)
tool_result.errorstringError message, set when the tool failed

Response to an upload_request with the upload URL:

{
"type": "upload_ready",
"session_id": "sess-abc123",
"upload_ready": {
"upload_id": "upl-xyz789",
"upload_url": "http://agent.example.com/media/upload/upl-xyz789",
"storage_ref": "omnia://sessions/sess-abc123/media/med-def456",
"expires_at": "2025-01-09T12:00:00Z",
"method": "PUT",
"headers": {"Content-Type": "image/jpeg"}
}
}
FieldTypeDescription
upload_ready.upload_idstringUnique upload identifier
upload_ready.upload_urlstringURL to send the file content to
upload_ready.storage_refstringStorage reference for the uploaded file
upload_ready.expires_atstringWhen the upload URL expires (ISO 8601)
upload_ready.methodstringHTTP method for the upload. PUT for every storage backend. Absent if the backend reports none; use PUT
upload_ready.headersobjectHeaders the upload request must carry, such as Content-Type and, for Azure, x-ms-blob-type: BlockBlob. Absent if none are required

Streaming media chunk for audio/video responses. Allows playback to begin before the entire media is generated:

{
"type": "media_chunk",
"session_id": "sess-abc123",
"media_chunk": {
"media_id": "audio-xyz789",
"sequence": 0,
"is_last": false,
"data": "//uQxAAAAAANIAAAAAExBTUUzLjEwMFVVVVVVVVVV...",
"mime_type": "audio/mp3"
}
}
FieldTypeDescription
media_chunk.media_idstringUnique identifier for the media stream
media_chunk.sequencenumberSequence number for ordering (0-indexed)
media_chunk.is_lastbooleanWhether this is the final chunk
media_chunk.datastringBase64-encoded chunk data
media_chunk.mime_typestringMIME type (e.g., “audio/mp3”, “video/mp4”)

A client buffers chunks by media_id and sequence and assembles the complete media when is_last: true arrives. See WebSocket message flows.

{"type": "interrupt"} signals a barge-in on a realtime audio session. The client discards buffered audio output and stops playback.

Relays the audio format the runtime negotiated for a realtime audio session. The server sends it once at the start of the session, before audio flows. The client captures audio at this format for the rest of the session.

{
"type": "session_config",
"session_config": {
"codec": "pcm",
"sample_rate": 24000,
"channels": 1
}
}
FieldTypeDescription
session_config.codecstringAudio encoding, such as pcm
session_config.sample_ratenumberSample rate in Hz
session_config.channelsnumberChannel count; 1 is mono

Error message:

{
"type": "error",
"error": {
"code": "INVALID_MESSAGE",
"message": "Failed to parse message"
}
}
FieldTypeDescription
error.codestringOne of the error codes
error.messagestringError description
error.detailsobjectStructured detail for some codes
CodeDescription
INVALID_MESSAGEMessage format is invalid, or its forwarded metadata breaks a limit
SESSION_EXPIREDThe session named by session_id cannot be resumed: its context expired, or the caller is not its owner
INTERNAL_ERRORInternal server error
UPLOAD_FAILEDFile upload operation failed
MEDIA_NOT_ENABLEDMedia storage is not enabled on the facade
RATE_LIMITEDThe message was shed by a rate or concurrency limit
TURN_IN_PROGRESSThe named session’s previous turn is still running. The message was not processed or queued; send it again after the reply arrives.
UNSATISFIABLE_FORMATThe runtime’s duplex media counter-offer requires a format the facade does not support, such as video
pack_compile_failedA compile-mode session did not open because its source did not compile. error.details holds error and warnings. The socket stays open.
SESSION_DECLARATION_REFUSEDThe runtime refused the session’s declare. error.details holds type, handle, reason and message. The session did not open, and the next message opens it again with whatever declare it carries. reason is the Sandbox API’s own, such as MountNotAuthorized, MaxInstances, DeclarationMismatch or UnknownMountRole, or the runtime’s: UnknownSandbox, InvalidSandboxDeclaration, SandboxUnavailable, UnknownDeclarationType, InvalidDeclaration, SessionInitFailed or SessionInitUnsupported (a runtime built without session init).
MAX_ROUNDS_EXCEEDEDThe turn stopped at the agent’s limit of tool-call rounds. The work so far is kept, and the next message starts a new turn.
PROVIDER_RATE_LIMITEDThe model provider answered HTTP 429 and the response outlasted the runtime’s retries. Retry after a short wait.
PROVIDER_UNAVAILABLEThe model provider was unreachable or answered a 5xx. 502, 503 and 504 are retried before this is sent; 500 is not, because it usually means the request itself failed. Retry shortly.
PROVIDER_STALLEDThe provider’s streamed response stopped partway and the idle timeout ended the turn. Retry to carry on.
PROVIDER_TIMEOUTThe model provider sent nothing for the Provider’s firstTokenTimeout (90 seconds by default), typically an endpoint that accepted the request and never answered, or a non-streaming call outlasted requestTimeout. Retry shortly.
PROVIDER_AUTH_FAILEDThe provider answered 401 or 403 to the runtime’s credentials. Fix the Provider or its workload identity; a retry does not help.
CREDENTIAL_EXPIREDThe message arrived after the connection’s management-plane or OIDC credential deadline and was not processed
DUPLEX_ERRORA realtime audio session failed

The PROVIDER_* messages name the model and its provider type, never the provider’s response, which can carry credentials. Any other failed turn reaches the client as INTERNAL_ERROR with a generic message.

CodeReasonMeaning
4401credential expiredA management-plane or OIDC credential expired without being refreshed. The facade closes the connection 30 seconds before the token’s expiry (half the token’s remaining life when that is shorter), once no turn is running.

See WebSocket message flows for the order of messages in a new conversation, a tool call, a file upload, a media stream and a session resume.

The facade creates a session for every connection and announces its ID in connected, the first frame on the socket. A message without session_id joins that session:

{"type": "message", "content": "Hello"}

The client does not have to wait for connected before it sends, and the facade does not send connected again. The frames of the reply carry the session’s session_id.

Include session_id to resume:

{
"type": "message",
"session_id": "sess-abc123",
"content": "Continue our conversation"
}

If the session exists and hasn’t expired, conversation history is preserved. A session ID the connection did not mint resumes only for the user the session archive attributes it to, as established by a credential.

When the AgentRuntime sets externalAuth.oidc.sessionClaim, an OIDC connection can resume only a session that the archive records as bound to the same claim value. Any other resume gets SESSION_EXPIRED, including when session-api is not deployed or cannot be read. A session the facade ran unrecorded, because session-api could not record it, cannot be resumed by a bound client either.

Resume depends on the AgentRuntime spec.context.ttl (duration, default 24h), the time-to-live of the context store entries.

Resuming a session whose context has expired returns the SESSION_EXPIRED error with the message session context has expired; start a new session. The message is not processed. The client sends it again without session_id, and it joins the connection’s own session; the reply’s frames carry that session’s ID. A session_id the archive has never seen opens as a new session.

When facade media storage is enabled, clients can upload files via HTTP before referencing them in WebSocket messages. This avoids base64-encoding large files in the WebSocket protocol. See Upload media to an agent for the procedure.

Media storage is configured on the AgentRuntime at spec.media.storage; none is the default and disables it.

The routes are served on the facade’s HTTP port, outside /ws.

RouteStorageDescription
POST /media/request-uploadAllRequest an upload URL
PUT /media/upload/{upload-id}localUpload file content
POST /media/confirm-upload/{upload-id}s3, gcs, azureConfirm a direct upload
GET /media/info/{session-id}/{media-id}AllRead media metadata
GET /media/download/{session-id}/{media-id}AllDownload media

POST /media/request-upload takes a JSON body:

FieldTypeDescription
session_idstringSession the media belongs to
filenamestringOriginal filename
mime_typestringMIME type of the file
size_bytesnumberFile size in bytes

The response is a JSON object. Its keys match the WebSocket upload_ready message:

FieldTypeDescription
upload_idstringUnique upload identifier
upload_urlstringURL to send the file content to
storage_refstringStorage reference to name in a message part
expires_atstringWhen the upload URL expires (ISO 8601)
headersobjectHeaders the upload request must carry, such as Content-Type, and x-ms-blob-type for Azure
methodstringHTTP method for the upload, PUT

PUT /media/upload/{upload-id} is registered only for local media storage. For s3, gcs and azure the upload URL is a presigned cloud URL, and the client then calls POST /media/confirm-upload/{upload-id}.

Response: 204 No Content on success. POST /media/confirm-upload/{upload-id} answers with the media metadata, in the same shape as the media info endpoint.

The HTTP response is the confirmation that the upload completed. The WebSocket sends no message for it.

Retrieve metadata about uploaded media:

Terminal window
GET /media/info/{session-id}/{media-id}

The response is a JSON object:

FieldTypeDescription
storage_refstringStorage reference
filenamestringOriginal filename
mime_typestringMIME type
size_bytesnumberFile size in bytes
created_atstringWhen the media was uploaded (ISO 8601)
expires_atstringWhen the media is deleted (ISO 8601). Absent when it does not expire

Download uploaded media:

Terminal window
GET /media/download/{session-id}/{media-id}

With local storage the response is the file with Content-Type and Content-Disposition headers. With cloud storage the response is a 307 redirect to a presigned URL.

See AgentRuntime media.storage configuration for the backend fields.

When binary frame support is enabled (binary=true query parameter), the server sends duplex audio as binary WebSocket frames. Base64 adds about 33% to the size of media in a JSON media_chunk. A binary frame carries the raw bytes plus a 32-byte header and JSON metadata. See Handle binary WebSocket frames for a client example.

flowchart LR
H["Header<br/>32 bytes"] --- M["Metadata<br/>JSON"] --- P["Binary payload"]
FieldOffsetSizeTypeDescription
Magic04bytes"OMNI" magic bytes
Version41uint8Protocol version (1)
Flags51uint8Bit flags (see below)
MessageType62uint16Message type (big-endian)
MetadataLen84uint32JSON metadata length (big-endian)
PayloadLen124uint32Binary payload length (big-endian)
Sequence164uint32Sequence number (big-endian)
MediaID2012bytesMedia stream identifier
BitMaskNameDescription
00x01CompressedReserved. The facade neither sets nor reads it; do not set it
10x02ChunkedPart of a chunked transfer
20x04IsLastLast chunk in a stream

A payload larger than 1 MB is split into 64 KB frames with the Chunked flag set. In a chunked transfer Sequence is the zero-based chunk index and the metadata carries total_chunks.

On a frame the client sends, IsLast marks the final frame of the entire audio call and ends the audio session. A client sets it only on the true final frame.

ValueNameDescription
1MediaChunkStreaming media chunk

Value 1 (MediaChunk) is the only binary message type. A frame with another type is answered with INVALID_MESSAGE and the message unknown binary message type.

The metadata is a JSON object:

{
"session_id": "sess-abc123",
"mime_type": "audio/mp3"
}
FieldTypeDescription
session_idstringSession identifier
mime_typestringMIME type of the payload
total_chunksnumberNumber of chunks in a chunked transfer
sample_ratenumberAudio sample rate in Hz; set on the first frame a client sends
channelsnumberAudio channel count; set on the first frame a client sends
codecstringAudio encoding; set on the first frame a client sends

The payload contains raw binary audio/video data (not base64-encoded).

When a client doesn’t request binary frames (binary=true not set), the server always sends JSON text frames with base64-encoded media data. This ensures backward compatibility with existing clients.

The server sends a WebSocket ping frame every ping interval. Most WebSocket client libraries answer pings with pong frames automatically.

The server closes the connection when nothing arrives from the client for the pong timeout. A pong and any other frame from the client both reset the timer.

SettingDefault
Ping interval30 seconds
Pong timeout60 seconds

The WebSocket protocol uses snake_case for JSON field names to match Go conventions:

{
"session_id": "...",
"mime_type": "image/png",
"size_bytes": 1024
}

The generated TypeScript types use snake_case property names that match the JSON (mime_type, session_id, size_bytes).