Chio/Docs

Proxy an AG-UI Event Stream

Check every agent-to-UI event against the session’s capability before your SSE or WebSocket transport forwards it.

Guide chio@5f63e6e

On this page

An agent that drives a user interface can stream text, open a form, ask the user to confirm a refund, or navigate the page. With the AG-UI proxy in your server, each of those events passes a capability check before your transport forwards it, and every decision leaves a signed receipt.

AG-UI, the Agent User Interaction Protocol, streams events such as text-message content and state updates; its event reference defines that wire format. Your host normalizes each upstream event into Chio’s AgUiEvent, a separate envelope for classifying UI effects, and keeps ownership of the connection.

Start

The proxy decides inside your transport, one event at a time

Your transport code calls proxy.evaluate(&event, capability, &mut transport) for every event it holds, then forwards or drops the event itself.

AG-UI event proxyChoose an outcome.

κcapabilityσscopegguards: not runBbudget: not run

Your transport forwards only what the proxy admits

Your transport hands each event and the session’s capability to proxy.evaluate. The proxy re-derives the classification, verifies the capability, matches its scope against the synthetic ag-ui tool server, and signs an AgUiReceipt; on Forward your transport writes the original frame to the UI client. AgUiReceipt.

One outbound AG-UI event from the agent through your transport to proxy.evaluate and on to the UI client. Inbound WebSocket messages, which take the same path, and receipt persistence in your host are left out.Receipts
  1. Re-derive. The proxy recomputes the EventClassification from the event’s event_type, and for a lifecycle event from its payload, then blocks on any mismatch with the classification the caller supplied.
  2. Check the capability. A classification in restricted_classifications needs a capability token that passes full verification. Any attached capability takes that same path, so attaching one is stricter than sending none. An event with no capability rides through only when its classification is unrestricted and allow_display_without_capability is set.
  3. Sign. The proxy 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.
  4. Return. It hands back a ProxyDecision, Forward or Block { reason }, with the receipt, and bumps the forwarded or blocked counter on your Transport.

SSE runs one way, from your server to the browser, so you evaluate outbound events. WebSocket is full duplex, so client-to-agent messages also pass through evaluate, as step 4 shows.

1. Add the proxy to your Rust server

The proxy is a Rust library: chio-ag-ui-proxy, configured through AgUiProxyConfig. Add it to the server that holds your agent’s event stream:

Terminalbash
cargo add chio-ag-ui-proxy --git https://github.com/backbay-labs/chio

You need an agent that emits AG-UI events and a client that subscribes over SSE or WebSocket. The wire format the client sees stays the same; the proxy adds a classification, a capability check, and a receipt per event.

2. Classify each event the way the server will

The UI stream reaches the user directly. A wrongly issued prompt can phish the user, a fabricated notification can trigger a support escalation, and unchecked navigation can send the user to an attacker’s page. Classification is how the proxy tells those effects apart, and it derives the classification itself:

event_typeDerived classificationRestricted by default
text_streamdisplayNo
state_updatemutateYes
navigationnavigateYes
lifecyclecreate, destroy, or mutate from the payload verbYes
form_actionsubmitYes
notification, erroralertNo
{"custom": "..."}None: always blockedNot applicable

You should see two consequences in the table. A custom event type has no server classification, and every classification error becomes a block, so a custom event never rides through. A lifecycle event carries its verb under action, lifecycle, or event in the payload; without one, derivation fails and the event is blocked before the capability is read. Add alert to restricted_classifications to gate notifications.

crates/protocol/chio-ag-ui-proxy/src/proxy/classify.rs3-17rust
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}"
        )),
    }
}
Lifecycle verbs and three normalized events
crates/protocol/chio-ag-ui-proxy/src/proxy/classify.rs19-42rust
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()),
    }
}

A read-only chat render classifies as display, outside the default restricted set, so it rides through with no capability when allow_display_without_capability is set:

event-text-stream.jsonjson
{
  "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."
  }
}

A component render that creates a form classifies as create, which is restricted, so the session capability must carry scope for it:

event-render-component.jsonjson
{
  "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"]
  }
}

A prompt asking the user to confirm a destructive action classifies as submit, because it solicits input; policy grants it a narrower capability than a plain text stream:

event-prompt-user.jsonjson
{
  "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?"
  }
}

3. Evaluate before each SSE frame

Your server owns the text/event-stream response and the open connection. Build the proxy from an AgUiProxyConfig, record the connection as a Transport of kind Sse, and evaluate each event before writing its frame:

server.rs (SSE handler)rust
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::try_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)?; // your fallible receipt journal; retain before forwarding
match decision {
    ProxyDecision::Forward => write_sse_frame(&upstream_event), // original wire message
    ProxyDecision::Block { reason } => tracing::warn!(%reason, "ag-ui event blocked"),
}

Here event is your host’s normalized record for upstream_event. Your host supplies the authenticated session, trusted issuer keys, signing key, fallible journal, and frame writer. Resolve the capability from that authenticated context, because an event payload cannot assign its own authority, and forward the same upstream message whose normalized effect you checked.

The browser subscribes to your server’s SSE endpoint, unchanged:

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

es.addEventListener("message", (ev) => {
  const event = JSON.parse(ev.data);
  // The host forwards the original event after evaluating its normalized form.
  render(event);
});

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

You should see a blocked event stay off the stream. Treat an evaluation or journal error as a refusal to forward. An invalid event identity or a signing failure returns an error with no receipt, so retain those errors in your host’s operational log.

4. Evaluate both directions on a WebSocket

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 can neither claim capabilities the session does not carry nor fabricate state that skips the capability check. Only the transport kind changes:

server.rs (WebSocket handler)rust
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)?;
client-ws.tstypescript
const ws = new WebSocket("wss://example.app/ag-ui?session=" + sessionId);

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

// The shape is whatever your server deserializes into
// chio_ag_ui_proxy::event::AgUiEvent.
function sendToAgent(partial: unknown) {
  ws.send(JSON.stringify(partial));
}

Make the proxy the only path between the client and the agent. A separate WebSocket endpoint on the agent, kept for convenience or debugging, lets a client skip the capability check, so close it or place the agent behind a network boundary that routes all traffic through your server.

5. Prove the gates, then read the receipt

evaluate decides from the event and the capability alone, so the crate’s own tests pin its gates. The four display-event tests are the four outcomes a display event can have:

ag-ui-proxy · display-gatestranscript
$ cargo test --release -p chio-ag-ui-proxy --lib \
    -- --test-threads 1 proxy::tests::display_event

running 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
exit 0in Chio source checkout

You should see a display event forwarded only when the configuration allows it and no capability is attached. With no capability and no allowance, with an untrusted capability, or with a trusted capability that lacks display scope, it is blocked: attaching a capability never loosens the gate. The re-derivation is pinned too, so a caller cannot label a state_update as display to slip past the restricted set:

ag-ui-proxy · classificationtranscript
$ cargo test --release -p chio-ag-ui-proxy --lib \
    -- --test-threads 1 classification

running 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
exit 0in Chio source checkout

Mirror these cases in your own suite against your AgUiProxyConfig. Each evaluation returns an AgUiReceipt with schema chio.ag-ui-receipt.v1; this one records the refund confirmation above, allowed over a WebSocket:

ag-ui-receipt.jsonjson
{
  "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>"
}

You should see the payload as a hash, never the payload itself, and an Ed25519 signature over the whole body. classification, event_type, and target are the axes an auditor queries, for example how many submit events reached a modal in the last hour.

AG-UI proxy reference

AgUiEvent fields

Your host deserializes or constructs a typed event from the chio-ag-ui-proxy::event module before evaluation.

FieldMeaningExamples
event_type
What the event semantically does
text_stream, state_update, navigation, lifecycle, form_action, notification, error, {"custom": "..."}
target.component_type
The UI component targeted
chat-window, sidebar, modal, toast, form
target.component_id
Instance identifier, if any
main, confirm-delete
classification
The effect, as the primary policy hook
display, mutate, navigate, create, destroy, submit, alert
payload
JSON hashed for the receipt; lifecycle reads its verb
Framework-specific

AgUiReceipt

A successful evaluation returns an AgUiReceipt (schema chio.ag-ui-receipt.v1). It records the proxy input and decision, hashes the payload, and carries an Ed25519 signature over the whole body:

crates/protocol/chio-ag-ui-proxy/src/receipt.rs19-53rust
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,
}
  • capability_id names the capability the event was evaluated under, or "<none>" when none was attached. Blocked events get a receipt too, so <none> appears on a display event that rode through and on a restricted event refused for having no token.
  • transport is sse or web_socket on the wire: the enum serializes under rename_all = "snake_case", while its Display impl prints websocket, so a log line and a receipt field differ. Match on the serialized form. 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 payload’s canonical JSON. The receipt stores the digest only, and an auditor checks it by recomputing from their own copy.
  • denial_reason, omitted when allowed, explains a block, for example "capability required for Submit events" or "capability verification failed: token has expired". Capability failures read capability verification failed: <reason>, where the reason is one of eight fixed strings.

verify_embedded_signature checks the embedded Ed25519 signature, and verify_with_trusted_kernel_keys also checks independently selected signer keys. That report identifies a trace observation with authorized: false: it attests the proxy’s decision, while delivery, browser rendering, and tool authority are established elsewhere. Metadata and the digest still fall under your application’s disclosure policy, and retention and cross-protocol correlation belong to your host. See Receipts.

AgUiProxyConfig

The proxy is configured in Rust through AgUiProxyConfig, separately from the kernel’s HushSpec policy.

  • 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: the declared per-proxy event ceiling, default 1000, for your transport to enforce.
  • trusted_issuers and revoked_capability_ids: the issuer public keys a capability may chain to, and a revocation set consulted on every capability-bearing event.
  • capability_trust_roots, with register_parent_budget and register_admitted_child_budget: chain-binding trust roots and the sibling-sum delegation budgets seeded for delegated capabilities.

Fine-grained targeting comes from the capability token’s own scope grants. A rule such as "only this capability may submit to the confirm-refund modal" is a grant the proxy matches against a synthetic ag-ui tool server, bound to the event’s event_id, session_id, and target component, so a grant scoped to one session cannot be replayed against another.

The decision function

crates/protocol/chio-ag-ui-proxy/src/proxy/core.rs156-176rust
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 }
}

Blocked events and their causes

  • lifecycle AG-UI event missing classifiable action. The payload carries no action, lifecycle, or event string. Add one; derivation runs before the capability check, so a capability cannot rescue this event.
  • lifecycle AG-UI event action is not classifiable. The verb is outside the recognized set. Use create, mount, open, destroy, unmount, close, update, or change, in either tense.
  • A custom event type is always blocked. EventType::Custom has no server-side classification. Map the behavior onto one of the seven typed kinds.
  • Adding a capability made a forwarded event start blocking. An attached capability routes the event through full verification and scope matching against the synthetic ag-ui server. Grant the classification’s scope, or send no capability for events that ride the display allowance.
  • capability verification failed: issuer is not trusted. The issuer key is missing from trusted_issuers. Register it, or for a delegated token register the chain root in capability_trust_roots.
  • Event floods reach the client. Enforce max_events_per_second in the transport.
  • Receipts are missing from the receipt store. The proxy returns each AgUiReceipt to your handler, and your host persists it.

Every capability failure reaches denial_reason as capability verification failed: followed by one of these:

crates/protocol/chio-ag-ui-proxy/src/proxy/helpers.rs6-17rust
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: join UI receipts with your other protocols