Skip to content

OpenAI SDK

@graphmind-ai/openai instruments the official OpenAI Node SDK. wrapClient returns a Proxy tree over your client — your original is never mutated and client instanceof OpenAI still holds — so every model request becomes a gated llm:step node, and wrapTools decorates the functions you dispatch tool calls to.

Both APIs are covered: Chat Completions and Responses, including their .stream(), .parse() and .runTools() helpers.

Terminal window
npm i -D @graphmind-ai/openai graphmind-ai

openai is a peer dependency, range >=5 <7; the primary target is v6.

support-agent.ts
import { graphmind } from '@graphmind-ai/openai';
import OpenAI from 'openai';
const gm = graphmind({ app: 'support-agent' });
const client = gm.wrapClient(new OpenAI());
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),
});
async function main() {
await gm.ready();
const answer = await gm.run('handle-ticket', async () => {
const messages: any[] = [
{ role: 'user', content: 'Order #4471 for alex@example.com arrived broken. Sort it out.' },
];
for (;;) {
const completion = await client.chat.completions.create({
model: 'gpt-5.4',
messages,
tools: toolSchemas,
});
const message = completion.choices[0].message;
if (!message.tool_calls?.length) return message.content;
messages.push(message);
// Parallel calls gate INDEPENDENTLY — hold one, let the other run.
const results = await Promise.all(
message.tool_calls.map(async (call) => ({
call,
// Pass the tool call as the 2nd argument so the debugger can
// correlate this execution with the model's tool_call id.
output: await tools[call.function.name](
JSON.parse(call.function.arguments),
call,
),
})),
);
for (const { call, output } of results) {
messages.push({
role: 'tool',
tool_call_id: call.id,
content: JSON.stringify(output),
});
}
}
});
console.log(answer);
await gm.dispose();
}
main();

Run graphmind in one terminal and this in another.

The Responses API works the same way:

const response = await client.responses.create({ model: 'gpt-5.4', input: 'hi' });
const stream = client.responses.stream({ model: 'gpt-5.4', input: 'hi' });
You callGatedStreamed to the viewer
chat.completions.create (incl. stream: true)before / error / after¹yes
chat.completions.stream()yesyes
chat.completions.parse()yesyes
chat.completions.runTools()model turns²yes
responses.create (incl. stream: true)before / error / after¹yes
responses.stream()yesyes
responses.parse()yesyes
Your tool functions via wrapToolsbefore / after / erroryes
OpenAI built-in tools (web search, code interpreter, file search, image gen, MCP)observe-onlyyes

¹ The after gate fires for non-streaming requests only — a streamed response is already live by the time it is returned, so there is nothing meaningful to hold there. ² The tools runTools() executes are gated when you hand it functions from gm.wrapTools().

  • node.started before anything is dispatched;
  • the before gate is awaited BEFORE the HTTP request goes out — nothing is in flight while a gate is held, so holds are indefinite by design and no server timeout is ticking;
  • the response stream is teed so batched node.token deltas (~30/sec per node) are observed without disturbing what your code consumes — both branches see the identical chunk sequence;
  • node.finished with text, tool calls, finish reason and token usage;
  • graph.hint from the request’s tools array plus every name you passed to wrapTools, on an invocation’s first step.

On error, the error gate fires before the SDK’s error reaches your code — a 500, a rate limit, a connection error. retry re-issues the request, inject substitutes a completion object as the result of create(), continue rethrows the SDK’s error untouched, abort ends the run. Pause-on-error is armed by default.

Chat Completions maps delta.content and delta.refusal to text, delta.reasoning_content to reasoning, and delta.tool_calls[].function.arguments to tool-args.

Responses maps response.output_text.delta / response.refusal.delta to text, response.reasoning_summary_text.delta and response.reasoning_text.delta to reasoning, and the function_call_arguments / custom_tool_call_input / mcp_call_arguments / code_interpreter_call_code deltas to tool-args.

Normalised onto the wire’s inputTokens / outputTokens, with totalTokens, cachedInputTokens and reasoningTokens carried as loose fields.

GateBehaviour
beforeFires before your function runs
afterPost-call, pre-return
errorFires when your function throws, before the error reaches your loop

At the error gate: inject swallows the error and returns your value as the tool result — the substitution lands in the tool message and reaches the model’s next turn; retry re-runs your function; continue rethrows the original error; abort aborts the run’s AbortController and surfaces an AbortError, which SDK retry logic never retries.

Only chat.completions.create and responses.create are intercepted. Every convenience helper the SDK ships — .stream(), .parse(), .runTools() — builds on this._client.<resource>.create(...) internally, so the wrapper calls them against a view of the resource whose _client points back at the wrapped client. One interception point covers them all, and nothing is instrumented twice.

Methods the adapter does not instrument are returned bound to the real object (the OpenAI client uses #private fields, and calling such a method with a Proxy as this would throw), so client.post(...), client.embeddings.create(...) and friends work exactly as before.

wrapClient is idempotent, and returns its input untouched when GraphMind is disabled. Any client exposing the same resources works — Azure OpenAI, or an OpenAI-compatible gateway.

chat.completions.runTools() runs its own tool loop inside the SDK. Its model turns are gated and reported normally. To gate the tool executions too, hand it functions from gm.wrapTools():

const tools = gm.wrapTools({ getWeather });
client.chat.completions.runTools({
model: 'gpt-5.4',
messages,
tools: [{
type: 'function',
function: {
name: 'getWeather',
description: '…',
parameters: { /* … */ },
function: tools.getWeather, // gated
parse: JSON.parse,
},
}],
});

The runner passes itself (not the tool call) as the second argument, so those executions get generated instance ids rather than OpenAI’s tool_call ids.

  • client.beta.*, the Realtime API (WebRTC/WebSocket sessions, not request/response) and the Assistants API — neither fits the gate-before-the-request model.
  • responses.retrieve(id, { stream: true }) — resuming an existing response’s stream.
  • Batch, files, embeddings, images, audio, moderations, fine-tuning — not agent execution steps.
  • responses.create({ background: true }) is reported as one step when the request returns, not for the lifetime of the background job.
NodenodeIdinstanceId
Agent (a run)agent:<runName>the run id
Model callllm:step<invocationId>:s<N>
Tool calltool:<toolName>OpenAI tool_call.id / call_id
Built-in tooltool:<name>the Responses output item id

Both APIs map onto the same llm:step node — an app migrating from Chat Completions to Responses keeps its graph — and the concrete API is reported as an api field on the node payloads.

Requests are grouped into invocations so the viewer can show “step 3 of the handle-ticket loop”. Grouping is scoped by the gm.run context; inside a scope two heuristics chain steps: an exact match on previous_response_id (Responses), and prompt-prefix growth. Outside gm.run all requests share one scope, so two concurrent loops with the same first message can merge — wrap concurrent work in gm.run.

The SDK’s own timeout option starts when the HTTP request is dispatched, which happens after the before gate releases — holds never eat into it.

An AbortSignal.timeout() you pass as options.signal is different: it is already running while the gate holds. While a debugger is attached the adapter chains — never replaces — your signal with the debugger’s run signal and filters timeout-driven aborts out of what the request sees, warning once. Your own aborts (any other reason) pass through untouched. Detached, signals are not touched at all.

  • Disabled session: wrapClient / wrapTools return their inputs unchanged (identity, zero overhead).
  • Enabled but detached: gates resolve continue on a fast path; events go to the replay ring buffer only.
  • The adapter never throws into the host app; internal failures degrade to rate-limited warnings and uninstrumented behaviour.
  • Disconnect mid-hold releases every held gate with continue.
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