Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.trellis.sh/llms.txt

Use this file to discover all available pages before exploring further.

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.

Connection

Connect to the WebSocket endpoint at:
wss://api.trellis.sh/v1/chats/ws
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.

Server-side keepalive (opt-in)

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.

Authentication

Concurrent connections

Each JWT may hold at most 3 concurrent WebSocket connections. A 4th connection receives a WS_CONNECTION_CAP error frame and is closed with code 4429:
{"event": "error", "data": {"code": "WS_CONNECTION_CAP", "retryable": false}}
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).

Frame rate limits

Each socket may send at most 10 send_message frames per minute with a 3-frame burst over 10 seconds. Excess frames receive an error event:
{"event": "error", "data": {"code": "RATE_LIMIT", "retryable": true, "retry_after_seconds": 7}}
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.

Sending Messages

After authentication, send chat messages using the send_message action:
action
string
required
Must be "send_message".
message
string
required
The natural language message to send.
integration_id
string
ID of the database integration to query. Required for database queries.
chat_id
string
required
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.
title
string
Optional title hint for the chat. If omitted, the chat keeps the title set at creation time.
model
string
AI model to use for this message. If omitted, the default model is used. See List Models for available values.
upload_ids
string[]
List of upload IDs to attach to this message. Upload files first via Upload Files, then reference their IDs here. Requires chat_id to be set.
include_tool_events
boolean
If true, emit tool_call and tool_result events for real-time tool execution visibility. Default: false.

Example message

{
  "action": "send_message",
  "message": "How many orders were placed last month?",
  "chat_id": "chat_abc123",
  "integration_id": "550e8400-e29b-41d4-a716-446655440000",
  "model": "claude-sonnet-4-6"
}

Example with file attachment

{
  "action": "send_message",
  "message": "Analyze the attached CSV data",
  "chat_id": "chat_abc123",
  "integration_id": "550e8400-e29b-41d4-a716-446655440000",
  "upload_ids": ["550e8400-e29b-41d4-a716-446655440000"]
}

Response Events

Events are delivered as JSON objects with event and data fields:
EventDataDescription
authenticated{"status": "ok"}Connection authenticated successfully
chat_metadata{"id": "chat_...", "user_message_id": "msg_..."}Chat ID and persisted user message ID. Emitted at the start of every response.
processing{"status": "starting" | "thinking" | "analyzing" | "processing"}AI processing status. starting is emitted within ~100ms of request receipt so the UI can render a spinner before agent startup completes.
tool_call{"tool": "...", "args": {...}}Tool invocation started (only if include_tool_events is true)
tool_result{...}Tool execution completed (only if include_tool_events is true)
visualizationFull chart or table payloadA chart or table was generated. See Visualizations.
text_delta{"content": "..."}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
error{"error": "...", "code": "...", "retryable": boolean, "debug_id": "...", "error_type": "..."}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

Example response stream

{"event": "chat_metadata", "data": {"id": "chat_abc123", "user_message_id": "msg_001"}}
{"event": "processing", "data": {"status": "thinking"}}
{"event": "processing", "data": {"status": "analyzing"}}
{"event": "visualization", "data": {"id": "ab12xy3456", "type": "chart", "chart_type": "bar", "title": "Orders by Region", "data": {"values": [{"label": "North", "value": 412}, {"label": "South", "value": 289}, {"label": "East", "value": 531}], "x_axis_label": "Region", "y_axis_label": "Orders"}}}
{"event": "text_delta", "data": {"content": "There were 1,232 orders "}}
{"event": "text_delta", "data": {"content": "placed last month, with the East "}}
{"event": "text_delta", "data": {"content": "region leading at 531."}}
{"event": "message", "data": {"content": "There were 1,232 orders placed last month, with the East region leading at 531.", "id": "msg_xyz"}}

Interrupting a Response

Send an interrupt action while the AI is streaming a response to cancel it:
{"action": "interrupt"}
The server cancels the active stream and responds with:
{"event": "interrupted", "data": {"status": "interrupted"}}
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.

Editing Messages

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.

Deleting Messages

Delete a message and all subsequent messages in the conversation:
{
  "action": "delete_message",
  "message_id": "msg_abc123"
}
Response:
{"event": "deleted", "data": {"message_id": "msg_abc123"}}

Keep-Alive

Send periodic pings to keep the connection alive:
{"action": "ping"}
Response:
{"event": "pong", "data": {}}

Interrupting a Response

Send an interrupt action while the AI is streaming to cancel the in-progress response:
{"action": "interrupt"}
The server cancels the stream and responds with:
{"event": "interrupted", "data": {"status": "interrupted"}}
If no response is currently streaming when interrupt is sent, an error event is returned instead.
For SSE connections, interruption is handled automatically when the HTTP connection is closed — no special action required.

Managing Messages

Delete a message

Send a delete_message action to remove a message and all subsequent messages from the chat. This is irreversible.
action
string
required
Must be "delete_message".
message_id
string
required
ID of the message to delete. The target message and every message after it are removed.
{"action": "delete_message", "message_id": "msg_xyz789"}
On success:
{"event": "deleted", "data": {"message_id": "msg_xyz789"}}

Edit a 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.
action
string
required
Must be "edit_message".
message_id
string
required
ID of the user message to edit.
content
string
required
The new message content to replace the original.
integration_id
string
ID of the database integration to query for the re-streamed response.
model
string
AI model to use for the re-streamed response.
include_tool_events
boolean
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"}}

Error Handling

Errors are delivered as events, not connection closures (unless authentication fails):
{
  "event": "error",
  "data": {
    "error": "Datasource not found",
    "code": "INTEGRATION_NOT_FOUND",
    "retryable": false,
    "debug_id": "4a8c0ae6",
    "error_type": "ValueError"
  }
}
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.

Connection close codes

CodeReason
4001Authentication required or invalid token
4002Invalid JSON message
4400JWT was passed in the URL (?token=). In practice the upgrade is rejected with HTTP 403 before the WebSocket exists, so this code is rarely observed.
4429Concurrent-connection cap (3 per JWT) reached
1011Internal server error

Error codes

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.
CodeRetryableTypical cause
INTEGRATION_NOT_FOUNDnoDatasource ID does not exist for this organization
INTEGRATION_NOT_CONFIGUREDnoDatasource is missing required configuration
DATABASE_CONNECTION_FAILEDyesNetwork or connection-pool failure reaching the datasource
DATABASE_QUERY_FAILEDnoSQL syntax error, missing column, or constraint violation
SERIALIZATION_ERRORnoA value in the payload is not JSON-serializable
VALIDATION_ERRORnoInvalid input (message content, parameters, IDs)
ACCESS_DENIEDnoCaller lacks permission for the requested resource
MISSING_CHAT_IDnosend_message or edit_message was sent without chat_id. Create one via Create Chat.
CHAT_NOT_FOUNDnoProvided chat_id does not belong to the caller. The connection stays open.
WS_CONNECTION_CAPnoThe 3-concurrent-connection cap for this JWT was reached; the connection is closed with code 4429.
RATE_LIMITyesEither 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_ERRORyesUpstream LLM (Anthropic / OpenAI / Google) returned an error
INTERNAL_ERRORyesUnclassified failure — check debug_id and retry with backoff
AGENT_RETRY_EXHAUSTEDyesThe 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.

Multi-Message Conversations

WebSocket connections persist, allowing you to send multiple messages in the same session:
// First message — uses chat_id obtained from POST /v1/chats/create
{"action": "send_message", "message": "Show me sales data", "chat_id": "chat_abc123", "integration_id": "..."}

// Response confirms chat_id and persisted user message
{"event": "chat_metadata", "data": {"id": "chat_abc123", "user_message_id": "msg_001"}}
{"event": "visualization", "data": {"id": "kl78mn9012", "type": "table", "headers": ["Region", "Sales"], "rows": [["North", "1,234,000"], ["South", "987,500"], ["East", "2,100,000"]]}}
{"event": "message", "data": {"content": "Here's the sales data broken down by region.", "id": "msg_002"}}

// Follow-up message — reuse the same chat_id
{"action": "send_message", "message": "Show that as a bar chart", "integration_id": "...", "chat_id": "chat_abc123"}

// Response
{"event": "chat_metadata", "data": {"id": "chat_abc123", "user_message_id": "msg_003"}}
{"event": "processing", "data": {"status": "thinking"}}
{"event": "visualization", "data": {"id": "op90qr1234", "type": "chart", "chart_type": "bar", "title": "Sales by Region", "data": {"values": [{"label": "North", "value": 1234000}, {"label": "South", "value": 987500}, {"label": "East", "value": 2100000}], "x_axis_label": "Region", "y_axis_label": "Sales (AED)"}}}
{"event": "message", "data": {"content": "Here's the regional breakdown as a bar chart. The East region leads with AED 2.1M.", "id": "msg_002"}}
const ws = new WebSocket("wss://api.trellis.sh/v1/chats/ws");

let streamingResponse = false;
let streamingBuffer = "";
let authenticated = false;

ws.onopen = () => {
  // Browser clients cannot set headers on the WS upgrade —
  // authenticate via the first frame instead.
  ws.send(JSON.stringify({ action: "authenticate", token: "YOUR_TOKEN" }));
};

ws.onmessage = (msg) => {
  const event = JSON.parse(msg.data);

  switch (event.event) {
    case "authenticated":
      authenticated = true;
      // Now that we're authenticated, send a message.
      ws.send(JSON.stringify({
        action: "send_message",
        message: "Show me orders by region as a bar chart",
        chat_id: "chat_abc123",
        integration_id: "550e8400-e29b-41d4-a716-446655440000",
      }));
      break;
    case "chat_metadata":
      console.log("Chat:", event.data.id, "| User message:", event.data.user_message_id);
      streamingResponse = true;
      streamingBuffer = "";
      break;
    case "visualization":
      if (event.data.type === "chart") {
        renderChart(event.data.chart_type, event.data.title, event.data.data);
      } else if (event.data.type === "table") {
        renderTable(event.data.headers, event.data.rows);
      }
      break;
    case "text_delta":
      // Append the incremental chunk and re-render
      streamingBuffer += event.data.content;
      renderStreamingMessage(streamingBuffer);
      break;
    case "message":
      // Replace buffer with authoritative full content + id
      displayMessage(event.data.content, event.data.id);
      streamingBuffer = "";
      streamingResponse = false;
      break;
    case "interrupted":
      // Any text already in streamingBuffer has been persisted server-side
      console.log("Response was interrupted");
      streamingResponse = false;
      break;
    case "deleted":
      console.log("Message deleted:", event.data.message_id);
      break;
    case "error":
      showError(event.data.error);
      streamingResponse = false;
      break;
    case "heartbeat":
      // Server keepalive — no action required
      break;
  }
};

// Interrupt a streaming response
function interruptResponse() {
  if (streamingResponse) {
    ws.send(JSON.stringify({ action: "interrupt" }));
  }
}

// Delete a message
function deleteMessage(messageId) {
  ws.send(JSON.stringify({ action: "delete_message", message_id: messageId }));
}

// Edit a message
function editMessage(messageId, newContent, integrationId) {
  ws.send(JSON.stringify({
    action: "edit_message",
    message_id: messageId,
    content: newContent,
    integration_id: integrationId,
  }));
}
{"event": "authenticated", "data": {"status": "ok"}}