Anthropic SDK
@graphmind-ai/anthropic instruments the official Anthropic TypeScript SDK. wrapClient
returns a Proxy of your client — your object is never mutated — so every messages.create
becomes a gated llm:step node, and wrapTools decorates plain async functions so each tool
call becomes a gated tool:<name> node.
The raw Anthropic SDK has no middleware hook and no tool runtime, so those two seams are the whole surface. Nothing is patched.
npm i -D @graphmind-ai/anthropic graphmind-aipnpm add -D @graphmind-ai/anthropic graphmind-aiyarn add -D @graphmind-ai/anthropic graphmind-ai@anthropic-ai/sdk is a peer dependency (>=0.60.0 <1; the suite runs against both ends of the
range).
End to end
Section titled “End to end”A complete hand-rolled tool loop, instrumented:
import Anthropic from '@anthropic-ai/sdk';import { graphmind } from '@graphmind-ai/anthropic';
const gm = graphmind({ app: 'support-agent' });
// A Proxy view of your client — instruments messages.* (and beta.messages.*).const client = gm.wrapClient(new Anthropic());
// Plain async functions in, gated functions out.const tools = gm.wrapTools({ searchOrders: async ({ email }: { email: string }) => db.orders.findMany({ where: { email } }), issueRefund: async ({ orderId, cents }: { orderId: string; cents: number }) => payments.refund(orderId, cents),});
const toolSchemas = [ { name: 'searchOrders', description: 'Find a customer’s recent orders', input_schema: { type: 'object' as const, properties: { email: { type: 'string' } }, required: ['email'], }, }, { name: 'issueRefund', description: 'Refund an order', input_schema: { type: 'object' as const, properties: { orderId: { type: 'string' }, cents: { type: 'number' } }, required: ['orderId', 'cents'], }, },];
async function main() { // Gates armed from the first event. `false` = no debugger, carry on. await gm.ready();
const answer = await gm.run('handle-ticket', async () => { const messages: Anthropic.MessageParam[] = [ { role: 'user', content: 'Order #4471 for alex@example.com arrived broken. Sort it out.' }, ];
for (;;) { const message = await client.messages.create({ model: 'claude-sonnet-4-5', max_tokens: 1024, tools: toolSchemas, messages, });
messages.push({ role: 'assistant', content: message.content }); if (message.stop_reason !== 'tool_use') return message;
// Parallel calls gate INDEPENDENTLY — hold one, let the other run. const results = await Promise.all( message.content .filter((b) => b.type === 'tool_use') .map(async (block) => ({ type: 'tool_result' as const, tool_use_id: block.id, content: JSON.stringify(await tools[block.name](block.input)), })), ); messages.push({ role: 'user', content: results }); } });
console.log(answer); await gm.dispose();}
main();Run graphmind in one terminal and this in another.
Streaming works the same way — both forms are instrumented:
const stream = await client.messages.create({ ...params, stream: true });for await (const event of stream) { /* ... */ }
const helper = client.messages.stream(params); // the MessageStream helperhelper.on('text', (delta) => process.stdout.write(delta));const final = await helper.finalMessage();graphmind(options?) | Create an adapter instance. Never throws. |
gm.wrapClient(client) | Instrumented view of an Anthropic client (a Proxy). |
gm.wrapTools({ … }) | Gate a record of tool functions. Wrapped tools are async. |
gm.tool(name, fn) | Gate a single function. |
gm.run(name, fn) | Explicit run boundary (recommended). |
gm.ready(opts?) | Attach guarantee — resolves true once the handshake lands. |
gm.session | The underlying session (stats, custom events). |
gm.dispose() | Release held gates, flush, close the socket. Idempotent. |
Gate points
Section titled “Gate points”Per messages.create call
Section titled “Per messages.create call”Streaming and non-streaming, on both client.messages and client.beta.messages:
node.started— kindllm, nodeIdllm:step, oneinstanceIdper call.- The
beforegate is awaited BEFORE the SDK method is called. Nothing is in flight while the gate is held: no socket, no request, no timeout clock. A test asserts the scripted HTTP layer saw zero requests during a hold. - The result is observed: a
Messageis reported directly; aStreamcomes back as a delegating Proxy that teesnode.tokendeltas — text, thinking →reasoning,tool_useinput JSON →tool-args— batched at one frame per node per ~34 ms. node.finishedwith usage:inputTokens/outputTokens, pluscacheReadTokens/cacheCreationTokenswhen the response reports them.graph.hinton the first call of an invocation, from the request’stoolsarray, so the viewer pre-renders the whole roster in grey.
Per wrapped tool call
Section titled “Per wrapped tool call”Parallel calls gate independently — each invocation is its own async frame, so
await Promise.all([...]) holds each separately.
| Gate | Behaviour |
|---|---|
before | Fires before the original function body runs |
after | Post-body, pre-return (step mode, or an explicit after breakpoint) |
error | Fires when the function throws, before the error reaches your loop |
At the error gate: inject swallows the error and returns your value as the result — it
becomes the next turn’s tool_result; retry re-invokes the original function (the before gate
fires again); continue rethrows the original error so your own handling wins; abort aborts
the run’s AbortController and throws an AbortError-named reason, and the next
messages.create in the run refuses to start.
Server-executed tools
Section titled “Server-executed tools”server_tool_use — web search, web fetch, code execution — runs on Anthropic’s side and cannot
be held. Those are observed from the response and emitted as tool nodes carrying
serverExecuted: true and ungated: true. graph.hint marks the same way for any tool
GraphMind cannot hold, including built-in tool definitions and any tool name you did not pass
through gm.wrapTools / gm.tool.
Node identity
Section titled “Node identity”| Node | nodeId | instanceId |
|---|---|---|
| Agent (a run) | agent:<runName> | the run id |
| Model call | llm:step | <invocationId>:s<N> |
| Tool call | tool:<toolName> | the model’s tool_use id |
| Server tool | tool:<toolName> | the server_tool_use id |
Because the raw SDK has no tool runtime, a wrapped tool function receives no call id. The
adapter queues the tool_use ids it observed on the LLM step, per (run scope, tool name), and
hands them out in issue order — so a tool node’s instanceId is the model’s real call id, even
for parallel calls. If the adapter never observed the requesting step (you wrapped tools but not
the client), a synthetic call_* id is used instead.
Successive messages.create calls chain into one invocation: within a run scope, a call whose
first message is unchanged and whose messages array has grown continues the previous
invocation (:s0, :s1, …).
Attach guarantee
Section titled “Attach guarantee”const attached = await gm.ready(); // default timeout 2000msconst attached = await gm.ready({ timeoutMs: 500 });or let the adapter do it on first use:
const gm = graphmind({ app: 'support-agent', waitForAttach: true }); // 2000msconst gm = graphmind({ app: 'support-agent', waitForAttach: 500 }); // 500msThe first gm.run() / first instrumented messages.create / first wrapped tool call waits;
later calls never do. On timeout the app continues detached.
abort aborts the run’s AbortController, which is chained into the request’s signal while a
debugger is attached — you do not have to thread a signal through yourself. A tool function
that is already running keeps running unless it observes the run signal itself.
Fail-open invariants
Section titled “Fail-open invariants”- Disabled (
enabled: falseor a kill switch):wrapClient,wrapToolsandtoolreturn their inputs unchanged — identity, zero overhead, nothing emitted, no network. - Enabled but detached: gates resolve
continueon a shared-resolved-promise fast path; events go to the replay ring buffer only. Measured well under 0.5 ms per fully gated tool call. - The adapter never throws into your app. Internal failures — including unserialisable payloads — degrade to rate-limited warnings and uninstrumented behaviour; your results and your errors pass through untouched.
- A debugger that disconnects mid-hold releases every held gate with
continue.
Known limits
Section titled “Known limits”Options
Section titled “Options”graphmind() accepts every @graphmind-ai/client session option plus app, sdk,
tokenFlushIntervalMs and waitForAttach:
| Option | Default | Meaning |
|---|---|---|
app | app name | Shown in the viewer’s run list |
tokenFlushIntervalMs | 34 | node.token batching interval per node |
waitForAttach | false | true (2000 ms) or a number: first call awaits ready() |
url | ws://127.0.0.1:4747/ingest | Ingest endpoint (or GRAPHMIND_URL) |
enabled | kill-switch logic | Force on/off (GRAPHMIND_DISABLED=1 still wins) |
meta | — | Extra metadata on every run.started |
pauseTimeoutMs | hold forever | Auto-continue a gate nobody resumes |