Skip to content

WebSocket events ​

Halo WebSocket events: connect and authenticate, subscribe to conversations, who receives what, and every event channel, terminal stream included.

Halo pushes progress over a WebSocket as it happens: the reply as it is written, thinking and tool calls, questions waiting for an answer, and changes to Digital Humans, teams, files and terminals. The HTTP API starts things; the WebSocket tells you what happened next. Halo's web app and Android app use the same protocol.

Connect ​

The WebSocket shares the address and port of the HTTP API, at the path /ws:

text
ws://<HOST>:<PORT>/ws

Take the HTTP API base URL, replace http with ws (or https with wss) and append /ws. Behind a reverse proxy path prefix, keep the prefix: wss://<HOST>/<PATH_PREFIX>/ws. On the desktop, connections are accepted only while Enable Remote Access is on.

Authenticate ​

A new connection is not authenticated yet. Send the access password as the first message:

json
{ "type": "auth", "payload": { "token": "<ACCESS_PASSWORD>" } }

On success you receive:

json
{ "type": "auth:success" }

On failure you receive the message below, and the server closes the connection about 100 ms later:

json
{ "type": "auth:failed", "error": "Invalid token" }
  • The password travels in the message, not in a header. It is the same access password the HTTP requests use.
  • Failed sign-ins share the lockout rules with the HTTP login: repeated failures from one IP lock it out for a while, and every sign-in during the lockout fails.
  • subscribe or terminal messages sent before authenticating get { "type": "error", "error": "Not authenticated" }.
  • After changing the access password, restart Halo so connections that are already open are closed.

Message format ​

Both sides send JSON text frames. Messages from the client look like { "type": "…", "payload": { … } }:

typepayloadPurpose
auth{ "token": "<ACCESS_PASSWORD>" }Authenticate; must be the first message
subscribe{ "conversationId": "…" }Receive every event of this conversation
unsubscribe{ "conversationId": "…" }Stop receiving this conversation's detailed events
ping—The server answers { "type": "pong" }
terminal-input{ "sessionId": "…", "data": "…" }Send keystrokes to a terminal session; see terminal streaming
terminal-resize{ "sessionId": "…", "cols": 120, "rows": 32 }Resize a terminal
terminal-attach{ "sessionId": "…" }Take part in a terminal's flow control as a viewer
terminal-detach{ "sessionId": "…" }Leave flow control
terminal-ack{ "sessionId": "…", "charCount": 4096 }Report how many characters you have rendered

The server sends five kinds of message: auth:success, auth:failed, error, pong and event. Messages that are not valid JSON, and unknown types, are ignored.

Halo instances that share a team across computers use the same socket with team invite credentials and extra message types (auth:challenge, federation). A client signed in with the access password never receives those, and this page does not cover them.

Who receives which events ​

Every authenticated connection receives three kinds of events:

  1. Conversations it subscribed to: every event of the conversation, including the reply as it is written, thinking and tool calls.
  2. Status events of all conversations: agent:turn-start, agent:complete, agent:error, agent:ask-question and agent:goal-updated reach every connection without subscribing, so a client can show what is running and what is waiting for an answer. Filter them by data.conversationId.
  3. Global events: changes to Digital Humans, teams, files, terminals, notifications and more, sent to every connection.

Subscriptions belong to the connection. After reconnecting, send auth and every subscribe again.

Which conversationId to use:

  • For a conversation with Halo's assistant: the conversation's id, the conversationId you passed to POST /api/agent/message.
  • For a chat with a Digital Human: the thread key. The main thread is app-chat:<appId>; for any other thread use the conversationId returned by POST /api/apps/:appId/chat/send.

Event envelope ​

Every event the server pushes looks like this:

json
{ "type": "event", "channel": "agent:message", "data": { } }

The data of a conversation event always carries spaceId and conversationId, so you can route it to the right conversation and user.

Events can be sensitive

Events carry file paths, tool inputs and outputs, whatever was printed in a terminal, and channel configuration. Handle them on a trusted server and do not forward them as they are to end users' browsers.

Conversation events ​

ChannelWhenMain fields in data
agent:turn-startA turn startstype: "turn-start"
agent:messageReply text: a new text block, streamed chunks, the end of a block, the final reply of the turndelta, content, isStreaming, isNewTextBlock, isComplete; see assembling the reply
agent:thoughtA thinking or tool block starts, or an intermediate step completesthought: id, type (thinking, text, tool_use, tool_result, system, result, error), content, timestamp, and optionally toolName, toolInput, toolOutput, isError
agent:thought-deltaA chunk of thinking, or a thinking block, tool input or tool result completesthoughtId, plus delta, or content with isComplete, or toolInput or toolResult
agent:tool-callA tool call's arguments are completeid, name, status: "running", input
agent:tool-resultA tool call returnstoolId, result (string), isError
agent:ask-questionThe turn stops to ask the user somethingid, questions: each has question, header, options (label, description) and multiSelect
agent:completeThe turn ends: finished, failed or stoppedduration (ms), optional tokenUsage: inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens, totalCostUsd, contextWindow
agent:errorSomething failederror, optional errorCode; errorType is interrupted for an interruption, or working_dir_unavailable when the working folder is missing (with workDirIssue)
agent:session-infoThe session initializesslashCommands, skills, agents (string arrays)
agent:compactThe context is compactedtrigger (manual or auto), preTokens
agent:goal-updatedThe conversation's goal is set or updatedgoal (objective, doneWhen, status and more, or null), source, seenByModel
agent:api-retryA model call is retried after an error, and again when retrying endsretry: attempt, maxRetries, delayMs, errorStatus, errorKind; null when it ends
agent:input-arrivedUser or IM input is recorded in the conversationmessages, live
agent:input-stoppedA stop voided input that was not handled yetmessages
toolsets:changedA capability of the conversation is opened or closedtoolsetId, action (open or close), openedBy, open (the capabilities now open)
toolsets:requestedThe AI asks the user to open a capabilitytoolsetId, displayName

Assembling the reply ​

Build a turn's reply from agent:message events with these rules:

  1. An event with delta: append delta to the current text.
  2. An event without delta: replace the current text with content. An event with isNewTextBlock: true has an empty content and starts a new text block.
  3. An event with isComplete: true: its content is the complete reply of the turn.
  4. The agent:complete that follows ends the turn.

A typical sequence for one reply (spaceId and conversationId left out):

json
{"type":"event","channel":"agent:message","data":{"type":"message","content":"","isComplete":false,"isStreaming":false,"isNewTextBlock":true}}
{"type":"event","channel":"agent:message","data":{"type":"message","delta":"This workspace holds","isComplete":false,"isStreaming":true}}
{"type":"event","channel":"agent:message","data":{"type":"message","delta":" 3 files…","isComplete":false,"isStreaming":true}}
{"type":"event","channel":"agent:message","data":{"type":"message","content":"This workspace holds 3 files…","isComplete":true}}
{"type":"event","channel":"agent:complete","data":{"type":"complete","duration":8421}}

If you only need the final answer, take the content of the event with isComplete: true. For a typewriter effect, update the display with rules 1 and 2 as events arrive.

Global events ​

Digital Humans ​

ChannelWhenMain fields in data
app:status_changedA Digital Human's run state changesappId, state (status, runningCount, nextRunAtMs, lastError and more)
app:list_changedA Digital Human is installed or uninstalledappId, change (installed or uninstalled)
app:deletedA Digital Human is deleted permanentlyappId
app:activity_entry:newAn activity entry is added or updatedappId, entry (id, runId, type, ts, content)
app:escalation:newA Digital Human asks a question during a run and waits for an answerappId, entryId, question, choices; in a team also teamId, epochId
app:escalation:resolvedThe question has been answeredappId, entryId, response (choice, text, answers)
app:im-session-updatedA chat thread of a Digital Human has a new message or changedappId, channel, chatId, chatType, instanceId, lastMessage (first 50 characters)

Teams ​

ChannelWhenMain fields in data
team:updatedA team, its members or its run state changedteamId, plus team, changed, liveStatus, removed and more
team:blackboardA task, finding or activity is added to the boardteamId, epochId, kind (task, finding, activity)
team:messageMembers exchanged a message (metadata only, no body)teamId, epochId, fromAppId, toAppId, messageId, ts
team:presenceComputers of a team across computers come online or go offlineteamId, nodes
team:office-statusA team across computers paused, resumed, needs an update and similarteamId, kind
team:member-historyA member's transcript grew; read it againteamId, appId, epochId

Files, knowledge bases and system ​

ChannelWhenMain fields in data
artifact:tree-updateFolders in a workspace's file tree changedspaceId, updatedDirs
artifact:changed-batchA batch of files was created, changed or deletedspaceId, changes (type, path, relativePath)
tlon:ingest-progressIndexing progress of a knowledge basekbId, total, completed, phase, current, errors
tlon:stats-updatedA knowledge base's statistics changedkbId, stats
task:state_changedThe task panel's data changed; no payload, read it again—
notification:toastHalo shows a notificationtitle, body, variant, duration, and related ids such as appId or teamId
agent:mcp-statusThe status of MCP servers changedservers (name, status, tools, error and more), timestamp
ai-sources:catalog-changedThe model list of model sources changedsourceIds
credential:decrypt-failedSome credentials could not be decrypted and must be entered againfailures (path, label), never ciphertext
im-channels:instance-updatedA message channel's configuration was updatedinstanceId, instance (the full channel configuration)
app:memory-pressureThe memory pressure level changedlevel (normal, low, critical)
ai-browser:active-view-changedThe page the AI Browser shows changedconversationId, spaceId, viewId, url, title
ai-browser:view-goneAn AI Browser page closedviewId
ai-browser:conversation-releasedA conversation released its AI BrowserconversationId

Terminal streaming ​

Terminal output reaches every connection as terminal:data events: data holds the sessionId and a chunk of raw terminal output, ANSI escape codes included. terminal:lifecycle reports a session being created, exiting, changing its title or being removed, with sessionId, type and info. The terminal is available on macOS and Windows only.

  • Input: send terminal-input; data is raw keystrokes and \r is Enter. It does the same as POST /api/terminal/input without an HTTP request per keystroke.
  • Resize: send terminal-resize with positive integers cols and rows.
  • Flow control: after terminal-attach, the connection is a viewer of that session and must report what it has rendered with terminal-ack. When a viewer's unacknowledged backlog grows too large, the terminal pauses its output until the slowest viewer catches up. Do not send terminal-attach if you do not need flow control, and send terminal-detach when you are done. A closed connection detaches automatically.
  • Catch up: read the output produced before you connected with POST /api/terminal/replay.

Terminal output can contain anything printed in the shell, secrets included.

Keepalive and reconnecting ​

  • Every 15 seconds the server sends a WebSocket protocol ping and disconnects clients that do not answer with a pong. Common WebSocket clients answer automatically.
  • At the application level you can send { "type": "ping" }; the server answers { "type": "pong" }.
  • After a disconnect, reconnect with backoff (Halo's web app starts at 1 second and doubles up to 30 seconds), then authenticate and subscribe again.

Complete example ​

This Node.js script (Node.js 22 or later ships a WebSocket client) authenticates, subscribes to one conversation and prints the reply as it arrives:

js
const conversationId = process.argv[2]
const ws = new WebSocket(process.env.HALO_URL.replace(/^http/, 'ws') + '/ws')
let text = ''

ws.onopen = () => {
  ws.send(JSON.stringify({ type: 'auth', payload: { token: process.env.HALO_TOKEN } }))
}

ws.onmessage = (event) => {
  const msg = JSON.parse(event.data)
  if (msg.type === 'auth:failed') {
    console.error('Access password rejected')
    process.exit(1)
  }
  if (msg.type === 'auth:success') {
    ws.send(JSON.stringify({ type: 'subscribe', payload: { conversationId } }))
    return
  }
  if (msg.type !== 'event' || msg.data.conversationId !== conversationId) return

  if (msg.channel === 'agent:message') {
    if (typeof msg.data.delta === 'string') {
      text += msg.data.delta
      process.stdout.write(msg.data.delta)
    } else {
      text = msg.data.content ?? ''
    }
  }
  if (msg.channel === 'agent:complete') {
    console.log('\n[done]')
    ws.close()
  }
  if (msg.channel === 'agent:error') {
    console.error('\n[error]', msg.data.error)
    ws.close()
  }
}

Set HALO_URL and HALO_TOKEN as in the quickstart, then run node watch.mjs <CONVERSATION_ID>.

基于 MIT 协议开源 · Open source under the MIT license