Streaming
SSE frames state|tool|token|approval|question|done|error, keepalive comments, reconnecting.
Once you've sent a message (widget,
server-to-server, or
POST /v2/conversations/{id}/messages),
you have a run_id. Follow the answer live with:
GET https://api.harnix.vn/v2/runs/{run_id}/stream
Authorization: Bearer <token>(or X-API-Key: <key> for server-to-server). The response is a
Server-Sent Events
stream, each frame a data: <json> line ending with a blank line.
EventSource can't carry Authorization
The browser's EventSource doesn't support custom headers, so it can't
carry Bearer <token>. Use fetch and read the response's
ReadableStream yourself (example below), or an SSE client that supports
custom headers.
Frame types
type | data | When |
|---|---|---|
state | { "state": "running", "reason": "claimed" } | The run changed state. |
token | { "delta": "Hello" } | A chunk of text the model just generated. |
tool | { "call_id": "...", "name": "knowledge_search", "phase": "start" | "end", "is_error"?: true } | A tool started or finished. |
approval | { "run_id": "...", "expires_at": "...", "calls": [...] } | The agent wants to call a tool the end-user must confirm; the run is paused. Not a last frame. |
question | { "run_id": "...", "call_id": "...", "expires_at": "...", "questions": [...] } | The agent calls ask_user to ask the end-user questions; the run pauses in waiting_input. Not a last frame. |
done | { "result": {...}, "citations": [...] } | The run finished successfully. Always the last frame. |
error | { "message": "..." } | The run failed or was cancelled. Always the last frame. |
done.data.citations is always an array ([] when there are none) — the
same value as done.data.result.citations, just placed somewhere more
convenient for the UI. The error frame has no citations key at all.
The approval frame — an action awaiting confirmation
When the agent calls a connector tool that needs
confirmation (by default: every tool the server doesn't mark read-only),
the run pauses and the stream sends an approval frame:
{
"type": "approval",
"data": {
"run_id": "0e2b...",
"expires_at": "2026-10-09T08:05:00+00:00",
"calls": [
{
"call_id": "call_1",
"server_name": "Shop",
"name": "create_order",
"title": "Create order",
"arguments": { "sku": "A-100", "quantity": 2 }
}
]
}
}calls carries the real arguments the agent is about to send — that is
what the user approves or declines; title is the human-readable name when
the server declares one, server_name the connector name you chose in the
console. Show them to the user (the assistant's text just before is the
explanation), then answer with
POST /v2/runs/{run_id}/approval
before expires_at (5 minutes after the pause by default).
After an approval frame, the stream stays open: once a decision comes
in, the run resumes and the same connection carries the following state
frames (pending, then running), tool and token frames as usual. A
declined or expired tool ends with a tool frame with phase: "end" and
is_error: true; the assistant tells the user the action was not carried
out, and the run ends with done as normal.
The question frame — questions awaiting user input
When the agent calls the built-in ask_user tool to ask the
user questions, the run pauses in waiting_input and the stream sends a
question frame:
{
"type": "question",
"data": {
"run_id": "0e2b...",
"call_id": "call_1",
"expires_at": "2026-10-10T12:10:00+00:00",
"questions": [
{
"header": "Branch",
"question": "Which branch should prepare your order?",
"multi_select": false,
"options": [
{ "label": "District 1", "description": "Open until 22:00" },
{ "label": "District 3", "description": "Open until 21:00" }
]
}
]
}
}Each question contains a concise title (header, up to 12 characters), question
text (question, up to 500 characters), a multi-choice flag (multi_select),
and 2–4 options (options). Display them to the user, then answer via
POST /v2/runs/{run_id}/answer — or
simply send a regular follow-up text message into the conversation — before
expires_at (10 minutes after the pause by default).
Just like with approval, the stream stays open: once answered (or
expired), the run resumes and the same connection carries the following
state frames (pending, then running), tool (carrying the ask_user
tool result), token and done frames as usual.
Between frames, the server may send : keepalive comment lines (not a
frame — ignore any line starting with :) to keep the connection alive
through an intermediate proxy while the model is thinking.
Example
curl -N https://api.harnix.vn/v2/runs/0e2b.../stream \
-H "Authorization: Bearer $TOKEN"Reconnecting
A finished run returns its full content straight from Postgres — nothing is
lost whether you connect late or reconnect mid-run. If the connection drops
while the run is still going, call the same URL again: the server replays
every state/tool frame you missed (it doesn't replay old token deltas —
just the final content in the done frame), then continues streaming live.
An action still awaiting confirmation is replayed as an approval frame; a
question still awaiting input is replayed as a question frame; one that
has already been resolved is not — you see its outcome in the matching tool
frame instead.
Streaming temporarily unavailable
If the streaming infrastructure (Redis) is temporarily unreachable, the
endpoint returns 503 with code redis_unavailable. The run itself is
not affected — it keeps running and gets recorded in full. Read the
answer back with
GET /v2/conversations/{id} after
retrying the stream, or poll that conversation every few seconds.
Cancelling a running run
POST /v2/runs/{run_id}/cancel — see
Idempotency & cancel. A run that is
waiting for confirmation or waiting for user input can be cancelled too.
Next
- Conversations — read history back once a run
finishes, along with
pending_approvalandpending_questionwhen reopening a waiting conversation. - Errors —
run_not_found,run_not_waiting,redis_unavailable.