Chio/Docs

BuildConnect

Proxy an AG-UI Event Stream

Check each AG-UI event against a capability scope and return a signed forward-or-block decision to the transport.

A library, not a server

The AG-UI proxy crate (chio-ag-ui-proxy) implements event classification, capability verification, and signed receipts with schema chio.ag-ui-receipt.v1, and exposes Sse / WebSocket as typed TransportKind values with forwarded and blocked counters. It does not implement SSE or WebSocket transport itself, and it is not a chio subcommand. The caller owns the connection; the proxy is configured programmatically through AgUiProxyConfig. This guide documents that API.

Prerequisites

  • Chio CLI installed. If not, see the Installation guide.
  • An agent that emits AG-UI events. This can be a CopilotKit-style runtime, a LangChain GenUI app, or any agent that serializes per-event messages to a client in the AG-UI shape described below.
  • A client subscribing over SSE or WebSocket. The proxy does not change the wire format visible to the client; it only adds classification, a capability check, and a receipt per event.

What AG-UI Is, Briefly

AG-UI is shorthand for protocols that stream structured events from an agent to a UI client so the browser can render in response to model reasoning. CopilotKit, LangChain's Generative UI, and similar frameworks all solve the same problem: the agent decides what to render, and the browser reacts. Each defines a per-event envelope (text streamed, component rendered, form prompted, notification fired) for the client to interpret.

This page is not an AG-UI tutorial. It describes how Chio mediates such a stream: the proxy reads the events your agent already emits, normalizes them into the AgUiEvent shape below, decides whether each is allowed given the session's capability, and records the decision as a signed receipt. If you are new to AG-UI itself, read the event format of whichever upstream framework (CopilotKit, LangChain GenUI, or similar) drives your client.


How the Proxy Works

Your transport code calls proxy.evaluate(&event, capability, &mut transport) for every event it holds, and the proxy does four things. Re-derive: it recomputes the EventClassification from the event's event_type server-side (display, mutate, navigate, create, destroy, submit, alert) and blocks outright on any mismatch with the classification the caller supplied. Capability-check: if the derived classification is in restricted_classifications, a capability token must be present and pass full verification; otherwise the event rides through when allow_display_without_capability is set or a capability is attached. Sign: it builds an AgUiReceipt recording the event id, classification, target, transport, capability id, and a SHA-256 hash of the payload, signed with the kernel's Ed25519 key. Return: it hands back a ProxyDecision (Forward or Block { reason }) and the receipt, and bumps the forwarded or blocked counter on your Transport. You forward or drop the event yourself.

rendering…
Your transport code calls proxy.evaluate() per event. The proxy re-derives the classification, checks the capability, and signs a receipt; your code forwards or drops based on the decision.

The two transports differ in one important respect: SSE is a one-way pipe from the server to the client, so you only call evaluate on the outbound side. WebSocket is full-duplex: messages also flow from the client back toward the agent, so you run those inbound client-to-agent events through evaluate too, and a compromised client cannot impersonate capabilities the session does not carry. In both cases the transport is yours; the proxy only re-derives, checks, and signs.


Event Classification

The proxy parses each event into a typed value before making a decision. The exposed types are from the chio-ag-ui-proxy::event module:

FieldMeaningExamples
event_typeWhat the event semantically does.text_stream, state_update, navigation, lifecycle, form_action, notification, error, custom("...")
target.component_typeWhich UI component the event targets.chat-window, sidebar, modal, toast, form
target.component_idInstance identifier, if any.main, confirm-delete
classificationWhat the event does from a security lens. The primary policy hook.display, mutate, navigate, create, destroy, submit, alert
payloadOpaque JSON. Hashed for the receipt; not interpreted by the proxy.Framework-specific

First, consider a read-only chat render. This is display and by default is blocked unless a capability is present or allow_display_without_capability is enabled:

event-text-stream.json
{
  "event_id": "evt_01HZ8A1",
  "timestamp": 1744993921,
  "agent_id": "agent-support",
  "session_id": "sess_01HZ...",
  "event_type": "text_stream",
  "target": {
    "component_type": "chat-window",
    "component_id": "main"
  },
  "classification": "display",
  "payload": {
    "text": "I'll check your order status now."
  }
}

Second, a component-render event that creates a form. The classification is create, which is in the default restricted set, so the session capability must carry scope for it:

event-render-component.json
{
  "event_id": "evt_01HZ8A2",
  "timestamp": 1744993923,
  "agent_id": "agent-support",
  "session_id": "sess_01HZ...",
  "event_type": "lifecycle",
  "target": {
    "component_type": "form",
    "component_id": "refund-request"
  },
  "classification": "create",
  "payload": {
    "fields": ["order_id", "reason", "amount"]
  }
}

Third, a prompt-user event asking the user to confirm a destructive action. This is submit because it solicits input; policy will want a narrower capability than a plain text stream:

event-prompt-user.json
{
  "event_id": "evt_01HZ8A3",
  "timestamp": 1744993928,
  "agent_id": "agent-support",
  "session_id": "sess_01HZ...",
  "event_type": "form_action",
  "target": {
    "component_type": "modal",
    "component_id": "confirm-refund"
  },
  "classification": "submit",
  "payload": {
    "prompt": "Issue $42.50 refund to order #1024?"
  }
}

A show_notification-style event classifies as alert, which is not in the default restricted set — add alert to restricted_classifications if you want it gated. A navigation event classifies as navigate, which is restricted by default and requires a capability.

Why UI-side governance matters

The agent-to-UI stream can directly affect the user. A wrongly issued prompt can phish the user, a fabricated notification can cause a support escalation, and unchecked navigation can send the user to an attacker-controlled page.

SSE Transport

Server-Sent Events is the simpler case: one unidirectional stream from your server to the browser. Your server owns the text/event-stream response and the open connection. Before writing each frame, evaluate the event and forward only on Forward. Build the proxy with an AgUiProxyConfig and record the connection as a Transport of kind Sse:

server.rs
use chio_ag_ui_proxy::{
    AgUiProxy, AgUiProxyConfig, EventClassification, ProxyDecision, Transport, TransportKind,
};

let config = AgUiProxyConfig {
    // Display rides through without a token; the five restricted
    // classifications below require a verified capability in the session.
    allow_display_without_capability: true,
    restricted_classifications: vec![
        EventClassification::Mutate,
        EventClassification::Navigate,
        EventClassification::Create,
        EventClassification::Destroy,
        EventClassification::Submit,
    ],
    max_events_per_second: 200,
    trusted_issuers: issuer_keys, // capability-issuer public keys
    ..AgUiProxyConfig::default()
};

let proxy = AgUiProxy::new(config, kernel_keypair);

// Your transport code owns the connection. Record it once, then evaluate
// every event before writing an SSE frame.
let mut transport =
    Transport::new(TransportKind::Sse, "conn-01".into(), "agent-support".into());

let (decision, receipt) = proxy.evaluate(&event, capability.as_ref(), &mut transport)?;
persist(receipt);
match decision {
    ProxyDecision::Forward => write_sse_frame(&event), // your code
    ProxyDecision::Block { reason } => tracing::warn!(%reason, "ag-ui event blocked"),
}

The browser receiver is ordinary and unchanged: it subscribes to your server's SSE endpoint, not to any endpoint the proxy owns.

client-sse.ts
const es = new EventSource("/ag-ui/stream?session=" + sessionId);

es.addEventListener("message", (ev) => {
  const event = JSON.parse(ev.data);
  // event is an AgUiEvent shape; your renderer is unchanged.
  render(event);
});

es.addEventListener("error", (err) => {
  console.warn("ag-ui stream closed", err);
});

Every event the client sees has already been re-derived, checked, and receipted. Blocked events are never written to the stream; they exist only in the receipt log.


WebSocket Transport

WebSocket is bidirectional, which changes the threat model. You call evaluate on both directions: outbound events are re-derived and receipted as with SSE, and inbound client-to-agent messages run through the same evaluate path so a compromised browser client cannot fabricate state that skips the capability check. The only change from the SSE setup is the transport kind:

server.rs
let mut transport =
    Transport::new(TransportKind::WebSocket, "conn-01".into(), "agent-support".into());

// Outbound (agent -> client) and inbound (client -> agent) events both flow
// through evaluate before you relay them.
let (decision, receipt) = proxy.evaluate(&event, capability.as_ref(), &mut transport)?;

The browser client remains unchanged:

client-ws.ts
const ws = new WebSocket("wss://example.app/ag-ui?session=" + sessionId);

ws.addEventListener("message", (ev) => {
  const event = JSON.parse(ev.data);
  render(event);
});

function sendToAgent(partial: Partial<AgUiEvent>) {
  ws.send(JSON.stringify(partial));
}

Do not let the client talk to the agent directly

The WebSocket proxy only protects you if it is the only path between the client and the agent. If you run the proxy and also expose a separate WebSocket endpoint on the agent for convenience or debugging, a client can skip the proxy and defeat the capability check entirely. Close the direct path, or place the agent behind a network boundary that forces all traffic through chio.

Receipt Shape

Every event decision produces an AgUiReceipt (schema chio.ag-ui-receipt.v1). The receipt records the proxy input and decision, hashes the event payload with SHA-256 so receipts are safe to publish without leaking user content, and carries an Ed25519 signature over the whole body:

ag-ui-receipt.json
{
  "id": "agui-evt_01HZ8A3",
  "timestamp": 1744993928,
  "event_id": "evt_01HZ8A3",
  "agent_id": "agent-support",
  "session_id": "sess_01HZ...",
  "capability_id": "cap-ui-confirm-01HZ...",
  "event_type": "form_action",
  "target": {
    "component_type": "modal",
    "component_id": "confirm-refund"
  },
  "classification": "submit",
  "transport": "websocket",
  "allowed": true,
  "payload_hash": "8f3b2a4e9c1d0f7b6a5e2c3d4f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a",
  "kernel_key": "ed25519:4f8d...",
  "signature": "ed25519:a3b4c5d6..."
}

Fields worth calling out:

  • classification, event_type, and target are the three axes an auditor uses to ask questions like "how many submit events hit a modal in the last hour?"
  • capability_id records which capability authorized the event, or "<none>" when a display-only event rode through without one.
  • transport is sse or websocket; cross-protocol joins with MCP, ACP, and A2A receipts use session_id and agent_id.
  • payload_hash is the SHA-256 hex digest of the canonical JSON of the payload. The payload itself is never stored; auditors verify hash match by recomputing from their own copy.
  • denial_reason (omitted when allowed) explains why an event was blocked, e.g. "capability required for Submit events" or "capability time validation failed: expired".

AG-UI receipts land in the same receipt store as every other Chio receipt and verify with the same Ed25519 key material. For the cross-protocol receipt model, verification procedure, and how AG-UI receipts fit alongside MCP and ACP receipts, see the Receipts concept page.


Configuring the Proxy

The proxy is configured programmatically through AgUiProxyConfig, not a HushSpec rule block. The knobs that shape enforcement:

  • restricted_classifications — the classifications that require a capability. Defaults to mutate, navigate, create, destroy, submit; add alert to gate notifications.
  • allow_display_without_capability — when true, tokenless display events ride through. Defaults to false.
  • max_events_per_second — a coarse per-proxy flood ceiling. Defaults to 1000.
  • trusted_issuers and revoked_capability_ids — the issuer public keys a capability may chain to, and an explicit revocation set consulted on every capability-bearing event.
  • capability_trust_roots, plus register_parent_budget / register_admitted_child_budget — chain-binding trust roots and the sibling-sum delegation budgets seeded for delegated capabilities.

Fine-grained targeting — "only this capability may submit to the confirm-refund modal" — is not a config field. It comes from the capability token's own scope grants, which the proxy matches against a synthetic ag-ui tool server and binds to the event's event_id, session_id, and target component, so a grant scoped to one session cannot be replayed against another.


Next Steps

  • Architecture · where the AG-UI proxy sits relative to the MCP, ACP, and A2A adapters.
  • Guards · the broader guard model that the AG-UI classification hooks into.
  • Write a Policy · authoring HushSpec rules for the kernel-side guard pipeline (the AG-UI proxy itself is configured in Rust via AgUiProxyConfig).
  • Bridge Protocols · how AG-UI receipts join MCP, ACP, and A2A evidence in one receipt log.