WebSocket Subscriptions
How a client opens a signed WebSocket subscription to an agents server and receives live account, agent, session, and run updates.

The Seed Agents WebSocket API delivers live account, agent, session, and run updates after a signed subscription handshake. The handshake uses the same signed envelope as the signed API.

Endpoint:

/agents/ws

Local URL (dev; release builds use port 3050):

ws://localhost:3051/agents/ws

URL helper: getAgentWebSocketUrl() in frontend/packages/ui/src/agents/client.ts.

Transport

Client → server:

    binary DAG-CBOR SignedActionEnvelope whose action is Subscribe.

Server → client:

    JSON string AgentWSEvent values.

Server-to-client events are not individually signed. Authorization happens once, at subscription time, on the socket.

Subscribe action

type Subscribe = { _: 'Subscribe' key: `account/${string}` | `agents/${string}` | `sessions/${string}` | `runs/${string}` afterSeq?: number }

The desktop must omit afterSeq when it has no value. Do not sign afterSeq: undefined. signAgentAction() adds a signed ts timestamp, and the server rejects stale or future subscriptions using the same five-minute window as HTTP actions.

Server events

type AgentWSEvent = | {_: 'connected'; connectedAt: number} | {_: 'subscribed'; key: string; accountId: string} | {_: 'append'; key: `sessions/${string}`; event: SessionEvent} | { _: 'appendPartial' key: `sessions/${string}` partialId: string patch: {textDelta?: string; done?: boolean; usage?: AgentRunUsage; activity?: AgentRunActivity} } | {_: 'change'; key: `sessions/${string}`; value: SessionInfo} | {_: 'change'; key: `agents/${string}`; value: AgentInfo} | { _: 'change' key: `account/${string}` value: {reason: string; agentId?: string; sessionId?: string; activity?: AgentActivity; session?: SessionInfo} } | {_: 'change'; key: `runs/${string}`; value: RunInfo} | {_: 'append'; key: `runs/${string}`; runId: string; seq: number; entry: Record<string, unknown>; createdAt: number} | { _: 'appendPartial' key: `runs/${string}` runId: string partialId: string patch: {progress?: {fraction?: number; label?: string}; activity?: AgentRunActivity; usage?: AgentRunUsage} } | {_: 'error'; message: string}

Subscription keys

account/<accountId>

Account-wide notifications. Every open desktop window and signed-in web tab holds one, because it feeds the unread indicator. So it carries only the small account/<id> change hints and run changes, and never transcript frames. Session appends, streaming partials, and session snapshots go only to direct sessions/<id> (and agents/<id>) subscribers. The sidebar and lists take the session snapshot and the agent's activity rollup from the hints. The key is the Seed account id.

Two reasons carry the agent's fresh activity rollup (AgentActivity: latest event time and kind, latest message time and sender, that message's session, and whether any run is live):

    session-event: coalesced to about one per session per 1.5 s while a transcript grows.

    session-updated: on a real session status transition, which is what flips busy.

Both also carry the session's fresh SessionInfo (session). Clients write the rollup into their cached agent rows and the snapshot into their cached session lists. An always-visible unread indicator or an open sidebar then costs no ListAgents, ListSessions, GetSession, or GetAgent refetch. The open transcript already streams on its own sessions/<id> subscription. A client that ignores the fields behaves as before.

agents/<agentId>

Agent detail updates and related session changes. The agent detail page uses this key.

sessions/<sessionId>

Session event stream from the Log. The session page uses this key and receives:

    replay of durable events after afterSeq;

    future durable append events from every actor, including the user. A verb the user ran through InvokeSessionTool arrives on this stream as tool_call and tool_result events stamped actor: 'user';

    session status change events;

    live appendPartial events carrying assistant text deltas, cumulative run usage, and the current AgentRunActivity (phase, toolName, toolCallId, detail, and the outputTail of a long-running tool call).

runs/<rootRunId>

One subscription streams a whole run tree. The key is the ROOT run id, and root_run_id is denormalized on every run row for this. On subscribe the server sends a snapshot, one change per run in the tree, followed by durable journal append replay (afterSeq applies per run). Live events:

    change with a RunInfo whenever any run in the tree changes status, usage, or plan;

    append with a workflow journal entry, tagged with the originating runId;

    appendPartial with ephemeral workflow progress (ctx.progress) and tool activity, tagged with runId.

The pinned run card on the session page is durable-first. It rebuilds from ListRuns and GetRunJournal, and uses this stream only for liveness.

Authorization

Service.verifySubscription() verifies:

    signed envelope shape;

    signed action timestamp is within five minutes of server local time;

    Ed25519 signature;

    signer authorization for account;

    requested key belongs to the account.

Rules:

    account/<accountId> must equal verified account ID.

    agents/<agentId> requires owner or accepted reader or writer access.

    sessions/<sessionId> requires owner or accepted reader or writer access to its agent.

    runs/<rootRunId> requires owner or accepted reader or writer access to its agent.

    Accepted collaborators receive the agent's live service events under their own account subscription. Pending and revoked collaborators do not.

    A socket may not switch accounts after a successful subscription.

Replay

Only durable session events are replayed. Live partials are not persisted and cannot be replayed.

For sessions/<id> with afterSeq, the server sends:

    subscribed;

    session change;

    durable append events where seq > afterSeq.

Durable appends vs partial appends

append

append is durable. It maps to a row in session_events.

Desktop behavior:

    inserts the event into the GetSession cache;

    removes matching optimistic user events;

    clears the visible partial for that session, because final durable data arrived;

    while that session is open, extracts hm:// references from structured tool results and assistant messages, and keeps them subscribed through the desktop sync service until the session closes. This runs only for the exact mounted sessions/<id> socket (a full session page or the selected Assistant-sidebar session). It never runs for account or agent sockets or background sessions. Comment references recursively subscribe to their target document. Newly published comments and documents from a remote agent server are then available locally before their links are opened.

appendPartial

appendPartial is non-durable. It represents in-progress assistant text.

Example:

{ "_": "appendPartial", "key": "sessions/abc", "partialId": "partial-uuid", "patch": {"textDelta": "hello"} }

The server eventually sends:

{ "_": "appendPartial", "key": "sessions/abc", "partialId": "partial-uuid", "patch": {"done": true} }

The desktop keeps the partial visible on done and clears it only when a durable append arrives. The Pi-backed runtime emits a fresh partial stream for each assistant turn and appends that turn's durable assistant message at Pi message_end, before any following tool execution events. Streamed text before a tool call then settles into the durable event list ahead of the durable tool_call row. It does not wait until the whole agent run ends.

Streaming diagnostics

Server logs:

    [agents/ws] open

    [agents/ws] subscribed

    [agents/ws] publish partial

    [agents/ws] send partial

    [agents/ws] skip partial; no subscription

    [agents/ws] close

Desktop logs:

    [agents/ws] connecting

    [agents/ws] open; signing subscribe

    [agents/ws] subscribe sent

    [agents/ws] subscribed event

    [agents/ws] partial event

    [agents/ws] partial state updated

    [agents/ws] partial marked done; keeping visible until durable append

    [agents/ws] ignored malformed message

Troubleshooting sequence:

    Confirm the desktop receives subscribed event.

    Confirm server logs publish partial.

    Confirm server logs send partial. skip partial means no subscription matched.

    Confirm the desktop logs partial event and partial state updated.

    Confirm UI logs rendering streaming assistant partial.

Known limitations

    Server-to-client events use JSON. They do not use CBOR.

    Events are not individually signed.

    Partial chunks are not durable and are not replayed.

    No explicit unsubscribe message exists.

    No heartbeat or ping protocol exists.

    No backpressure or subscription-limit handling exists.

    Desktop reconnect resubscribes, but there is no full persistent cursor manager for every resource type.

Future work

The planned WebSocket protocol v2 (heartbeat, explicit unsubscribe, subscription limits, backpressure, reconnect cursors, and metrics) is on the roadmap.

Do you like what you are reading? Subscribe to receive updates.

Unsubscribe anytime