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
whileloop over a bound model — have no middleware seam to hook, so they use the tool floor. Still fully supported.
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.
npm install @novarch/sdk langchain @langchain/core @langchain/langgraphLangChain is an optional peer dependency, not bundled — the core SDK
keeps its zero-dependency install. The adapter is a subpath export,
@novarch/sdk/langchain, so nothing LangChain-shaped loads unless you
import it. Pinned against langchain >= 1.5.4 and
@langchain/langgraph >= 1.4.8.
The gate middleware (recommended)
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_agentfrom novarch import augurfrom 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)]})import { withSession } from '@novarch/sdk'import { novarchGateMiddleware } from '@novarch/sdk/langchain'import { createAgent } from 'langchain'
await withSession( { tenantId: 'acme', teamId: 'ap', agentId: 'po-agent' }, async (session) => { const agent = createAgent({ model, tools, middleware: [ novarchGateMiddleware({ session, toolKinds: { lookup_vendor: 'read', process_invoice: 'write' }, writeSpecs: { process_invoice: { actionType: 'process_invoice', contextBuilder: buildApContext, // (args) => ActionContext }, }, }), ], }) return agent.invoke({ messages: [{ role: 'user', content: question }] }) },)The middleware takes the session as a value — there is no ambient session to discover. Everything else about the mapping is identical to Python’s.
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
| Verdict | What the agent sees |
|---|---|
| judge pass / operator approve | the tool runs |
| judge kill | NovarchKillError raised — it propagates out of invoke and the run 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 /
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 Anyfrom novarch import augurfrom 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: ...Wrap tools with session.readTool / session.writeTool and call the wrapped
functions from your own loop. See the
TypeScript SDK for both signatures.
await withSession({ tenantId: 'acme', teamId: 'ap', agentId: 'po-agent' }, async (session) => { const lookupVendor = session.readTool('lookup_vendor', (id: string) => vendorDb.lookup(id)) const submitPayment = session.writeTool( { actionType: 'process_invoice', contextBuilder: (args) => buildApContext(args.invoice) }, async (args: { invoice: Invoice }) => paymentsApi.submit(args.invoice), ) // drive your own loop, calling the wrapped functions})There is no TypeScript equivalent of augur_tool — nothing here needs to
produce a BaseTool, because a hand-rolled loop calls the wrapped function
itself.
See augur decorators and the TypeScript SDK for the tool semantics each floor composes with, and Concepts for the Novarch engine.