Proxy an AG-UI Event Stream
When an agent streams UI events to a browser client, every event is a thing the agent is telling your user. Toolbars light up, components render, forms appear, notifications fire. That surface is the most user-visible thing your agent does, and it is exactly where a bad event becomes an incident. The AG-UI proxy sits between the agent and the UI client, classifies each event by type, target component, and action, checks it against a capability scope, and emits a signed receipt. Transports are SSE for unidirectional streams and WebSocket for bidirectional channels.
Adapter status: early
chio-ag-ui-proxy) implements event classification, capability-token time-bound validation, signed receipts with schema chio.ag-ui-receipt.v1, and both transport kinds as typed enums with forwarded and blocked counters. It does not yet ship as a standalone chio subcommand. This guide describes the adapter surface and how a policy expresses the classifications the proxy recognizes. Where a flag is planned rather than shipped today, this page labels it.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
Four steps per event. Intercept: the proxy terminates the transport server-side, so UI clients subscribe to the proxy rather than the agent directly. Classify: each event is parsed into an AgUiEvent with an event_type, an optional target (component type plus optional component id), and an EventClassification (display, mutate, navigate, create, destroy, submit, alert). Capability-check: if the classification is in restricted_classifications, a time-bound capability token must be present; otherwise the event rides through if allow_display_without_capability is set or a capability is attached. Sign: the proxy builds an AgUiReceipt that records the event id, classification, target, transport, capability id, and a SHA-256 hash of the payload, signs it with the kernel's Ed25519 key, then either forwards the event or increments the blocked counter.
The two transports differ in one important respect: SSE is a one-way pipe from the server to the client, so the proxy only has to enforce on the outbound side. WebSocket is full-duplex: messages also flow from the client back toward the agent. For WebSocket, the proxy sits on both directions and applies the same classification machinery to client-to-agent messages so client-side events cannot impersonate capabilities the session does not carry.
Event Classification
The proxy parses each event into a typed shape before any decision is made. The exposed types, lifted 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 surface 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 |
Three concrete examples. First, 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 and is restricted by default; a navigation event classifies as navigate and should almost always require an explicit capability.
Why UI-side governance matters
SSE Transport
Server-Sent Events is the simpler transport: one unidirectional stream from the proxy to the browser. The proxy terminates the client HTTP request, keeps it open, and pushes classified events as text/event-stream frames. Configure with SSE as the transport kind and a policy that lists the restricted classifications; the config mirrors AgUiProxyConfig:
hushspec: "0.1.0"
name: ag-ui-sse
rules:
ag_ui:
enabled: true
transport: sse
# Display-only events can render without a capability token.
# Useful when your agent streams plain text into a fixed chat pane.
allow_display_without_capability: true
# Any event with one of these classifications requires a capability
# in the session scope.
restricted_classifications:
- mutate
- navigate
- create
- destroy
- submit
- alert
# Short-circuit floods. The proxy counts events per connection.
max_events_per_second: 200
# Component-level allowlist. Events whose target.component_type is not
# listed are denied regardless of classification.
ag_ui_components:
enabled: true
allow_components:
- chat-window
- sidebar
- toastA minimal browser receiver is unchanged; the client subscribes to the proxy endpoint exactly as it would to the agent:
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 classified, checked, and receipted. Blocked events are never delivered; they exist only in the receipt log.
WebSocket Transport
WebSocket is bidirectional, which changes the threat model. The proxy enforces on both directions: outbound events are classified and receipted as with SSE, and inbound client-to-agent messages run through the same EventClassification machinery so a compromised browser client cannot fabricate state that skips the capability check.
hushspec: "0.1.0"
name: ag-ui-ws
rules:
ag_ui:
enabled: true
transport: websocket
allow_display_without_capability: false
restricted_classifications:
- mutate
- navigate
- create
- destroy
- submit
- alert
max_events_per_second: 100
# With WebSocket, inbound client messages that carry tool-call-like
# payloads also flow through the classifier. Deny client-originated
# navigation or form submission unless the client side explicitly
# presents its own capability.
ag_ui_inbound:
enabled: true
default: block
allow_classifications:
- display
- mutate
ag_ui_components:
enabled: true
allow_components:
- chat-window
- form
- toastThe browser client is again largely 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 exactly what the proxy saw and decided, 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.
Policy Patterns
A handful of patterns cover most real UI surfaces. Start with the narrowest plausible scope and widen when a legitimate use case demands it.
Allowlist of component ids
The simplest guard: the agent may only render into known UI surfaces. Any event whose target.component_id does not appear in the allowlist is blocked with the denial reason unknown component id.
ag_ui_components:
enabled: true
allow_ids:
- chat-window:main
- sidebar:agent-status
- toast:default
default: blockDeny prompt_user except on narrow capability
Soliciting user input is a phishing vector. Default-deny any event with classification: submit and widen only via a capability scope that names the specific target:
ag_ui:
enabled: true
restricted_classifications:
- submit
- alert
- navigate
capability_scopes:
# Only capability tokens with this exact scope can authorize submit
# events targeting a confirm-refund modal.
- scope_id: "ui:submit:modal:confirm-refund"
allow_event_types: [form_action]
allow_targets:
- { component_type: modal, component_id: confirm-refund }Rate-limit notifications per session
Notifications are cheap for an agent to emit and expensive for a user to ignore. Use max_events_per_second for a coarse floor, and a velocity rule for per-session caps over a longer window:
ag_ui:
enabled: true
max_events_per_second: 50
velocity:
enabled: true
scope: session
match:
event_type: notification
max_invocations: 20
window_seconds: 300For the full policy grammar, see Write a Policy.
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, including the AG-UI rule block.
- Bridge Protocols · how AG-UI receipts join MCP, ACP, and A2A evidence in one audit surface.