Streaming
Render a live run in your own interface over AG-UI events.
Webhooks tell your backend a run finished; streaming is for the surface your customer is looking at while it runs. Every run exposes a server-sent-events stream that speaks AG-UI, the open protocol front-end agent toolkits already understand — so your interface renders a Levain run the same way it would render any other agent.
Open the stream
Start a run, then connect to its stream with the same org key and workspace header as the rest of the run surface:
RUN_ID=$(curl -s -X POST https://api.levainlabs.com/api/v1/agents/support-triage/runs \
-H "Authorization: Bearer $LEVAIN_ORG_KEY" \
-H "X-Workspace-Id: $CUSTOMER_WORKSPACE_ID" \
-H "Content-Type: application/json" \
-d '{"prompt": "Triage the tickets that arrived overnight."}' | jq -r .id)
curl -N "https://api.levainlabs.com/api/v1/runs/$RUN_ID/events/stream" \
-H "Authorization: Bearer $LEVAIN_ORG_KEY" \
-H "X-Workspace-Id: $CUSTOMER_WORKSPACE_ID"Each SSE frame's data is one AG-UI event as JSON. Every connection is a
complete AG-UI stream: it opens with RUN_STARTED (held until the run leaves
pending), carries the run's timeline, and closes with RUN_FINISHED or
RUN_ERROR, after which the stream ends. Connect any time — a run already
underway replays its stored timeline first, so a late subscriber renders the
same transcript as a live one, and a finished run replays and closes.
Event mapping
| Stream event | Fires for | Payload |
|---|---|---|
RUN_STARTED | Run left pending | threadId (the session id), runId |
TEXT_MESSAGE_START → TEXT_MESSAGE_CONTENT → TEXT_MESSAGE_END | An agent message | messageId, role: "assistant"; the full text in one delta |
TOOL_CALL_START → TOOL_CALL_ARGS → TOOL_CALL_END | A tool invocation | toolCallId, toolCallName; arguments JSON in one delta |
TOOL_CALL_RESULT | A tool result | toolCallId, content, role: "tool" |
CUSTOM | Progress events | name (status_pulse, status_node, sandbox_event, custom), value with the event's payload |
RUN_FINISHED | Run succeeded | threadId, runId |
RUN_ERROR | Run failed or cancelled | message, code (failed or cancelled) |
Every event carries a timestamp (milliseconds since epoch). RUN_STARTED
appears exactly once per connection and terminal events exactly once per run, so
strict AG-UI clients validate the stream as-is. The input side of the
conversation is yours already — user messages are not echoed back.
Runs of fleet agents stream the reduced timeline their session surface exposes: messages, lifecycle, and status text, without execution internals.
Resume after a dropped connection
Timeline frames carry an SSE id. On reconnect, send the standard
Last-Event-ID header — the browser EventSource does this automatically — or
pass ?since=<id> and the stream resumes after that event instead of replaying
the whole run. Connections are also capped at one hour server-side; resume the
same way. Comment frames (: keep-alive) tick every 15 seconds while the run is
quiet, so idle proxies keep the connection open.
Render it
The stream works with anything that consumes AG-UI events — the
@ag-ui/client toolchain and its
framework integrations, or a few lines of EventSource:
const stream = new EventSource(
`/your-backend/levain/runs/${runId}/stream`, // proxy that adds the org key
)
stream.onmessage = ({ data }) => {
const event = JSON.parse(data)
switch (event.type) {
case "TEXT_MESSAGE_CONTENT":
appendToTranscript(event.messageId, event.delta)
break
case "CUSTOM":
if (event.name === "status_pulse") showStatus(event.value.text)
break
case "RUN_FINISHED":
case "RUN_ERROR":
finishRun(event)
stream.close()
break
}
}Keep the org key on your backend: terminate the customer's connection in your
product and proxy the stream, exactly as you would any other org-authenticated
call. Reconnection and Last-Event-ID pass through such a proxy unchanged.
Streaming covers the run in progress; for durable completion signals that survive nobody watching, pair it with webhooks.