SignalR
Real-time hubs — chat streaming, task status, approvals, 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:
| 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) |
Client → server methods:
| Method | Purpose |
|---|---|
SendMessage | Submit a user message |
CancelTask | Cancel in-flight task |
JoinSession | Subscribe to a session's events (auto-subscribed on SendMessage) |
LeaveSession | Stop receiving events for a session |
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:
user:{userId}— every event for this usertenant:{tenantId}:{userId}— every event for this user in this tenant (when multi-tenant)session:{sessionId}— every event in a specific chat sessiontask:{taskId}— events for one specific taskworkflow:{workflowId}— workflow run eventsnode:{nodeId}— command / heartbeat events for one node
Clients are auto-joined to user:{userId} on connect. Other groups are joined on Subscribe-style 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
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