@looppause/langgraph
v0.1.1
Published
Native LoopPause human-in-the-loop nodes for LangGraph (TypeScript)
Maintainers
Readme
@looppause/langgraph
Native LoopPause human-in-the-loop nodes for LangGraph (TypeScript).
"The missing primitive so agents don't go rogue — or die waiting for approval."
What it does
Provides two factory functions that wrap a LoopPause approval checkpoint into a LangGraph node, plus a router and a webhook-resume helper:
| Export | Pattern | Use when |
|---|---|---|
| makePollingGate | Long-poll until responded | Simple flows, short timeouts, no checkpointer needed |
| makeInterruptGate | Creates pause → interrupt() → resumes via webhook | Long timeouts, durable graphs, multi-turn agents |
| routeOnGate | Conditional edge helper | Both patterns |
| resumeThread | Wake a hibernating graph | makeInterruptGate only |
Both patterns fail closed: if LoopPause is unreachable or the response is
rejected, approved is false and the graph routes to "halt".
system_fallback is never treated as human approval.
Installation
npm install @looppause/langgraph @langchain/langgraphRequires Node.js 18+ (uses global fetch and node:crypto).
Configuration
| Environment variable | Required | Description |
|---|---|---|
| LOOPPAUSE_API_KEY | ✅ Yes | Your API key (sk_live_…) |
| LOOPPAUSE_SIGNING_SECRET | ✅ Yes | Signing secret for HMAC verification |
| LOOPPAUSE_API_URL | No | Override API base URL (default: https://api.looppause.com) |
Get your keys at looppause.com/dashboard.
Pattern 1: Polling gate
The node creates a pause and holds the graph open, polling until the human responds or the deadline passes. Simple — no checkpointer, no webhook.
import { StateGraph, Annotation } from "@langchain/langgraph";
import {
LoopPauseClient,
makePollingGate,
routeOnGate,
} from "@looppause/langgraph";
const GraphState = Annotation.Root({
amount: Annotation<number>(),
vendor: Annotation<string>(),
looppause: Annotation<any>(),
});
const client = new LoopPauseClient(); // reads env vars
const approveTransfer = makePollingGate(client, {
agentId: "billing-agent",
actionBuilder: (state) => ({
type: "approval",
description: `Transfer £${state.amount} to ${state.vendor}`,
details: { amount: String(state.amount), vendor: state.vendor },
}),
recipients: [
{ channel: "slack", target: "#finance-approvals" },
],
timeoutHours: 4,
});
const builder = new StateGraph(GraphState)
.addNode("approve_transfer", approveTransfer)
.addNode("execute_transfer", executeTransfer)
.addNode("halt", haltNode)
.addEdge("__start__", "approve_transfer")
.addConditionalEdges("approve_transfer", routeOnGate(), {
approved: "execute_transfer",
halt: "halt",
});
const graph = builder.compile();
const result = await graph.invoke({ amount: 12450, vendor: "Globex Corp" });
// result.looppause.approved === true → transfer executed
// result.looppause.approved === false → graph halted, reason in haltReasonReading the gate result:
interface GateResult {
approved: boolean; // true only when human authorized AND decision === "approved"
authorizationType: string; // "human" or "system_fallback"
responder: string; // email of the human who responded
comment: string | null; // optional comment from the approver
pauseId: string; // LoopPause pause_id for audit
haltReason: string | null; // set when approved === false
}Pattern 2: Interrupt gate (durable, webhook-driven)
The interrupt gate creates a pause, then calls LangGraph's interrupt() to
hibernate the graph in the checkpointer. When LoopPause fires the webhook,
your handler wakes the graph with the signed proof via resumeThread.
This is the right pattern for long-running approvals (hours, days) where you cannot keep a connection open.
The reentrancy fix
LangGraph re-executes the node from the top on resume, so createPause is
called twice for the same action. makeInterruptGate passes a deterministic
Idempotency-Key derived from (thread_id, action) — the second call returns
the existing pause_id rather than creating a duplicate.
Full example
import { StateGraph, Annotation, MemorySaver } from "@langchain/langgraph";
import {
LoopPauseClient,
makeInterruptGate,
routeOnGate,
resumeThread,
type Proof,
} from "@looppause/langgraph";
const GraphState = Annotation.Root({
amount: Annotation<number>(),
vendor: Annotation<string>(),
looppause: Annotation<any>(),
});
const client = new LoopPauseClient();
const checkpointer = new MemorySaver(); // use LangGraph Cloud or Redis in prod
const approveTransfer = makeInterruptGate(client, {
agentId: "billing-agent",
webhookUrl: "https://api.yourapp.com/webhooks/looppause",
actionBuilder: (state) => ({
type: "approval",
description: `Transfer £${state.amount} to ${state.vendor}`,
details: { amount: String(state.amount), vendor: state.vendor },
}),
recipients: [
{ channel: "slack", target: "#finance-approvals", fallback_email: "[email protected]" },
],
timeoutHours: 24,
});
const builder = new StateGraph(GraphState)
.addNode("approve_transfer", approveTransfer)
.addNode("execute_transfer", executeTransfer)
.addNode("halt", haltNode)
.addEdge("__start__", "approve_transfer")
.addConditionalEdges("approve_transfer", routeOnGate(), {
approved: "execute_transfer",
halt: "halt",
});
const graph = builder.compile({ checkpointer });
// Start the graph — it hibernates at interrupt()
const threadId = "thread_abc123";
await graph.invoke(
{ amount: 12450, vendor: "Globex Corp" },
{ configurable: { thread_id: threadId } },
);
// Later — your webhook handler receives the signed proof from LoopPause:
async function handleLoopPauseWebhook(req: Request): Promise<void> {
const proof = (await req.json()) as Proof;
// threadId must be recoverable from the proof — store it when creating the pause,
// or encode it in the webhook URL: /webhooks/looppause?thread=thread_abc123
await resumeThread(graph, threadId, proof);
}Storing the thread ID
makeInterruptGate does not automatically map pause_id → thread_id. A
minimal pattern: store the mapping when the graph starts, keyed on the
pause_id returned from the interrupt() value:
// After graph.invoke() returns (or throws NodeInterrupt), the pause_id is in
// the interrupted node's value. A simpler approach: encode thread_id in the
// webhook URL path or query param.
// Webhook URL per thread:
webhookUrl: `https://api.yourapp.com/webhooks/looppause/${threadId}`,Escalation
Pass an escalation config to automatically escalate if the primary recipient
does not respond:
makePollingGate(client, {
// ...
escalation: {
after_hours: 2,
recipients: [{ channel: "email", target: "[email protected]" }],
},
});API reference
LoopPauseClient
new LoopPauseClient(opts?: {
apiKey?: string; // default: LOOPPAUSE_API_KEY env var
signingSecret?: string; // default: LOOPPAUSE_SIGNING_SECRET env var
baseUrl?: string; // default: https://api.looppause.com
requestTimeoutMs?: number; // default: 10 000
})makePollingGate(client, config)
Returns a LangGraph node function. Polls until responded or deadline.
interface GateConfig {
agentId: string;
actionBuilder: (state: any) => Record<string, unknown>;
recipients: Array<Record<string, unknown>>;
stateKey?: string; // default: "looppause"
requireHuman?: boolean; // default: true — system_fallback is never approved
timeoutHours?: number; // default: 24
escalation?: Record<string, unknown>;
}makeInterruptGate(client, config)
Returns a LangGraph node function. Creates pause → interrupt() → resumes with
signed proof. webhookUrl is required.
routeOnGate(stateKey?)
Returns a conditional edge function: "approved" when state[stateKey].approved
is true, otherwise "halt".
resumeThread(graph, threadId, signedProof)
Wakes a hibernating graph thread with the signed proof as the interrupt resume value. Call from your webhook handler.
verifySignature(proof, signingSecret)
Verifies the HMAC-SHA256 signature on a proof object. Timing-safe.
Relationship to @looppause/mcp
| | @looppause/langgraph | @looppause/mcp |
|---|---|---|
| For | LangGraph TypeScript graphs | Any MCP-compatible agent (Claude Code, Cursor) |
| Pattern | Graph node + conditional edge | Two MCP tools: request_approval + check_approval |
| Reentrancy | Handled via idempotency key | Not applicable |
| Verification | HMAC-SHA256 via verifySignature | Ed25519 via verifyLoopPauseProof or verify_proof tool |
Local development
# From the monorepo root
cd packages/langgraph
npm run build # tsc → dist/
npm run type-check # no emitLicense
MIT
