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.
npm i -D @graphmind-ai/langgraph graphmind-aipnpm add -D @graphmind-ai/langgraph graphmind-aiyarn add -D @graphmind-ai/langgraph graphmind-aiPeer 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).
The minimum
Section titled “The minimum”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.
End to end
Section titled “End to end”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.
Capability matrix
Section titled “Capability matrix”This is the honest version. LangChain callbacks are a one-way channel, so a handler can hold work but never change it.
| Where | pause / step | pause on error | abort | retry | inject |
|---|---|---|---|---|---|
LangGraph node (handleChainStart) | yes — holds the node body | yes | yes | no | no |
| LCEL chain / runnable | yes | yes | yes | no | no |
| Chat model / LLM | yes — holds before the request | yes | yes | no | no |
| Tool via callbacks only | yes — holds before the body | yes | yes | no | no |
| Retriever | yes | yes | yes | no | no |
Tool via gm.wrapStructuredTool() | yes | yes | yes | yes | yes |
Function via gm.tool() / gm.wrapTools() | yes | yes | yes | yes | yes |
after gate (inspect a finished node) | observe-only | — | yes | no | no |
after gate on a wrapped tool | yes | — | yes | yes | yes |
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.
Getting the full gate set on tools
Section titled “Getting the full gate set on tools”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 nodeconst 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:
beforegate —injectreturns your value without running the tool,abortthrows,retryis a no-op;errorgate on a throw, fired before LangChain sees the failure —injectswallows the error and returns your value,retryre-runs the tool,continuerethrows the original,abortthrows anAbortError;aftergate post-execute, pre-return —injectpatches 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.
How abort works
Section titled “How abort works”Two mechanisms, both on by default:
- The handler throws an
AbortError-named error out of the callback. LangChain propagates it (the handler runs withraiseError: true), so the node — and normally the whole graph invocation — fails immediately. handler.signalis aborted. Pass it as the LangChain config’ssignal(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.
What lands on the canvas
Section titled “What lands on the canvas”| LangChain run | Node kind | nodeId | instanceId |
|---|---|---|---|
| Root run (the graph / chain you invoked) | agent | agent:<name> | LangChain run id |
| LangGraph node | chain | chain:<langgraph_node> | LangChain run id |
| LCEL chain / runnable | chain | chain:<runName> | LangChain run id |
| Chat model / LLM | llm | llm:<model or class> | LangChain run id |
| Tool | tool | tool:<name> | toolCallId (or run id) |
| Retriever | retriever | retriever:<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.
Shaping the graph
Section titled “Shaping the graph”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().nodesEach 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.
Attach guarantee
Section titled “Attach guarantee”const attached = await gm.ready(); // default 2000msconst gm = graphmind({ app: 'research-agent', waitForAttach: true }); // or automaticfalse is not an error — it means “continue detached”.
Fail-open invariants
Section titled “Fail-open invariants”- Disabled (
enabled: falseor a kill switch): the handler is inert and every wrapper is an identity function. - Enabled but detached: gates resolve
continueon 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 doesgm.dispose().
Known limits
Section titled “Known limits”Python
Section titled “Python”For LangGraph in Python, see the Python page — same protocol, same viewer.