# 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](/docs/agent-messaging) 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.
## 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 (plus any already-accumulated `debugoutput`).
5. **Stream** — listen for `agent_start` → many `agent_output` → `agent_end` / `agent_error` / `agent_cancel`.
6. **Close** — disconnect after a terminal event, or keep the socket open and subscribe to the next `agenttoken`.
### 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.
```json
{
"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)
```json
{
"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](#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](#multi-session-subscription).
### 3. Subscribe errors
If `agenttoken` is missing or blank, the server replies with:
```json
{
"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.
```text
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. 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`). Connection stays open; retry with a valid frame. |
| ↓ | `agent_subscribed` | Subscribe acknowledged. Carries the current lifecycle `status` plus any accumulated `debugoutput`. If the agent already finished before you subscribed, this frame is your snapshot of the final output and no further events will fire. |
| ↓ | `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_output` | Streaming chunk. Emits **many times** — each carries the full accumulated `raw` text so far plus real-time metrics (`speed`, `elapsedTime`, `tokenCount`, `wordCount`). Replace (don't append) your UI 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. |
## Message Format
Every WebSocket frame is a JSON object. All **agent lifecycle frames** (`agent_subscribed` / `agent_start` / `agent_output` / `agent_end` / `agent_error` / `agent_cancel`) share this base shape:
```json
{
"type": "agent_output",
"agenttoken": "aB3xK9mR2pLqWzVn7tYhCd5sFgJkNb",
"message": { ... },
"result": true
}
```
| Field | Type | Description |
|---|---|---|
| `type` | string | Event name. See [Event Types](#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`, a `progressGenerate` object for `agent_output` / `agent_end` (and for the object-shaped `agent_error`), and a plain string for string-shaped `agent_error` and `agent_cancel`. |
| `result` | boolean | `true` for success-side events (`agent_subscribed` / `agent_start` / `agent_output` / `agent_end`), `false` for failure-side events (`agent_error` / `agent_cancel`). See [The `result` field](#the-result-field). |
The **control frames** (`connected`, `error`) use a different shape with no `agenttoken`:
```json
// 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 additionally carries `status` (the DB row's current status) and, when the token is known, `debugoutput` (accumulated text so far). When the token is unknown, `status` is `"unknown"` and `debugoutput` is omitted entirely.
### 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 and pending/active** (known to the server, not yet finished), `debugoutput` is always present — an empty string `""` if nothing has streamed yet, or the accumulated text so far.
- 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:
```json
{
"type": "agent_subscribed",
"agenttoken": "aB3xK9mR2pLqWzVn7tYhCd5sFgJkNb",
"status": "agent_queue",
"debugoutput": "",
"result": true
}
```
**Unknown token** — `status` is `"unknown"` and no `debugoutput` field:
```json
{
"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. |
| `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:
```json
{
"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:
```json
{
"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...",
"thinking": [],
"answer": ["Here is the accumulated response text so far..."],
"isThinking": false
},
"result": true
}
```
The `raw` field contains the full response as a single string. The `answer` array contains the same text split into segments. To display streaming text, replace your UI content with `raw` (or join `answer`) on each event.
### 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:
```json
{
"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...",
"thinking": [],
"answer": ["The complete agent response text..."],
"isThinking": false
},
"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:
```json
{
"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.** Strings like `"Bridge timeout"`, `"OpenClaw returned HTTP 500"`, `"SSE request timeout (30min)"`, `"Could not resolve agent endpoint"` are recorded in the database `debugoutput` field (retrievable via `POST /UserAgent/Message/Detail`) but **replaced with the generic sentence above** before being pushed to subscribed clients. This is intentional so end users never see internal infrastructure errors. Log the raw string from `Message/Detail`; show the sanitized string from the WebSocket event.
**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:
```json
{
"type": "agent_error",
"agenttoken": "aB3xK9mR2pLqWzVn7tYhCd5sFgJkNb",
"message": {
"type": "progressGenerate",
"task": "Generate",
"speed": "2.5",
"speedType": "words/s",
"elapsedTime": "1.2s",
"tokenCount": 3,
"wordCount": 1,
"raw": "...",
"thinking": [],
"answer": ["..."],
"isThinking": false
},
"result": false
}
```
Check the runtime type of `message` to branch:
```javascript
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):
```json
{
"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`).
## The `result` Field
Every agent lifecycle event includes a `result` boolean:
| Value | Events |
|---|---|
| `true` | `agent_subscribed`, `agent_start`, `agent_output`, `agent_end` |
| `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 | Total tokens generated so far. |
| `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.
## Thinking Model Support
When the agent is backed by a thinking-capable model (e.g. DeepSeek-R1, QwQ), the response may include thinking blocks alongside the answer:
```json
{
"type": "agent_output",
"agenttoken": "aB3xK9mR2pLqWzVn7tYhCd5sFgJkNb",
"message": {
"type": "progressGenerate",
"task": "Generate",
"speed": "8.3",
"speedType": "words/s",
"elapsedTime": "4.1s",
"tokenCount": 89,
"wordCount": 64,
"raw": "Let me work through this step by step...Based on my analysis...",
"thinking": ["Let me work through this step by step..."],
"answer": ["Based on my analysis..."],
"isThinking": true
},
"result": true
}
```
| Field | Description |
|---|---|
| `isThinking` | `true` while the model is in a thinking phase, `false` when emitting the answer. |
| `thinking` | Array of thinking block text segments. Empty if the model does not use thinking. |
| `answer` | Array of answer text segments. This is the user-facing response. |
| `raw` | The unprocessed output including `` tags. Use `thinking` and `answer` instead for display. |
**Rendering guidance:**
- While `isThinking` is `true`, show a "Thinking..." indicator or render the `thinking` text in a collapsible block.
- When `isThinking` becomes `false`, the model has finished reasoning and is now producing the answer.
- On `agent_end`, join the `answer` array for the final display text.
## 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:**
```bash
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`:
```json
{
"result": true,
"errors": [],
"messageguid": "c3d4e5f6-a7b8-9012-cdef-345678901234",
"agenttoken": "aB3xK9mR2pLqWzVn7tYhCd5sFgJkNb",
"status": "agent_queue"
}
```
**Step 2 — Subscribe via WebSocket:**
```javascript
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: "" }
← agent_start { message: "" }
← agent_output { message: { raw: "Quantum", wordCount: 1 } }
← agent_output { message: { raw: "Quantum computing uses", wordCount: 3 } }
← agent_output { message: { raw: "Quantum computing uses qubits...", wordCount: 28 } }
← agent_end { message: { raw: "Quantum computing uses qubits that...", wordCount: 118 } }
```
`←` = server → client, `→` = client → server. Each `agent_output` contains the full accumulated text. Replace (don't append) your display content on each event.
## Code Examples
### JavaScript
```javascript
const agentToken = 'your-agent-token';
const ws = new WebSocket('wss://socket.wiro.ai/v1');
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_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
```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
```javascript
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
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\#
```csharp
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
```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
```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
```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
```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):
```json
{
"type": "connected",
"version": "1.0"
}
```
**Subscribe frame** (client → server):
```json
{
"type": "agent_info",
"agenttoken": "aB3xK9..."
}
```
**`error` — malformed subscribe** (server → client, sent when `agent_info` is missing `agenttoken`):
```json
{
"type": "error",
"message": "agenttoken-required",
"result": false
}
```
**`agent_subscribed` — valid token** (empty `debugoutput`):
```json
{
"type": "agent_subscribed",
"agenttoken": "aB3xK9...",
"status": "agent_queue",
"debugoutput": "",
"result": true
}
```
**`agent_subscribed` — unknown token** (`status: "unknown"`, no `debugoutput`):
```json
{
"type": "agent_subscribed",
"agenttoken": "wrongtoken",
"status": "unknown",
"result": true
}
```
**`agent_start`:**
```json
{
"type": "agent_start",
"agenttoken": "aB3xK9...",
"message": "",
"result": true
}
```
**`agent_output`** — streaming partials, emitted multiple times:
```json
{
"type": "agent_output",
"agenttoken": "aB3xK9...",
"message": {
"raw": "Accumulated text...",
"speed": "12.5",
"wordCount": 28
},
"result": true
}
```
**`agent_end`** — final response:
```json
{
"type": "agent_end",
"agenttoken": "aB3xK9...",
"message": {
"raw": "Complete response...",
"speed": "14.2",
"wordCount": 118
},
"result": true
}
```
**`agent_error`** — sanitized string (any exception during streaming):
```json
{
"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:
```json
{
"type": "agent_cancel",
"agenttoken": "aB3xK9...",
"message": "AbortError",
"result": false
}
```
## 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_end` / `agent_error` / `agent_cancel`, you can close the socket safely — no more events will be sent for that `agenttoken`.
## Correlating Events With Your Messages
Every agent lifecycle frame (`agent_subscribed` / `agent_start` / `agent_output` / `agent_end` / `agent_error` / `agent_cancel`) carries `agenttoken` — this is the **only** correlation key available on the wire. The wire payload does **not** include `messageguid`, `sessionkey`, or `useragentguid`. If you need any of those fields to route events back to a specific message in your UI, build the mapping yourself when you call `Message/Send`.
### 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 `agenttoken`s at once (see [Multi-session subscription](#multi-session-subscription)) — the per-event `agenttoken` lets your handler dispatch into the right UI element.
### Recommended client pattern
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 agent lifecycle frame, look up your stored mapping, and update the matching UI element.
5. When you see a terminal event (`agent_end` / `agent_error` / `agent_cancel`), delete the mapping so it doesn't leak memory across sessions.
```javascript
const tokenToMessageId = new Map()
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_output':
updateUI(uiMessageId, { streaming: msg.message.raw })
break
case 'agent_end':
updateUI(uiMessageId, { final: msg.message.raw, status: 'agent_end' })
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 `agenttoken`s. Both reach the bridge in queue order; both stream events independently. Your client must:
- Subscribe to **both** `agenttoken`s (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`:
```json
{ "type": "connected", "version": "1.0" }
```
```json
{ "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. Three cases:
- `status` is `agent_queue` / `agent_start` / `agent_output` → stream is still live; accumulated text so far is in `debugoutput`. Future events will be forwarded normally.
- `status` is `agent_end` → the agent already finished. `debugoutput` holds the full final response. **No further WebSocket events will fire.** Fetch `POST /UserAgent/Message/Detail` for the canonical record (including `metadata`, `attachments`, `endedat`), 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 the past `agent_output` frames — only events emitted after re-subscribe. Use the `debugoutput` on `agent_subscribed` as the snapshot of what you missed.
### Example retry strategy
```javascript
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 `agentmessages` row exists (Wiro does not auto-purge rows 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. |
| 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; DB row status is updated to terminal. |
| Bridge exception | Emits `agent_error` with sanitized string; DB row status → `agent_error`, raw error in `debugoutput`. |
| `Message/Cancel` during active stream | Bridge aborts, emits `agent_cancel`; DB row status → `agent_cancel`. |
| `Message/Cancel` while queued | DB row status → `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.