Chio/Docs

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

The AG-UI proxy crate (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.

rendering…
Every agent-to-UI event passes through chio before it can render. Classification, capability check, and a signed receipt happen per event.

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:

FieldMeaningExamples
event_typeWhat the event semantically does.text_stream, state_update, navigation, lifecycle, form_action, notification, error, custom("...")
target.component_typeWhich UI surface 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

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-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 and is restricted by default; a navigation event classifies as navigate and should almost always require an explicit capability.

Why UI-side governance matters

The agent-to-UI stream is where an unbounded agent directly becomes a user-facing incident. A wrongly issued prompt can phish the user, a fabricated notification can cause a support escalation, an unchecked navigation can send the user to an attacker-controlled page. Every other edge surface is infrastructure; this one is the eyes of the person using your product.

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:

ag-ui-sse-policy.yaml
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
      - toast

A minimal browser receiver is unchanged; the client subscribes to the proxy endpoint exactly as it would to the agent:

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 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.

ag-ui-ws-policy.yaml
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
      - toast

The browser client is again largely 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 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:

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.


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.

policy-component-allowlist.yaml
ag_ui_components:
  enabled: true
  allow_ids:
    - chat-window:main
    - sidebar:agent-status
    - toast:default
  default: block

Deny 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:

policy-submit-scope.yaml
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:

policy-notification-rate.yaml
ag_ui:
  enabled: true
  max_events_per_second: 50

velocity:
  enabled: true
  scope: session
  match:
    event_type: notification
  max_invocations: 20
  window_seconds: 300

For 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.
Proxy an AG-UI Event Stream · Chio Docs