Skip to article
Browse chapters

Schemas and protocols

The two committed JSON Schemas, the versions they carry, and the three wire formats bingo speaks.

Two schemas, both generated

bingo commits two JSON Schema documents at the root of the repository. Both are generated by schemars from the sdk’s own types, so the schema is the code rather than a description of it.

filetitleprotocolwhat it describes
schema/rpc.jsonbingo rpc1the JSON-RPC surface bingo serve speaks
schema/plugin.jsonbingo plugin5the wire a cross-process plugin speaks

Each holds $defs for every wire type — 81 in rpc.json, 110 in plugin.json — plus a methods table and a notifications table whose entries are $refs into those definitions. plugin.json also carries a manifest reference and a hostService block.

Two tests keep them honest. One regenerates the document and fails on any difference, naming the command that updates it:

BINGO_UPDATE_SCHEMA=1 cargo test -p bingo-surface-rpc
BINGO_UPDATE_SCHEMA=1 cargo test -p bingo-plugin-rpc

The other asserts that every property name is camelCase, which is what the journal, the RPC surface and the plugin wire all agree on.

Read the schema, not this page, when you are writing against either wire. What follows is orientation.

The RPC surface

JSON-RPC 2.0, one message per line, UTF-8, over stdin and stdout (ADR-0007).

The method table is the kernel’s host API one for one, plus a handshake. initialize must come first — anything else is NOT_INITIALIZED.

methodfor
initializethe handshake: client identity and protocol in, name, version and capabilities out
shutdownend the server
session/listsessions matching a filter
session/openattach: returns the session and a snapshot
session/closedetach; the session keeps running
session/deletedelete it
session/historypage a long transcript
session/eventsresync: frames after a since are re-sent
session/submitone input
session/interruptstop a turn
session/answeranswer an open interaction
session/deliverpeer delivery into another session’s queue
session/extendpublish durable plugin state
session/signalpublish ephemeral plugin state
catalog/readone of models, providers, tools, commands, skills, plugins
gateway/subscribegateway-level events

Two notifications: event, carrying a Frame exactly as the sdk serialises it, and gateway/event.

Three properties are worth knowing before writing a client:

  • Events are verbatim. A client folds them with SessionState::apply — the same reducer the kernel runs — and derives its view. The session/open response, a snapshot at seq N, is written before any event of that session with seq greater than N; frames of one session arrive in seq order; and Lagged { from, to } means “call session/events with your seq”.
  • Writes return {}. submit, interrupt and answer answer as soon as the mailbox took them. The outcome arrives as the IntentAck event whose intent is the ULID the client minted, which is also the idempotency key.
  • Errors are two-layered. JSON-RPC’s own codes for protocol faults — -32700 parse, -32600 invalid request, -32601 unknown method, -32602 invalid params — and -32000 for every kernel error, whose stable string travels in error.data.code and whose text is error.message:
{"jsonrpc":"2.0","id":3,"error":{"code":-32000,"message":"no such session",
 "data":{"code":"SESSION_NOT_FOUND"}}}

One stdio server serves one client. Two processes on one session are refused by the store’s lock. Concurrent clients of one server arrive with the WebSocket transport, which will carry the same bytes.

The plugin wire

Also JSON-RPC 2.0 over NDJSON, and also the sdk’s own types as JSON — the bridge adds envelopes, never shapes.

Nine methods: initialize, tool/call, command/run, command/complete, context/contribute, compactor/compact, provider/stream, hook/decide, and service/call — the one that travels in both directions. Five notifications: tool/progress, tool/cancel, provider/delta, provider/cancel, hook/observe.

The PROTOCOL version is 5, sent in the handshake and echoed back. A major the host does not speak is refused with a notice rather than guessed at. The method count is not a literal any more — it is a pin derived from the committed schema, so opening a new capability means regenerating the document and bumping the protocol.

hostService in the schema names the one service the bridge reserves, bingo.host, and the two methods behind it. See wire services.

The stream-json envelope

--print --output-format stream-json is a lossy compatibility encoder, not a third protocol (ADR-0007 §8). It projects the same frames onto Claude Code’s dialect so a host that already speaks it can drive bingo without a plugin. The kernel does not know it exists.

Line types: system with subtype init first, then assistant and user lines as the turn runs, and one result line at the end.

{"type":"system","subtype":"init","session_id":"…","cwd":"…","tools":[…],
 "model":"…","permissionMode":"default","apiKeySource":"none"}
{"type":"result","subtype":"success","is_error":false,"duration_ms":8412,
 "duration_api_ms":0,"num_turns":3,"result":"…","session_id":"…",
 "total_cost_usd":0.0,"usage":{…}}

result exists only on the success arm; the error arms carry errors in its place. Fields bingo cannot fill are left out rather than invented — uuid, mcp_servers, slash_commands, modelUsage, permission_denials, the result line’s stop_reason. What is written but constant is written honestly: apiKeySource is none, total_cost_usd is 0.0 because nothing here prices a turn, duration_api_ms is 0 because only the whole turn is timed, and a message’s usage is zero because bingo counts tokens per round and reports the total once, in the result line.

The matching input direction, --input-format stream-json, is documented in running headless.

For anything more than compatibility, --output-format json gives you the frames themselves.

The journal format

A session on disk is its journal written down (ADR-0005). Line 1 is a header:

{"format":"bingo-journal","version":1,"session":"<id>"}

Every following line is one durable Frame exactly as it serialises, in seq order, appended and flushed per frame, never rewritten. Ephemeral frames are never written.

Three rules follow. A torn last line — a crash mid-write — is dropped, and replay ends at the last whole frame. An unreadable line anywhere else is a STORAGE error naming the line: corruption is reported, not skipped. And a header version newer than the reader’s is refused, because version 1 is never edited in place. A format change is a new version and a migrator that re-folds.

The two files beside it are .lock, the only claim of ownership, and summary.json, which is derived — a missing one is rebuilt from the journal, and deleting every one loses nothing but the speed of list.