Skip to main content
Use the per-agent WebSocket for foreground, token-by-token chat. One authenticated socket can multiplex turns from many chats, and a turn continues running if the socket disconnects.

Connection

Keep the API hostname configurable:
Read agent_id from Create Chat or Get Chat. A legacy chat can return null; fall back to your organization’s configured published agent UUID and verify it against List Agents. Do not infer it from the display name or is_trellis_default flag. The legacy row remains null, so apply the configured fallback on every load. The server validates the agent during setup:
Do not put the JWT in a ?token= query parameter. It is rejected with code 4400 because query strings can be written to access logs.

Authentication

Authenticate either on the upgrade request or in the first frame.
Success returns a bare frame:
A missing, invalid, or expired token closes with 4001. Malformed JSON in the first frame closes with 4002.

Connection and submission limits

At most three external sockets may be open for one authenticated member. A fourth receives a bare error and closes with 4429:
The submission budget is also member-scoped and shared across transports: WebSocket send_message, WebSocket edit_message, and REST POST .../turns consume one combined budget of 10 per minute and 3 per 10 seconds. Separate sockets or newly minted JWTs for the same member do not create new budgets. Rejected submissions count in both windows. Excess WebSocket submissions receive an enveloped pre-turn error:
ping, interrupt, resume, and delete_message do not consume this submission budget.

Keepalive

The server sends a bare heartbeat every 10 seconds for the socket’s entire lifetime:
You may also send {"action":"ping"}. The server replies {"event":"pong","data":{}}.

Frame envelope

Every frame associated with a turn is wrapped as:
string | null
Chat that owns the turn. It can be null on an error raised before the request identified a chat.
string | null
Durable turn ID. Submission errors sent before turn_accepted use null; resume and other control errors can echo a client-supplied turn ID.
integer
Monotonic sequence within one turn. turn_accepted and request/control errors outside an accepted turn use 0; replayable turn frames start at 1.
string
Event name.
object | string
Event-specific payload.
Partition by turn_id and order/deduplicate by (turn_id, seq). One socket can stream several chats concurrently. Bare control frames have only {event, data}: authenticated, heartbeat, pong, deleted, the interrupt acknowledgement named interrupted, and handshake/invalid-JSON/unknown-action/connection-cap errors. The enveloped interrupted event is a different frame: it terminalizes a cancelled turn.

Actions

Unknown actions receive a bare error; the socket remains open.

Send a message

string
required
Must be "send_message".
string
required
Existing, member-owned chat. Missing returns MISSING_CHAT_ID; unknown/unowned returns CHAT_NOT_FOUND; a non-null binding to another agent returns AGENT_CHAT_MISMATCH.
string
required
Non-empty user text.
string
Optional on WebSocket, but strongly recommended. Length 1–128. Retrying the same semantic payload with the same ID reattaches to the original turn; reusing it for a different payload returns IDEMPOTENCY_CONFLICT. Generate a new ID when any generation input changes.
string
Model override for this turn. Populate choices from List Models.
string
Database integration for this and subsequent turns. A valid change persists on the chat.
string[]
IDs returned by Upload Files.
string
Execute a stored query before the agent answers. An integration must be bound.
object
Saved-query parameters as string values.
string[]
Project scope to persist on the chat. Omit the field to retain stored scope; send [] to clear it. In request order, the server keeps the first occurrence of each valid, organization-owned, permitted project and persists at most the first two surviving IDs. Duplicate, malformed, unknown, out-of-organization, non-permitted, and additional IDs are silently discarded.
string
Legacy shorthand for a one-element scoped_project_ids. It is ignored when scoped_project_ids is present.

Compatibility-only send fields

The handler also reads these fields, but new external clients should avoid them: A second turn on the same chat is rejected with TURN_ALREADY_RUNNING until the running row reaches a terminal state.

Idempotent acceptance

An accepted send or edit begins with an enveloped acknowledgement at sequence zero:
Persist the turn_id immediately. A duplicate client_request_id with an identical payload returns the original turn_accepted, then replays/reattaches to that turn rather than writing another user message.

Event reference

All events in this table use the turn envelope. The success event is named message, not final.

Two tool_result shapes

Normal agent tool results are self-describing. The result body is data.output:
An explicit saved_query_id produces a synthetic unwrapped result instead:
This saved-query shape has no tool, output, tool_call_id, or message_id, and its rows are objects rather than positional arrays. Decode with:
The paired synthetic tool_call also has no tool_call_id; it arrives immediately before the result.

Example turn

Interrupt a turn

Send the turn_id captured from turn_accepted:
The first interrupted frame is a bare acknowledgement that cancellation was requested:
It is not terminal and does not prove the producer has stopped. Keep reading until the turn emits its separate enveloped terminal:
The terminal frame’s content is the authoritative persisted partial answer. Cancellation is cooperative and can take a few seconds across instances. Omitting turn_id returns an enveloped pre-turn error with {"error":"turn_id is required"}; an unknown/unowned turn returns the generic {"error":"Turn not found"}.

Resume after a disconnect

A disconnect does not cancel a turn. Treat Get Chat as the correctness path after a disconnect; exact WebSocket replay is an optimization for recovering intervening event frames.
  1. Call GET /v1/chats/{chat_id} and replace local transcript rows with messages.
  2. Render active_turn.partial_content as the current assistant text and replace the local cursor with running_turn_last_seq. The snapshot contains canonical content through that watermark, so discard any older local partial buffer instead of concatenating it.
  3. If running_turn_id still identifies the turn, keep the composer locked, reconnect, and request frames strictly after that refreshed cursor:
When exact replay is available, the server sends every frame with seq > last_seq, then live-tails the turn. Deduplicate by (turn_id, seq). Do not rely on exact replay as the only recovery path: the server can instead send a fallback resume control frame:
reason is gap, degraded, or expired. status is the raw durable value running, done, error, or cancelled. When this frame arrives, that subscription is finished; waiting on it loses the answer. Repeat the REST reconciliation above immediately, then resume from the new running_turn_last_seq after backoff if running_turn_id still matches. is_processing: false alone does not unlock the chat; it is only a liveness hint. When running_turn_id becomes null, the terminal persisted rows are in messages; stop resuming and unlock the composer. The server may also send resume without a reconnect if a slow client falls behind. Use the same reconciliation path.

Edit a user message

Editing deletes the target user message and all later messages, inserts the replacement user message, and starts a new durable turn. Only an existing user message can be edited.
string
required
Must be "edit_message".
string
required
Chat containing the message.
string
required
Existing user-message ID.
string
required
Non-empty replacement text.
string
Optional 1–128 character idempotency key. Strongly recommended.
string
Model override for the regenerated turn.
string
Datasource for the regenerated turn; persists on the chat.
string[]
Omit to retain stored scope; send [] to clear it. The server preserves first-occurrence request order, silently filters duplicate, invalid, unknown, out-of-organization, and non-permitted IDs, and persists at most the first two surviving IDs.
connection_id and connection_type are accepted as legacy datasource aliases. No other send-only fields are read: an edit cannot add or change upload_ids, attached_files, a saved query, parameters, project_id, or an internal schedule handoff.
Edits share the submission throttle, idempotency behavior, turn_accepted acknowledgement, and one-running-turn rule with sends. The destructive truncation and turn acceptance are atomic: a rejected edit does not truncate the transcript.

Delete a message

Deleting truncates the message and everything after it:
Success returns a bare frame:
This is also available over REST via Delete Message.

Error handling

Close codes

Error frames

WebSocket errors have three distinct wire contexts:
  1. Socket setup and control errors are bare {event, data} frames with no chat/turn envelope. Authentication, agent setup, invalid JSON, unknown-action, and connection-cap failures use this context.
  2. Request and control errors outside an accepted turn can be enveloped with seq: 0. Submission errors use turn_id: null; resume and other control errors can echo the supplied turn_id. Examples include a missing message, invalid idempotency key, rate limit, chat lookup failure, or rejected resume.
  3. Turn failures after turn_accepted are terminal turn frames with a non-null turn_id and positive seq. On the external socket, their data is normalized to error, code, retryable, and debug_id.
There is no universal error payload across all three contexts. Bare and pre-acknowledgement errors can omit code, retryable, debug_id, or human-readable error; inspect fields defensively.
When code is present, branch on it rather than the human-readable error. Retry only when retryable is explicitly true, include debug_id in support requests when present, and treat missing or unknown codes as non-retryable.