LangChain.js / LangGraph.js
Governing LangChain.js and LangGraph.js agents.
Install
npm install @raxitlabs/raxitHow it's wired: a Node --import preload
Python's autopatch loads via sitecustomize on PYTHONPATH. Node has no equivalent hook, so
raxit run uses an ESM preload instead: it sets RAXIT_AUTOLOAD=1 and
NODE_OPTIONS=--import raxit/autopatch-preload, and Node evaluates that module before your
agent's entrypoint runs. This has to be --import (ESM), not --require (CommonJS) — a
require()-loaded copy of a framework module is a separate module instance from the one your
agent's own import resolves, so a prototype patch applied to the require()'d copy would
never be visible to your code.
When running against an installed @raxitlabs/raxit package rather than a resolvable bare
specifier, the CLI resolves the preload to an absolute file:// URL via
RAXIT_TS_PRELOAD_URL, so --import works regardless of how your project's node_modules is
laid out.
raxit init
raxit run --agent-id my-agent -- node agent.mjsIf the preload can't patch a detected framework (its internal API drifted from what raxit
expects), it fails closed — writes to stderr and exits with code 78 (EX_CONFIG) — rather than
running your agent ungoverned.
What's hooked
LangChain.js patches StructuredTool/BaseTool.prototype methods invoke, ainvoke,
run, and arun, resolved from @langchain/core/tools (falling back to langchain/tools).
This mirrors the Python patcher's four-method interception point exactly.
LangGraph.js patches ToolNode.prototype.runTool — deliberately not invoke/run on
ToolNode, because those receive the whole batch of tool calls as one opaque blob, with the
node's own internal name rather than the real tool name. runTool(call, config, state) is the
per-call interception point that receives the individual {name, args, id} before the tool body
runs — the same shape as the Python LangGraph patcher's _execute_tool_sync.
Worked example
import { tool } from "@langchain/core/tools";
import { z } from "zod";
const calc = tool(
async ({ expression }) => String(eval(expression)),
{ name: "calc", description: "Evaluate a simple arithmetic expression.",
schema: z.object({ expression: z.string() }) },
);
const postToWebhook = tool(
async ({ url, payload }) => {
console.log(`[webhook] would POST to ${url}: ${payload}`);
return "ok";
},
{ name: "post_to_webhook", description: "POST a payload to an external webhook URL.",
schema: z.object({ url: z.string(), payload: z.string() }) },
);
for (const [t, args] of [[calc, { expression: "21 * 2" }], [postToWebhook, { url: "https://example.com/hook", payload: "hi" }]]) {
console.log(`\n>>> agent calls ${t.name}(${JSON.stringify(args)})`);
try {
console.log(`<<< result: ${await t.invoke(args)}`);
} catch (err) {
console.log(`<<< ${err.message}`);
}
}Run it exactly like the Python quickstart — raxit init scans the file and writes
security.yaml with the real discovered tool names, raxit plan/raxit apply preview and
enforce a rule change (here post_to_webhook: allow → review), and raxit run governs the
process. The CLI output is the engine's, not the SDK's, so the shape is identical regardless of
which language spawned it. Captured from a real run against this exact agent.mjs:
$ raxit init
✓ Tools discovered: 2 — calc, post_to_webhook
✓ .raxit/security.yaml written
✓ Agent id: default-agent (use --agent default-agent, or edit agent: in security.yaml)
$ raxit plan
raxit plan: 1 change vs last-applied state b5f1c762d2b0
~ post_to_webhook (agent default-agent): allow -> review [defer]
0 removed · 1 unchanged
Run `raxit apply` to enforce.
$ raxit apply
✓ Policy compiled + tested (.raxit/security.yaml)
✓ Enforce mode: on
policy hash: 13f41fc87ca7
no daemon running -- the new policy is enforced by the next `raxit run`/`raxit serve`.Defer, block, and resume — parity with Python
The block-and-resume defer flow mirrors the Python SDK's: a review rule suspends the call and
prints a pending block with an approval token; if a human approves it in time, the call resumes
and runs; otherwise it denies. The run below was captured with RAXIT_DEFER_TIMEOUT=5 (default
120s) so the expiry doesn't take two minutes — every line is verbatim from that run:
$ RAXIT_DEFER_TIMEOUT=5 raxit run --agent-id default-agent -- node agent.mjs
raxit run: daemon ready at http://localhost:8181
>>> agent calls calc({"expression":"21 * 2"})
<<< result: 42
>>> agent calls post_to_webhook({"url":"https://example.com/hook","payload":"hi"})
[raxit] waiting for human approval (up to 5s)… approval id dt_4cf81b367edb6d54
[runtime-core PENDING] 'post_to_webhook' deferred — waiting for human approval.
Token: dt_4cf81b367edb6d54
Approve: raxit approvals approve dt_4cf81b367edb6d54
Deny: raxit approvals deny dt_4cf81b367edb6d54
[raxit] approval expired (id dt_4cf81b367edb6d54)
<<< deny.approval_expiredThe expired defer surfaces to the agent as a thrown error carrying the reason code
deny.approval_expired — the webhook body never ran. The daemon side of the same run logs each
decision with a call_id you can hand to raxit explain. Approving from another terminal while
the run is waiting resumes it as a permit instead (a second captured run):
$ raxit approvals approve dt_010840cb8566ff9a --reason "looks fine"
dt_010840cb8566ff9a: approved (approver=operator)[raxit] approval approved (id dt_010840cb8566ff9a)
[webhook] would POST to https://example.com/hook: hi
<<< result: okSame defer → approve → permit.approved flow as the
LangChain (Python) worked example.
`.mjs` scanning
raxit init scans .mjs files (common for ESM LangChain.js agents) for tool calls, the same
as .js/.ts/.py. Available in the published raxit package (PyPI/npm, 0.0.4).

