This section as Markdown: View Download

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

  1. Connect — open a WebSocket connection to wss://socket.wiro.ai/v1.
  2. Receive welcome — the server pushes a one-shot connected frame confirming the upgrade.
  3. Subscribe — send an agent_info frame with your agenttoken.
  4. Receive agent_subscribed — the server acknowledges the subscribe and reports the current lifecycle status, accumulated answer text, and persisted timeline[].
  5. Stream — listen for agent_start, agent_timeline_delta block updates, provisional agent_output answer snapshots, then agent_end / agent_error / agent_cancel.
  6. Receive agent_usage_report — after a successful agent_end, a one-shot token-usage/billing frame arrives on the same agenttoken (~250–500 ms later). Optional to consume; keep the socket open briefly if you need it.
  7. Close — keep the socket open for agent_usage_report if needed. After completion or reconnect, read Message/Detail for 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 by POST /UserAgent/Message/Send. Required. If missing or empty, the server responds with an error frame (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_info and agent_info frames both map to the same connection's token list (tasks under taskTokens, agents under agentTokens).

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, debugoutput is always present and timeline is an array (possibly empty).
  • If the agenttoken is unknown (typo, expired, already cleaned up from the buffer), debugoutput is omitted entirely from the payload (no field at all). Always use "debugoutput" in payload or payload.debugoutput !== undefined to 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:

  1. Treat blockid as an opaque deterministic identity.
  2. Keep the incoming block only if there is no stored block with that ID, or its version is strictly greater than the stored version.
  3. Replace only that block; a newer update for one block must never evict another block.
  4. Sort the merged values by callindex ASC, contentindex ASC, then blockid 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 via POST /UserAgent/Message/Detail) but replaced with the generic sentence above before being pushed to subscribed clients. Log the raw debugoutput for 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 message field 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; use type === "agent_cancel" as the signal. Subscribers that cancel from a queued state will receive no event at all (the message is simply marked agent_cancel in the database; check with POST /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 messageguid to 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_runtask is emitted when the agent calls a model mid-turn. Model runs you launch yourself with POST /Run (your own x-api-key) do not produce this frame — you already hold their socketaccesstoken from the /Run response.

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/History read would show. Turns you start with Message/Send keep streaming on their own agenttoken; 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 sessiontoken gets { "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_subscribe again 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/Send issues exactly one agenttoken per message and returns it alongside messageguid in the HTTP response.
  • The bridge stamps the same agenttoken on every event it emits for that message.
  • Multiple connections can subscribe to the same agenttoken and each gets the full event stream — so agenttoken is also the fan-out key.
  • A single socket can hold subscriptions for many agenttokens at once (see Multi-session subscription) — the per-event agenttoken lets your handler dispatch into the right UI element.
  1. Call POST /UserAgent/Message/Send — you get back { messageguid, agenttoken, status: "agent_queue", ... }.
  2. Store the mapping agenttoken → messageguid (or agenttoken → your UI message id).
  3. Send { "type": "agent_info", "agenttoken } on an open socket (new or existing — connections can be reused).
  4. In ws.onmessage, read msg.agenttoken on every lifecycle frame. For agent_timeline_delta, merge msg.message.block by blockid and strictly newer per-block version; never append in arrival order.
  5. 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 after agent_end) before deleting, so you can attribute the usage to the right message; for agent_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 (one agent_info frame each — the server de-duplicates automatically).
  • Key all UI updates on agenttoken, not on sessionkey (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

  1. Detect disconnect — ws.onclose / stream exception / ping timeout.
  2. Reconnect — open a new WebSocket to wss://socket.wiro.ai/v1.
  3. Wait for welcome — receive { "type": "connected", "version": "1.0" } (optional but clean).
  4. Re-subscribe — send { "type": "agent_info", "agenttoken": "..." } with the same token.
  5. Handle agent_subscribed — the server reports the current status and persisted timeline[]. Three cases:
    • status is agent_queue / agent_start / agent_output → stream is still live; accumulated provisional answer text is in debugoutput. Seed your block map from timeline, then merge future deltas.
    • status is agent_end → the agent already finished. debugoutput holds the full final response. Fetch POST /UserAgent/Message/Detail for the canonical record (including complete timeline, metadata, attachments, and timestamps), then close the socket.
    • status is agent_error / agent_cancel → the agent already failed / was cancelled. debugoutput may contain partial output. No further events. Fetch POST /UserAgent/Message/Detail for 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 an unknown status — the work is either done or the token is gone.
  • Idempotent re-subscribe: sending the same agenttoken again 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/Detail at 1–2 second intervals until status is 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.