Connection
Keep the API hostname configurable: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:
Authentication
Authenticate either on the upgrade request or in the first frame.- First frame
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 with4429:
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:{"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.
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: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:
saved_query_id produces a synthetic unwrapped result instead:
tool, output, tool_call_id, or message_id, and its rows are objects rather than positional arrays. Decode with:
tool_call also has no tool_call_id; it arrives immediately before the result.
Example turn
Interrupt a turn
Send theturn_id captured from turn_accepted:
interrupted frame is a bare acknowledgement that cancellation was requested:
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.- Call
GET /v1/chats/{chat_id}and replace local transcript rows withmessages. - Render
active_turn.partial_contentas the current assistant text and replace the local cursor withrunning_turn_last_seq. The snapshot contains canonical content through that watermark, so discard any older local partial buffer instead of concatenating it. - If
running_turn_idstill identifies the turn, keep the composer locked, reconnect, and request frames strictly after that refreshed cursor:
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.
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:Error handling
Close codes
Error frames
WebSocket errors have three distinct wire contexts:- 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. - Request and control errors outside an accepted turn can be enveloped with
seq: 0. Submission errors useturn_id: null; resume and other control errors can echo the suppliedturn_id. Examples include a missing message, invalid idempotency key, rate limit, chat lookup failure, or rejected resume. - Turn failures after
turn_acceptedare terminal turn frames with a non-nullturn_idand positiveseq. On the external socket, theirdatais normalized toerror,code,retryable, anddebug_id.
code, retryable, debug_id, or human-readable error; inspect fields defensively.
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.