Realtime API¶
Core’s stateful API: JSON-RPC 2.0 over WebSocket
(ADR-0018: REST for Stateless, WebSocket + JSON-RPC + MessagePack for Stateful,
APIs and Protocols). Implemented by kit/rpc.
Endpoint¶
GET /rpc on Core, upgraded to WebSocket. The client must offer one of
these subprotocols (Sec-WebSocket-Protocol):
Subprotocol |
Frames |
Use |
|---|---|---|
|
binary, MessagePack |
default |
|
text, JSON |
debugging and tooling |
Without a supported subprotocol the server closes the connection with
status 1008 (policy violation).
Messages¶
Every message is a JSON-RPC 2.0 envelope (a map with the same keys in MessagePack):
{"jsonrpc": "2.0", "id": 7, "method": "system.ping", "params": {}}
{"jsonrpc": "2.0", "id": 7, "result": {"time": 1790830000000, "subject": "8751…"}}
{"jsonrpc": "2.0", "id": 8, "error": {"code": -32601, "message": "method x not found"}}
{"jsonrpc": "2.0", "method": "$/heartbeat", "params": {"time": 1790830000000}}
Requests have an integer
id; responses carry the sameid.Notifications have no
idand get no response.The connection is symmetric: the server also sends requests and notifications to the client.
Notifications are sent and must be processed in order (stream events depend on it); requests may be processed concurrently.
Authentication¶
The first message must be an auth request:
{"jsonrpc": "2.0", "id": 1, "method": "auth", "params": {"token": "<OIDC access token>"}}
Result: {"subject": "…", "expires_at": <Unix seconds>}. Without a
valid auth request within 10 seconds, or with an invalid token, the
server closes the connection with status 4001.
Before expires_at, the client sends auth.refresh with a new token
for the same subject (kit/rpc clients do this automatically at
80 % of the token lifetime). When the token expires without refresh, the
server closes the connection with status 4001.
Heartbeats¶
Both sides send $/heartbeat notifications ({"time": <Unix ms>})
every 15 seconds. A side that receives nothing from its peer for three
intervals closes the connection with status 4000; clients then
reconnect. Browsers cannot send WebSocket ping frames, which is why
heartbeats are application messages.
Methods¶
Method |
Direction |
Description |
|---|---|---|
|
client → server |
See above |
|
both |
Liveness notification |
|
client → server |
Returns |
|
client → server |
Subscribe to a change stream, see below |
|
client → server |
|
|
server → client |
One event on a subscription |
|
server → client |
The server ended a subscription |
|
client → server |
Planner chat, see below |
|
server → client |
Live output of a planner turn |
Change streams¶
Streams carry the events of Core’s event log (Data) as they are recorded:
Stream |
Events |
Permission |
|---|---|---|
|
Everything in the project: items, dependencies, … |
|
|
Changes of one item |
|
stream.subscribe—{"subscription", "stream", "from_seq"?}→{"subscription", "seq"}subscriptionis an ID chosen by the client (1–64 characters, unique on the connection); everystream.eventcarries it. The client chooses it because replayed events can arrive before the subscribe response.seqis the event log position at subscription time. Withoutfrom_seq, only events afterseqare delivered. Withfrom_seq, the server first replays the stream’s events afterfrom_seq, then continues live — no gaps, no duplicates. If more than 1000 events would have to be replayed, it answers error-32010(resync required).
stream.event notification:
{"subscription": "s1", "seq": 1042, "type": "item.state_changed",
"entity_type": "item", "entity_id": "0199…", "entity_key": "WEB-42",
"occurred_at": 1790830000000,
"actor": {"kind": "human", "subject": "8751…"},
"payload": {"from": "backlog", "to": "ready"}}
stream.closed — {"subscription", "reason"}: reason lagging
means the client did not keep up; resubscribe with from_seq = last
received seq.
Client recipe (what the web UI does):
stream.subscribewithoutfrom_seq; remember the returnedseq.Load the snapshot over REST.
Apply
stream.eventnotifications, remembering the lastseq(events may already be in the snapshot; apply idempotently, e.g. by refetching the entity or comparingversion).After a reconnect, resubscribe with
from_seq= lastseq; on-32010, start again at step 1.
Events reach subscribers within about 200 ms of being recorded.
Planner chat¶
Planner sessions are created and read over REST
(/api/v1/projects/{project}/planner/sessions,
/api/v1/planner/sessions/{session} with the full transcript); the
conversation itself runs here
(ADR-0020: Planner Runs In-Process in Core).
planner.send—{"session", "text"}→{"seq"}Stores the human’s message (
seqis its position in the transcript) and starts the planner’s turn in the background: the model answers, possibly calling tools, until it stops. Needstracker.writeon the project and a human caller. One turn runs per session at a time:-32009(conflict) while the planner is still answering.planner.cancel—{"session"}→{}Stops the running turn, if any. Text streamed but not yet stored is discarded; stored messages stay.
planner.watch—{"subscription", "session"}→{"subscription", "running"}Delivers the session’s live output as
planner.outputnotifications untilstream.unsubscribe.runningtells whether a turn is in progress now. Needstracker.read.
planner.output notifications carry subscription, session
and type:
|
Meaning |
|---|---|
|
A piece of the assistant’s answer ( |
|
The planner called |
|
The tool returned |
|
Message |
|
Messages up to |
|
The turn failed or hit |
|
The turn ended; |
Live output is not replayed. A client that reconnects (or receives
stream.closed with lagging) watches again and reloads the
transcript over REST. Every stored message also records a
planner.message event on the project stream.
Error codes¶
Code |
Meaning |
|---|---|
|
Parse error / invalid request |
|
Method not found |
|
Invalid params |
|
Internal error (details only in server logs) |
|
Unauthenticated |
|
Forbidden |
|
Not found |
|
Conflict |
|
Resync required ( |
Close codes¶
Code |
Meaning |
|---|---|
|
Normal closure |
|
No supported subprotocol |
|
Heartbeat timeout |
|
Unauthenticated: missing/invalid |