Skip to content

LangGraph & LangChain (JS)

@graphmind-ai/langgraph hooks LangChain’s callback system — the same public interface LangSmith uses — so it works with LangGraph graphs, LCEL chains, prebuilt agents and bare Runnables alike. Chains, retrievers, LLM calls and tools each get their own node kind, so a LangChain run renders as itself rather than as a row of generic boxes.

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

Peer ranges: @langchain/core >=0.3.40 <2 (validated against 1.2.9) and an optional @langchain/langgraph >=0.3 <2 (validated against 1.4.13).

import { graphmind } from '@graphmind-ai/langgraph';
const gm = graphmind({ app: 'research-agent' });
await graph.invoke(input, { callbacks: [gm.handler()] });

gm.config() is the batteries-included version — a fresh handler and its abort signal, merged onto whatever config you pass:

const cfg = gm.config({ configurable: { thread_id: threadId } });
await graph.invoke(input, cfg);

Use one handler per invocation (both forms above do): a handler carries that invocation’s abort signal.

support-agent.ts
import { ChatAnthropic } from '@langchain/anthropic';
import { tool } from '@langchain/core/tools';
import { graphmind } from '@graphmind-ai/langgraph';
import { createReactAgent } from '@langchain/langgraph/prebuilt';
import { z } from 'zod';
const gm = graphmind({ app: 'support-agent' });
// Wrap the tools to get the FULL gate set (inject + retry, not just pause).
const searchOrders = gm.wrapStructuredTool(
tool(async ({ email }) => db.orders.findMany({ where: { email } }), {
name: 'searchOrders',
description: 'Find a customer’s recent orders',
schema: z.object({ email: z.string() }),
}),
);
const issueRefund = gm.wrapStructuredTool(
tool(async ({ orderId, cents }) => payments.refund(orderId, cents), {
name: 'issueRefund',
description: 'Refund an order',
schema: z.object({ orderId: z.string(), cents: z.number() }),
}),
);
async function main() {
await gm.ready();
const agent = createReactAgent({
llm: new ChatAnthropic({ model: 'claude-sonnet-4-5' }),
tools: [searchOrders, issueRefund],
});
// gm.config() supplies both the handler and its abort signal.
const result = await agent.invoke(
{ messages: [{ role: 'user', content: 'Order #4471 arrived broken. Sort it out.' }] },
gm.config(),
);
console.log(result.messages.at(-1)?.content);
await gm.dispose();
}
main();

Run graphmind in one terminal and this in another.

This is the honest version. LangChain callbacks are a one-way channel, so a handler can hold work but never change it.

Wherepause / steppause on errorabortretryinject
LangGraph node (handleChainStart)yes — holds the node bodyyesyesnono
LCEL chain / runnableyesyesyesnono
Chat model / LLMyes — holds before the requestyesyesnono
Tool via callbacks onlyyes — holds before the bodyyesyesnono
Retrieveryesyesyesnono
Tool via gm.wrapStructuredTool()yesyesyesyesyes
Function via gm.tool() / gm.wrapTools()yesyesyesyesyes
after gate (inspect a finished node)observe-onlyyesnono
after gate on a wrapped toolyesyesyesyes

Why pause genuinely works. CallbackManager awaits handler methods when the handler asks it to, and it awaits them before the announced work runs — StructuredTool.call awaits handleToolStart before _call, a Pregel node awaits handleChainStart before the node body, a chat model awaits handleChatModelStart before the provider request. Awaiting a GraphMind gate there genuinely stops the clock. The test suite holds a tool for a full second and asserts its body had not started.

Why inject and retry cannot work through callbacks. A handler is told what happened; it has no return channel into the thing it observed. If the debugger sends inject or retry at a callback-only gate, the adapter degrades it to continue and warns, naming the wrapper you need. That happens at all three gate points — before, after and error — and each gets its own warning budget, so a warning at before earlier in the run does not silence the one at error, which is the gate you are most likely to be sitting at. It never pretends to have substituted something, which is why the answer is always to wrap the tool rather than to keep clicking Inject on the node.

Parallel branches gate independently. LangGraph runs fan-out nodes concurrently and each holds its own gate; pausing one branch does not freeze the others.

Wrap the tool. The wrapper sits around the function the tool actually executes, which is a real position in the call stack:

import { tool } from '@langchain/core/tools';
const searchFlights = gm.wrapStructuredTool(
tool(async ({ from, to }) => api.search(from, to), {
name: 'searchFlights',
description: 'Search for flights',
schema: z.object({ from: z.string(), to: z.string() }),
}),
);
// several at once (records or arrays; LangChain tools and plain functions)
const tools = gm.wrapTools({ searchFlights, checkBudget });
// a plain async function called inside a graph node
const scoreLead = gm.tool('scoreLead', async (lead) => model.score(lead));

wrapStructuredTool returns a clone with the same prototype, name and schema — isStructuredTool, ToolNode, serialisation and your original tool all keep working. Per call:

  • before gateinject returns your value without running the tool, abort throws, retry is a no-op;
  • error gate on a throw, fired before LangChain sees the failureinject swallows the error and returns your value, retry re-runs the tool, continue rethrows the original, abort throws an AbortError;
  • after gate post-execute, pre-return — inject patches the result.

When the callback handler is attached it has already announced the tool run (with LangChain’s run id, parentage and toolCallId), so the wrapper stays quiet and just annotates the result (injected: true, attempts: n). With no handler attached the wrapper emits its own node events, so a wrapped tool is useful on its own.

Two mechanisms, both on by default:

  1. The handler throws an AbortError-named error out of the callback. LangChain propagates it (the handler runs with raiseError: true), so the node — and normally the whole graph invocation — fails immediately.
  2. handler.signal is aborted. Pass it as the LangChain config’s signal (gm.config() does) so in-flight provider requests and the Pregel loop between steps stop too.

abortMode: 'signal' turns off (1) if you would rather keep full control of error flow; then signal is the only channel, so you must pass it.

LangChain runNode kindnodeIdinstanceId
Root run (the graph / chain you invoked)agentagent:<name>LangChain run id
LangGraph nodechainchain:<langgraph_node>LangChain run id
LCEL chain / runnablechainchain:<runName>LangChain run id
Chat model / LLMllmllm:<model or class>LangChain run id
Tooltooltool:<name>toolCallId (or run id)
Retrieverretrieverretriever:<name>LangChain run id

One logical node per code location; executions light it up. Parentage follows LangChain’s parentRunId. LangGraph’s hidden internals (anything tagged langsmith:hidden, such as __start__) are skipped, and their children re-attach to the nearest visible ancestor.

Payloads carry extra fields the wire schema preserves: langgraphNode, langgraphStep, threadId, tags, toolCallId, provider, modelId, and gates (full for wrapper-gated tools, before+error otherwise).

Streamed tokens are batched into node.token (~30/sec per node). Usage is read from usage_metadata, llmOutput.tokenUsage, llmOutput.usage or generationInfo.usage — whichever your provider fills in.

graphmind({
chains: 'all', // 'all' (default) | 'langgraph' | 'none'
maxPayloadChars: 20000, // bigger inputs/outputs ship as a truncated preview
});

chains: 'langgraph' renders only the graph and its named nodes (no inner LCEL noise); 'none' keeps only LLMs, tools and retrievers.

Graph state can be large, cyclic or full of class instances, so every payload goes through a sanitiser: cycles become '[Circular]', unserialisable values degrade instead of throwing, and anything over maxPayloadChars is replaced by a truncated preview marker.

Pre-render the whole graph grey before anything executes:

gm.hintGraph(compiledGraph); // reads compiledGraph.getGraph().nodes

Each root invocation gets its own GraphMind run automatically, named after the root runnable (LangGraph for a compiled graph) — so two graph.invoke() calls are two runs on the canvas, not one merged blob. Override the name with gm.handler({ runName: 'nightly-research' }).

For an explicit boundary around more than the graph call, use gm.run:

await gm.run('handle-ticket', async () => {
const plan = await planner.invoke(input, { callbacks: gm.callbacks() });
return graph.invoke(plan, { callbacks: gm.callbacks() });
});

A handler started inside gm.run joins that run instead of opening its own. autoRun: false turns the automatic run off entirely.

const attached = await gm.ready(); // default 2000ms
const gm = graphmind({ app: 'research-agent', waitForAttach: true }); // or automatic

false is not an error — it means “continue detached”.

  • Disabled (enabled: false or a kill switch): the handler is inert and every wrapper is an identity function.
  • Enabled but detached: gates resolve continue on a fast path; events go to the replay ring buffer only.
  • The adapter never throws into your graph. Every handler body is wrapped in a guard that rethrows only the one abort it was asked to raise; anything else degrades to a one-shot warning and observe-only behaviour for that event.
  • A debugger that disconnects mid-hold releases every held gate with continue; so does gm.dispose().

For LangGraph in Python, see the Python page — same protocol, same viewer.