Skip to content

Realtime API

The engine exposes server-sent events at GET /api/v1/stream and a bidirectional WebSocket at WS /api/v1/events. Both require the runtime bearer token.

Server-sent events

Connect with Authorization: Bearer <token>. The response is 200 text/event-stream with Cache-Control: no-cache, Connection: keep-alive, and X-Accel-Buffering: no. Each item contains only a compact JSON data line:

data: {"type":"...","timestamp":"...","data":{...}}

There are no SSE event, id, retry, heartbeat, history, or replay fields. A subscriber sees only future events. Each subscriber queue holds 100 events; when full, the oldest pending event is dropped.

Event type data Published by
thread.created {threadId,workspaceId,title} REST thread creation and WebSocket implicit thread creation
thread.updated {threadId,title,description} REST thread update only
message.created {messageId,threadId,role,content} REST message creation and WebSocket user/tool/assistant/system messages
graph.execution {thread_uuid,type,...graph fields} Foreground graph activity during WebSocket chat

Graph event types and fields are run.started {run_id,graph_id}, node.started {node_id}, model.delta {node_id,delta}, node.completed {node_id}, run.completed {run_id}, run.aborted {run_id}, and run.failed {run_id,error}. Workspace changes, deletes, files, settings, models, tools, RAG, and accounts do not publish SSE events.

WebSocket connection and envelope

Connect to ws://{host}:{port}/api/v1/events with the Bearer header. For client compatibility only, ?token=<token> is accepted when no Bearer value was supplied. Invalid authentication closes before acceptance with code 4401.

{"v":1,"type":"chat.send","id":"turn-123","data":{}}

Inbound frames require string type; string id is optional; non-object data becomes {}; inbound v is ignored. Outbound frames always contain {v:1,type,data} and include id when correlated. A missing/unknown type produces an uncorrelated error without closing the connection.

Optional client registration

client.hello is optional for chat, but registered sessions appear in the tray and receive cross-client broadcasts. Send {clientId?:string,clientName?:string}. The engine responds without a correlation ID:

  • client.hello.ack {clientId} — requested ID accepted or a generated ID assigned.
  • client.hello.reject {reason} — ID already belongs to an active connection; the socket remains open.

Client IDs are process-wide and must be released by disconnecting before another active socket can claim them.

Inbound WebSocket frames

type Correlation and data Behavior
client.hello No correlation; {clientId?:string,clientName?:string} Registers client identity; sends ack/reject.
ping ID/data ignored Sends uncorrelated pong with data:null.
chat.send id is turn ID or generated; ChatSend data Starts one asynchronous turn.
chat.cancel Envelope id, otherwise data.turn_id Cancels only the matching active turn; a non-match is silent.
chat.notification id or generated; {content:nonblank string,thread_uuid:string} Persists a system message; sends correlated done/error.
tool.approval.response Optional turn id; {approval_id:string,decision:string} Only case-insensitive approve approves; every other decision denies.
tool.register {tools:[{id:string},...]} Registers allowlisted UI tools for this connection; no acknowledgement.
tool.unregister {id:string} Removes one registered UI tool; no acknowledgement.
client.tool.result Mandatory turn id; {request_id:string,ok:boolean,result?:any,error?:string} Completes a pending client/UI tool call.
profile.set Ignored Accepted placeholder.
tool.result Ignored Accepted legacy placeholder.
any other type — Sends error {error:"Unknown frame type: ..."}.

A malformed tool.register.data.tools value that is not iterable is not guaranteed a protocol error and can terminate the handler. Send an array.

ChatSend

{
  content: string,
  thread_uuid: string XOR workspace_uuid: string,
  model_id?: string,
  tools_enabled?: boolean = true,
  file_context?: FileContext
}

Exactly one target is required. content must be a string but may be empty. Only literal JSON false disables built-in tools. Client-registered UI tools remain available regardless of tools_enabled.

FileContext = {
  active_file?: null|ContextFile,
  tagged_files?: ContextFile[] (max 20),
  attachments?: Attachment[] (max 5)
}
ContextFile = { root_index: integer>=0, path: string(1..1024), label?: any }
Attachment = {
  name: plain filename(1..255), media_type: string(max 255),
  data: valid base64 (max decoded 2 MiB)
}

Total decoded attachments are limited to 8 MiB. Tagged files are deduplicated by (root_index,path). If label is not a nonblank string, it defaults to path; nonblank labels are truncated to 1,024 characters. Empty attachment media type becomes application/octet-stream. File existence and root scope are checked only when a workspace-file tool reads the reference.

Binary parts are forwarded to non-echo models only for these exact, case-sensitive MIME strings: application/pdf, application/msword, application/vnd.ms-excel, application/vnd.openxmlformats-officedocument.wordprocessingml.document, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, text/plain, text/csv, text/html, text/markdown, image/jpeg, image/png, image/gif, image/webp, audio/wav, audio/mpeg, audio/ogg, audio/flac, audio/aiff, and audio/aac. Other media types are accepted within attachment limits but their bytes are not forwarded as model binary parts; only their context metadata remains available.

Client/UI tools

Only these IDs are accepted by tool.register: set_ui_theme_color, set_ui_lighting_mode, open_ui_workspace_file, navigate_ui, and set_workspace_builtin_tool_enabled. Definitions beyond id are ignored. Every UI tool always requests approval, then emits client.tool.request; the client has 60 seconds to return client.tool.result with the same turn ID and request ID.

Outbound WebSocket frames

type id data
client.hello.ack None {clientId}
client.hello.reject None {reason}
pong None null
error None {error}
chat.delta Turn {thread_uuid,delta}
chat.done Turn {thread_uuid}
chat.cancelled Turn {thread_uuid}
chat.error Turn {thread_uuid,error}
chat.notification.done Notification {thread_uuid,message_uuid}
chat.notification.error Notification {error}
agui.event Turn {thread_uuid,type,...graph fields}
tool.approval.request Turn {approval_id,thread_uuid,tool_name,arguments,operation}
client.tool.request Turn {request_id,tool_name,arguments}
thread.created None {threadId,workspaceId,title}
message.created None {messageId,threadId,role,content}

The agui.event name is active even though HTTP POST /agui is a placeholder. It carries graph lifecycle/model-delta events described in the SSE section.

Chat turn sequence

  1. The client sends chat.send with a unique id.
  2. With workspace_uuid, the engine creates a thread titled from normalized message text (60 characters maximum; blank becomes New conversation). With thread_uuid, it loads that thread and workspace.
  3. Model precedence is explicit model_id, thread default, workspace default, then echo. The resolved configuration ID is persisted back to thread.default_model_id, including a per-turn override.
  4. The user message commits before model execution. An implicitly created thread also becomes visible at this point and emits thread.created.
  5. Graph activity arrives as correlated agui.event; approvals/client tool calls pause until answered. Tool records are committed as compact JSON role=tool messages.
  6. The assistant message commits. When the final text is non-empty, the engine sends one chat.delta containing that complete text; it always follows with chat.done.

Do not assume chat.delta is token streaming

Stream-enabled graph nodes emit token/chunk updates as agui.event with type model.delta. A non-empty final response also produces one full-text chat.delta. When no graph engine is attached, that optional full-text frame is the only delta; an empty final response proceeds directly to chat.done.

Cancellation or failure after step 4 leaves the thread and user message stored; committed tool messages can also remain without an assistant reply. chat.cancelled or chat.error reports the outcome.

Approval, cancellation, and concurrency

Only one turn may run per WebSocket connection. A second send produces an uncorrelated error; separate connections can concurrently operate on the same thread because there is no per-thread lock. Approval requests have no timeout and wait until response, cancellation, or disconnect. Disconnect cancels the active turn and pending approval/client-tool futures.

A tool.approval.response may omit the turn ID, but if present it must match. A client.tool.result must include and match the turn ID. Stale, missing, or mismatched request IDs produce uncorrelated error frames.

Cross-client broadcasts

Only hello-registered sessions receive WebSocket broadcasts. Implicit WebSocket thread creation broadcasts thread.created; WebSocket user/tool/assistant/system persistence broadcasts message.created; the originating client is excluded. REST mutations publish SSE but are not WebSocket-broadcast. Deletes and workspace changes have no realtime notification. Clients should refresh after reconnect and must not treat these frames as a complete replication log.