# 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](/reference/platform/websocket-protocol/). For
*why* the policy engine is shaped the way it is, see
[Policy engine architecture](/explanation/security/policy-engine/).

## 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 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.

## 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

`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

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:

```sh
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](/how-to/security/authoring-a-custom-runtime/) for
the full checklist.

## 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.

:::caution[Do not hand-copy this proto file]
Generate your client from
`api/proto/runtime/v1/runtime.proto` at a **pinned git ref**, record the
`Contract-Version` marker you generated against, and report it back from your
`Health` RPC via `contract_version`. A hand-copied, unpinned `.proto` cannot
tell you when it has diverged: an unsupported LangChain runtime drifted six
months and seven features behind the contract this way — silently dropping
`audio_input` and `client_tool_result` because the
generated types for them did not exist in its copy.
:::

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](#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](/how-to/security/authoring-a-custom-runtime/), and
verify your implementation with the [conformance suite](#conformance).

## 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.

```protobuf
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`](#watchinvocation-function-progress). Optional: the facade treats `Unimplemented` as "no progress" |

### `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)

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](#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)

`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)

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`.

:::note[Identity does not travel as a message field]
None of the messages above carry a user-identity field. Caller identity and
claims travel as **gRPC metadata** on the call, described next — not inside
`ClientMessage`, `InvocationRequest`, or their `metadata` maps.
:::

## 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

| 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](#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 |

:::danger[The raw `Authorization` bearer token is withheld]
`Authorization` is absent from `headerKeyMap` and is never propagated
outbound. The reasoning, quoted from the code (`pkg/policy/context.go`):

> The caller's inbound bearer token must never be re-emitted as the outbound
> `Authorization` on a tool call: it would leak the user's credential to
> arbitrary third-party upstreams, and it would overwrite a tool's own
> `authSecretRef` credential, which the runtime applies first in
> `buildHTTPHeaders`. User identity travels via the `X-Omnia-Claim-*` headers
> instead.

The token is retained in-process (`Authorization(ctx)` /
`ContextKeyAuthorization`), but it does not cross
the gRPC hop as a propagated credential and is never attached to a tool call.
:::

### 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](/how-to/security/configure-tool-policies/) for how CEL
rules reference claims.

### 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](#claim-propagation) above for how claim headers are
named and canonicalized.

### `identity.workspace` semantics

`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.

## 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

| 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 |