Skip to main content

Reconnect & Resume

WebSockets close — every long-running socket eventually does. The protocol is designed so your client can reconnect and pick up the same conversation cleanly.

What's persistent​

Across reconnects, the platform keeps:

  • The conversation itself (all turns).
  • The message history that the agent uses as context.
  • The usage counters for the conversation.

What's not persistent:

  • The WebSocket itself (you make a new one).
  • Any in-flight LLM call (it's cancelled when the socket closes).
  • Any in-flight tool request (you'll have to re-execute on the next attempt).

Resuming a conversation​

You resume by passing conversationId in the handshake:

{
"type": "handshake",
"content_type": "json",
"content": {
"conversationType": "text",
"conversationId": "cnv_abc123"
}
}

The server replies with a handshake_ack whose content.status is "resumed":

{
"type": "handshake_ack",
"content_type": "json",
"content": { "conversationId": "cnv_abc123", "status": "resumed" },
"metadata": { "model_id": "anthropic:claude-sonnet-4-6" }
}

If the conversation ID is unknown for this agent, you get "created" instead and a fresh conversation starts.

What does NOT come over the wire on resume​

The server does not replay the message history through the socket. If your UI needs the past messages, fetch them via REST first:

GET /user/agents/{agent_id}/conversations/{conversation_id}

This returns every turn in the conversation. Render it in your UI, then connect the WebSocket and continue.

When to reconnect​

Common triggers:

TriggerWhat you'll see
TTL expirederror with code TIMED_OUT, then close 1001. Reconnect after the suggested retryAfterSeconds.
Server deployClose 1001 with no warning. Reconnect with backoff.
Network bliponerror then close. Reconnect.
Token expiredClose 1008 after an error. Refresh the token, then reconnect.

Reconnect strategy​

A reasonable client policy:

  1. On any close other than 1000, wait with exponential backoff: 1 s, 2 s, 4 s, 8 s, capped at 16 s.
  2. Refresh the JWT before each retry (cheap insurance against token-expiry races).
  3. Reconnect with the same conversationId.
  4. Verify the handshake_ack.content.status is "resumed" — if it's "created", the server didn't find the conversation and you've lost continuity. Show that to the user.
  5. Cap retries (e.g., 5 attempts over 30 s) and show a clear error if you can't get back.

Clients that don't care about history​

Some clients (a stateless device, a simple integration) don't need to resume — every reconnect can be a brand-new conversation. Just omit conversationId and you'll get a fresh cnv_....

Server-initiated close codes​

Close codeMeaningRecover?
1000Normal close. The server uses 1000 for most error-and-close paths today (handshake parse errors, audio-config validation failures, QUOTA_EXCEEDED at handshake, etc.). The preceding error message is what tells you why.Decide based on the error code, not the close code.
1001Going away (TTL or server deploy).Yes — reconnect after backoff.
1003Unsupported data — currently used only for unknown conversationType and unsupported voice models.Fix the client; don't blindly retry.
1008Policy violation — emitted on authentication failure.Refresh token, reconnect.

Idempotency around tool calls​

If the socket closes mid-tool_request (you received the request but haven't sent the response), the in-flight LLM call is cancelled server-side. After reconnect, the agent will likely retry the same tool from scratch when the user sends another turn.

If your tools have side effects, make them idempotent — track which request_ids you've already executed, or rely on the tool's own idempotency at the destination service.

Sketch​

let attempts = 0;
let conversationId = null;

function connect() {
const ws = new WebSocket(`wss://api.example.com/user/agents/${agentId}/ws?token=${getJwt()}`);

ws.onopen = () => {
ws.send(JSON.stringify({
type: "handshake",
content_type: "json",
content: {
conversationType: "text",
...(conversationId && { conversationId }),
},
}));
};

ws.onmessage = (ev) => {
const msg = JSON.parse(ev.data);
if (msg.type === "handshake_ack") {
conversationId = msg.content.conversationId;
attempts = 0;
}
// ... handle other types
};

ws.onclose = (ev) => {
if (ev.code === 1000) return; // clean exit
if (ev.code === 1003) return showFatal(ev.reason); // bad protocol — don't retry
const delayMs = Math.min(1000 * 2 ** attempts++, 16000);
setTimeout(connect, delayMs);
};
}

connect();

On this page