Skip to content

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.

Terminal window
npm i -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).

A complete hand-rolled tool loop, instrumented:

support-agent.ts
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 helper
helper.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.sessionThe underlying session (stats, custom events).
gm.dispose()Release held gates, flush, close the socket. Idempotent.

Streaming and non-streaming, on both client.messages and client.beta.messages:

  • node.started — kind llm, nodeId llm:step, one instanceId per call.
  • The before gate 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 Message is reported directly; a Stream comes back as a delegating Proxy that tees node.token deltas — text, thinking → reasoning, tool_use input JSON → tool-args — batched at one frame per node per ~34 ms.
  • node.finished with usage: inputTokens / outputTokens, plus cacheReadTokens / cacheCreationTokens when the response reports them.
  • graph.hint on the first call of an invocation, from the request’s tools array, so the viewer pre-renders the whole roster in grey.

Parallel calls gate independently — each invocation is its own async frame, so await Promise.all([...]) holds each separately.

GateBehaviour
beforeFires before the original function body runs
afterPost-body, pre-return (step mode, or an explicit after breakpoint)
errorFires 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_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.

NodenodeIdinstanceId
Agent (a run)agent:<runName>the run id
Model callllm:step<invocationId>:s<N>
Tool calltool:<toolName>the model’s tool_use id
Server tooltool:<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, …).

const attached = await gm.ready(); // default timeout 2000ms
const attached = await gm.ready({ timeoutMs: 500 });

or let the adapter do it on first use:

const gm = graphmind({ app: 'support-agent', waitForAttach: true }); // 2000ms
const gm = graphmind({ app: 'support-agent', waitForAttach: 500 }); // 500ms

The 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.

  • Disabled (enabled: false or a kill switch): wrapClient, wrapTools and tool return their inputs unchanged — identity, zero overhead, nothing emitted, no network.
  • Enabled but detached: gates resolve continue on 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.

graphmind() accepts every @graphmind-ai/client session option plus app, sdk, tokenFlushIntervalMs and waitForAttach:

OptionDefaultMeaning
appapp nameShown in the viewer’s run list
tokenFlushIntervalMs34node.token batching interval per node
waitForAttachfalsetrue (2000 ms) or a number: first call awaits ready()
urlws://127.0.0.1:4747/ingestIngest endpoint (or GRAPHMIND_URL)
enabledkill-switch logicForce on/off (GRAPHMIND_DISABLED=1 still wins)
metaExtra metadata on every run.started
pauseTimeoutMshold foreverAuto-continue a gate nobody resumes