§ Guide · LangChain

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.

  1. Define sensitive LangChain tools normally.
  2. Intercept selected tool calls with a LangGraph interrupt.
  3. Create a Nuvouch approval from the interrupt payload.
  4. For synchronous workers, wait with approvals.requestAndWait.
  5. For production LangGraph runs, verify the signed webhook and resume with approve or reject.
  6. Let LangChain execute the original tool only after approval.
§ Step 1

Define the sensitive tool

Keep the tool implementation focused on the side effect. The approval gate sits before this function is called.

tools.ts
langchain tool
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(),
    }),
  },
);
§ Step 2

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.

review-tool-call.ts
langgraph interrupt
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",
  });
}
§ Step 3

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.

create-nuvouch-approval.ts
server endpoint
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,
    },
  });
}
§ Option A

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.

approval-wait-tool.ts
bounded wait
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;
  }
}
§ Option B

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.

webhooks/nuvouch.ts
verified resume
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 });
}
§ Option

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.

frontend-flow.ts
conceptual
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[].action maps to Nuvouch action.type and display details.
  • LangChain actionRequests[].args maps to Nuvouch details and metadata.args.
  • LangGraph thread_id maps to Nuvouch source.key and metadata.threadId.
  • LangChain toolCallId maps to Nuvouch externalRequestId for retry-safe dedupe.