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:
- the gRPC message surface the facade and runtime exchange
(
api/proto/runtime/v1/runtime.proto), and - 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.
Contract version
Section titled “Contract version”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 ofapi/proto/runtime/v1/runtime.proto - the
contract.Versionconstant inpkg/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.
Capabilities
Section titled “Capabilities”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):
| Capability | Meaning |
|---|---|
invoke | serves the one-shot Invoke RPC (function mode, spec.mode: function) |
duplex_audio | serves duplex audio sessions (DuplexStart / audio_input / media_chunk) |
client_tools | completes the client-side tool round-trip (tool_call → client_tool_result) |
media_storage_ref | resolves storage_ref attachments to fetchable media |
interruption | emits 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.
Per-session negotiation
Section titled “Per-session negotiation”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.
Conformance
Section titled “Conformance”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:
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:
- Handle every
ClientMessagefield it may receive, or fail loudly on the ones it does not.ClientMessageis not aoneof: several fields may be set on the same message. - Emit
ServerMessagevariants for the surfaces it advertises. - Serve
Invokefor function-mode AgentRuntimes (spec.mode: function); a runtime that only servesspec.mode: agentmay omit it. - Read caller identity from the flat
x-omnia-*gRPC metadata below (context.invocation_metadata()or your language’s equivalent). The rawAuthorizationbearer token is withheld. - 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. - 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.
gRPC service surface
Section titled “gRPC service surface”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);}| RPC | Shape | Used by |
|---|---|---|
Converse | bidi stream ClientMessage → ServerMessage | mode: agent runtimes (WebSocket / A2A facades) |
Invoke | unary InvocationRequest → InvocationResponse | mode: function runtimes (REST / MCP facades, POST /functions/{name}) |
Health | unary HealthRequest → HealthResponse | readiness checks; reports contract_version and capabilities |
HasConversation | unary HasConversationRequest → HasConversationResponse | the facade’s resume probe when a client reconnects to an existing session; returns RESUME_STATE_RESUMABLE, RESUME_STATE_NOT_FOUND or RESUME_STATE_UNAVAILABLE |
EndConversation | unary 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 |
WatchInvocation | server stream WatchInvocationRequest{invocation_id} → InvocationProgress | the 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” |
ClientMessage (facade → runtime)
Section titled “ClientMessage (facade → runtime)”| Field | Type | Description |
|---|---|---|
session_id | string | Conversation session for state management |
content | string | User message text (legacy; use parts for multimodal) |
metadata | map<string,string> | Optional key-value context |
parts | repeated ContentPart | Multimodal 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_result | ClientToolResult | Result of a client-side tool execution |
duplex_start | DuplexStart | On the first message, switches the stream into bidirectional audio mode |
audio_input | AudioInputChunk | One inbound audio frame during a duplex session |
record_sequence_base | int64 | The facade’s current ordering position in the conversation; the runtime numbers the records it writes from here. Zero on a session’s first message |
ServerMessage (runtime → facade)
Section titled “ServerMessage (runtime → facade)”A oneof — exactly one variant per message:
| Variant | Type | Description |
|---|---|---|
chunk | Chunk | Partial streaming text |
tool_call | ToolCall | Client-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 |
done | Done | Turn 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) |
error | Error | Error with machine-readable code and human message |
media_chunk | MediaChunk | Progressive media delivery (raw bytes, no base64) |
interruption | Interruption | Barge-in signal; client clears buffered audio |
runtime_hello | RuntimeHello | First frame of every stream: per-session capabilities and, for duplex, the media counter-offer (see Per-session negotiation) |
heartbeat | Heartbeat | Keepalive 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.
Invoke (function mode)
Section titled “Invoke (function mode)”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.
WatchInvocation (function progress)
Section titled “WatchInvocation (function progress)”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:
- The facade mints the invocation id and calls
WatchInvocationwith it. - The runtime registers the watcher and sends
readyas the first message. - The facade calls
Invokewith the same id. - While the invocation runs, the runtime sends
tool_call(call_id,name) andtool_result(call_id,name,ok,duration_ms), in the order its event bus published them. - 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.
Identity & claims metadata
Section titled “Identity & claims metadata”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.
Flat metadata keys
Section titled “Flat metadata keys”| Metadata key | Populated by | Description |
|---|---|---|
x-omnia-agent-name | Facade | Name of the AgentRuntime |
x-omnia-namespace | Facade | Kubernetes namespace of the agent pod |
x-omnia-session-id | Facade | Current session identifier |
x-omnia-request-id | Facade | Per-request trace identifier |
x-omnia-user-id | Facade | Who the call is FOR — the pseudonymised end-user id |
x-omnia-subject-id | Facade | Who 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-email | Facade | The caller’s email address |
x-omnia-origin | Facade | Validator that admitted the request; surfaces as identity.origin |
x-omnia-workspace | Facade | Workspace the request targets; surfaces as identity.workspace |
x-omnia-claim-<name> | Facade | One 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 key | Populated by | Description |
|---|---|---|
x-omnia-client-tools | Facade | true 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-open | Facade | true 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-bin | Facade | The 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):
| Header | Populated by | Description |
|---|---|---|
x-omnia-provider | Runtime | The 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-model | Runtime | The Provider’s spec.model. Absent when the Provider leaves the model to the pack |
x-omnia-tool-name | Runtime | Tool being invoked |
x-omnia-tool-registry | Runtime | ToolRegistry containing the tool |
x-omnia-param-<PascalCase> | Runtime | Promoted scalar tool parameters |
Claim propagation
Section titled “Claim propagation”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.
Role as a claim
Section titled “Role as a claim”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.
identity.workspace semantics
Section titled “identity.workspace semantics”x-omnia-workspace (and therefore the CEL identity.workspace) resolves in the
facade via identityScope (internal/facade/identity_scope.go):
- Token workspace scope — if the admitting validator produced a
workspace-scoped identity (e.g. a management-plane JWT carrying a
workspaceclaim), that value is used. - 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.
How identity reaches ToolPolicy CEL
Section titled “How identity reaches ToolPolicy CEL”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 field | Reconstructed from | Notes |
|---|---|---|
identity.origin | x-omnia-origin | Validator that admitted the request |
identity.workspace | x-omnia-workspace | Token scope else agent’s deployed workspace |
identity.subject | x-omnia-subject-id | The token-holder. Falls back to x-omnia-user-id when the header is absent (a facade predating it) |
identity.endUser | x-omnia-user-id | The person the call is for |
identity.agent | x-omnia-agent-name | The agent this tool call runs under |
identity.claims | x-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.
Source of truth
Section titled “Source of truth”| Location | Defines |
|---|---|
api/proto/runtime/v1/runtime.proto | gRPC message surface (facade ↔ runtime) |
pkg/policy/context.go | headerKeyMap, header constants, ToOutboundHeaders / ToGRPCMetadata, CanonicalClaimHeader |
internal/runtime/interceptor.go, pkg/policy/grpc_incoming.go | Rehydration of metadata into runtime request context |
pkg/policy/broker_contract.go | DecisionRequest / IdentityPayload wire contract, IdentityPayloadFromPropagation |
ee/pkg/policy/evaluator.go | How identity.* surfaces into ToolPolicy CEL |