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 server-side from the event's event_type, and for a lifecycle event from its payload as well (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. Attaching a capability to any event, restricted or not, routes it down the same verification path, so a capability is a stricter thing to send than nothing. An event with no capability rides through only when its classification is unrestricted and allow_display_without_capability is set. 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, and {"custom": "..."}, which is never classifiable |
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 |
Classification is derived, never accepted. The server recomputes it and blocks on any disagreement with what the caller sent, which is the whole reason the field is present on the wire at all:
pub(super) fn derive_server_classification(
event: &AgUiEvent,
) -> Result<EventClassification, String> {
match &event.event_type {
EventType::TextStream => Ok(EventClassification::Display),
EventType::StateUpdate => Ok(EventClassification::Mutate),
EventType::Navigation => Ok(EventClassification::Navigate),
EventType::Lifecycle => derive_lifecycle_classification(&event.payload),
EventType::FormAction => Ok(EventClassification::Submit),
EventType::Notification | EventType::Error => Ok(EventClassification::Alert),
EventType::Custom(name) => Err(format!(
"custom AG-UI event type cannot be server-classified: {name}"
)),
}
}Note the two arms that return Err. A custom event type cannot be classified, and every classification error becomes a block, so a custom event never rides through. A lifecycle event routes into a second function that reads the payload:
fn derive_lifecycle_classification(
payload: &serde_json::Value,
) -> Result<EventClassification, String> {
let action = payload
.get("action")
.or_else(|| payload.get("lifecycle"))
.or_else(|| payload.get("event"))
.and_then(serde_json::Value::as_str)
.map(str::to_ascii_lowercase);
match action.as_deref() {
Some("create" | "created" | "mount" | "mounted" | "open" | "opened") => {
Ok(EventClassification::Create)
}
Some("destroy" | "destroyed" | "unmount" | "unmounted" | "close" | "closed") => {
Ok(EventClassification::Destroy)
}
Some("update" | "updated" | "change" | "changed") => Ok(EventClassification::Mutate),
Some(other) => Err(format!(
"lifecycle AG-UI event action is not classifiable: {other}"
)),
None => Err("lifecycle AG-UI event missing classifiable action".to_string()),
}
}First, consider a read-only chat render. This is display, which is not in the default restricted set, so it rides through when allow_display_without_capability is set and no capability is attached:
{
"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. The payload has to carry the lifecycle verb under action, lifecycle, or event; without one of those keys the derivation fails and the event is blocked before the capability is looked at:
{
"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": {
"action": "create",
"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);
});
// There is no published TypeScript type for an AG-UI event; the shape is
// whatever your server deserializes into chio_ag_ui_proxy::event::AgUiEvent.
function sendToAgent(partial: unknown) {
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:
pub struct AgUiReceipt {
/// Unique receipt ID.
pub id: String,
/// Unix timestamp (seconds) when the receipt was created.
pub timestamp: u64,
/// The event ID that was evaluated.
pub event_id: String,
/// Agent that produced the event.
pub agent_id: String,
/// Session, if bound.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
/// Capability ID used for authorization.
pub capability_id: String,
/// Type of event.
pub event_type: EventType,
/// Target component.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target: Option<TargetComponent>,
/// Action classification.
pub classification: EventClassification,
/// Transport used.
pub transport: TransportKind,
/// Whether the event was allowed or denied.
pub allowed: bool,
/// Denial reason, if applicable.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub denial_reason: Option<String>,
/// SHA-256 hash of the event payload.
pub payload_hash: String,
/// Kernel public key for verification.
pub kernel_key: PublicKey,
/// Signature over the receipt body.
pub signature: Signature,
}Serialized, with the two hex fields and the payload digest standing in for values a run produces:
{
"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": "web_socket",
"allowed": true,
"payload_hash": "<sha256 of the canonical payload>",
"kernel_key": "<64 hex>",
"signature": "<128 hex>"
}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 the event was evaluated under, or"<none>"when none was attached. Blocked events get a receipt too, so<none>appears on both the display event that rode through and the restricted event that was refused for having no token.transportissseorweb_socketon the wire. The underscore is not a typo: the enum serializes underrename_all = "snake_case", while the type'sDisplayimpl printswebsocket, so a log line and a receipt field disagree. Match on the serialized form. 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 verification failed: token has expired". Capability failures all take the formcapability verification failed: <reason>, and the reason half is one of eight fixed strings.
An AgUiReceipt is its own artifact with its own schema id and its own verify, not a ChioReceipt. It is signed with Ed25519 like every other Chio artifact, so the same key material verifies it, but persisting it and joining it to MCP or ACP receipts is work your server does. For the cross-protocol receipt model and the verification procedure, 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 declared per-proxy ceiling, defaulting to 1000. The proxy does not read it, so rate limiting belongs in your transport rather than here.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 is not a config field. A rule like "only this capability may submit to the confirm-refund modal" 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.
Verify the result
There is no service to curl: the proxy is a library call, and evaluate decides from the event and the capability alone. The gates it enforces are pinned by the crate's own tests, and the four names below are the four outcomes a display event can have:
$ cargo test --release -p chio-ag-ui-proxy --lib \
-- --test-threads 1 proxy::tests::display_eventrunning 4 tests test proxy::tests::display_event_allowed_when_configured ... ok test proxy::tests::display_event_blocked_without_capability_by_default ... ok test proxy::tests::display_event_rejects_untrusted_capability_by_default ... ok test proxy::tests::display_event_requires_display_scope_when_capability_supplied ... ok test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 31 filtered out; finished in 0.00s
Read them as a policy statement. A display event is forwarded only when the config allows it and no capability is attached. With no capability and no allowance it is blocked. With an untrusted capability it is blocked. With a trusted capability that lacks display scope it is blocked. Attaching a capability never loosens the gate.
The re-derivation is pinned too: a caller cannot label a state_update as display to slip past the restricted set.
$ cargo test --release -p chio-ag-ui-proxy --lib \
-- --test-threads 1 classificationrunning 1 test test proxy::tests::state_update_cannot_downgrade_classification_to_display ... ok test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 34 filtered out; finished in 0.00s
When you wire the proxy into your own transport, mirror these four cases in your test suite against your own AgUiProxyConfig. The decision function is small enough to read in full:
fn decide(&self, event: &AgUiEvent, capability: Option<&CapabilityToken>) -> ProxyDecision {
let requires_capability = self
.config
.restricted_classifications
.contains(&event.classification);
if let Some(capability) = capability {
return self.decide_capability_bound_event(event, capability);
}
if self.config.allow_display_without_capability && !requires_capability {
return ProxyDecision::Forward;
}
let reason = if requires_capability {
format!("capability required for {:?} events", event.classification)
} else {
"no capability token provided".to_string()
};
ProxyDecision::Block { reason }
}Failures and recovery
| Symptom | Cause and recovery |
|---|---|
A lifecycle event is blocked with lifecycle AG-UI event missing classifiable action. | The payload carries no action, lifecycle, or event string. Add one. The derivation runs before the capability check, so no capability rescues this. |
A lifecycle event is blocked with lifecycle AG-UI event action is not classifiable. | The verb is present but outside the recognized set. Use one of create, mount, open, destroy, unmount, close, update, or change, in either tense. |
| A custom event type is always blocked. | Working as built. EventType::Custom has no server-side classification, so it can never be admitted. Map the behavior onto one of the seven typed kinds instead. |
| Adding a capability made a previously forwarded event start blocking. | Expected. Any attached capability routes the event through full verification and scope matching against the synthetic ag-ui server. Either grant the classification scope or send no capability for events you want to ride the display allowance. |
A block reads capability verification failed: issuer is not trusted. | The issuer key is not in trusted_issuers. Register it, or if the token is delegated, register the chain root in capability_trust_roots. |
| Event floods are not being throttled. | max_events_per_second is a declared value the proxy never reads. Enforce the ceiling in the transport. |
| Receipts are not showing up in the receipt store. | An AgUiReceipt is returned to your handler and nothing else. Persisting it is your code's job. |
Every capability failure reaches denial_reason as capability verification failed: followed by one of these:
pub(super) fn capability_error_message(error: &CapabilityError) -> &'static str {
match error {
CapabilityError::UntrustedIssuer => "issuer is not trusted",
CapabilityError::InvalidSignature => "signature did not verify",
CapabilityError::NotYetValid => "token is not yet valid",
CapabilityError::Expired => "token has expired",
CapabilityError::CryptoFloorRejected(_) => "capability crypto floor rejected",
CapabilityError::AttenuationViolation(_) => "capability rejected by chain binding",
CapabilityError::BudgetSplitRejected(_) => "capability rejected by sibling-sum budget",
CapabilityError::Internal(_) => "internal verification error",
}
}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.