LetspingLetsPing

LangGraph production human approval before tool execution

Insert LetsPing as a checkpoint in your LangGraph so that calls to dangerous tools become approval requests: the graph is frozen, a human approves or edits the payload, and only then does your backend invoke the real tool. This article shows the minimal wiring to pause a LangGraph agent before it calls tools like Stripe or Terraform and to resume it only after an operator has approved or patched the payload.

1. Freeze the graph at the approval point

With LetsPingCheckpointer, you treat the approval step as a checkpoint in the graph. When the agent reaches a risky tool, it calls lp.defer() instead of the tool directly. LetsPing encrypts the state, stores it, and returns a request id.

import { StateGraph, START } from "@langchain/langgraph";
import { LetsPing } from "@letsping/sdk";
import { LetsPingCheckpointer } from "@letsping/sdk/integrations/langgraph";

const lp = new LetsPing(process.env.LETSPING_API_KEY!);
const checkpointer = new LetsPingCheckpointer(lp);

const builder = new StateGraph({ channels: { thread_id: null, step: null, tool_payload: null } })
  .addNode("dangerous_tool", async (state) => {
    if (state.step === "START") {
      const { id } = await lp.defer({
        service: "billing-agent",
        action: "stripe:refund",
        priority: "high",
        payload: state.tool_payload,
        state_snapshot: { thread_id: state.thread_id },
      });
      return { ...state, step: "NEEDS_APPROVAL", request_id: id };
    }
    return state;
  })
  .addEdge(START, "dangerous_tool");

export const graph = builder.compile({ checkpointer });

2. Resume after approval and call the tool safely

When the human approves in the LetsPing dashboard, a webhook is fired. Your webhook handler verifies the signature, decrypts the state, and resumes the graph with the approved or patched payload.

import { NextRequest, NextResponse } from "next/server";
import { LetsPing } from "@letsping/sdk";
import { graph } from "@/lib/langgraph-graph";

const lp = new LetsPing();

export async function POST(req: NextRequest) {
  const rawBody = await req.text();
  const sig = req.headers.get("x-letsping-signature") || "";

  const { data, state_snapshot } = await lp.webhookHandler(
    rawBody,
    sig,
    process.env.WEBHOOK_SIGNING_SECRET!
  );

  const threadId = state_snapshot?.thread_id as string | undefined;
  if (!threadId) {
    return NextResponse.json({ error: "missing_thread_id" }, { status: 400 });
  }

  const safePayload = data.patched_payload ?? data.payload;

  await graph.invoke(
    { step: "NEEDS_APPROVAL", tool_payload: { overwrite: safePayload } },
    { configurable: { thread_id: threadId } }
  );

  return NextResponse.json({ ok: true });
}

3. How this satisfies production and InfoSec requirements

  • Every risky tool call has an explicit approval or rejection recorded with actor id.
  • Graph state is encrypted before persistence; no plaintext state in your database.
  • Webhooks are signed and bounded by a five-minute replay window.

For a deeper architectural deep dive, see The 2026 Guide to Securing LangGraph in Production.