Skip to content

Facade ↔ runtime protocol

Every Omnia agent pod runs two sidecars: the facade (external protocol translation — WebSocket, REST, A2A, MCP) and the runtime (LLM calls, tool execution, sessions). They talk to each other over a private gRPC connection. This page is the authoritative reference for two things:

  1. the gRPC message surface the facade and runtime exchange (api/proto/runtime/v1/runtime.proto), and
  2. the flat x-omnia-* identity/claims metadata the facade attaches to every gRPC call so that the runtime, and the ToolPolicy decision broker it calls, can make identity-aware decisions.

For the client-facing WebSocket protocol (browser ↔ facade), see the WebSocket protocol reference. For why the policy engine is shaped the way it is, see Policy engine architecture.

The contract is versioned. The current version is 1.9.0, declared in two places that are asserted equal by pkg/runtime/contract/version_test.go:

  • the // Contract-Version: marker at the top of api/proto/runtime/v1/runtime.proto
  • the contract.Version constant in pkg/runtime/contract/version.go

The minor version is bumped for additive changes — a new message, a new optional field, a new oneof variant. The major version is bumped for any change that would break an existing conformant runtime. pkg/runtime/contract/surface_test.go pins a fingerprint of the proto’s wire surface to the version, so a change to the contract fails the build until the version is bumped with it.

A runtime advertises which optional contract surfaces it implements via the repeated string capabilities field on its Health response. This lets the operator and facade adapt, and lets a runtime say what it does not do. The set is open: a newer runtime may advertise a name not listed here, and consumers must display or ignore unknown names rather than reject them. An empty set means “pre-negotiation” (legacy): the platform assumes the lowest-common-denominator behaviour.

The capability names this contract build defines (pkg/runtime/contract/capabilities.go):

CapabilityMeaning
invokeserves the one-shot Invoke RPC (function mode, spec.mode: function)
duplex_audioserves duplex audio sessions (DuplexStart / audio_input / media_chunk)
client_toolscompletes the client-side tool round-trip (tool_call → client_tool_result)
media_storage_refresolves storage_ref attachments to fetchable media
interruptionemits realtime voice interruption (barge-in) signals

Advertisement must match the runtime’s actual behaviour: an over-claiming runtime (one that advertises a capability whose probe then fails) fails conformance (see below). The operator gates on advertised capabilities: an AgentRuntime with duplex enabled requires duplex_audio and interruption, and one with a rest or mcp facade requires invoke. When the running runtime advertises less (or reports nothing within 60 seconds of its Deployment becoming Available), the operator sets CapabilitiesSatisfied=False with reason CapabilitiesMissing, emits a Warning event and scales the agent to zero until the spec or image changes.

RuntimeHello is the runtime’s first ServerMessage on every Converse stream. It carries the session’s authoritative capabilities and, for a duplex session, a bounded MediaNegotiation counter-offer — the audio format the runtime requires (codec / sample_rate / channels). The message also carries frame_rate / resolution for video; the facade’s duplex path is audio-only, so a counter-offer that sets either of them fails the session closed with UNSATISFIABLE_FORMAT.

The facade relays the counter-offer to the browser as a session_config WebSocket message, and the client (re)captures at that format — or the facade fails the session closed (UNSATISFIABLE_FORMAT) before any audio flows when it cannot be satisfied. A runtime that never sends a RuntimeHello is treated as legacy: the facade proceeds with the client’s unilateral DuplexStart format, exactly as before negotiation existed.

A duplex session’s hello also carries voice_mode (contract 1.9.0): the pipeline the runtime actually chose at open, VOICE_MODE_REALTIME (provider-native audio streaming) or VOICE_MODE_CASCADED (STT → LLM → TTS), including the automatic choice when spec.duplex.mode is unset. The facade relays it on the same session_config as voice_mode: "realtime" | "cascaded" so the console can label the call and set latency expectations. A custom runtime that leaves it VOICE_MODE_UNSPECIFIED (the zero value) gets no label.

The contract ships a protocol conformance suite — pkg/runtime/conformance and the runtime-conformance CLI. Point it at any runtime’s gRPC endpoint to check it against this contract:

Terminal window
runtime-conformance --addr <host:port>

It probes Health/contract-version, the first-frame RuntimeHello, text-turn shape, graceful handling of malformed input, and capability honesty (an advertised invoke / duplex_audio must actually work; an unadvertised invoke must return Unimplemented, and the duplex_audio check is skipped when it is not advertised). Use it to measure any runtime’s gap, including an unsupported LangChain container’s. See Authoring a custom runtime for the full checklist.

Implementing this contract in your own runtime

Section titled “Implementing this contract in your own runtime”

Any container that serves omnia.runtime.v1.RuntimeService can be an Omnia runtime: set spec.framework.type: custom and spec.framework.image on the AgentRuntime. Only promptkit has a built-in image; every other framework type must supply one explicitly (via spec.framework.image, or an operator-level --framework-image=<type>=<image> flag), and blocks with FrameworkImageUnavailable if it does not.

A conformant runtime must:

  1. Handle every ClientMessage field it may receive, or fail loudly on the ones it does not. ClientMessage is not a oneof: several fields may be set on the same message.
  2. Emit ServerMessage variants for the surfaces it advertises.
  3. Serve Invoke for function-mode AgentRuntimes (spec.mode: function); a runtime that only serves spec.mode: agent may omit it.
  4. Read caller identity from the flat x-omnia-* gRPC metadata below (context.invocation_metadata() or your language’s equivalent). The raw Authorization bearer token is withheld.
  5. Serve Health, report the contract version it was built against, and advertise only the capabilities it actually implements (see Capabilities); conformance fails an over-claiming runtime.
  6. Never forward the caller’s credentials to third-party tool upstreams.

For step-by-step guidance see Authoring a custom runtime, and verify your implementation with the conformance suite.

The contract is defined in api/proto/runtime/v1/runtime.proto (package omnia.runtime.v1). The facade is the gRPC client; the runtime is the server.

service RuntimeService {
// Bidirectional streaming for agent conversations.
rpc Converse(stream ClientMessage) returns (stream ServerMessage);
// One-shot, non-conversational Function call (mode: function).
rpc Invoke(InvocationRequest) returns (InvocationResponse);
// Readiness probe.
rpc Health(HealthRequest) returns (HealthResponse);
// Whether a session's working context can still be resumed.
rpc HasConversation(HasConversationRequest) returns (HasConversationResponse);
// A conversation ended (best-effort; added in contract 1.7.0).
rpc EndConversation(EndConversationRequest) returns (EndConversationResponse);
// Progress of one Invoke call (best-effort; added in contract 1.8.0).
rpc WatchInvocation(WatchInvocationRequest) returns (stream InvocationProgress);
}
RPCShapeUsed by
Conversebidi stream ClientMessage → ServerMessagemode: agent runtimes (WebSocket / A2A facades)
Invokeunary InvocationRequest → InvocationResponsemode: function runtimes (REST / MCP facades, POST /functions/{name})
Healthunary HealthRequest → HealthResponsereadiness checks; reports contract_version and capabilities
HasConversationunary HasConversationRequest → HasConversationResponsethe facade’s resume probe when a client reconnects to an existing session; returns RESUME_STATE_RESUMABLE, RESUME_STATE_NOT_FOUND or RESUME_STATE_UNAVAILABLE
EndConversationunary EndConversationRequest{session_id} → EndConversationResponse{}the WebSocket facade, when a connection closes after its last turn, or a parked realtime session expires. The only end-of-conversation signal: Converse is opened per message. Best-effort; the facade ignores Unimplemented. Omnia’s runtime uses it to start the release of the conversation’s sandboxes (spec.sandbox.releaseGrace). REST and A2A never send it
WatchInvocationserver stream WatchInvocationRequest{invocation_id} → InvocationProgressthe REST facade, for a POST /functions/{name} whose caller sent Accept: text/event-stream. Opened before the Invoke it watches; see WatchInvocation. Optional: the facade treats Unimplemented as “no progress”
FieldTypeDescription
session_idstringConversation session for state management
contentstringUser message text (legacy; use parts for multimodal)
metadatamap<string,string>Optional key-value context
partsrepeated ContentPartMultimodal content for the turn. Entries with Media set (image, audio, video, file) are attached to the turn. When any entry carries text, those text entries joined in order with a blank line are the turn and content is ignored; otherwise the turn’s text is content
client_tool_resultClientToolResultResult of a client-side tool execution
duplex_startDuplexStartOn the first message, switches the stream into bidirectional audio mode
audio_inputAudioInputChunkOne inbound audio frame during a duplex session
record_sequence_baseint64The facade’s current ordering position in the conversation; the runtime numbers the records it writes from here. Zero on a session’s first message

A oneof — exactly one variant per message:

VariantTypeDescription
chunkChunkPartial streaming text
tool_callToolCallClient-side tool invocation the facade must forward to the browser. Server-side tool calls are handled internally by the runtime and are not sent on this stream
doneDoneTurn completion, with final_content/parts, usage, record_sequence_high_water (the last ordering position the runtime recorded) and finish_reason (why the turn’s last model call stopped: stop, max_output_tokens, tool_use, safety or refusal; empty when unreported; added in contract 1.6.0)
errorErrorError with machine-readable code and human message
media_chunkMediaChunkProgressive media delivery (raw bytes, no base64)
interruptionInterruptionBarge-in signal; client clears buffered audio
runtime_helloRuntimeHelloFirst frame of every stream: per-session capabilities and, for duplex, the media counter-offer (see Per-session negotiation)
heartbeatHeartbeatKeepalive for a turn in progress. Carries nothing and is not forwarded to the client. The runtime sends one every 30 seconds while a text turn runs, so a turn that is silent on the stream (a server-side tool call, for example) is not cut by the facade’s inactivity timeout (120 seconds, OMNIA_RUNTIME_STREAM_INACTIVITY_TIMEOUT). A facade that predates it ignores it

ToolCall carries execution (TOOL_EXECUTION_SERVER / TOOL_EXECUTION_CLIENT), plus an optional consent_message and consent categories for client-side tools; only CLIENT calls require the facade to round-trip a ClientToolResult back to the runtime.

ClientToolResult carries call_id and either result_json or a rejection: is_rejected with rejection_reason. A rejection with is_denied set is a refusal, and the runtime records the call as denied: the client declined it, or never answered it. A rejection without it is the client’s tool failing, and is recorded as error. is_denied was added in contract 1.5.0; a facade that predates it never sets it.

InvocationRequest carries input_json (already validated by the facade against spec.inputSchema), an invocation_id for correlation, and a metadata map, plus record_sequence_base. InvocationResponse returns output_json, usage, duration_ms, the echoed invocation_id and record_sequence_high_water. The runtime is schema-agnostic — the facade validates input and output.

An observation channel beside Invoke, never a second way to run a function: the output, usage and errors of the call still travel on the unary Invoke. The sequence is:

  1. The facade mints the invocation id and calls WatchInvocation with it.
  2. The runtime registers the watcher and sends ready as the first message.
  3. The facade calls Invoke with the same id.
  4. While the invocation runs, the runtime sends tool_call (call_id, name) and tool_result (call_id, name, ok, duration_ms), in the order its event bus published them.
  5. When the invocation has finished and every event it produced has been sent, the runtime ends the stream with status OK, on success and failure alike.

InvocationProgress is a oneof of ready, tool_call and tool_result; a facade skips a variant it does not know. There is no model-text variant on purpose. Raw deltas would reach the caller before any whole-message guardrail ran on the final message, and would expose the tool loop’s narration, which a function’s output omits. Tool events carry no arguments or results: those are recorded on the session’s tool calls, under its retention and privacy rules. Delivery is best-effort: a runtime may drop events for a watcher that falls behind. A runtime that does not implement the RPC answers Unimplemented. The facade also stops waiting for ready after 3 seconds and runs the invocation unwatched.

Omnia’s runtime serves WatchInvocation to loopback callers only, because the facade is a sidecar that always dials localhost. Any other peer gets PermissionDenied.

The facade builds a set of propagation fields once per connection (internal/facade/server.go, buildConnectionContext) and attaches them as gRPC metadata on every call via policy.ToGRPCMetadata (pkg/policy/context.go). The runtime rehydrates them from incoming metadata in a gRPC interceptor (internal/runtime/interceptor.go, extractPolicyFromMetadata, which calls policy.FromIncomingGRPCMetadata in pkg/policy/grpc_incoming.go) and exposes them to tool adapters and the ToolPolicy broker.

The exact set of keys is the single source of truth headerKeyMap in pkg/policy/context.go. Every key below is emitted only when its value is non-empty.

Metadata keyPopulated byDescription
x-omnia-agent-nameFacadeName of the AgentRuntime
x-omnia-namespaceFacadeKubernetes namespace of the agent pod
x-omnia-session-idFacadeCurrent session identifier
x-omnia-request-idFacadePer-request trace identifier
x-omnia-user-idFacadeWho the call is FOR — the pseudonymised end-user id
x-omnia-subject-idFacadeWho presented the credential — the pseudonymised token-holder. Equal to x-omnia-user-id when the end user held their own token; different when something acts on their behalf (a management-plane call, a ServiceAccount). Absent only from a facade built before this header existed
x-omnia-user-emailFacadeThe caller’s email address
x-omnia-originFacadeValidator that admitted the request; surfaces as identity.origin
x-omnia-workspaceFacadeWorkspace the request targets; surfaces as identity.workspace
x-omnia-claim-<name>FacadeOne entry per mapped JWT claim (see claim propagation)

Three more keys describe the facade hop rather than the caller, so they are not in headerKeyMap and never reach a tool or the policy broker:

Metadata keyPopulated byDescription
x-omnia-client-toolsFacadetrue on a Converse call whose surface delivers tool calls to a client and returns the results: the WebSocket facade, text and duplex. Absent on A2A and on any facade built before the key existed, which a runtime must read as “no client can answer”. The pkg/runtime SDK exposes it as Turn.ClientToolsAvailable
x-omnia-session-openFacadetrue on the one Converse stream whose message opened the session. The facade opens a stream per message, so this is how the runtime tells a session’s first message from a later one; it runs session initialisation on that stream
x-omnia-declare-binFacadeThe client’s session declare object, forwarded byte for byte as binary metadata (the -bin suffix makes gRPC base64-encode it on the wire). Sent only together with x-omnia-session-open

On the subsequent runtime → tool and runtime → policy-broker HTTP hops, the runtime adds tool-context headers via SetAllOutboundHeaders (internal/runtime/tools/context_headers.go):

HeaderPopulated byDescription
x-omnia-providerRuntimeThe runtime’s LLM provider type: the Provider’s spec.type (for example claude, openai), not the Provider resource’s name. Set by the runtime and overrides any value the facade sent; absent when the runtime has no configured provider
x-omnia-modelRuntimeThe Provider’s spec.model. Absent when the Provider leaves the model to the pack
x-omnia-tool-nameRuntimeTool being invoked
x-omnia-tool-registryRuntimeToolRegistry containing the tool
x-omnia-param-<PascalCase>RuntimePromoted scalar tool parameters

Every mapped JWT claim crosses as its own metadata entry: x-omnia-claim-<name>, one per claim, value verbatim. The facade sources these from the admitting validator’s claim map (AuthenticatedIdentity.Claims), regardless of which validator (OIDC, edge-trust, API key, shared token, management plane) admitted the request.

Casing matters. Claim headers are lowercase on the gRPC hop (x-omnia-claim-<name>, because gRPC metadata keys are lowercase). But when the runtime forwards them to the policy-broker or a downstream HTTP tool, it emits them through an http.Request, so they land MIME-canonicalized on the wire: claim tier becomes X-Omnia-Claim-Tier, customer_id becomes X-Omnia-Claim-Customer_id. Only the first letter of each hyphen-separated segment is upper-cased — the claim-name segment itself is not otherwise transformed.

Any read path (ToolPolicy requiredClaims lookups and CEL rules that reference headers['X-Omnia-Claim-*']) must use the canonical form. The single source of truth is policy.CanonicalClaimHeader(claim); referencing the raw lowercase prefix misses silently. See Configure Tool Policies for how CEL rules reference claims.

Roles are not a structured field. There is no dedicated role header or identity field — a role rides like any other claim, via x-omnia-claim-role, if the admitting validator’s claim map includes a role entry. On the runtime/broker side it surfaces as identity.claims.role, not as a separate identity.role field. See claim propagation above for how claim headers are named and canonicalized.

x-omnia-workspace (and therefore the CEL identity.workspace) resolves in the facade via identityScope (internal/facade/identity_scope.go):

  1. Token workspace scope — if the admitting validator produced a workspace-scoped identity (e.g. a management-plane JWT carrying a workspace claim), that value is used.
  2. Agent’s deployed workspace — otherwise it falls back to the workspace the AgentRuntime is deployed into.

This guarantees identity.workspace is non-empty for every validator style. Note it is distinct from x-omnia-namespace (the Kubernetes namespace), which is propagated separately.

The runtime never has the facade’s structured AuthenticatedIdentity — that object is in-process on the facade side only and does not cross the gRPC hop (see the ContextKeyIdentity doc in pkg/policy/context.go). On the runtime side, the flat metadata above is all that survives.

When the runtime calls the policy-broker (internal/runtime/tools/policy_broker_client.go), it rebuilds a structured identity from those flat fields via policy.IdentityPayloadFromPropagation and sends it as the identity object on the decision request (pkg/policy/broker_contract.go). The broker exposes it to CEL rules as the identity root (ee/pkg/policy/evaluator.go):

CEL fieldReconstructed fromNotes
identity.originx-omnia-originValidator that admitted the request
identity.workspacex-omnia-workspaceToken scope else agent’s deployed workspace
identity.subjectx-omnia-subject-idThe token-holder. Falls back to x-omnia-user-id when the header is absent (a facade predating it)
identity.endUserx-omnia-user-idThe person the call is for
identity.agentx-omnia-agent-nameThe agent this tool call runs under
identity.claimsx-omnia-claim-*The claim map, verbatim

When no identity is attached (unauthenticated / dev-mode traffic), the identity root is populated with zero-valued strings and an empty claims map, so rules referencing identity.* evaluate without error. Use has(identity.claims.<name>) to test whether a claim is present.

LocationDefines
api/proto/runtime/v1/runtime.protogRPC message surface (facade ↔ runtime)
pkg/policy/context.goheaderKeyMap, header constants, ToOutboundHeaders / ToGRPCMetadata, CanonicalClaimHeader
internal/runtime/interceptor.go, pkg/policy/grpc_incoming.goRehydration of metadata into runtime request context
pkg/policy/broker_contract.goDecisionRequest / IdentityPayload wire contract, IdentityPayloadFromPropagation
ee/pkg/policy/evaluator.goHow identity.* surfaces into ToolPolicy CEL