Connect via WebSocket for real-time, bidirectional chat communication. WebSocket is ideal for applications that need to send multiple messages without reconnecting.
For event types and general chat concepts, see the Chat Overview.
The JWT must be supplied via the Authorization: Bearer header on the upgrade request, or via a first-message authenticate frame for browser clients.
Breaking change. Passing the JWT as a ?token= query parameter is no longer accepted — the server rejects the upgrade with HTTP 403 so the credential is treated as compromised on arrival. Query strings appear in load-balancer access logs, which is why this form was deprecated.
Long-running tool calls — for example a multi-query analytics tool that takes a minute to complete — can leave the WebSocket silent for tens of seconds while the server works. Some load balancers and mobile clients close idle connections in that window.To prevent that, append ?heartbeat=1 to the connection URL. The server will then emit a small heartbeat event every 15 s while a turn is streaming, which keeps the connection lit on every hop:
wss://api.trellis.sh/v1/chats/ws?heartbeat=1
Clients should treat the event as a no-op (ignore it explicitly, or fall through any unknown-event handler):
case "heartbeat": // server keepalive — no action required break;
Heartbeats are opt-in today and may become the default in a future release. Clients with strict event-type validation should add explicit handling before enabling the flag.
Set the Authorization: Bearer <jwt> header on the WebSocket upgrade request. This is the preferred form for non-browser clients (Python, Go, mobile SDKs):
import websocketsasync with websockets.connect( "wss://api.trellis.sh/v1/chats/ws", additional_headers=[("Authorization", "Bearer YOUR_TOKEN")],) as ws: ...
If the header is present but the JWT is invalid or expired, the connection is closed with code 4001.
The browser WebSocket constructor cannot set custom headers on the upgrade. Connect without auth, then send an authenticate frame as the first message:
Slots are released as connections close. To raise the ceiling for a workload, request a separate JWT (each call to Authenticate returns a token with its own jti claim).
The throttle is keyed per-socket; combined with the 3-connection cap, the per-JWT ceiling on send_message is 30 per minute. Other actions (ping, interrupt, delete_message, edit_message) are not throttled.
ID of an existing chat. Obtain one from Create Chat. Omitting this field returns an error event with code: "MISSING_CHAT_ID"; passing an ID that doesn’t belong to the caller returns code: "CHAT_NOT_FOUND". The connection stays open in both cases.
An incremental chunk of the final assistant text, streamed as the model generates it. Multiple frames arrive per turn. Concatenate content values in order to build the response. See Chat Overview → Streaming text deltas.
message
{"content": "...", "id": "msg_..."}
The final response. Always carries the authoritative full content plus the persisted message id. Safe to overwrite any streaming buffer with content on receipt.
interrupted
{"status": "interrupted"}
The in-progress response was cancelled by an interrupt action
An error occurred. See Error codes for the stable code enum and retry semantics.
pong
{}
Response to ping action
heartbeat
{"ts": <unix-seconds float>}
Server-initiated keepalive emitted every 15 s while a turn is streaming. Treat as a no-op. Only emitted when the WebSocket is opened with ?heartbeat=1 — see Server-side keepalive.
deleted
{"message_id": "msg_..."}
A message was successfully deleted via delete_message
The connection remains open for subsequent messages after an interrupt.
Any assistant text already streamed via text_delta frames before the interrupt is persisted as a regular assistant message and appears in subsequent GET /v1/chats/{id} responses. Your client’s streaming buffer at the moment of interrupt is a faithful preview of what got saved.
Edit a previously sent user message. This truncates all messages after the edited one and re-streams a new response.
{ "action": "edit_message", "message_id": "msg_abc123", "content": "Show me the top 10 instead", "integration_id": "550e8400-e29b-41d4-a716-446655440000"}
The server responds with chat_metadata followed by a new response stream, just like send_message.
Send an edit_message action to replace a user message’s content. All messages after the edited message are removed and a fresh agent response is streamed back.
Only user messages can be edited. Attempting to edit an assistant message returns an error event.
If true, internal tool events are included in the stream. Defaults to false.
{ "action": "edit_message", "message_id": "msg_xyz789", "content": "Show me the top 10 projects by budget instead", "integration_id": "550e8400-e29b-41d4-a716-446655440000"}
The server streams back a fresh response starting with chat_metadata:
{"event": "chat_metadata", "data": {"id": "chat_abc123", "user_message_id": "msg_new001"}}{"event": "processing", "data": {"status": "thinking"}}{"event": "message", "data": {"content": "Here are the top 10 projects by budget.", "id": "msg_new002"}}
The error string is human-readable and may change. Branch on code for retry logic. The debug_id is an 8-hex-char correlation ID — include it when reporting issues to support.
The code field on error events is a stable enum. Branch on it for retry logic rather than string-matching the error text. New codes may be added — treat unknown codes as non-retryable.
Code
Retryable
Typical cause
INTEGRATION_NOT_FOUND
no
Datasource ID does not exist for this organization
INTEGRATION_NOT_CONFIGURED
no
Datasource is missing required configuration
DATABASE_CONNECTION_FAILED
yes
Network or connection-pool failure reaching the datasource
DATABASE_QUERY_FAILED
no
SQL syntax error, missing column, or constraint violation
SERIALIZATION_ERROR
no
A value in the payload is not JSON-serializable
VALIDATION_ERROR
no
Invalid input (message content, parameters, IDs)
ACCESS_DENIED
no
Caller lacks permission for the requested resource
MISSING_CHAT_ID
no
send_message or edit_message was sent without chat_id. Create one via Create Chat.
CHAT_NOT_FOUND
no
Provided chat_id does not belong to the caller. The connection stays open.
WS_CONNECTION_CAP
no
The 3-concurrent-connection cap for this JWT was reached; the connection is closed with code 4429.
RATE_LIMIT
yes
Either the per-socket send_message frame throttle (10/min + 3/10s burst) or an upstream LLM rate limit. The payload includes retry_after_seconds for the frame throttle.
MODEL_ERROR
yes
Upstream LLM (Anthropic / OpenAI / Google) returned an error
INTERNAL_ERROR
yes
Unclassified failure — check debug_id and retry with backoff
AGENT_RETRY_EXHAUSTED
yes
The model produced invalid tool arguments repeatedly and the agent exhausted its retry budget. Often resolved by rephrasing the request or retrying with a different model.