Skip to content

LangChain

Both SDKs gate LangChain. There are two ways to wire it in, and which one you want depends on how your agent runs.

  • Agents built with create_agent / createAgent (LangChain 1.x) use the native seam: register one middleware and every tool the agent has is gated, including tools you didn’t write. This is the recommended path.
  • Hand-rolled loops — your own while loop over a bound model — have no middleware seam to hook, so they use the tool floor. Still fully supported.
Terminal window
pip install "novarch[langchain]"

LangChain is imported lazily, so import novarch.integrations.langchain works on a bare install. The middleware adapter needs langchain >= 1 (the extra pins it); the seam it uses landed in the 1.x line.

Instead of wrapping each tool, you declare which tool names are reads and which are gated writes, then pass one middleware to the agent:

from langchain.agents import create_agent
from novarch import augur
from novarch.integrations.langchain_middleware import NovarchGateMiddleware, WriteSpec
@augur.session(tenant_id="acme", team_id="ap", agent_id="po-agent")
def run(question: str):
gate = NovarchGateMiddleware(
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
)},
)
agent = create_agent(model, tools, middleware=[gate])
return agent.invoke({"messages": [("user", question)]})

Under the hood the agent compiles its middleware into the same tool-execution seam the LangGraph adapter uses. In Python the two adapters share one gate implementation and behave identically; in TypeScript the middleware is the only way in, because the JS ToolNode does not expose that seam directly — see LangGraph.

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

Put the gate last in the middleware list

Middleware composes outside-in: the first entry is outermost. Put the gate last so it is innermost and sees the final tool call that will actually execute. If another middleware sits inside the gate, it could rewrite the arguments after the verdict was issued — the gate would have approved something other than what runs.

Outer error-handling or retry middleware must not swallow NovarchKillError. If one does, the gate latches: every later tool call through it re-raises, so nothing gated executes in a killed session.

Verdict mapping

VerdictWhat the agent sees
judge pass / operator approvethe tool runs
judge killNovarchKillError raised — it propagates out of invoke and the run 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 / toolKinds, or any internal adapter error, blocks the tool with a ToolMessage — an unmapped tool never executes ungated.

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.

Catching a kill in TypeScript

A kill thrown by the middleware does not reach your catch intact: LangChain’s JS runtime wraps any middleware exception in its own error type, copying .name from the original, so e instanceof NovarchKillError is false while e.name still reads 'NovarchKillError'. Use asNovarchKillError(e) around agent.invoke():

import { asNovarchKillError } from '@novarch/sdk'
try {
await agent.invoke({ messages: [{ role: 'user', content: question }] })
} catch (e) {
const kill = asNovarchKillError(e)
if (kill) console.error(kill.ruleIdCited, kill.rationale)
}

There is no opt-out hook; the middleware cannot prevent the wrap. Python has no equivalent wrapping layer, so a kill propagates unwrapped there and a bare except NovarchKillError is correct.

Async agents (Python)

The middleware implements both the sync and async hooks, so agent.invoke and await agent.ainvoke are both gated (the async path uses the 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; release it at teardown:

async with gate:
await agent.ainvoke({"messages": [("user", question)]})
# or: await gate.aclose()

TypeScript needs no counterpart — every gated call is async there, and the session closes with withSession.

Hand-rolled loops

If you drive the model yourself rather than through the agent constructor, there is no middleware seam — use the tool floor.

augur_tool is a thin wrapper over LangChain’s @tool that composes with @augur.tool(kind=...). Stack @augur_tool(...) outside @augur.tool(...) so LangChain invokes the augur-wrapped function and the read-buffer / write-gate fires from inside your tool body:

from typing import Any
from novarch import augur
from novarch.integrations.langchain import augur_tool
@augur_tool(description="Look up a vendor record.")
@augur.tool(kind="read")
def lookup_vendor(vendor_id: str) -> dict[str, Any]:
...

The outer augur_tool(...) produces a langchain_core.tools.BaseTool that ChatModel.bind_tools and AgentExecutor accept directly. Both forms are accepted, mirroring LangChain’s own @tool ergonomics: bare @augur_tool (no parens) and parameterized @augur_tool(name_or_callable=..., description=...):

def augur_tool(
name_or_callable: Any = None,
*,
description: str | None = None,
) -> Any: ...

See augur decorators and the TypeScript SDK for the tool semantics each floor composes with, and Concepts for the Novarch engine.