Architecture
Reasoning channel
The reasoning channel is request/response:- A
ReasoningJobis created by the backend and published to theassistant_channelpubsub topic for the integration. - The assistant plugin subscribes to an SSE stream and receives the job envelope.
- The plugin executes the reasoning (via local LLM, remote API, or subagent).
- The plugin posts the result back to the backend’s reasoning job completion endpoint.
Envelope shape
BYO encryption
When the job is BYO-encrypted, thepayload contains a BYOEnvelope with ciphertext and nonce fields. The plugin decrypts using the XChaCha20-Poly1305 key derived from the X25519 key exchange performed during pairing.
SSE stream
The plugin connects toGET /api/v1/openclaw/integrations/{id}/stream with the relay token as Bearer auth. Events are delivered as Server-Sent Events.
Heartbeat
The plugin posts heartbeat updates to/api/v1/openclaw/integrations/{id}/heartbeat every 30 seconds, carrying host status and capability information.
Capability probe
When the backend sends a reasoning job withpurpose: "capability_probe", the assistant self-describes its available skills and returns a capability profile. This is displayed in the desktop assistant settings.
Chat channel
The chat with a paired assistant is keyed bysession_id (the relay session). Each user message starts a task identified by task_id. The first answer-bearing message ends that task. The same session spans the live call and the post-call review, and the post-call summary is another task in that session.
In the direct-relay topology there is no backend. The client (desktop or mobile) POSTs everything it wants to add to the conversation to /v1/incoming on the assistant, then reads replies back over GET /v1/events, a connection the client opens outbound and holds open. Because the connection is outbound from the client, this works from behind NAT, a firewall, or a phone network; the assistant never has to reach the client. An incoming event may instead carry a callback block asking for replies to be pushed to an HTTP endpoint; see Callback (alternative). StageWhisper’s own desktop and mobile clients never send that block, they always read from the stream.
Reply stream
ServeGET /v1/events?session_id={session_id} with the same Bearer token used for /v1/incoming. A request with no session_id gets 400. Keep the response open as text/event-stream and write each reply as an SSE frame:
data is one of the reply bodies described under Body, serialized as compact JSON on a single line. task_id has to be in the body here, unlike in a callback POST, because there is no URL path to carry it.
Only answer-bearing terminal replies have an id and enter the retained backlog. Send typing and tool_call as live-only progress frames without an id:
task_id for slow readers so it cannot displace a terminal reply.
id is opaque to the client but must be stable and increasing within a session:
epochidentifies your process; regenerate it on restart.generationidentifies the session’s current backlog; regenerate it if the session’s history is evicted and later recreated.seqis a counter starting at 1, per generation.
: open) so proxies flush their buffers, and send a comment (: keep-alive) roughly every 20 seconds while idle so Tailscale and other intermediaries don’t time the connection out.
Resuming after a disconnect
Clients reconnect withLast-Event-ID: {epoch}:{generation}:{seq} set to the last id they saw. Replay every retained terminal reply after that position first. Then send a reconciliation frame naming only the terminal replies known to have been evicted after the client’s cursor:
Last-Event-ID never gets a resync. A reconnect fails only the pending tasks listed in missed_task_ids. Every other pending task remains active because its reply may still be coming. Set recovery_complete to false if the bounded missed-task ledger no longer covers the requested cursor. Clients still do not infer that unnamed tasks were lost.
Retain at least the last 64 terminal replies or 1 MiB per session, whichever runs out first, and cap any single reply at 256 KiB. Keep a bounded ledger of evicted task_id values for exact reconciliation. If a reply doesn’t fit, enqueue an errored reply (error_code: "reply_too_large") in its place. Cap concurrent readers too; refuse a subscriber over the limit with 429.
The client opens a session subscription before dispatch. One physical subscription is shared by every pending task in that session and closes when the last task settles. The recording owns the separate insights: subscription for the recording lifetime.
Callback (alternative)
An incoming task event may carry a callback block:{callback.url}/tasks/{task_id} instead of writing it to the stream, with the callback token as Bearer auth. The path task_id is only a correlation handle, not a lock; what identifies the conversation is still session_id in the body. Retry 408, 425, 429, and 5xx responses (the reference plugins allow 4 attempts total, 5 second timeout each, backoff starting at 250ms and doubling); treat redirects and other 4xx as permanent and stop retrying.
Validate callback.url before trusting it. The reference plugins only allow a loopback origin, or an origin explicitly listed in an operator-configured allowlist, so a malicious event can’t make you POST into an internal network.
Body
session_id and status are required. For status: "message", send a stable, unique message_id. user_message_id is optional and links the answer or activity to the user message. Echo session_id back exactly as received. A session prefixed with insights: routes to Insights instead of chat.
Statuses
How a conversation flows
Sendtyping or tool_call with a short label while the task is active. Send one terminal message, completed, errored, or silent when the task settles. The agent may send that terminal reply from background work after dispatch returns. Dispatch completion is not task completion.
User messages arrive as plain text and belong to the same session memory. An approval reply such as “/approve” or “continue” is another task in that session. Do not special-case its text.
Messages for a session_id the desktop no longer knows are dropped with {"ok": true, "ignored": true, "reason": "session_ended"}.
Insights
Live insights for the Insights tab come from the transcript, not the chat. When the desktop forwards a transcript turn for analysis, it setssession_id to insights:<call-session>. Reply to it exactly as you would any other turn (a normal message with reply_text); because you echo session_id back unchanged, the desktop sees the insights: prefix and renders your reply in the Insights tab instead of the conversation. Keep it to one short sentence, and reply none (or stay silent) when nothing is worth surfacing. The coaching framing for these turns (the situation, the active playbook, and the one-sentence contract) is set by the desktop and sent once as a system_prelude on the insights: session when the call starts, so treat that prelude as the standing context for the session and do not prepend a coaching instruction of your own. Insights never appear in the chat, and transcript analysis never pollutes it.
Providers
StageWhisper’s provider registry mapsprovider_kind values (e.g., openclaw, hermes) to descriptors with install commands, pairing instructions, and protocol version requirements. Each provider implements the same envelope contract.
Current providers: OpenClaw, Hermes (coming soon).