28 Oct 2026 · LangGraph & Agents
Human-in-the-Loop AI Agents in Next.js, Checkpoints and Approval Gates
Why a production agent must pause before it sends email or spends money, how LangGraph stores that pause in PostgreSQL, and how an Approve button resumes the same thread.
If an agent can send email, create a Stripe charge, or post to a customer Slack, you need a human to click Approve before that step runs. A system prompt that says be careful is not a control. The model will still call the tool.
AgenticPacks OSS is streaming chat only. There is no graph: Agentic-Packs/agenticpacks-oss. Pro ships the LangGraph supervisor, Tavily researcher, summarizer, and the threaded chat UI. You hang an approval gate on that graph. The streaming side is in LangGraph.js in Next.js 15.
The problem
A naive agent route does this:
- Receive the user message.
- Run every node until the graph ends.
- Stream tokens to the browser.
- Side effects happen in the same request.
That is fine for search and summarise. It is not fine for sendEmail or createInvoice. By the time the user sees the draft, the mailer already ran.
You need the graph to stop before the dangerous node, show the planned action in the UI, and continue only after Approve.
Why the pause has to live in the database
If graph state lives only in the Node process, two things break.
First, a deploy or a restart wipes the pause. The user clicks Approve on a new request and there is nothing to resume.
Second, you cannot hold one HTTP request open until a human comes back. Proxies drop idle SSE. That is the same timeout family as SSE connection drops. Close the stream when you interrupt. Open a new stream when you resume.
LangGraph checkpointers write the graph state to PostgreSQL (or Redis). Your Conversation.id is the thread_id you pass into graph.stream. After a deploy, the new container loads the same checkpoint and can continue.
That is another reason this stack wants a long-lived node server.js on a VPS, not a 30 second serverless function. See self-hosting architecture and cost.
Interrupt before the tool, not after
const graph = new StateGraph(State)
.addNode("supervisor", supervisor)
.addNode("researcher", researcher)
.addNode("mailer", mailer)
.compile({
checkpointer,
interruptBefore: ["mailer"],
});
When the supervisor decides to send mail, execution stops before mailer. The stream ends with a pending payload: tool name, arguments, thread id.
Store that payload:
model Approval {
id String @id @default(cuid())
conversationId String
status String // pending | approved | rejected
payload Json
createdAt DateTime @default(now())
}
Show it in the same thread as the chat, not on a separate admin page. A banner is enough: recipient, subject, body, Approve, Reject.
The Approve Server Action
The button is a Server Action. Treat it like any other mutation.
- Load the session. Reject anonymous users.
- Load the conversation and prove this user (or this workspace member) owns it. See Server Actions authorization.
- Load the pending
Approvalrow. If it is notpending, stop. - Resume the graph on that
thread_id. Do not callmailerfrom the action yourself. The graph owns the side effect. - Mark the row
approved.
Reject writes rejected and does not resume. The checkpoint stays. You can leave it or add a node that records the refusal.
"use server";
export async function approveAgentStep(approvalId: string) {
const user = await requireUser();
const approval = await prisma.approval.findFirst({
where: { id: approvalId, status: "pending" },
include: { conversation: true },
});
if (!approval || approval.conversation.userId !== user.id) {
throw new Error("forbidden");
}
await graph.invoke(null, {
configurable: { thread_id: approval.conversationId },
});
await prisma.approval.update({
where: { id: approvalId },
data: { status: "approved" },
});
}
In production you will want graph.stream again so the UI can show the mailer output. The idea is the same: resume from checkpoint, do not start a new graph.
Credits
Debit the expensive tool on resume, not on the first run, if that tool is the interrupted one. If the researcher already spent tokens before the pause, those tokens are already gone. Be explicit per node. The ledger is described in team credit pools.
Tired of an agent that emails customers because the prompt said be helpful?
Streaming chat and Auth.js are open-source: Agentic-Packs/agenticpacks-oss.
The LangGraph thread you hang an Approve gate on is in LangGraph checkpoints in Pro.
What not to interrupt
Search and summarise can run without a click. Send, delete, pay, and ingest-everything should wait. If you interrupt every node, you built a slow wizard.
Pro does not force an Approve button on every tool. The research graph is allowed to run. You add interruptBefore on the nodes that can hurt a customer.
Where to Go From Here
You do not have to ship an agent with no confirm step.
Option A: Free open-source baseline (MIT)
- GitHub: github.com/Agentic-Packs/agenticpacks-oss
- What is included: Next.js 15, Auth.js, Postgres, streaming chat, Compose, single-plan Stripe. No graph, no checkpoint.
- Cost: free, MIT.
Option B: Ship with AgenticPacks Pro ($149 one-time)
If you want the graph and persistence already there:
- Automated Hetzner provisioning: Terraform, Caddy, Compose, Redis.
- Production multi-agent core: LangGraph, pgvector, threaded chat, state in Postgres.
- Usage & Unit Economics: Stripe, team pools, token ledger.
