Skip to content

LangGraph

The LangGraph adapter is Novarch’s first native-seam integration: instead of decorating every tool, you register one hook at LangGraph’s blocking tool-execution seam (wrap_tool_call / awrap_tool_call on ToolNode) and declare which tools are reads and which are gated writes. It exports:

__all__ = ["NovarchToolGate", "WriteSpec", "kill_safe_handle_tool_errors"]

Install the optional extra:

Terminal window
pip install "novarch[langgraph]"

Requires langgraph-prebuilt >= 1.0.2 (the seam is absent from earlier releases; the pin enforces this). LangGraph is imported lazily, so import novarch.integrations.langgraph works on a bare install.

Usage

Keep @augur.session on your entry point, construct the gate inside it, and hand its wrapper to your ToolNode:

from langgraph.prebuilt import ToolNode
from novarch import augur
from novarch.integrations.langgraph import NovarchToolGate, WriteSpec
@augur.session(tenant_id="acme", team_id="ap", agent_id="po-agent")
def run(question: str):
gate = NovarchToolGate(
tool_kinds={"lookup_vendor": "read", "process_invoice": "write"},
write_specs={"process_invoice": WriteSpec(
action_type="process_invoice",
context_builder=build_ap_context, # (args: dict) -> ActionContext
)},
)
node = ToolNode(tools, wrap_tool_call=gate.wrap())
# async graphs: ToolNode(tools, awrap_tool_call=gate.awrap())
... # build and invoke the graph with this node

The gate resolves the active session at construction and holds it explicitly, so gating survives sync ToolNode’s thread-pool executor — including parallel tool fan-out — where ContextVars don’t propagate.

Reads execute, then buffer into the next write’s evidence. A write materializes an Action from its WriteSpec and gates on the judge verdict. Pass the agent’s rationale as a reasoning argument on the write tool (add it to the tool’s signature); augur.set_reasoning() also still works. A write that carries neither is recorded missing, unless the session declared reasoning_profile="unavailable" on the way in. See Declaring that your agent has no reasoning.

Verdict mapping

VerdictWhat the agent sees
judge pass / operator approvethe tool runs
judge killNovarchKillError raised — the graph halts
operator denya blocked ToolMessage (status="error") — the agent continues
operator timeout / skipNovarchKillError raised (fail-closed)

Fail-closed applies throughout: a tool name missing from tool_kinds, or any internal adapter error, blocks the tool with a ToolMessage — an unmapped tool never executes ungated.

Do not set handle_tool_errors on a gated ToolNode — or if you must, use the kill-safe wrapper. ToolNode’s default error handling re-raises wrapper exceptions, which is what lets a BLOCKED verdict halt the graph. Any custom handle_tool_errors (True, a string, a type, a callable) converts the raised NovarchKillError into an error message and the agent continues past the block:

from novarch.integrations.langgraph import kill_safe_handle_tool_errors
ToolNode(tools, wrap_tool_call=gate.wrap(),
handle_tool_errors=kill_safe_handle_tool_errors())

Even if a kill is swallowed by a custom handler, the gate latches: every subsequent tool call through it re-raises the kill, so nothing gated executes in a killed session.

On parallelism: parallel reads in a model turn are fully supported (each becomes evidence for the next write). A write dispatched in the same turn as other gated calls is fail-closed blocked — under concurrent fan-out the gate can’t attribute evidence correctly, so it tells the agent to re-issue the write alone rather than judging it on incomplete evidence.

Async graphs

gate.awrap() mirrors gate.wrap() on the async gate path (async judge round-trip and async HOLD long-poll — nothing blocks the event loop). If the session has no async client, the gate creates and owns one; call await gate.aclose() at teardown.

HOLD semantics

A held write long-polls the Novarch server until an operator decides (the same HTTP mechanism as every other adapter). LangGraph’s native interrupt() durable pause is deliberately not used yet: interrupts re-execute the node from the top on resume, and parallel sibling interrupts have a known collapse bug (langgraph#6533).

TypeScript

@novarch/sdk gates LangGraph agents, but not through ToolNode. Which path you take depends on how the graph is built.

Agents built with createAgent are gated by novarchGateMiddleware — createAgent compiles to a LangGraph graph, so this is the LangGraph path for the overwhelming majority of TypeScript agents. One registration, every tool gated, same verdict mapping as the table above.

Graphs you assemble yourself — your own StateGraph wired to a raw ToolNode — have no native seam in TypeScript. The JS ToolNode does not accept a wrapToolCall option: it is absent from the type declaration (which carries only name, tags, handleToolErrors) and at runtime the option is silently ignored, so the callback never fires. Verified against langchain@1.5.4 and @langchain/langgraph@1.4.8.

Shipping a @novarch/sdk/langgraph subpath anyway would look like coverage and enforce nothing, which is worse than not shipping it — a gate that silently does not gate is the one failure mode this product cannot have. So the subpath does not exist, and the gap is recorded as a dated waiver in the cross-language conformance corpus (contract/connectors.json) rather than papered over. It is re-checked whenever the @langchain/langgraph pin moves.

Until then, hand-built graphs use the tool floor: wrap tools with session.readTool / session.writeTool and pass the wrapped functions to your node. Same engine, same audit trail, same verdicts — you list the tools yourself instead of the framework listing them for you. See the TypeScript SDK.