@canonmsg/framework
v0.3.0
Published
Canon Framework (Node) — thin adapter client for the Bridge local JSON-RPC transport
Maintainers
Readme
@canonmsg/framework
The Canon Framework (Node): the thin adapter-side client for the Canon
Bridge (canon-bridge), the per-identity daemon that owns everything hard
about talking to Canon — auth, the SSE stream, reconnect/replay, RTDB, dedup,
media bytes, HITL durability, throttles. You write an adapter: receive
decoded events, call typed methods. That's the whole job.
Python twin: canon-framework (packages/framework-py). Contract source of
truth: @canonmsg/backend-contracts (bridgeLocal.ts, protocolVersion 1).
Concept docs: docs/design/adapter-interface.md (what you implement) and
docs/design/bridge-local-api.md (the full local API).
Quickstart — the §7 hello world
import { connectBridge } from '@canonmsg/framework';
const { client: canon } = await connectBridge({
profile: 'my-agent', // identity from ~/.canon/agents.json
hello: { clientType: 'my-adapter', wantFamilies: ['messages'] },
configure: (client) => {
// Register handlers HERE (before hello): replayed events start flowing
// immediately after the handshake and must not race your registration.
client.onNotification('onMessage', async (params) => {
const { conversationId, message } = params as any;
await canon.sendMessage({ conversationId, text: `hello: ${message.text}` });
});
},
});That is a complete, working Canon integration. connectBridge lazily
auto-spawns the daemon (ssh-agent model) when none is running.
The reference adapter is examples/hello-world.ts —
the §7 shape grown with every recommended surface (capabilities, control
signals, streaming, typing, HITL, media, §G resume + resync recovery), fully
deterministic and scriptable. It is exercised end-to-end by the Phase-4 gate
suite (packages/bridge/test/e2e/gate.e2e.test.ts) and seeds the Ring-1
MockRuntimeAdapter. Run it:
npm run build -w packages/framework
npx tsx packages/framework/examples/hello-world.ts my-agentSpawn modes
await connectBridge({ profile, mode, hello, ... })| mode | behavior |
|---|---|
| auto (default) | connect; on miss spawn a detached daemon (dev machines — survives you) |
| connect | connect only; typed BridgeUnavailableError when no daemon is up |
| spawn-detached | like auto; explicit about the detached lifecycle |
| managed-child | spawned daemon dies with your process (containers — holds a stdin pipe) |
Concurrent auto callers race one O_EXCL spawn lock under ~/.canon/run/;
exactly one daemon wins and everyone connects to it. Binary resolution:
binPath option → CANON_BRIDGE_BIN → the shared install at
~/.canon/bin/canon-bridge. CANON_HOME redirects the whole ~/.canon tree.
Receiving events (handlers you implement)
Only onMessage is required. Everything else is opt-in — subscribe to what
you handle, declare families in hello.wantFamilies
(messages | contacts | typing_presence | runtime_turn | voice; [] = a
pure-request adapter that receives nothing).
Every replayable notification carries an opaque cursor (§G). Persist the
last cursor you fully processed and present it as hello.resumeCursor on
reconnect: the bridge replays exactly the gap — no duplicates, no holes. When
it cannot (daemon restarted, cursor evicted), the hello result carries an
explicit resync: { reason } instead — recover out-of-band with
getConversations() + getMessages() reconciled against your processed ids
(hello-world's recover() is the reference implementation). Never silent loss.
Handle onControlSignal at least for stop: interrupt/stop_and_drop must
end the running turn.
Calling methods
BridgeClient has typed wrappers for the whole §B surface: messaging
(sendMessage, reactToMessage, forwardMessage, …), reading
(getMessages, getConversations), groups, contacts/admission/blocking,
live state (setTyping, publishStreaming, clearStreaming), runtime
presentation, voice, and registration. Untyped escape hatch:
client.call(method, params).
Streaming: send deltas as fast as you produce them —
publishStreaming({ conversationId, turnId, delta }). The bridge owns the
one 250 ms throttle and coalesces; done: true (and clearStreaming) always
flush. Never pace your token stream on the RPC.
Typing: setTyping({ conversationId, state: 'typing' | 'thinking' | 'clear' }).
The keepalive re-send loop is bridge-internal.
HITL (durable approvals / input / cards)
import { createHitlSurface } from '@canonmsg/framework';
const hitl = createHitlSurface(canon);
const { requestId } = await hitl.requestApproval({
conversationId, detail: 'rm -rf /prod', risk: 'destructive', category: 'command',
});
const resolution = await hitl.awaitHitl(requestId); // event await — never a held RPCRequests are durable bridge-side (~/.canon/state/<agentId>/hitl.json,
survives kill -9); resolutions arrive as onHitlResolved, still-open
requests are re-announced as onHitlPending on every reconnect. awaitHitl
seeds from getHitlState, so it settles even if the answer landed while you
were away.
Media
import { uploadMedia, getAttachment } from '@canonmsg/framework';
const { url, attachment } = await uploadMedia(canon, {
path: '/abs/file.png', // streamed by the bridge — no size ceiling
mime: 'image/png', fileName: 'file.png', conversationId,
});
const got = await getAttachment(canon, { url: ref.url, as: 'path' }); // or 'bytes'You never fetch Storage URLs yourself. as: 'path' returns a stable
bridge-cache path (preferred for anything big); as: 'bytes' inlines base64
and fails typed above the ceiling.
Error bands
Failures are JsonRpcError with typed codes (JSON_RPC_ERROR_CODES) —
branch on error.code, never parse messages:
| band | codes | meaning |
|---|---|---|
| JSON-RPC reserved | −32700, −32600..−32603 | parse / envelope / unknown method / bad params / handler bug |
| session | −32000..−32002 | protocol-version mismatch, hello required, hello repeated |
| media (§B8) | −32010..−32013 | bytes ceiling, fetch failed (retryable), upload failed, unsupported |
| upstream (§B) | −32040..−32044 | auth failed (401/403), not found (404), rate limited (429, data.retryAfterMs), Canon unreachable / offline stub, other HTTP (data.status) |
data always carries { op } for upstream failures. The same table ships in
canon-framework (Python).
What you must NOT build
Canon auth, SSE reconnect/replay, RTDB access, REST retry/backoff, inbound
dedup, self/control filtering, media byte transfer, HITL persistence,
streaming throttles, registration protocol. If you are writing any of these,
stop — the bridge owns them (adapter-interface.md §1).
