@graphmind-ai/client
v0.5.1
Published
Adapter-agnostic runtime for the GraphMind live agent debugger: session, event transport, and the cooperative gate engine (pause / step / breakpoints / resume).
Readme
@graphmind-ai/client
Adapter-agnostic runtime for the GraphMind live agent debugger. This package owns the session (WebSocket transport + event buffer), the run context, and the gate engine — the cooperative pause points that let a viewer hold, step, inject into, retry, or abort a running agent.
It has no dependency on any AI SDK. Adapters (e.g. for Vercel's ai
package) are separate packages that translate SDK callbacks/middleware into
session.emit(...) and await session.gate(...).
Publishing note: private until the npm scope question is settled (the
@graphmindscope is taken); see@graphmind-ai/schema's README.
Quick start (what an adapter does)
import { createSession } from '@graphmind-ai/client';
const session = createSession({
appName: 'trip-planner',
sdk: { name: 'ai', version: '7.0.79' },
});
await session.run('book-trip', async (ctx) => {
session.emit('node.started', {
nodeId: 'tool:searchFlights', kind: 'tool', name: 'searchFlights',
instanceId: 'call-1', input: { from: 'VIE', to: 'LIS' },
});
const decision = await session.gate('before', {
nodeId: 'tool:searchFlights', kind: 'tool', name: 'searchFlights',
});
if (decision.action === 'inject') return decision.output; // skip execution
if (decision.action === 'abort') throw ctx.signal.reason; // see "Abort"
// ... run the real tool, passing ctx.signal into SDK calls ...
});
await session.dispose();Behavior guarantees
- Never throws into the host. Every public method catches internal
errors, degrades to a no-op, and logs one rate-limited
console.warn(default: at most one line per failure kind per minute). Errors thrown by your function insidesession.runare yours and propagate untouched. - Fail-open. If the viewer disconnects (or the session is disposed),
every held gate resolves
{action:'continue'}immediately — measured under 100ms in tests — and breakpoints/mode are forgotten until the nexthello.ackre-arms them. - Free when detached.
gate()short-circuits to a shared resolved promise when no viewer is attached or nothing matches. The test suite asserts average awaited-gate cost < 1ms (typical is microseconds; the spike measured 0.03ms worst-case). - Lazy, resilient transport. Nothing touches the network until the first
run/emit/gate(or an explicitready()— see the attach guarantee below). Connects get 300ms (connectTimeoutMs), the handshake 1s, then the session stays detached and retries in the background every 10s (retryIntervalMs). Losing an established attachment is treated as the urgent case it is: the first reconnects happen after 200ms, 400ms and 800ms before the steady-state interval takes over, because every millisecond dark is events being pushed through a finite buffer. (Measured with the soak harness: a blip costs 206ms dark, where the flat 10s interval cost 9.99s.) All timers are unref'd — the session never keeps your process alive. - Replay-on-attach. Events are kept in a bounded ring buffer (default 5000
frames or 8 MiB, whichever binds first, drop-oldest;
bufferSize/maxBufferBytes). On attach the whole buffer is replayed oldest-first with originalseqnumbers, so a viewer that attaches mid-run still renders history (and deduplicates byseqon reconnects). - Loss is never silent. If the debugger is unreachable for longer than the
buffer holds, the events that never made it are counted with their
seqrange and announced two ways: a gap marker on the next attach — a realgraph.hintenvelope carryingpayload.gap = {droppedCount, fromSeq, toSeq, reason}, which the server stores and the viewer can render — and a rate-limited warning in your own logs.session.stats().lostis the honest count (droppedalso counts frames that were delivered and then aged out of the replay buffer, which is not loss).
Attach guarantee: session.ready()
The transport is lazy, so a run that starts immediately after createSession
can fail-open past its first gates before the handshake lands. When you need
pause guarantees from the very first event:
const attached = await session.ready(); // default timeout 2000ms
const attached = await session.ready({ timeoutMs: 500 });ready() force-starts the connection immediately (even before any emit) and
resolves true once the handshake completes — breakpoints/mode from the
hello.ack are armed before it resolves. It resolves false on timeout,
and immediately when the session is disabled or disposed. It never throws
(and never rejects): false means "still detached — carry on", keeping the
fail-open contract. Concurrent calls share one connection attempt; a call
after attachment resolves true instantly; after a disconnect a new call
re-arms (it kicks an immediate reconnect instead of waiting out
retryIntervalMs).
Kill switches
| Condition | Effect |
|---|---|
| GRAPHMIND_DISABLED=1 | Disabled. Beats everything, including enabled: true. |
| enabled option set | As given (unless the above). |
| NODE_ENV=production | Disabled unless GRAPHMIND=1. |
| otherwise | Enabled. |
Disabled sessions no-op everything: no sockets, no buffering, no warnings —
but session.run still executes your function and still hands it a working
RunContext (ids + abort signal), so adapter code never needs to branch.
GRAPHMIND_URL overrides the default endpoint
ws://127.0.0.1:4747/ingest (or pass url).
Recording less: the redaction switches (0.5.0)
Four whole-field kill switches stop values being recorded at all. Set them in
the environment of the instrumented app (not the server), or pass them as
session options on any adapter (graphmind({ hideInputs: true, … })); either
source turning a switch on turns it on — the environment is a floor code
cannot lower.
| Switch | Replaces with "__REDACTED__" |
|---|---|
| GRAPHMIND_HIDE_INPUTS / hideInputs | every node's input (prompts, messages, tool arguments, MCP params); streamed tool-argument deltas are emptied |
| GRAPHMIND_HIDE_OUTPUTS / hideOutputs | every node's output; every streamed token delta is emptied (v: "", a chars length survives) |
| GRAPHMIND_HIDE_TOOL_ARGS / hideToolArgs | only tool nodes' input |
| GRAPHMIND_HIDE_TOOL_RESULTS / hideToolResults | only tool nodes' output |
What the tool-only switches do not cover. The tool-only switches hide the tool node's own
input/output. In an agent loop the same values also travel through the model: itstool_useblocks (LLM output) and thetool_resultmessages of the next request (LLM input). To keep tool arguments and results out of the recording entirely, setGRAPHMIND_HIDE_INPUTS(andGRAPHMIND_HIDE_OUTPUTS). Error messages are never redacted.
Values 1 or true, case-insensitive (as options: true, 1, "1" or "true" — a privacy switch fails closed). Redaction runs inside the session
before the ring buffer, so late-attaching debuggers, SQLite, exports,
graphmind mcp-proxy recordings and the read-only MCP tools only ever see the
placeholder; run names, node names, kinds, ids, timings and token counts are
always recorded (graphmind mcp-proxy additionally drops the server's command-line arguments from the run's label and metadata under GRAPHMIND_HIDE_INPUTS). Affected events carry redaction: {count, keys}. node.error
is never redacted — an error message can echo data, so HIDE_OUTPUTS is not a
guarantee against error text. The Python SDK and the Ruby gem implement the same switches, env names, placeholder and wire fields (0.5), held to the same conformance fixtures.
Gating model
session.gate(point, node) — point is before | after | error, node is
{nodeId, kind: agent|llm|tool|custom, name}. The decision is:
| decision | adapter's obligation |
|---|---|
| {action:'continue'} | proceed normally |
| {action:'inject', output} | skip execution (or replace the failed result) and use output |
| {action:'retry'} | re-run the node's execution (typically after an error gate) |
| {action:'abort'} | stop the run — see below |
Pauses happen when a viewer is attached and a breakpoint matcher hits
(kind?/name?/point?, point defaults to before) — or on every
before/error gate in step mode (mode.set: step). While held, the
session emits exec.paused; on release (viewer resume, pauseTimeoutMs
auto-continue, disconnect fail-open, dispose) it emits exec.resumed.
Parallel gates are independent: two concurrent tool calls hold two pauses, each resumable on its own (spike assertions b.1–b.4).
Abort (why there is an AbortController)
Spike RESULTS.md, risk #4: throwing a plain Error out of SDK middleware
lands in the SDK's retry logic — an "abort" would be retried
maxRetries times before surfacing. So the abort path is cooperative
cancellation instead:
- Every
session.runcontext carries anAbortController;ctx.signalmust be passed into SDK calls by the adapter. - When a gate resolves
{action:'abort'}, the session aborts that run's controller with anAbortError-named reason before the gate promise resolves. The adapter then throwsctx.signal.reason(or simply lets the SDK observe the signal). AI SDKs treatAbortErroras terminal — no retries — andsession.runrecords the run asstatus: 'aborted'.
Options reference
createSession({
url, // default GRAPHMIND_URL ?? ws://127.0.0.1:4747/ingest
appName, sdk, meta, // reported in hello / run.started
enabled, // override kill-switch logic (GRAPHMIND_DISABLED still wins)
connectTimeoutMs, // 300
handshakeTimeoutMs, // 1000
retryIntervalMs, // 10_000 steady state (200/400/800ms burst after a blip)
bufferSize, // 5000 events, drop-oldest
maxBufferBytes, // 8 MiB — second, byte-wise bound on the same buffer
pauseTimeoutMs, // auto-continue held gates after N ms (default: hold forever)
webSocket, // WebSocket constructor override (default: global WebSocket, Node >= 22)
logger, warnIntervalMs, env, // testing / embedding hooks
})session.stats() returns {enabled, attached, buffered, dropped, lost,
pendingGaps, heldGates, seq} for diagnostics. lost counts events that were
evicted before ever reaching the debugger — real holes in the recorded
run; dropped is the blunter lifetime eviction count and includes frames that
were delivered first.
Scripts
pnpm typecheck—tscover src + tests (schema resolved from source)pnpm test— vitest: gate hold/resume/inject/retry/abort, parallel independence, disconnect fail-open < 100ms, detached overhead < 1ms, ring-buffer replay + overflow, gap markers + loss accounting + fast reconnect, handshake + version-mismatch detachment, kill switches, host-crash immunitypnpm build— emitdist/(ESM +.d.ts; requires@graphmind-ai/schemabuilt first, which pnpm's topological ordering does for you)
