Use Nuvouch for LangChain tool approvals
LangChain and LangGraph already provide human-in-the-loop interrupts for tool calls. Nuvouch fits at the decision boundary: when an agent proposes a sensitive tool, pause the graph, create a Nuvouch approval, then resume the same thread only after your webhook verifies the signed Nuvouch callback.
- Define sensitive LangChain tools normally.
- Intercept selected tool calls with a LangGraph interrupt.
- Create a Nuvouch approval from the interrupt payload.
- For synchronous workers, wait with
approvals.requestAndWait. - For production LangGraph runs, verify the signed webhook and resume with
approveorreject. - Let LangChain execute the original tool only after approval.
Define the sensitive tool
Keep the tool implementation focused on the side effect. The approval gate sits before this function is called.
import * as z from "zod";
import { tool } from "langchain";
export const chargeCustomer = tool(
async ({ customerId, amountCents, currency, reason }) => {
const charge = await payments.capture({
customerId,
amountCents,
currency,
reason,
});
return {
chargeId: charge.id,
status: charge.status,
};
},
{
name: "charge_customer",
description: "Capture a customer payment. Requires explicit user approval.",
schema: z.object({
customerId: z.string(),
amountCents: z.number().int().positive(),
currency: z.string().length(3),
reason: z.string(),
}),
},
);Interrupt before selected tools run
Use a review function around model-proposed tool calls. The interrupt payload should include enough structured context to create the Nuvouch approval and later resume the correct thread.
import { ToolMessage } from "@langchain/core/messages";
import type { ToolCall } from "@langchain/core/messages/tool";
import { interrupt } from "@langchain/langgraph";
const HITL_TOOLS = new Set(["charge_customer"]);
type NuvouchHitlDecision =
| { decision: "approve" }
| { decision: "reject"; reason?: string };
export function reviewToolCall(toolCall: ToolCall): ToolCall | ToolMessage {
if (!HITL_TOOLS.has(toolCall.name)) {
return toolCall;
}
const decision = interrupt({
type: "nuvouch_approval_required",
actionRequests: [
{
action: toolCall.name,
args: toolCall.args,
description: `Approve LangChain tool call: ${toolCall.name}`,
},
],
reviewConfigs: [{ allowedDecisions: ["approve", "reject"] }],
metadata: {
toolCallId: toolCall.id,
toolName: toolCall.name,
},
}) as NuvouchHitlDecision;
if (decision.decision === "approve") {
return toolCall;
}
return new ToolMessage({
content: decision.reason ?? "The user rejected this action in Nuvouch.",
name: toolCall.name,
tool_call_id: toolCall.id ?? "unknown_tool_call",
});
}Create the Nuvouch approval from the interrupt
When your LangGraph server or client receives the interrupt, call your backend to create a Nuvouch approval. Store the paused threadId, toolCallId, and externalRequestId so the webhook can resume the same graph thread.
import { Nuvouch } from "@nuvouch/node";
const nuvouch = new Nuvouch({ apiKey: process.env.NUVOUCH_API_KEY });
export async function createLangChainToolApproval(input: {
threadId: string;
userId: string;
agentName: string;
toolCallId: string;
toolName: string;
args: Record<string, unknown>;
}) {
const externalRequestId = `lc_${input.threadId}_${input.toolCallId}`;
const amountCents = Number(input.args.amountCents ?? 0);
const currency = String(input.args.currency ?? "USD");
return nuvouch.approvals.create({
subject: {
id: input.userId,
},
source: {
key: `langchain:${input.threadId}`,
name: "LangChain agent",
},
action: {
type: `langchain.tool.${input.toolName}`,
title: `Approve ${input.toolName}`,
description: "Allow the agent to execute this LangChain tool call.",
},
amount:
amountCents > 0
? {
value: amountCents / 100,
currency,
}
: undefined,
details: [
{ label: "Tool", value: input.toolName },
{ label: "Thread", value: input.threadId },
{ label: "Arguments", value: JSON.stringify(input.args) },
],
actor: {
type: "ai_agent",
name: input.agentName,
},
risk: {
level: "high",
signals: ["langchain_tool_call", "external_side_effect"],
},
decisions: [
{ label: "Approve", value: "approve" },
{ label: "Deny", value: "deny" },
],
externalRequestId,
metadata: {
threadId: input.threadId,
toolCallId: input.toolCallId,
toolName: input.toolName,
args: input.args,
},
});
}Use approval and wait for synchronous tools
The Node SDK includes approvals.requestAndWait and approvals.wait. Use this pattern for CLIs, tests, demos, durable workers, or controlled server-side tool runners where blocking the current operation is acceptable.
import { ToolMessage } from "@langchain/core/messages";
import type { ToolCall } from "@langchain/core/messages/tool";
import {
Nuvouch,
NuvouchTimeoutError,
} from "@nuvouch/node";
const nuvouch = new Nuvouch({ apiKey: process.env.NUVOUCH_API_KEY });
export async function approveThenExecuteTool(input: {
threadId: string;
userId: string;
agentName: string;
toolCall: ToolCall;
executeTool: (toolCall: ToolCall) => Promise<ToolMessage>;
}) {
const externalRequestId = `lc_${input.threadId}_${input.toolCall.id}`;
try {
const approval = await nuvouch.approvals.requestAndWait(
{
subject: { id: input.userId },
source: {
key: `langchain:${input.threadId}`,
name: "LangChain agent",
},
action: {
type: `langchain.tool.${input.toolCall.name}`,
title: `Approve ${input.toolCall.name}`,
description: "Allow the agent to execute this LangChain tool call.",
},
details: [
{ label: "Tool", value: input.toolCall.name },
{ label: "Arguments", value: JSON.stringify(input.toolCall.args) },
],
actor: {
type: "ai_agent",
name: input.agentName,
},
risk: {
level: "high",
signals: ["langchain_tool_call", "external_side_effect"],
},
decisions: [
{ label: "Approve", value: "approve" },
{ label: "Deny", value: "deny" },
],
externalRequestId,
metadata: {
threadId: input.threadId,
toolCallId: input.toolCall.id,
toolName: input.toolCall.name,
args: input.toolCall.args,
},
},
{
timeoutMs: 120_000,
intervalMs: 2_000,
},
);
if (approval.status !== "approved") {
return new ToolMessage({
content: `Nuvouch returned ${approval.status}; tool was not executed.`,
name: input.toolCall.name,
tool_call_id: input.toolCall.id ?? "unknown_tool_call",
});
}
return input.executeTool(input.toolCall);
} catch (error) {
if (error instanceof NuvouchTimeoutError) {
return new ToolMessage({
content: "Timed out waiting for Nuvouch approval; tool was not executed.",
name: input.toolCall.name,
tool_call_id: input.toolCall.id ?? "unknown_tool_call",
});
}
throw error;
}
}Resume LangGraph from the signed callback
Verify the webhook over the raw body, persist the delivery, and resume the paused graph with a LangGraph Command. The resumed value becomes the return value of interrupt() inside reviewToolCall.
import { Command } from "@langchain/langgraph";
import { Nuvouch } from "@nuvouch/node";
import { agent } from "./agent";
const nuvouch = new Nuvouch({ apiKey: process.env.NUVOUCH_API_KEY });
export async function handleNuvouchWebhook(req: Request) {
const rawBody = await req.text();
const event = nuvouch.webhooks.verify({
rawBody,
signature: req.headers.get("x-nuvouch-signature"),
secret: process.env.NUVOUCH_CALLBACK_SECRET!,
});
if (await hasProcessed(event.deliveryId)) {
return new Response("duplicate", { status: 200 });
}
const approval = event.data.approvalRequest;
const metadata = approval.metadata as {
threadId: string;
toolCallId: string;
toolName: string;
};
const config = {
configurable: {
thread_id: metadata.threadId,
},
};
const resume =
event.type === "nuvouch.approval_request.approved"
? { decision: "approve" }
: {
decision: "reject",
reason: `Nuvouch returned ${approval.status}`,
};
await runOnce(approval.externalRequestId, async () => {
await agent.invoke(new Command({ resume }), config);
});
await markProcessed(event.deliveryId);
return new Response("ok", { status: 200 });
}Using LangChain native HITL UI
If you already use LangChain's native HITL frontend, keep the same interrupt payload and decision types. Replace the local approve button with a call to create a Nuvouch approval, show the returned approval state, and submit the resume command only after your server receives the verified Nuvouch callback.
if (stream.interrupt?.value?.type === "nuvouch_approval_required") {
const approval = await fetch("/api/langchain/nuvouch-approval", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
threadId: stream.threadId,
interrupt: stream.interrupt.value,
}),
}).then((res) => res.json());
showWaitingForNuvouchDecision(approval.id);
}
// Do not call stream.submit(...resume...) from the browser approve button.
// The verified Nuvouch webhook should resume the graph server-side.Continue with the webhook reference for signature verification and delivery dedupe details.
Mapping summary
- LangChain
actionRequests[].actionmaps to Nuvouchaction.typeand display details. - LangChain
actionRequests[].argsmaps to Nuvouchdetailsandmetadata.args. - LangGraph
thread_idmaps to Nuvouchsource.keyandmetadata.threadId. - LangChain
toolCallIdmaps to NuvouchexternalRequestIdfor retry-safe dedupe.