# Authoring a custom facade

Run your own image as an agent's facade so the agent speaks a protocol Omnia does not ship, while the runtime sidecar, policy broker, sessions and exposure stay as they are. Your container terminates the client protocol, authenticates those clients, and sends each turn to the runtime sidecar over gRPC with the caller's identity attached as metadata.

`examples/custom-facade/` in the Omnia repository is a minimal conformant facade. Start from it.

:::note[Enterprise]
A `type: custom` facade is an Enterprise feature, checked only at admission and only when the Enterprise validating webhook is installed (`enterprise.enabled: true` and `webhook.enabled: true`). See [Licensing](/explanation/platform/licensing/).
:::

## Prerequisites

- A PromptPack, the Providers the agent uses, and an image the cluster can pull.
- An AgentRuntime in `mode: agent`. The CRD rejects a custom facade in `mode: function`.
- If the Enterprise webhook is installed: a licence that includes custom facades, or dev mode (Enterprise enabled, no licence), which admits everything.

## Declare the facade

1. Add one `type: custom` entry to `spec.facades[]` with `image` set. The CRD rejects a custom facade without `image`.

   ```yaml
   apiVersion: omnia.altairalabs.ai/v1alpha1
   kind: AgentRuntime
   metadata:
     name: my-agent
   spec:
     mode: agent
     promptPackRef:
       name: my-pack
     facades:
       - type: custom
         image: ghcr.io/acme/my-facade:v1.0.0
         port: 8080
         managementPlane: true
         expose:
           enabled: true
   ```

   `port` defaults to `8080`, `managementPlane` defaults to `true`, and `expose.enabled` defaults to `false`.

2. Make the custom facade the only facade. The API server rejects a `custom` entry beside any other: the operator takes the image, port, `extraEnv`, `managementPlane`, `drainTimeout` and `expose` from the primary facade, and a `websocket`, `rest` or `a2a` entry would outrank `custom`.

`drainTimeout` sets the pod's termination grace period (the drain timeout plus 30 seconds). The operator passes neither `drainTimeout` nor `clientToolTimeout` to your container. See the [AgentRuntime reference](/reference/core/agentruntime/) for every facade field.

## Read the injected contract

Your container reads environment variables and binds ports. The diagram shows the ports in the agent pod.

```mermaid
flowchart LR
    Client -->|"your protocol :8080"| Facade
    Dashboard -->|"management JWT :18080"| Facade
    Kubelet -->|"/healthz /readyz :8081"| Facade
    subgraph Pod["Agent pod"]
        Facade["facade container (your image)"]
        Runtime["runtime container"]
        Facade -->|"gRPC localhost:9000"| Runtime
    end
```

1. Read these environment variables.

   | Variable | Value |
   |----------|-------|
   | `OMNIA_AGENT_NAME` | AgentRuntime name, from the pod's `app.kubernetes.io/instance` label |
   | `OMNIA_NAMESPACE` | Pod namespace |
   | `OMNIA_FACADE_PORT` | External listen port (`facades[].port`, default `8080`) |
   | `OMNIA_HEALTH_PORT` | `8081` |
   | `OMNIA_HANDLER_MODE` | `facades[].handler`, default `runtime` |
   | `OMNIA_RUNTIME_ADDRESS` | `localhost:9000`. Set only when the handler mode is `runtime`. |
   | `OMNIA_MGMT_PLANE_JWKS_URL` | Dashboard JWKS URL. Set only when the dashboard is enabled. |
   | `OMNIA_VARIANT` | `stable` or `candidate` |
   | `POD_IP` | Pod IP |
   | `OMNIA_ROUTE_REDIS_URL` | Redis URL. Set only with a Redis context store. |
   | `OMNIA_TRACING_ENABLED`, `_ENDPOINT`, `_INSECURE` | Set only when tracing is enabled on the operator |

   The operator appends `facades[].extraEnv` to the environment.

2. Bind these ports.

   | Port | Purpose | Required |
   |------|---------|----------|
   | `OMNIA_FACADE_PORT` | Your external protocol listener | Yes |
   | `8081` | `/healthz` and `/readyz` | Yes |
   | `18080` | Management-plane twin | Only when `managementPlane` is `true` |

## Call the runtime

Your facade is the gRPC client. Dial `OMNIA_RUNTIME_ADDRESS` and call `omnia.runtime.v1.RuntimeService`, defined in `api/proto/runtime/v1/runtime.proto`. In `mode: agent` use the streaming `Converse` RPC. `Invoke` serves `mode: function`.

1. Open a `Converse` stream and send a `ClientMessage` with `session_id` and either `content` or `parts`.
2. Handle each `ServerMessage` variant:
   - `chunk`: a partial text fragment. Forward it to your client.
   - `tool_call`: a client-side tool call when `execution` is `TOOL_EXECUTION_CLIENT`. Forward it, collect the result, and send it back on the same stream as a `ClientToolResult` in a `ClientMessage`. Server-side tool calls never appear on the stream.
   - `done`: the turn is complete, with `final_content`, `parts` and `usage`.
   - `error`: a machine-readable `code` and a `message`.
   - `media_chunk`: raw binary media (no base64).
   - `interruption`: a barge-in on duplex audio. Clear buffered audio.
   - `runtime_hello` and `heartbeat`: the runtime's capabilities and a keepalive. Do not forward either.
3. For duplex audio, set `duplex_start` on the first `ClientMessage` of the stream and send inbound frames as `audio_input`.

The [facade and runtime protocol reference](/reference/platform/facade-runtime-protocol/) lists every message and field.

## Authenticate and propagate identity

Omnia does not sit in front of your external port. Validate your protocol's credential, then attach the caller's identity to every `Converse` call as lowercase `x-omnia-*` gRPC metadata. ToolPolicy rules that read `identity.*` see empty values without it.

1. Emit a key only when its value is non-empty. The keys are `x-omnia-agent-name`, `x-omnia-namespace`, `x-omnia-session-id`, `x-omnia-request-id`, `x-omnia-user-id`, `x-omnia-user-email`, `x-omnia-origin`, `x-omnia-workspace` and one `x-omnia-claim-<name>` per identity claim. The [flat metadata keys table](/reference/platform/facade-runtime-protocol/#flat-metadata-keys) defines each one, including `x-omnia-subject-id`.
2. In Go, build the metadata with `PropagationFields` and `ToGRPCMetadata` from `pkg/policy`, as the example does.
3. Do not emit `x-omnia-provider` or `x-omnia-model`. The runtime sets both and overwrites any value you send.
4. Do not forward the caller's `Authorization` token to the runtime. The propagation map excludes it so that tool upstreams never receive the caller's credential. Keep the raw token inside your process.

See [Configure tool policies](/how-to/security/configure-tool-policies/) for writing rules against the identity you send.

## Serve health checks

The operator probes your container on port `8081`:

- Readiness: `GET /readyz`
- Liveness: `GET /healthz`

1. Serve both endpoints and return a `2xx` status when healthy. Without them the pod never becomes Ready and never joins the Service endpoints.
2. Serve `/readyz` only once your runtime connection and external listener are up.
3. Serve `GET /prestop` on the same port. The operator's pre-stop hook calls it before `SIGTERM`. The built-in facade holds the request for 5 seconds so endpoint removal can propagate.

## Serve the management-plane twin

Set `managementPlane: false` to skip this step. The operator then allocates no `facade-mgmt` Service port and no `status.managementEndpoints` entry, and the dashboard's "Try this agent" view does not work against the agent.

With `managementPlane: true`, the operator adds a ClusterIP-only `facade-mgmt` Service port for `18080` and publishes it as `status.managementEndpoints.ws`. Your container must:

1. Bind a second listener on port `18080`.
2. Accept only RS256 JWTs management-api mints for the dashboard, verified against the keys at `OMNIA_MGMT_PLANE_JWKS_URL`. Fetch keys on demand and refetch when a key ID is unknown. The built-in facade also requires the `aud` claim `omnia-facade` and rejects a token whose `agent` claim names another agent.
3. Reject every request without a valid token.
4. Serve the [WebSocket protocol](/reference/platform/websocket-protocol/) at `/ws` for the dashboard's debug view, which management-api relays as a WebSocket client.

`expose.enabled: true` creates a host-based HTTPRoute to the agent's Service on the facade port when the platform has a default-exposure Gateway. Exposure adds no authentication.

## Verify

1. Apply the AgentRuntime and check its phase.

   ```bash
   kubectl get agentruntime my-agent -o jsonpath='{.status.phase}{"\n"}'
   ```

   The phase is `Running`.

2. Check the pod and the facade log.

   ```bash
   kubectl get pods -l app.kubernetes.io/instance=my-agent
   kubectl logs deploy/my-agent -c facade
   ```

   Every container in the pod is Ready.

3. With the management plane on, read the published port.

   ```bash
   kubectl get agentruntime my-agent -o jsonpath='{.status.managementEndpoints}{"\n"}'
   ```

   The output contains `"ws":18080`.

4. Send a request through your protocol and confirm the reply.

## Troubleshooting

### The AgentRuntime is rejected at admission

The Enterprise webhook found no licence entitlement. Install a licence, or run Enterprise without a licence for dev mode. See [Install a licence](/how-to/operations/install-license/).

### The pod never becomes Ready

The container does not return `2xx` on `:8081/readyz`. Read the pod events with `kubectl describe pod`.

## Related pages

- [Facade and runtime protocol](/reference/platform/facade-runtime-protocol/): the gRPC messages and metadata contract.
- [AgentRuntime reference](/reference/core/agentruntime/): `facades[]` fields.
- [Configure agent authentication](/how-to/security/configure-authentication/): how the built-in facade authenticates.
- [Configure tool policies](/how-to/security/configure-tool-policies/): rules against the identity your facade sends.
- [Authoring a custom runtime](/how-to/security/authoring-a-custom-runtime/): the runtime counterpart.