Agent WebSocket
Receive real-time agent response streaming via a persistent WebSocket connection.
Connection URL
wss://socket.wiro.ai/v1
Connect to this URL after calling the Message / Send endpoint. Use the agenttoken from the send response to subscribe to the agent session. This is the same WebSocket server used for model tasks — you can subscribe to both task events (task_info) and agent events (agent_info) on the same connection.
No API key or auth header is required on the WebSocket itself. Authorization is enforced via the agenttoken, which is issued by POST /UserAgent/Message/Send against your API key and is scoped to a single message run. To follow a whole session instead, use the sessiontoken from Message/History; see Following a session.
Connection Flow
- Connect — open a WebSocket connection to
wss://socket.wiro.ai/v1. - Receive welcome — the server pushes a one-shot
connectedframe confirming the upgrade. - Subscribe — send an
agent_infoframe with youragenttoken. - Receive
agent_subscribed— the server acknowledges the subscribe and reports the current lifecycle status, accumulated answer text, and persistedtimeline[]. - Stream — listen for
agent_start,agent_timeline_deltablock updates, provisionalagent_outputanswer snapshots, thenagent_end/agent_error/agent_cancel. - Receive
agent_usage_report— after a successfulagent_end, a one-shot token-usage/billing frame arrives on the sameagenttoken(~250–500 ms later). Optional to consume; keep the socket open briefly if you need it. - Close — keep the socket open for
agent_usage_reportif needed. After completion or reconnect, readMessage/Detailfor the authoritative persisted timeline and answer.
1. Welcome frame (server → client)
Right after the WebSocket upgrade succeeds, the server sends this frame on its own. You don't request it; it arrives before you send anything.
{
"type": "connected",
"version": "1.0"
}
Use it as a signal that the socket is fully ready to receive a subscribe. Most clients can ignore the payload — the version field is informational.
2. Subscribe frame (client → server)
{
"type": "agent_info",
"agenttoken": "aB3xK9mR2pLqWzVn7tYhCd5sFgJkNb"
}
type— must be the literal string"agent_info". Other values are ignored (the server routes by type; unknown types are silently dropped).agenttoken— the token returned byPOST /UserAgent/Message/Send. Required. If missing or empty, the server responds with anerrorframe (see Subscribe errors).
The server keeps the mapping connection ↔ agenttoken in memory for the life of the connection. You can send additional agent_info frames on the same socket to subscribe to more tokens — see Multi-session subscription.
3. Subscribe errors
If agenttoken is missing or blank, the server replies with:
{
"type": "error",
"message": "agenttoken-required",
"result": false
}
The connection stays open — no disconnect. Fix the payload and resend. The same frame is used for any shape-level rejection of a subscribe; message routing failures (unknown types, internal exceptions) are silently dropped and produce no frame at all.
Multi-session subscription
A single WebSocket connection can hold subscriptions to multiple agent sessions simultaneously. Send one agent_info frame per token; the server de-duplicates, so resending the same token is a no-op.
WS connect → { "type": "connected", "version": "1.0" }
{ agent_info, token: A } → { agent_subscribed ... token: A }
{ agent_info, token: B } → { agent_subscribed ... token: B }
{ agent_info, token: A } → (no-op — already subscribed)
After this, every server-side event for either token is forwarded to this connection. All events carry the agenttoken field, so your handler can route them back to the right UI surface.
Typical use cases:
- Multi-tab chat clients that keep several live conversations.
- Dashboards watching several users' agents in parallel.
- Combining task streaming and agent streaming on the same connection —
task_infoandagent_infoframes both map to the same connection's token list (tasks undertaskTokens, agents underagentTokens).
There is no unsubscribe frame for agent_info tokens. To stop listening to a token without reconnecting, ignore its events client-side; tokens are cleaned up automatically when the connection closes.
Event Types
Frames flow in two directions. ↓ = server → client, ↑ = client → server.
| Direction | Event Type | Description |
|---|---|---|
| ↓ | connected |
Welcome frame pushed by the server right after the WebSocket upgrade. Fires exactly once per connection. |
| ↑ | agent_info |
Client-initiated subscribe frame. Carries the agenttoken issued by Message/Send. Can be sent multiple times on the same socket (one per token). |
| ↓ | error |
Server-side rejection of a malformed subscribe (missing agenttoken, or a malformed sessiontoken). Connection stays open; retry with a valid frame. |
| ↓ | agent_subscribed |
Subscribe acknowledged. Carries the current lifecycle status, accumulated debugoutput, messageguid, and persisted timeline[]. If the agent already finished before you subscribed, this frame is your reconnect snapshot. |
| ↓ | agent_start |
The bridge has opened an SSE stream to the agent container. The underlying model is now generating. Emits exactly once per message. |
| ↓ | agent_timeline_delta |
One ordered public timeline-block update. Carries messageguid and one block; merge by blockid and that block's version, then sort by callindex, contentindex, and blockid. |
| ↓ | agent_output |
Provisional answer snapshot. Emits many times — each carries the full accumulated raw answer text so far plus real-time metrics (speed, elapsedTime, tokenCount, wordCount). Replace (don't append) the typing surface on each event. |
| ↓ | agent_end |
Terminal success event. Same payload shape as agent_output but contains the final complete text with total metrics. Emits at most once. |
| ↓ | agent_error |
Terminal failure event. message is either a sanitized string ("Agent is temporarily unavailable…" when an exception was caught) or a progressGenerate object (when the stream finished but content was a degenerate "..." / "Error: internal error"). Emits at most once. |
| ↓ | agent_cancel |
Terminal cancel event. Fires only when an already-active message is aborted mid-stream (via Message/Cancel or upstream abort). Cancels against a still-queued message do not broadcast this event — check Message/Detail for those. Emits at most once. |
| ↓ | agent_usage_report |
Post-terminal usage/billing frame. Fires once, ~250–500 ms after a successful agent_end, on the same agenttoken. Carries the final token counts, model, tokencost, and remainingcredits for the message identified by messageguid. Not emitted for replayed usage callbacks or for turns with no chat message (cron/hook turns). |
| ↓ | agent_wiroai_runtask |
Model-run discovery frame. Fires whenever the agent itself launches a Wiro model run during a turn (e.g. generating an image or video via int-wiro-aimodels). Broadcast on the session-key channel (not the per-turn agenttoken), carrying the new task's socketaccesstoken so you can subscribe to that run's task_* lifecycle and collect its outputs. See Model runs the agent triggers. |
| ↑ | agent_session_subscribe |
Follow a session with the sessiontoken from Message/History. See Following a session. |
| ↓ | agent_session_subscribed |
Session follow acknowledged. |
| ↓ | agent_message_inserted |
A message was added to a followed session between turns (a scheduled report, files sent after an answer, the result of background work). Carries the sessiontoken and the new row in message. |
| ↑ | agent_session_unsubscribe |
Stop following a session. No reply. |
Message Format
Every WebSocket frame is a JSON object. Agent stream frames (agent_start / agent_timeline_delta / agent_output / agent_end / agent_error / agent_cancel) — plus the post-terminal agent_usage_report frame — share this base shape:
{
"type": "agent_output",
"agenttoken": "aB3xK9mR2pLqWzVn7tYhCd5sFgJkNb",
"message": { ... },
"result": true
}
| Field | Type | Description |
|---|---|---|
type |
string | Event name. See Event Types for the full list. |
agenttoken |
string | The token you subscribed with. Present on every agent lifecycle frame so multi-token subscribers can route the event to the right session. |
message |
varies | Empty string ("") for agent_start, { messageguid, block } for agent_timeline_delta, a progressGenerate answer object for agent_output / agent_end (and for the object-shaped agent_error), a plain string for string-shaped agent_error and agent_cancel, and a token-usage object for agent_usage_report. |
result |
boolean | true for success-side events (agent_subscribed / agent_start / agent_timeline_delta / agent_output / agent_end / agent_usage_report), false for failure-side events (agent_error / agent_cancel). See The result field. |
The control frames (connected, error) use a different shape with no agenttoken:
// Welcome — one per connection
{ "type": "connected", "version": "1.0" }
// Subscribe rejection — stays connected, just indicates the last agent_info was malformed
{ "type": "error", "message": "agenttoken-required", "result": false }
The agent_subscribed frame is a snapshot rather than a message wrapper. For a known token it carries status, debugoutput, messageguid, and timeline at the top level. When the token is unknown, status is "unknown" and those row-backed fields are omitted.
agent_subscribed
Sent immediately after the server accepts your subscription. The status field reflects where the agent currently is in its lifecycle.
- If the agenttoken is valid,
debugoutputis always present andtimelineis an array (possibly empty). - If the agenttoken is unknown (typo, expired, already cleaned up from the buffer),
debugoutputis omitted entirely from the payload (no field at all). Always use"debugoutput" in payloadorpayload.debugoutput !== undefinedto distinguish unknown-token from empty-output, rather than relying on truthiness.
Valid token, queued — debugoutput present and empty:
{
"type": "agent_subscribed",
"agenttoken": "aB3xK9mR2pLqWzVn7tYhCd5sFgJkNb",
"status": "agent_queue",
"debugoutput": "",
"messageguid": "c3d4e5f6-a7b8-9012-cdef-345678901234",
"timeline": [],
"result": true
}
Unknown token — status is "unknown" and no debugoutput field:
{
"type": "agent_subscribed",
"agenttoken": "wrongtoken123",
"status": "unknown",
"result": true
}
Possible status values:
| Status | Meaning |
|---|---|
agent_queue |
Message is queued, waiting for the agent to pick it up. |
agent_start |
Agent has started processing. |
agent_output |
Agent is actively streaming. debugoutput will contain accumulated text. |
agent_end |
Agent already finished. debugoutput contains the complete response. |
agent_error |
Agent encountered an error. debugoutput may contain partial output. |
agent_cancel |
Message was cancelled. debugoutput may contain partial output. |
agent_done |
Status of a model_change marker row — a zero-content separator inserted when the chat model is switched mid-session. It is only used for these markers; normal turns terminate at agent_end and never carry this status. See Agent Messaging for model_change markers. |
unknown |
Status could not be determined. Treat as an error. |
agent_start
Signals that the agent has begun generating a response. The message field is an empty string:
{
"type": "agent_start",
"agenttoken": "aB3xK9mR2pLqWzVn7tYhCd5sFgJkNb",
"message": "",
"result": true
}
agent_output (streaming)
Emitted multiple times as the agent generates its response. Each event contains the full accumulated text up to that point (not just the delta), along with real-time performance metrics:
{
"type": "agent_output",
"agenttoken": "aB3xK9mR2pLqWzVn7tYhCd5sFgJkNb",
"message": {
"type": "progressGenerate",
"task": "Generate",
"speed": "12.5",
"speedType": "words/s",
"elapsedTime": "2.4s",
"tokenCount": 35,
"wordCount": 28,
"raw": "Here is the accumulated response text so far...",
"answer": ["Here is the accumulated response text so far..."]
},
"result": true
}
The raw field contains the provisional cumulative SSE response as a single string. The answer array contains the same text split into segments. Replace your typing surface with raw (or joined answer) on each event; keep merging timeline updates independently until the corresponding answer block catches up.
agent_timeline_delta
Each event carries exactly one changed public block:
{
"type": "agent_timeline_delta",
"agenttoken": "aB3xK9mR2pLqWzVn7tYhCd5sFgJkNb",
"message": {
"messageguid": "c3d4e5f6-a7b8-9012-cdef-345678901234",
"block": {
"blockid": "c0:i0",
"callindex": 0,
"contentindex": 0,
"type": "reasoning",
"text": "I’ll compare the available evidence before making a recommendation.",
"toollabel": null,
"toolstatus": null,
"phase": "stream",
"version": 4,
"startedat": 1743350401,
"updatedat": 1743350404
}
},
"result": true
}
The hard-cutover merge contract is:
- Treat
blockidas an opaque deterministic identity. - Keep the incoming block only if there is no stored block with that ID, or its
versionis strictly greater than the stored version. - Replace only that block; a newer update for one block must never evict another block.
- Sort the merged values by
callindex ASC,contentindex ASC, thenblockid ASC.
version is per block, not global. A turn can therefore render Thinking → Answer or Tool → Thinking → Answer across multiple calls. reasoning blocks contain only provider-supplied OpenAI GPT-5 summaries, never raw chain-of-thought. answer blocks expose safe answer text. tool blocks expose only toollabel and toolstatus; their text is null, and tool IDs, arguments, and results are not public.
The complete public block shape is blockid, callindex, contentindex, type, text, toollabel, toolstatus, phase, version, startedat, and updatedat. phase is stream, end, or error; timestamps can be null when unavailable. No additional internal correlation or execution metadata is emitted in this block.
agent_end
Fires when the agent finishes responding. The structure is identical to agent_output — the message contains the final complete text with total metrics:
{
"type": "agent_end",
"agenttoken": "aB3xK9mR2pLqWzVn7tYhCd5sFgJkNb",
"message": {
"type": "progressGenerate",
"task": "Generate",
"speed": "14.2",
"speedType": "words/s",
"elapsedTime": "8.1s",
"tokenCount": 156,
"wordCount": 118,
"raw": "The complete agent response text...",
"answer": ["The complete agent response text..."]
},
"result": true
}
agent_error
An error occurred during processing. The message field can take two forms — a sanitized string when the stream is aborted by an exception, or a progress object when the stream finished naturally but the model returned a non-response.
Sanitized string error — any exception during streaming (bridge timeout, upstream HTTP 5xx, worker crash, SSE read error, etc.) surfaces as a single user-safe sentence:
{
"type": "agent_error",
"agenttoken": "aB3xK9mR2pLqWzVn7tYhCd5sFgJkNb",
"message": "Agent is temporarily unavailable. Please try again shortly.",
"result": false
}
The raw runtime error is never broadcast over WebSocket. Internal failure messages are recorded in
debugoutput(retrievable viaPOST /UserAgent/Message/Detail) but replaced with the generic sentence above before being pushed to subscribed clients. Log the rawdebugoutputfor your own debugging; show the sanitized string from the WebSocket event to end users.
Progress-object error — the SSE stream completes normally but the model returns "..." or "Error: internal error". In that case Wiro flags the message as agent_error and broadcasts the same progressGenerate shape as agent_output / agent_end, so the client can render the non-response to the user:
{
"type": "agent_error",
"agenttoken": "aB3xK9mR2pLqWzVn7tYhCd5sFgJkNb",
"message": {
"type": "progressGenerate",
"task": "Generate",
"speed": "2.5",
"speedType": "words/s",
"elapsedTime": "1.2s",
"tokenCount": 3,
"wordCount": 1,
"raw": "...",
"answer": ["..."]
},
"result": false
}
Check the runtime type of message to branch:
if (msg.type === 'agent_error') {
if (typeof msg.message === 'string') {
showToast(msg.message)
} else {
renderResponse(msg.message.raw)
}
}
agent_cancel
Sent when the user cancels a message before the agent completes its response (only when the abort hits the bridge mid-flight — queued-state cancels don't broadcast this event):
{
"type": "agent_cancel",
"agenttoken": "aB3xK9mR2pLqWzVn7tYhCd5sFgJkNb",
"message": "AbortError",
"result": false
}
The
messagefield carries the abort reason from the runtime (typically"AbortError"or a short technical string). It is not a fixed user-facing message — do not parse it for exact strings; usetype === "agent_cancel"as the signal. Subscribers that cancel from a queued state will receive no event at all (the message is simply markedagent_cancelin the database; check withPOST /UserAgent/Message/Detail).
agent_usage_report
A post-terminal frame that reports the final token usage and billing for the turn. It arrives shortly after agent_end (~250–500 ms later, once the turn's token usage has been metered and billed) on the same agenttoken-keyed channel as agent_start / agent_output / agent_end. A client already subscribed to the turn receives it automatically — no extra subscribe is needed.
{
"type": "agent_usage_report",
"agenttoken": "aB3xK9mR2pLqWzVn7tYhCd5sFgJkNb",
"result": true,
"message": {
"messageguid": "c3d4e5f6-a7b8-9012-cdef-345678901234",
"inputtokens": 3450,
"outputtokens": 820,
"cachereadtokens": 2400,
"cachewritetokens": 0,
"totaltokens": 6670,
"model": "openai/gpt-5.6-sol",
"tokencost": 5,
"processedms": 4200,
"remainingcredits": 9995
}
}
The frame's agenttoken identifies the turn; the message.messageguid identifies the exact assistant message to patch. result is always true.
| Field | Type | Description |
|---|---|---|
messageguid |
string | UUID of the assistant message this usage applies to. Match it to the message row you want to update. |
inputtokens |
number | Uncached prompt tokens billed at the model's input rate. |
outputtokens |
number | Completion tokens generated by the model. |
cachereadtokens |
number | Prompt tokens served from the provider's prompt cache (billed at the cache-read rate). |
cachewritetokens |
number | Prompt tokens written to the provider's prompt cache during this turn. |
totaltokens |
number | Exact sum of input, output, cache-read, and cache-write tokens for the turn. |
model |
string | Provider/model slug used for this turn (e.g. "openai/gpt-5.6-sol"). |
tokencost |
number | Credits charged for this turn. |
processedms |
number | Server-side processing time for the turn, in milliseconds. |
remainingcredits |
number | Your account credit balance after this turn was billed. |
Use it to patch the per-message token columns and the live credit balance without re-fetching Message/History: when the frame arrives, look up the row by messageguid and write the token counts, model, tokencost, and remainingcredits straight onto it.
Like agent_output, it is a live broadcast — it is not replayed to clients that subscribe after it has already fired. If you reconnect after the fact, read usage from POST /UserAgent/Message/Detail (or Message/History) instead.
It is not emitted for:
- Idempotent / replayed usage callbacks — a turn is billed once, and a replayed usage callback does not re-broadcast the frame.
- Turns with no chat message — e.g. cron- or hook-triggered turns that produce no assistant message row have no
messageguidto key on, so no frame is sent.
Model runs the agent triggers
When the agent itself kicks off a Wiro model run during a turn — for example generating an image or a video through the int-wiro-aimodels skill — the bridge emits an agent_wiroai_runtask discovery frame. It hands you the new model run's socketaccesstoken so you can subscribe to that run and stream its progress and final media outputs. This is how a chat product surfaces a "live activity feed" of the media the agent is producing.
{
"type": "agent_wiroai_runtask",
"agenttoken": "user-42",
"message": {
"taskid": 534574,
"socketaccesstoken": "eDcCm5yy7Z…",
"slugowner": "openai",
"slugproject": "gpt-image-1",
"categories": ["image"],
"status": "task_queue",
"response": { "result": true, "taskid": 534574, "socketaccesstoken": "eDcCm5yy7Z…" }
},
"result": true
}
| Field | Type | Description |
|---|---|---|
taskid |
number | The model run's task id. |
socketaccesstoken |
string | The run's task token. Subscribe to it (see below) to stream the model run's lifecycle and outputs. |
slugowner |
string | Model owner slug (e.g. "openai", "black-forest-labs"). |
slugproject |
string | Model slug (e.g. "gpt-image-1"). |
categories |
array<string> | The model's categories (e.g. ["image"], ["video"]). |
status |
string | Always "task_queue" at discovery — the run was just enqueued. |
response |
object | Convenience echo { result, taskid, socketaccesstoken }. |
This frame is keyed on the session, not the per-turn token. Unlike the lifecycle frames above, agent_wiroai_runtask is broadcast on the sessionkey you passed to Message/Send (the frame's agenttoken field carries that session key, not a per-turn token). To receive it, send a second subscribe frame with your session key as the token:
{ "type": "agent_info", "agenttoken": "user-42" }
A single socket can hold both subscriptions at once — the per-turn agenttoken (for the chat stream) and the sessionkey (for run discovery).
Then pivot to the task socket for outputs. Take message.socketaccesstoken and subscribe to the standard model-run WebSocket exactly as documented in WebSocket — send { "type": "task_info", "tasktoken": "<socketaccesstoken>" } and stream task_queue → task_start → task_output → task_postprocess_end. The final task_postprocess_end carries the output array (media URLs). The model-run task_* events are only delivered on the task's own socketaccesstoken channel — they are never re-broadcast on the agent channel.
Only fires for agent-initiated runs.
agent_wiroai_runtaskis emitted when the agent calls a model mid-turn. Model runs you launch yourself withPOST /Run(your ownx-api-key) do not produce this frame — you already hold theirsocketaccesstokenfrom the/Runresponse.
Following a session
Some messages are added to a session between turns: a scheduled report, files the agent sends after its answer, or the result of longer work it finished in the background. They belong to no turn, so they never stream on an agenttoken. To receive them as they are added, follow the session with the sessiontoken that Message/History returns for it:
{
"type": "agent_session_subscribe",
"sessiontoken": "Lp4vQ8sWn2KxT7cRb1YhZ6mDg9FjA3uEo5iNk0wXyHt"
}
The server acknowledges the follow:
{ "type": "agent_session_subscribed", "result": true }
Each message added to that session afterwards arrives as an agent_message_inserted frame:
{
"type": "agent_message_inserted",
"sessiontoken": "Lp4vQ8sWn2KxT7cRb1YhZ6mDg9FjA3uEo5iNk0wXyHt",
"message": {
"guid": "d4e5f6a7-b8c9-0123-def4-567890123456",
"uuid": "ada-uuid",
"sessionkey": "user-42",
"content": "",
"response": "Your product video is ready: https://cdn.wiro.ai/…/video.mp4",
"status": "agent_end",
"metadata": { "type": "subagent" },
"createdat": 1743350700,
"startedat": 1743350700,
"endedat": 1743350700
},
"result": true
}
| Field | Type | Description |
|---|---|---|
guid |
string | The new message's GUID. Add each guid once; skip it if History already gave it to you. |
uuid |
string | The row's uuid, as Message/History returns it. |
sessionkey |
string | The session the message was added to. |
content |
string | Always "": no prompt of yours belongs to these messages. |
response |
string | The message text. |
status |
string | Always "agent_end". |
metadata |
object | type is "subagent" for the result of work the agent handed to a sub-agent, "system" for the other messages. Other keys may appear; ignore the ones you don't use. |
createdat / startedat / endedat |
number | Unix timestamps in seconds, all set to when the message was added. |
- What arrives. A follower receives exactly the new messages its
Message/Historyread would show. Turns you start withMessage/Sendkeep streaming on their ownagenttoken; following the session doesn't repeat them. - Stop following with
{ "type": "agent_session_unsubscribe", "sessiontoken": "…" }. There is no reply. - Limit. One connection follows up to 16 sessions. Following another one drops the oldest follow.
- Errors. A malformed
sessiontokengets{ "type": "error", "message": "sessiontoken-invalid", "result": false }, and the connection stays open. The acknowledgement doesn't check which session a token belongs to, so send the token exactly as History returned it. - Reconnects. Frames aren't replayed, and follows end with the connection. After reconnecting, send
agent_session_subscribeagain and read History once to catch anything added while you were away. - Keep the token private. Whoever holds it receives the session's new messages. Read it from History when you open the session instead of storing it.
The result Field
Every agent lifecycle event includes a result boolean:
| Value | Events |
|---|---|
true |
agent_subscribed, agent_start, agent_timeline_delta, agent_output, agent_end, agent_usage_report, agent_session_subscribed, agent_message_inserted |
false |
error, agent_error, agent_cancel |
Use result to quickly determine whether the event represents a successful state. When result is false, inspect message for error details or cancellation context. The welcome connected frame has no result field — it's a one-shot ack and always implies success (you got the frame, so the upgrade worked).
Streaming Metrics
Each agent_output and agent_end event includes real-time performance data in the message object:
| Field | Type | Description |
|---|---|---|
speed |
string | Current generation speed (e.g. "12.5"). |
speedType |
string | Speed unit — always "words/s" for agent responses. |
elapsedTime |
string | Wall-clock time since the stream started (e.g. "2.4s"). |
tokenCount |
number | Number of answer chunks received so far. Authoritative model token counts arrive in agent_usage_report. |
wordCount |
number | Total words in the accumulated response. |
These metrics update with every agent_output event, allowing you to display a live speed indicator or progress bar in your UI.
Timeline safety boundary
Public reasoning is available only as type: "reasoning" timeline blocks. Wiro publishes provider-authored summaries from supported OpenAI GPT-5 calls, not raw hidden chain-of-thought or generic provider traces. Tool blocks publish only a safe label and status. Wiro does not parse literal <think> tags from agent_output; treat any such text in raw as ordinary provisional answer output.
Full Integration Example
A typical integration follows this pattern: call the REST API to send a message, then subscribe via WebSocket to stream the response.
Step 1 — Send a message via REST:
curl -X POST https://api.wiro.ai/v1/UserAgent/Message/Send \
-H "x-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"useragentguid": "your-useragent-guid",
"message": "Explain quantum computing in simple terms",
"sessionkey": "user-42"
}'
The response includes an agenttoken:
{
"result": true,
"errors": [],
"messageguid": "c3d4e5f6-a7b8-9012-cdef-345678901234",
"agenttoken": "aB3xK9mR2pLqWzVn7tYhCd5sFgJkNb",
"status": "agent_queue"
}
Step 2 — Subscribe via WebSocket:
const ws = new WebSocket('wss://socket.wiro.ai/v1');
ws.onopen = () => {
ws.send(JSON.stringify({
type: 'agent_info',
agenttoken: 'aB3xK9mR2pLqWzVn7tYhCd5sFgJkNb'
}));
};
Step 3 — Handle streaming events:
← connected { version: "1.0" }
→ agent_info { agenttoken: "..." }
← agent_subscribed { status: "agent_queue", debugoutput: "", timeline: [] }
← agent_start { message: "" }
← agent_timeline_delta { message: { messageguid: "c3d4...", block: { blockid: "c0:i0", callindex: 0, contentindex: 0, type: "reasoning", version: 1 } } }
← agent_output { message: { raw: "Quantum", wordCount: 1 } }
← agent_timeline_delta { message: { messageguid: "c3d4...", block: { blockid: "c0:i1", callindex: 0, contentindex: 1, type: "answer", version: 1 } } }
← agent_output { message: { raw: "Quantum computing uses qubits...", wordCount: 28 } }
← agent_end { message: { raw: "Quantum computing uses qubits that...", wordCount: 118 } }
← agent_usage_report { result: true, message: { messageguid: "c3d4...", totaltokens: 6670, remainingcredits: 9994 } }
← = server → client, → = client → server. agent_output contains provisional full accumulated answer text. Replace (don't append) that typing surface; merge timeline blocks independently.
The full observable wire order for a normal turn is:
agent_queue → agent_start → (agent_timeline_delta | agent_output) × N → agent_end → agent_usage_report
agent_queue is not a WebSocket frame — it's the status returned synchronously in the Message/Send response (and reflected by agent_subscribed if you subscribe while the message is still queued). Timeline and provisional answer events can interleave between start and terminal status. On the failure path, agent_error or agent_cancel takes the place of agent_end, and no agent_usage_report follows. Message/Detail is authoritative for the final persisted timeline.
agent_wiroai_runtask is not part of this linear order — it fires out-of-band (zero or more times per turn, whenever the agent launches a model run) on the session-key channel rather than the per-turn agenttoken.
Code Examples
JavaScript
const agentToken = 'your-agent-token';
const ws = new WebSocket('wss://socket.wiro.ai/v1');
const timelineByMessage = new Map();
function mergeTimelineBlock(messageguid, block) {
const byId = timelineByMessage.get(messageguid) || new Map();
const previous = byId.get(block.blockid);
if (!previous || block.version > previous.version) {
byId.set(block.blockid, block);
}
timelineByMessage.set(messageguid, byId);
return [...byId.values()].sort(
(a, b) => a.callindex - b.callindex
|| a.contentindex - b.contentindex
|| a.blockid.localeCompare(b.blockid)
);
}
ws.onopen = () => {
console.log('Connected');
ws.send(JSON.stringify({
type: 'agent_info',
agenttoken: agentToken
}));
};
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
switch (msg.type) {
case 'connected':
// One-shot welcome frame from server. OK to ignore.
break;
case 'error':
// Subscribe shape rejected (e.g. missing agenttoken).
console.error('Subscribe error:', msg.message);
ws.close();
break;
case 'agent_subscribed':
if (msg.status === 'unknown') {
console.error('Unknown token:', msg.agenttoken);
ws.close();
} else if (['agent_end', 'agent_error', 'agent_cancel'].includes(msg.status)) {
// We subscribed late — the agent already finished.
console.log('Already finished. Snapshot:', msg.debugoutput);
ws.close();
}
break;
case 'agent_start':
console.log('Agent started generating');
break;
case 'agent_output':
// message is a progressGenerate object; replace (don't append) your UI.
console.log('Streaming:', msg.message.raw);
break;
case 'agent_timeline_delta':
// Merge msg.message.block by blockid + per-block version, then sort
// by callindex/contentindex. Do not append in arrival order.
mergeTimelineBlock(msg.message.messageguid, msg.message.block);
break;
case 'agent_end':
console.log('Final:', msg.message.raw);
ws.close();
break;
case 'agent_error':
// message is either a sanitized string or a progressGenerate object.
if (typeof msg.message === 'string') console.error('Error:', msg.message);
else console.error('Non-response:', msg.message.raw);
ws.close();
break;
case 'agent_cancel':
console.warn('Cancelled:', msg.message);
ws.close();
break;
}
};
ws.onerror = (err) => console.error('WebSocket error:', err);
ws.onclose = () => console.log('Disconnected');
Python
import asyncio
import websockets
import json
async def listen_agent(agent_token):
uri = "wss://socket.wiro.ai/v1"
async with websockets.connect(uri) as ws:
await ws.send(json.dumps({
"type": "agent_info",
"agenttoken": agent_token
}))
print("Subscribed to agent session")
async for message in ws:
msg = json.loads(message)
print(f"Event: {msg['type']}")
if msg["type"] == "agent_output":
print("Streaming:", msg["message"].get("raw"))
elif msg["type"] == "agent_end":
print("Final:", msg["message"].get("raw"))
break
elif msg["type"] in ("agent_error", "agent_cancel"):
print("Error:", msg.get("message"))
break
asyncio.run(listen_agent("your-agent-token"))
Node.js
const WebSocket = require('ws');
const ws = new WebSocket('wss://socket.wiro.ai/v1');
ws.on('open', () => {
ws.send(JSON.stringify({
type: 'agent_info',
agenttoken: 'your-agent-token'
}));
});
ws.on('message', (data) => {
const msg = JSON.parse(data.toString());
console.log('Event:', msg.type);
if (msg.type === 'agent_output') {
console.log('Streaming:', msg.message?.raw);
}
if (msg.type === 'agent_end') {
console.log('Final:', msg.message?.raw);
ws.close();
}
});
ws.on('error', console.error);
ws.on('close', () => console.log('Disconnected'));
PHP
<?php
// Requires: composer require textalk/websocket
use WebSocket\Client;
$client = new Client("wss://socket.wiro.ai/v1");
$client->send(json_encode([
"type" => "agent_info",
"agenttoken" => "your-agent-token"
]));
while (true) {
$msg = json_decode($client->receive(), true);
echo "Event: " . $msg["type"] . PHP_EOL;
if ($msg["type"] === "agent_output") {
echo "Streaming: " . ($msg["message"]["raw"] ?? "") . PHP_EOL;
}
if ($msg["type"] === "agent_end") {
echo "Final: " . ($msg["message"]["raw"] ?? "") . PHP_EOL;
break;
}
}
$client->close();
C#
using System.Net.WebSockets;
using System.Text;
using System.Text.Json;
using var ws = new ClientWebSocket();
await ws.ConnectAsync(
new Uri("wss://socket.wiro.ai/v1"),
CancellationToken.None);
var subscribe = JsonSerializer.Serialize(new {
type = "agent_info",
agenttoken = "your-agent-token"
});
await ws.SendAsync(
Encoding.UTF8.GetBytes(subscribe),
WebSocketMessageType.Text, true,
CancellationToken.None);
var buffer = new byte[8192];
while (ws.State == WebSocketState.Open) {
var result = await ws.ReceiveAsync(
buffer, CancellationToken.None);
var json = Encoding.UTF8.GetString(
buffer, 0, result.Count);
using var doc = JsonDocument.Parse(json);
var type = doc.RootElement
.GetProperty("type").GetString();
Console.WriteLine("Event: " + type);
if (type == "agent_end") {
Console.WriteLine("Done!");
break;
}
}
Go
package main
import (
"encoding/json"
"fmt"
"log"
"github.com/gorilla/websocket"
)
func main() {
conn, _, err := websocket.DefaultDialer.Dial(
"wss://socket.wiro.ai/v1", nil)
if err != nil { log.Fatal(err) }
defer conn.Close()
sub, _ := json.Marshal(map[string]string{
"type": "agent_info",
"agenttoken": "your-agent-token",
})
conn.WriteMessage(websocket.TextMessage, sub)
for {
_, message, err := conn.ReadMessage()
if err != nil { break }
var msg map[string]interface{}
json.Unmarshal(message, &msg)
fmt.Println("Event:", msg["type"])
if msg["type"] == "agent_end" {
fmt.Println("Done!")
break
}
}
}
Swift
import Foundation
let url = URL(string: "wss://socket.wiro.ai/v1")!
let task = URLSession.shared.webSocketTask(with: url)
task.resume()
let subData = try! JSONSerialization.data(
withJSONObject: [
"type": "agent_info",
"agenttoken": "your-agent-token"
])
task.send(.string(
String(data: subData, encoding: .utf8)!
)) { _ in }
func receive() {
task.receive { result in
switch result {
case .success(let message):
switch message {
case .string(let text):
let msg = try! JSONSerialization
.jsonObject(with: text.data(
using: .utf8)!)
as! [String: Any]
print("Event:", msg["type"] ?? "")
if msg["type"] as? String == "agent_end" {
print("Done!")
return
}
case .data(let data):
print("Binary:", data.count, "bytes")
@unknown default: break
}
receive()
case .failure(let error):
print("Error:", error)
}
}
}
receive()
Kotlin
// Requires: org.java-websocket:Java-WebSocket
import org.java_websocket.client.WebSocketClient
import org.java_websocket.handshake.ServerHandshake
import java.net.URI
import org.json.JSONObject
val client = object : WebSocketClient(
URI("wss://socket.wiro.ai/v1")) {
override fun onOpen(h: ServerHandshake) {
send(JSONObject(mapOf(
"type" to "agent_info",
"agenttoken" to "your-agent-token"
)).toString())
}
override fun onMessage(message: String) {
val msg = JSONObject(message)
println("Event: " + msg.getString("type"))
if (msg.getString("type") == "agent_end") {
println("Done!")
close()
}
}
override fun onClose(
code: Int, reason: String, remote: Boolean
) { println("Disconnected") }
override fun onError(ex: Exception) {
ex.printStackTrace()
}
}
client.connect()
Dart
import 'dart:convert';
import 'package:web_socket_channel/web_socket_channel.dart';
final channel = WebSocketChannel.connect(
Uri.parse('wss://socket.wiro.ai/v1'),
);
channel.sink.add(jsonEncode({
'type': 'agent_info',
'agenttoken': 'your-agent-token',
}));
channel.stream.listen((message) {
final msg = jsonDecode(message);
print('Event: ' + msg['type'].toString());
if (msg['type'] == 'agent_output') {
print('Streaming: ' + (msg['message']?['raw'] ?? ''));
}
if (msg['type'] == 'agent_end') {
print('Done!');
channel.sink.close();
}
});
Quick Reference
connected — welcome frame (server → client, sent once on upgrade):
{
"type": "connected",
"version": "1.0"
}
Subscribe frame (client → server):
{
"type": "agent_info",
"agenttoken": "aB3xK9..."
}
error — malformed subscribe (server → client, sent when agent_info is missing agenttoken):
{
"type": "error",
"message": "agenttoken-required",
"result": false
}
agent_subscribed — valid token (empty debugoutput):
{
"type": "agent_subscribed",
"agenttoken": "aB3xK9...",
"status": "agent_queue",
"debugoutput": "",
"messageguid": "c3d4e5f6-...",
"timeline": [],
"result": true
}
agent_subscribed — unknown token (status: "unknown", no debugoutput):
{
"type": "agent_subscribed",
"agenttoken": "wrongtoken",
"status": "unknown",
"result": true
}
agent_start:
{
"type": "agent_start",
"agenttoken": "aB3xK9...",
"message": "",
"result": true
}
agent_timeline_delta — one public block update:
{
"type": "agent_timeline_delta",
"agenttoken": "aB3xK9...",
"message": {
"messageguid": "c3d4e5f6-...",
"block": {
"blockid": "c0:i0",
"callindex": 0,
"contentindex": 0,
"type": "reasoning",
"text": "I’ll verify the relevant constraints.",
"toollabel": null,
"toolstatus": null,
"phase": "stream",
"version": 1,
"startedat": 1743350401,
"updatedat": 1743350402
}
},
"result": true
}
agent_output — streaming partials, emitted multiple times:
{
"type": "agent_output",
"agenttoken": "aB3xK9...",
"message": {
"raw": "Accumulated text...",
"speed": "12.5",
"wordCount": 28
},
"result": true
}
agent_end — final response:
{
"type": "agent_end",
"agenttoken": "aB3xK9...",
"message": {
"raw": "Complete response...",
"speed": "14.2",
"wordCount": 118
},
"result": true
}
agent_error — sanitized string (any exception during streaming):
{
"type": "agent_error",
"agenttoken": "aB3xK9...",
"message": "Agent is temporarily unavailable. Please try again shortly.",
"result": false
}
agent_cancel — active-processing abort only; queued-state cancels don't broadcast:
{
"type": "agent_cancel",
"agenttoken": "aB3xK9...",
"message": "AbortError",
"result": false
}
agent_usage_report — post-terminal usage/billing frame, ~250–500 ms after agent_end:
{
"type": "agent_usage_report",
"agenttoken": "aB3xK9...",
"result": true,
"message": {
"messageguid": "c3d4e5f6-...",
"totaltokens": 6670,
"model": "openai/gpt-5.4",
"tokencost": 5,
"remainingcredits": 9995
}
}
Connection Keep-Alive
The Wiro WebSocket server sends a ping every 30 seconds to keep the connection alive. Most standard WebSocket client libraries respond to pings automatically; if your client implements a custom frame handler, make sure it sends a pong within a few seconds of each ping or the server will drop the connection. After agent_error / agent_cancel you can close the socket immediately. After agent_end, one more frame (agent_usage_report) follows ~250–500 ms later; wait for it if you need usage/billing data. Read Message/Detail whenever you need the authoritative completed timeline.
Correlating Events With Your Messages
Every agent lifecycle frame carries agenttoken, so keep the mapping returned by Message/Send. Most stream frames do not include messageguid; agent_timeline_delta additionally carries message.messageguid, and agent_subscribed carries top-level messageguid. No frame carries useragentguid.
The post-terminal agent_usage_report also names message.messageguid, but it arrives only after agent_end, so you still need the agenttoken → messageguid mapping for start/output/end/error/cancel routing.
Why agenttoken is the correlation key
Message/Sendissues exactly oneagenttokenper message and returns it alongsidemessageguidin the HTTP response.- The bridge stamps the same
agenttokenon every event it emits for that message. - Multiple connections can subscribe to the same
agenttokenand each gets the full event stream — soagenttokenis also the fan-out key. - A single socket can hold subscriptions for many
agenttokens at once (see Multi-session subscription) — the per-eventagenttokenlets your handler dispatch into the right UI element.
Recommended client pattern
- Call
POST /UserAgent/Message/Send— you get back{ messageguid, agenttoken, status: "agent_queue", ... }. - Store the mapping
agenttoken → messageguid(oragenttoken → your UI message id). - Send
{ "type": "agent_info", "agenttoken }on an open socket (new or existing — connections can be reused). - In
ws.onmessage, readmsg.agenttokenon every lifecycle frame. Foragent_timeline_delta, mergemsg.message.blockbyblockidand strictly newer per-blockversion; never append in arrival order. - When the turn ends, delete the mapping so it doesn't leak memory across sessions. For successful turns, wait for the trailing
agent_usage_report(it arrives ~250–500 ms afteragent_end) before deleting, so you can attribute the usage to the right message; foragent_error/agent_cancel, delete immediately — no usage report follows.
const tokenToMessageId = new Map()
const timelineByMessageId = new Map()
function mergeTimelineBlock(uiMessageId, block) {
const byId = timelineByMessageId.get(uiMessageId) || new Map()
const previous = byId.get(block.blockid)
if (!previous || block.version > previous.version) byId.set(block.blockid, block)
timelineByMessageId.set(uiMessageId, byId)
const timeline = [...byId.values()].sort(
(a, b) => a.callindex - b.callindex
|| a.contentindex - b.contentindex
|| a.blockid.localeCompare(b.blockid)
)
updateUI(uiMessageId, { timeline })
}
async function sendMessage(text, uiMessageId) {
const resp = await fetch('https://api.wiro.ai/v1/UserAgent/Message/Send', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-api-key': 'YOUR_API_KEY' },
body: JSON.stringify({
useragentguid: 'your-useragent-guid',
message: text,
sessionkey: 'user-42'
})
}).then(r => r.json())
tokenToMessageId.set(resp.agenttoken, uiMessageId)
ws.send(JSON.stringify({ type: 'agent_info', agenttoken: resp.agenttoken }))
return { messageguid: resp.messageguid, agenttoken: resp.agenttoken }
}
ws.onmessage = (event) => {
const msg = JSON.parse(event.data)
if (!msg.agenttoken) return // control frames (connected / error) have no token
const uiMessageId = tokenToMessageId.get(msg.agenttoken)
if (!uiMessageId) return // unknown token — probably stale
switch (msg.type) {
case 'agent_timeline_delta':
mergeTimelineBlock(uiMessageId, msg.message.block)
break
case 'agent_output':
updateUI(uiMessageId, { streaming: msg.message.raw })
break
case 'agent_end':
// Keep the mapping — agent_usage_report still follows for successful turns.
updateUI(uiMessageId, { final: msg.message.raw, status: 'agent_end' })
break
case 'agent_usage_report':
updateUI(uiMessageId, { usage: msg.message, remainingCredits: msg.message.remainingcredits })
tokenToMessageId.delete(msg.agenttoken)
break
case 'agent_error':
case 'agent_cancel':
updateUI(uiMessageId, { error: msg.message, status: msg.type })
tokenToMessageId.delete(msg.agenttoken)
break
}
}
Concurrency: multiple in-flight messages on one session
Sending two messages back-to-back in the same sessionkey produces two independent agenttokens. Both reach the bridge in queue order; both stream events independently. Your client must:
- Subscribe to both
agenttokens (oneagent_infoframe each — the server de-duplicates automatically). - Key all UI updates on
agenttoken, not onsessionkey(which is shared) or timestamps (which can interleave).
The server never mixes streams — every event is stamped with the originating agenttoken so routing is unambiguous even when chunks from two messages interleave on the wire.
Control frames have no agenttoken
The welcome frame and the subscribe-error frame are connection-level signals, not per-message events. They intentionally omit agenttoken:
{ "type": "connected", "version": "1.0" }
{ "type": "error", "message": "agenttoken-required", "result": false }
Always null-check msg.agenttoken before looking it up in your mapping — see the if (!msg.agenttoken) return guard in the example above.
Reconnection & Recovery
The agent keeps running server-side regardless of whether any client is subscribed. A disconnected socket never cancels the agent. This means a dropped connection is always recoverable — just reconnect and re-subscribe with the same agenttoken.
Recovery flow
- Detect disconnect —
ws.onclose/ stream exception / ping timeout. - Reconnect — open a new WebSocket to
wss://socket.wiro.ai/v1. - Wait for welcome — receive
{ "type": "connected", "version": "1.0" }(optional but clean). - Re-subscribe — send
{ "type": "agent_info", "agenttoken": "..." }with the same token. - Handle
agent_subscribed— the server reports the current status and persistedtimeline[]. Three cases:statusisagent_queue/agent_start/agent_output→ stream is still live; accumulated provisional answer text is indebugoutput. Seed your block map fromtimeline, then merge future deltas.statusisagent_end→ the agent already finished.debugoutputholds the full final response. FetchPOST /UserAgent/Message/Detailfor the canonical record (including completetimeline,metadata, attachments, and timestamps), then close the socket.statusisagent_error/agent_cancel→ the agent already failed / was cancelled.debugoutputmay contain partial output. No further events. FetchPOST /UserAgent/Message/Detailfor the persisted error details.
On reconnect you do not receive replays of past agent_output or agent_timeline_delta frames. Use debugoutput and timeline on agent_subscribed as the snapshot of what you missed.
Example retry strategy
const MAX_BACKOFF_MS = 30000
let backoff = 1000
function connect(agenttoken, onStreamingChunk, onFinal, onFailure) {
const ws = new WebSocket('wss://socket.wiro.ai/v1')
let finished = false
ws.onopen = () => {
backoff = 1000
ws.send(JSON.stringify({ type: 'agent_info', agenttoken }))
}
ws.onmessage = (event) => {
const msg = JSON.parse(event.data)
if (msg.type === 'connected') return
if (msg.type === 'agent_subscribed') {
if (msg.status === 'unknown') {
finished = true
onFailure({ reason: 'unknown-token' })
ws.close()
return
}
if (['agent_end', 'agent_error', 'agent_cancel'].includes(msg.status)) {
finished = true
onFinal(msg.debugoutput || '')
ws.close()
}
return
}
if (msg.type === 'agent_output') onStreamingChunk(msg.message)
if (msg.type === 'agent_end') {
finished = true
onFinal(msg.message.raw)
ws.close()
}
if (['agent_error', 'agent_cancel'].includes(msg.type)) {
finished = true
onFailure({ reason: msg.type, message: msg.message })
ws.close()
}
}
ws.onclose = () => {
if (finished) return
setTimeout(() => connect(agenttoken, onStreamingChunk, onFinal, onFailure), backoff)
backoff = Math.min(backoff * 2, MAX_BACKOFF_MS)
}
}
Guidance:
- Exponential backoff, capped at 30 seconds. The server is usually responsive, so don't hammer it.
- Stop retrying once you hit a terminal event (
agent_end/agent_error/agent_cancel) or anunknownstatus — the work is either done or the token is gone. - Idempotent re-subscribe: sending the same
agenttokenagain on a fresh socket is always safe. - Fall back to polling if WebSocket is blocked (strict corporate proxies, mobile cellular with long-poll fallbacks). Use
POST /UserAgent/Message/Detailat 1–2 second intervals untilstatusis terminal.
Token Lifecycle
An agenttoken is issued per message by POST /UserAgent/Message/Send and stays addressable on the WebSocket for as long as the underlying message exists (Wiro does not auto-purge messages on a short timer; tokens remain queryable indefinitely after the run ends).
| Event | Effect on token |
|---|---|
Message/Send |
Token is minted, row is inserted with status: "agent_queue", broadcast to all queue subscribers. |
| Worker picks up | Emits agent_start to every active subscriber. |
| Timeline block changes | Emits one agent_timeline_delta update with { messageguid, block }; the persisted row keeps the merged ordered timeline. |
| Each SSE chunk | Emits agent_output to every active subscriber (with full accumulated raw). |
| Stream finishes | Emits agent_end (or agent_error for "..." / internal-error content) with final progressGenerate payload; the message's status becomes terminal. |
| Usage billed | ~250–500 ms after a successful agent_end, emits agent_usage_report with final token counts, model, tokencost, and remainingcredits. Not emitted for replayed usage callbacks or for turns with no chat message (cron/hook turns). |
| Bridge exception | Emits agent_error with sanitized string; the message's status becomes agent_error, raw error in debugoutput. |
Message/Cancel during active stream |
Bridge aborts, emits agent_cancel; the message's status becomes agent_cancel. |
Message/Cancel while queued |
The message's status becomes agent_cancel immediately. No WebSocket event is broadcast (the bridge never started). Clients checking via the socket must consult Message/Detail for queued-state cancels. |
Multi-subscriber semantics: multiple WebSocket connections can subscribe to the same agenttoken and all receive the same event stream in parallel. The server does not enforce a subscriber limit per token. This is how the Wiro Dashboard shows the same agent chat on multiple tabs for the same user — each tab opens its own socket and subscribes independently.
Cross-user subscription: the agenttoken alone authenticates subscription — if you leak a token to another user, they can read the stream. Treat tokens like short-lived secrets scoped to the message.