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
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.
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:
| Field | Meaning | Examples |
|---|---|---|
event_type | What the event semantically does. | text_stream, state_update, navigation, lifecycle, form_action, notification, error, custom("...") |
target.component_type | Which UI component the event targets. | chat-window, sidebar, modal, toast, form |
target.component_id | Instance identifier, if any. | main, confirm-delete |
classification | What the event does from a security lens. The primary policy hook. | display, mutate, navigate, create, destroy, submit, alert |
payload | Opaque 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_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_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_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
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:
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.
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:
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:
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
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:
{
"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, andtargetare the three axes an auditor uses to ask questions like "how manysubmitevents hit a modal in the last hour?"capability_idrecords which capability authorized the event, or"<none>"when a display-only event rode through without one.transportissseorwebsocket; cross-protocol joins with MCP, ACP, and A2A receipts usesession_idandagent_id.payload_hashis 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 tomutate,navigate,create,destroy,submit; addalertto gate notifications.allow_display_without_capability— when true, tokenlessdisplayevents ride through. Defaults to false.max_events_per_second— a coarse per-proxy flood ceiling. Defaults to 1000.trusted_issuersandrevoked_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, plusregister_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.