SignalRNEW
Real-time hubs — chat streaming, task status, approvals, voice transcription, node commands, discussions, and push events.
SignalR is Sophon's real-time transport. The Dashboard, Mobile, Desktop, and Sophon Node all connect to SignalR hubs for streaming, live status, and bidirectional commands. This page is the protocol reference.
Hubs
Sophon exposes six hubs, mounted under /hubs/*:
| Hub | Purpose |
|---|---|
/hubs/chat | Chat streaming, tool-call events, session updates |
/hubs/tasks | Task ledger live updates |
/hubs/approvals | Approval request / response events |
/hubs/node | Sophon Node command dispatch + heartbeats |
/hubs/discussion | Discussion run events |
/hubs/notifications | Generic push-event stream |
A single client can connect to multiple hubs over one WebSocket (SignalR multiplexes).
Authentication
Same JWT bearer as REST. Pass via query string (SignalR's WebSocket limitation):
import { HubConnectionBuilder } from "@microsoft/signalr";
const connection = new HubConnectionBuilder()
.withUrl("https://gw.example.com/hubs/chat", { accessTokenFactory: () => jwt })
.withAutomaticReconnect()
.build();Chat hub
await connection.invoke("SendMessage", {
sessionId: "sess_abc",
agentId: "sophon",
content: "What's on my calendar today?",
channelId: "webchat", // or null for default
attachments: [],
isStreaming: true
});Server → client events. As of 1.11.0, task stream, status, lifecycle, and approval events are delivered to the session group rather than the whole user — see Session scoping.
| Event | Payload | Meaning |
|---|---|---|
TaskQueued | {taskId, sessionId} | Message accepted, background task queued |
TaskStarted | {taskId, startedAt} | Worker picked up the task |
AgentStatus | {taskId, status, detail} | Periodic checkpoint (thinking, calling_tool, waiting_approval) |
StreamChunk | {taskId, delta, isFinal} | Token stream (if streaming enabled) |
ToolCallStarted | {taskId, toolName, argsHash} | Tool invocation begins |
ToolCallCompleted | {taskId, toolName, durationMs, ok} | Tool returned |
ReceiveMessage | {messageId, sessionId, role, content, toolCalls?} | Final agent message |
TaskCompleted | {taskId, durationMs, tokensUsed} | Task done |
TaskFailed | {taskId, error} | Task errored |
SessionUpdated | {sessionId, title, updatedAt} | Session metadata changed (title, last activity) |
SessionNotification | {sessionId, kind, refId, success, excerpt, timestamp} | User-wide activity event (see SessionNotification) |
VoiceInterimTranscript | {sessionId, text} | Live partial transcript during voice input — sent only to the connection streaming the audio |
VoiceFinalTranscript | {sessionId, text, confidence} | Committed voice utterance — enters the agent pipeline; sent only to the connection streaming the audio |
Client → server methods:
| Method | Purpose |
|---|---|
SendMessage | Submit a user message |
CancelTask | Cancel in-flight task |
JoinSession | Subscribe to a session's events — the primary subscription mechanism (see Session scoping) |
LeaveSession | Stop receiving events for a session |
StreamAudio | Stream microphone audio for realtime transcription (see Voice streaming) |
Session scoping
As of 1.11.0, JoinSession(sessionId) is the primary subscription mechanism, and per-task events target the session group instead of broadcasting user-wide:
- Clients are auto-joined to sessions they create — sending the first message on a new session subscribes you implicitly.
- Ownership is validated on every join — a client can only join sessions owned by its user (and tenant). The Canvas (whiteboard) hub validates session ownership on join the same way.
- Legacy clients that never call
JoinSessionfall back to user-wide events, so older integrations keep working unchanged.
This is what keeps multiple devices from interfering: a Dashboard tab watching one session never receives another session's stream chunks, and approvals shown in the CLI aren't disturbed by activity on other devices. Devices not joined to a session still learn about its activity via SessionNotification.
SessionNotification
SessionNotification is a lightweight, user-wide activity event — every connected device receives it, whether or not it has joined the session:
| Field | Meaning |
|---|---|
sessionId | The session with activity |
kind | What happened — one of the seven kinds below |
refId | ID of the thing the event refers to (task, approval, or info request) |
success | Outcome flag, where the kind has one (e.g. false on taskFailed) |
excerpt | Short content excerpt, when one is available |
timestamp | When the event occurred |
| Kind | Fires when |
|---|---|
taskStarted | A task begins running |
taskCompleted | A task finishes successfully |
taskFailed | A task errors |
taskCancelled | A task is cancelled |
approvalPending | An approval is waiting for a decision |
infoRequestPending | The agent is waiting on a user answer |
heartbeatActioned | A heartbeat run took action — see Heartbeat |
The excerpt gives clients a short preview to render; full message content and detail still require JoinSession or REST. Clients use the event to update unread badges and pending-approval counters. The Desktop app's native notifications are driven by this event.
Voice streaming
StreamAudio(sessionId, audioChunks, mode?) is a client-to-server streaming method for realtime speech-to-text:
import { Subject } from "@microsoft/signalr";
const audioChunks = new Subject<string>();
await connection.send("StreamAudio", sessionId, audioChunks, "Auto");
// feed base64-encoded PCM chunks as the mic produces them
audioChunks.next(base64Chunk);
// Manual mode only: end the utterance explicitly
audioChunks.complete();- Audio format — 16 kHz mono linear16 PCM, base64-encoded per chunk. This works over the standard SignalR JSON protocol; no binary transport is required.
mode: "Auto"(default) — hands-free; the STT provider's pause detection ends the utterance.mode: "Manual"— push-to-talk; the client ends the stream by completing it.
Transcripts are delivered only to the connection streaming the audio — not to the session group, so other devices joined to the session don't see them: VoiceInterimTranscript fires while the user speaks (each interim replaces the previous one), and VoiceFinalTranscript carries the committed utterance, which enters the agent pipeline like a typed message. See Voice for provider setup and per-user listening preferences.
Tasks hub
Subscribes to the task ledger (all tasks, not just one session).
Server → client:
TaskQueued,TaskStarted,TaskCompleted,TaskFailed— same shapes as chat hubTaskCancelled—{taskId, cancelledBy}
Client → server:
Watch— start receiving events (no args; server pushes all tasks for the user)Unwatch— stop receiving events
Approvals hub
Drives the pending-approval queue.
Server → client:
| Event | Payload |
|---|---|
ApprovalRequested | Full ApprovalRequest object |
ApprovalResolved | {id, outcome, outcomeDetail} — approved/rejected/edited/timeOut |
InfoRequested | InfoRequest object |
InfoResolved | {id, response} |
Client → server:
| Method | Payload |
|---|---|
Approve | {approvalId} |
Reject | {approvalId, reason?} |
Edit | {approvalId, modifiedParams} |
RespondInfo | {infoId, response} |
Node hub (/hubs/node)
Bidirectional command channel between the Gateway and Sophon Nodes. Nodes connect as clients.
Gateway → Node (methods Nodes implement):
| Method | Payload |
|---|---|
ExecuteCommand | {commandId, command, params} — Node runs it, returns result |
RefreshScopes | {scopes} — update permission scopes without reconnect |
RevokeAccess | {} — force disconnect |
Node → Gateway:
| Method | Payload |
|---|---|
Heartbeat | {nodeId, status, uptime} — every 30 s |
CommandResult | {commandId, ok, result, error?} — response to ExecuteCommand |
ScopeChanged | {scopes} — ack of RefreshScopes |
See Sophon Node for command semantics and Node Protocol for field schemas.
Discussion hub
Server → client:
| Event | Payload |
|---|---|
DiscussionStarted | {runId, topic, panelists, judge} |
RoundStarted | {runId, roundNumber} |
TurnStarted | {runId, panelistId, roundNumber} |
TurnCompleted | {runId, panelistId, contentHash, durationMs} |
JudgeStarted | {runId} |
Verdict | {runId, verdict: DiscussionVerdict} |
DiscussionCompleted | {runId, verdictHash, durationMs} |
DiscussionFailed | {runId, error} |
DiscussionCancelled | {runId, cancelledBy} |
Client → server:
Subscribe/Unsubscribe— byrunIdCancelRun—{runId}
Notifications hub
Generic pub/sub for user-scoped events. Used by the Home feed, insight cards, and any event clients want to watch live.
Events:
InsightCardAdded/InsightCardDismissedWorkflowRunStarted/WorkflowRunCompleted/WorkflowRunFailedCronFired/CronFailedDocumentUploaded/DocumentIndexedNodeOnline/NodeOfflineSystemAnnouncement—{title, body, severity}for admin-broadcast messagesLicenseWarning— license expiring / expired (admin only)
Groups
SignalR groups are the mechanism for user / tenant / session scoping. As of 1.11.0, all group names are tenant-qualified — session groups are scoped to the tenant that owns the session, and user groups are prefixed with the tenant ID. A shared Gateway hosting multiple tenants can never deliver one tenant's events to another tenant's connections. See Tenants & Multi-Tenancy.
| Group | Scope |
|---|---|
| User group | User-wide events for one user in one tenant (SessionNotification, session list updates) |
| Session group | Every event in one chat session — joined via JoinSession, ownership-validated |
| Task group | Events for one specific task |
| Workflow group | Workflow run events |
| Node group | Command / heartbeat events for one node |
Clients are auto-joined to their user group on connect and to session groups for sessions they create. Group names are server-internal — clients only ever pass IDs (sessionId, taskId, runId) to subscribe methods.
Reconnection
SignalR's withAutomaticReconnect() handles network blips. Reconnection retries with exponential backoff (0 ms, 2 s, 10 s, 30 s, then every 30 s).
On reconnect:
- Subscriptions are re-established (groups rejoined)
- Missed events are not replayed — use REST to catch up (e.g.,
GET /tasks/{id}for task state) - Streaming tasks that were in flight continue; the client sees a possibly-incomplete stream followed by
TaskCompletedonce the task finishes
Rejoining mid-task: after JoinSession, fetch the persisted message (GET /sessions/{id}/messages) and replace any partial stream buffer with it — appending renders duplicated text. Guard for staleness: the probe may discover the task already finished while you were reconnecting; in that case render the final message and don't re-arm any waiting or streaming state. The Dashboard and Mobile apps do this automatically.
Backpressure
Server buffers outgoing events per-connection (default: 100 events). Beyond that, the slowest consumer is dropped. This prevents a slow client from OOMing the Gateway.
The mobile app and Dashboard rarely hit this. Custom SignalR clients that can't keep up should either fan out to a queue or switch to polling REST.
Transport selection
Preference order:
- WebSockets — default; lowest latency, bidirectional
- Server-Sent Events — for browsers that block WebSockets
- Long polling — fallback for restrictive proxies
Sophon Node additionally supports HTTP polling fallback (GET /api/nodes/me/commands) for environments where WebSockets are blocked outbound.
Client examples
TypeScript (browser)
import { HubConnectionBuilder } from "@microsoft/signalr";
const conn = new HubConnectionBuilder()
.withUrl("https://gw.example.com/hubs/chat", { accessTokenFactory: () => jwt })
.withAutomaticReconnect()
.build();
conn.on("StreamChunk", (c) => process.stdout.write(c.delta));
conn.on("TaskCompleted", (c) => console.log("\nDone in", c.durationMs, "ms"));
await conn.start();
await conn.invoke("SendMessage", {
sessionId, agentId: "sophon", content: "hi", isStreaming: true
});.NET / C#
var conn = new HubConnectionBuilder()
.WithUrl("https://gw.example.com/hubs/chat", o => o.AccessTokenProvider = () => Task.FromResult(jwt))
.WithAutomaticReconnect()
.Build();
conn.On<StreamChunkEvent>("StreamChunk", c => Console.Write(c.Delta));
await conn.StartAsync();
await conn.InvokeAsync("SendMessage", new { sessionId, agentId = "sophon", content = "hi", isStreaming = true });Python (signalrcore)
from signalrcore.hub_connection_builder import HubConnectionBuilder
hub = (HubConnectionBuilder()
.with_url(f"https://gw.example.com/hubs/chat?access_token={jwt}")
.with_automatic_reconnect()
.build())
hub.on("StreamChunk", lambda msg: print(msg[0]["delta"], end=""))
hub.start()
hub.send("SendMessage", [{"sessionId": sid, "agentId": "sophon", "content": "hi", "isStreaming": True}])Where to go next
- REST API — everything SignalR doesn't stream
- Node Protocol — the Node-specific hub contract
- Webhooks — for consumers that prefer push over WebSockets
- Voice — STT/TTS provider configuration behind
StreamAudio