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:
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 ToolNodefrom novarch import augurfrom 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 nodeThe 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
| Verdict | What the agent sees |
|---|---|
| judge pass / operator approve | the tool runs |
| judge kill | NovarchKillError raised — the graph halts |
| operator deny | a blocked ToolMessage (status="error") — the agent continues |
| operator timeout / skip | NovarchKillError 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.