@graphmind-ai/schema
v0.5.1
Published
Versioned wire contract (Zod schemas + TypeScript types) for the GraphMind live agent debugger protocol.
Downloads
2,952
Readme
@graphmind-ai/schema
The versioned wire contract of the GraphMind live agent debugger: Zod schemas + inferred TypeScript types for every message that crosses the WebSocket between an instrumented app and a viewer, plus a version-negotiating parser and a JSON Schema export for adapter authors in other languages.
Publishing note: the
@graphmindnpm scope is currently taken by an unrelated project, so this package is"private": trueand workspace-internal until the published name is decided. The wire contract itself is name-independent.
The envelope
Every frame is one JSON text message:
{
"gm": 1, // protocol MAJOR version — see "Versioning"
"seq": 42, // per-sender monotonically increasing counter
"ts": 1756200000000, // sender wall clock, epoch ms
"runId": "run_ab12cd34ef56", // owning run, or "*" if not run-bound
"type": "node.started",
"payload": { /* type-specific */ }
}seqlets receivers deduplicate replays: after a reconnect the app re-sends its ring buffer, and envelopes keep their originalseq.runIdis"*"(WILDCARD_RUN_ID) for handshake and breakpoint/mode messages, which are not bound to a run.
Events (app → viewer)
| type | payload |
|---|---|
| run.started | app, sdk {name, version}, meta? (record) |
| run.finished | status: ok\|error\|aborted, error? {name, message, stack?} |
| graph.hint | nodes: [{nodeId, kind, name, parentId?}] — optional static structure |
| node.started | nodeId, parentId?, kind: agent\|llm\|tool\|custom, name, instanceId, input |
| node.token | nodeId, deltas: [{t: text\|reasoning\|tool-args, v}] |
| node.finished | nodeId, output, usage? {inputTokens, outputTokens}, durationMs, status |
| node.error | nodeId, error {name, message, stack?} |
| exec.paused | pauseId, nodeId, point: before\|after\|error |
| exec.resumed | pauseId, action: continue\|retry\|inject\|abort |
exec.resumed is emitted by the app for every release of a held gate —
including releases the app performed itself (fail-open auto-continue on
disconnect, pause timeout) — so a viewer can always reconstruct pause history.
Controls (viewer → app)
| type | payload |
|---|---|
| exec.resume | pauseId, action: continue\|retry\|inject\|abort, output? (inject only) |
| breakpoint.set | matcher {kind?, name?, point?} |
| breakpoint.clear | matcher {kind?, name?, point?} — removes by exact matcher equality |
| mode.set | mode: run\|step |
Breakpoint matcher semantics: every present field must match; absent
fields match anything; point defaults to before. {} therefore means
"pause before every node". Matchers are deduplicated and cleared by exact
field equality.
Handshake
The app is the WebSocket client (it dials the viewer, default
ws://127.0.0.1:4747/ingest).
- app → viewer:
hello—versions {protocol, client},capabilities: ["pause", "step", "inject", "retry", "abort", ...],app?,sdk? - viewer → app:
hello.ack—versions {protocol, viewer},capabilities,breakpoints(current matchers) andmode(current mode)
Until hello.ack arrives the app must consider itself detached (gates pass
through). The ack carries the viewer's full desired debug state so an app
that reconnects — or attaches mid-run — is re-armed in a single message.
Capability strings are open-ended: unknown ones must be ignored, not
rejected.
Versioning & forward compatibility
gm is the protocol major. The rules, implemented by parseEnvelope:
- Reject any envelope whose
gmdiffers from yours →version-mismatch. - Tolerate unknown message
types →unknown-type(opaque but valid envelope; don't error, don't crash). - Tolerate unknown payload fields and unknown envelope fields — all object schemas are loose and preserve extra keys.
- Anything structurally broken →
invalid(withreason+issues).
import { parseEnvelopeJson } from '@graphmind-ai/schema';
const result = parseEnvelopeJson(frame); // never throws
switch (result.kind) {
case 'ok': /* result.envelope is the discriminated union */ break;
case 'unknown-type': /* forward compat: ignore or display raw */ break;
case 'version-mismatch': /* stop talking; report result.received */ break;
case 'invalid': /* log result.reason, drop the frame */ break;
}Sending side:
import { createEnvelope, serializeEnvelope } from '@graphmind-ai/schema';
ws.send(serializeEnvelope(createEnvelope({
type: 'node.token',
payload: { nodeId: 'n1', deltas: [{ t: 'text', v: 'Hel' }] },
seq: seq++,
runId,
})));JSON Schema artifact
schema.json (package root, regenerated by pnpm build) is a JSON Schema
(draft 2020-12) rendering of the whole contract, for adapters not written in
TypeScript. exportJsonSchema() / exportJsonSchemaString() produce it
programmatically. A golden-file test pins its exact content: any diff to
test/fixtures/schema.golden.json is a contract change and should be
reviewed as one.
Divergences from the validated spike (examples/spike)
The spike (24/24 assertions against [email protected]) proved the gating mechanism;
this package generalizes its ad-hoc protocol. Differences, with reasons:
- Gate points are node-relative (
before | after | error), not SDK-relative (before-step | before-tool | on-error). The spike's points only made sense for theaipackage's step/tool loop; production nodes areagent | llm | tool | custom, so "before an llm node" == the spike's "before-step" and "before a tool node" == "before-tool". - Structured breakpoint matchers replace the spike's string keys
(
"before-tool:searchFlights"). Matchers addkindand defaultpoint: 'before', and clear by exact equality instead of set-string identity. hello/hello.ackreplace the spike's implicit handshake (the spike armed breakpoints via a barebp.seton connect and treated the firstbp.setas "handshake done"). The ack now carries versions, capabilities, breakpoints and mode, so reconnects re-arm atomically — a direct lesson from the spike'sconnect()waiting onbp.set.exec.resumeis flat (action+ optionaloutput) rather than the spike's nested{ type, output }action objects — simpler to validate and to extend with per-action fields later.run.finishedgained optionalerrorso a viewer can show why a run failed without correlating a separatenode.error.runIdis mandatory on every envelope (the spike had none). Control messages not bound to a run use"*".- New event types (
run.*,graph.hint,node.token,node.finishedusage/duration) — the spike only had a free-formexec.nodeevent; a real viewer needs typed lifecycle events and token streams (the spike's stream-tee finding f.1/f.2 is whatnode.tokencarries). exec.resumedis a first-class event (the spike only traced resolution internally) so pause history survives on the wire.
Scripts
pnpm typecheck—tscover src + testspnpm test— vitest (round-trip property tests, forward-compat, version negotiation, golden file)pnpm build— emitdist/(ESM +.d.ts) and regenerateschema.json
