Skip to content

Wire protocol

@graphmind-ai/schema is the wire contract. Everything else in GraphMind — the CLI, the viewer, every adapter, in any language — is an implementation of this document.

If you are writing an adapter, this is what you target. The package also exports the whole contract as a JSON Schema (draft 2020-12) via exportJsonSchema(), so non-JavaScript implementations can validate against it directly.

One WebSocket. The app dials the viewer at ws://127.0.0.1:4747/ingest (override with GRAPHMIND_URL). Every message is a single JSON text frame containing one envelope.

{
gm: 1, // protocol MAJOR version
seq: 0, // per-sender, monotonically increasing
ts: 1756254187512, // sender wall clock, epoch ms
runId: 'run_01H8…', // the run, or '*' for run-independent messages
type: 'node.started',
payload: { /* type-specific */ }
}
FieldRule
gmProtocol major version, currently 1. Peers MUST reject envelopes whose gm differs from theirs.
seqPer-sender counter. Receivers deduplicate replays on (runId, seq) and can detect gaps.
tsSender’s epoch milliseconds.
runIdThe run this belongs to, or * (WILDCARD_RUN_ID) for handshake, breakpoints and mode.
typeThe message type.
payloadType-specific; every payload object is loose.

Three rules, and they are the reason the protocol can grow without a flag day:

  1. New event types and new payload fields do NOT bump gm. Only an incompatible change does.
  2. Receivers must tolerate unknown types. An envelope with an unfamiliar type is structurally valid; treat its payload as opaque, do not error.
  3. Receivers must tolerate unknown fields. Every object schema is loose — unknown fields are preserved, never stripped or rejected. This is how adapters attach extras like providerExecuted, injected or provider-specific usage counters.

The reference parser resolves in this order: not envelope-shaped → invalid; gm mismatch → version-mismatch; unknown typeunknown-type (tolerate); known type with a bad payload → invalid; otherwise ok. It never throws.

The app dials; the viewer answers. Until the ack lands, the app must consider itself detached — which means every gate fails open.

app ──► viewer hello { versions: { protocol, client }, capabilities, app?, sdk? }
app ◄── viewer hello.ack { versions: { protocol, viewer }, capabilities, breakpoints, mode }

The ack carries the viewer’s full desired debug state, so an app that reconnects — or attaches mid-run — is re-armed in one message.

Capabilities are plain strings on the wire so future clients can announce ones this version does not know. The known set:

CapabilityThe client…
pausecan hold execution at gates and honours exec.resume
stephonours mode.set with mode step
injecthonours exec.resume with action inject + output
retryhonours exec.resume with action retry
aborthonours exec.resume with action abort
TypePayload
run.started{ app, sdk, meta? }
run.finished{ status, error? }
graph.hint{ nodes: GraphNodeHint[] } — static structure, pre-rendered grey
node.started{ nodeId, parentId?, kind, name, instanceId, input? }
node.token{ nodeId, deltas: TokenDelta[] } — batched by the sender
node.finished{ nodeId, instanceId?, output?, usage?, durationMs, status }
node.error{ nodeId, instanceId?, error }
exec.paused{ pauseId, nodeId, point }
exec.resumed{ pauseId, action }

exec.resumed is emitted even when the client releases a gate on its own — fail-open auto-continue, pause timeout, dispose — so a viewer can always reconstruct the full pause history.

TypePayloadEffect
exec.resume{ pauseId, action, output? }Release a held gate. output is meaningful only for inject.
breakpoint.set{ matcher }Add a breakpoint. Matchers dedupe by exact field equality.
breakpoint.clear{ matcher }Remove a breakpoint set with an identical matcher.
mode.set{ mode }Switch between run and step.

Control envelopes use runId: '*' except exec.resume, which the server routes to the app socket owning that run.

type NodeKind = 'agent' | 'llm' | 'tool' | 'chain' | 'retriever'
| 'server' | 'resource' | 'prompt' // MCP
| 'custom';
type RunStatus = 'ok' | 'error' | 'aborted';
type PausePoint = 'before' | 'after' | 'error';
type ResumeAction = 'continue' | 'retry' | 'inject' | 'abort';
type RunMode = 'run' | 'step';
interface ErrorInfo { name: string; message: string; stack?: string }
interface TokenUsage { inputTokens: number; outputTokens: number }
interface TokenDelta { t: 'text' | 'reasoning' | 'tool-args'; v: string }
interface SdkInfo { name: string; version: string }
interface GraphNodeHint { nodeId: string; kind: NodeKind; name: string; parentId?: string }
interface BreakpointMatcher {
kind?: NodeKind;
name?: string;
point?: PausePoint; // defaults to 'before'
}

Every field present on a BreakpointMatcher must match; absent fields match anything. An empty matcher {} therefore means “pause before every node”.

stack on ErrorInfo is optional and may be redacted by the sender.

The convention every adapter follows (and the reason graphs stay legible):

NodenodeIdinstanceId
Agent (a run)agent:<runName>the run id
Model stepllm:step<invocationId>:s<N>
Tool calltool:<toolName>the tool call id
Chain / graph nodechain:<name>the framework’s run id
Retrieverretriever:<name>the framework’s run id

nodeId is stable per logical node — one place in your code. instanceId is unique per execution. The viewer draws one node per nodeId and lights it up per instance.

Clients keep a bounded ring buffer of emitted events. On attach, the whole buffer is replayed oldest-first with the original seq values. Receivers deduplicate on (runId, seq) — the CLI’s storage does it with INSERT OR IGNORE on that key, so re-ingesting a run is idempotent.

import { exportJsonSchema, exportJsonSchemaString } from '@graphmind-ai/schema';
const schema = exportJsonSchema(); // draft 2020-12 document
const text = exportJsonSchemaString(); // deterministic pretty-printed form

The build writes it to schema.json at the package root, and a golden-file test pins its exact content so accidental contract drift fails CI.

PROTOCOL_VERSION is a single integer acting as the MAJOR version. It bumps only on an incompatible change. Additions — new event types, new payload fields — are made under the existing version and absorbed by the tolerance rules above. Peers reject a mismatched gm rather than guessing.

Next: writing an adapter walks the whole implementation against this contract.