LangGraph & OpenAI Agents: an approval check before an agent acts
When an agent can refund money, delete data or send messages, you may want a person to say yes first. This guide shows two ways to do that with spurvik@1.0.8:
- A. Signed channel links you run yourself. Send Approve / Block links to Slack, Teams, email and more, and check the click in your own endpoint. No Spurvik account needed.
- B. Framework helpers for OpenAI Agents and LangGraph. They send the action to the hosted review queue in the Spurvik console and let the agent continue only on
approved.rejectedandoverriddenboth stop. The hosted review queue is on Pro and higher.
This is an engineering pattern. It is not a legal classification of your system, and Spurvik does not decide whether your use case needs it.
A. Signed Approve / Block links (no account)
npm i spurvik1. Send the request
// approval/request.ts: ask a person before the agent acts
import { createMultiChannelApprovalRouter } from "spurvik/channels";
const router = createMultiChannelApprovalRouter({
signingSecret: process.env.SPURVIK_SIGNING_SECRET!, // at least 16 characters
channels: [{ type: "slack", webhookUrl: process.env.SLACK_WEBHOOK_URL! }],
});
export async function askBeforeRefund(orderId: string, amountEur: number) {
const { links, receipts } = await router.requestApproval({
gateId: crypto.randomUUID(),
actionName: "stripe.refunds.create",
summary: `Refund €${amountEur} on order ${orderId}`,
context: { amountEur, currency: "EUR" },
callbackBaseUrl: "https://your-app.example/api/spurvik/callback", // your own GET endpoint
});
return { gateId: links.gateId, receipts };
}callbackBaseUrlis required.createSignedApprovalLinks()and the router throw without it. The SDK has no console default.- Each request gets separate Approve and Block tokens signed with HMAC SHA256 over the gate id, an action HMAC, the decision, a timestamp and a random nonce.
summaryis sent as you write it. The SDK does not add the prompt, so keep personal data out.- Channels:
slack,telegram,email(Resend),discord,teams,linear,jira,monday,asana. Monday only creates a board item with the gate id; it gets no Approve or Block links.
2. Check the click in your own endpoint
There is no hosted handler. Your endpoint receives the GET click and checks it:
// app/api/spurvik/callback/route.ts: your endpoint; there is no hosted handler
import { verifyChannelApprovalToken } from "spurvik/channels";
const usedNonces = new Set<string>(); // use your database in production
export async function GET(req: Request) {
const q = new URL(req.url).searchParams;
const gateId = q.get("gateId");
const decision = q.get("decision");
const nonce = q.get("nonce");
if (!gateId || !nonce || (decision !== "approved" && decision !== "rejected")) {
return new Response("Link invalid.", { status: 400 });
}
const valid = verifyChannelApprovalToken({
gateId,
actionHmac: q.get("hmac") ?? "",
decision,
issuedAtUnix: Number(q.get("ts")),
signature: q.get("sig") ?? "",
nonce,
signingSecret: process.env.SPURVIK_SIGNING_SECRET!,
maxAgeSeconds: 3600, // default is 86400
});
if (!valid || usedNonces.has(nonce)) {
return new Response("Link invalid, expired or already used.", { status: 400 });
}
usedNonces.add(nonce);
// Continue the action only when decision === "approved".
// Record the decision for this gateId in your own store.
return new Response(decision === "approved" ? "Approved." : "Blocked.");
}verifyChannelApprovalToken()returnstrueorfalse. ReadgateId,decisionandnoncefrom the link yourself, as above; the check fails if any of them, or the timestamp, differs from what was signed.- The check runs in constant time and enforces a maximum link age (24 hours by default).
- A link can be reused until it expires, so record the nonce and refuse a repeat.
- This checks Spurvik's own link token only. It does not check a Slack, Teams or other platform webhook signature.
B. Helpers for OpenAI Agents and LangGraph (hosted review queue)
These helpers use the hosted review queue in the Spurvik console. They need a spurvik_live_ project key from a console workspace, and the queue is on Pro and higher. A reviewer decides in the console; the helper waits, 120 seconds by default, and throws a timeout error if nobody decides.
OpenAI Agents SDK
npm i spurvik @openai/agents zod// agents.ts: OpenAI Agents SDK
import { Agent, OpenAIProvider, run, tool } from "@openai/agents";
import { z } from "zod";
import {
withSpurvikModel,
createSpurvikNeedsApproval,
resolveAgentInterruptions,
} from "spurvik/openai-agents";
const config = {
apiKey: process.env.SPURVIK_API_KEY!,
signingSecret: process.env.SPURVIK_SIGNING_SECRET!,
contentHash: { mode: "keyed" as const, secret: process.env.SPURVIK_CONTENT_HASH_SECRET! },
baseUrl: process.env.SPURVIK_BASE_URL!,
riskTier: "limited_art50" as const, // your own classification
};
const issueRefund = tool({
name: "issue_refund",
description: "Refund an order",
parameters: z.object({ orderId: z.string(), amountEur: z.number() }),
needsApproval: createSpurvikNeedsApproval(config, { actionName: "issue_refund" }),
execute: async ({ orderId, amountEur }) => `Refunded €${amountEur} on ${orderId}`,
});
const model = withSpurvikModel(await new OpenAIProvider().getModel("gpt-4o-mini"), config);
const agent = new Agent({ name: "ops", model, tools: [issueRefund] });
let result = await run(agent, "Refund order 42 for €40");
if (result.interruptions.length > 0) {
// Sends each pending tool call to the review queue and waits for a decision.
// Only "approved" calls state.approve; "rejected" and "overridden" call state.reject.
await resolveAgentInterruptions(result.interruptions, config, { state: result.state });
result = await run(agent, result.state);
}
await model.flush();createSpurvikNeedsApproval()is aneedsApprovalcallback for an Agents SDK tool. It checks the project's halt state, then returns true (or the result of yourwhenpredicate), so the run pauses with an interruption before the tool runs.resolveAgentInterruptions()sends each unique tool call to the queue. Only anapproveddecision callsstate.approve.rejectedandoverriddencallstate.reject; overridden means do not continue.
LangGraph
npm i spurvik @langchain/langgraph @langchain/core// graph.ts: LangGraph
import { StateGraph, MessagesAnnotation, START, END } from "@langchain/langgraph";
import { AIMessage } from "@langchain/core/messages";
import { spurvikInterrupt } from "spurvik/langgraph";
const config = {
apiKey: process.env.SPURVIK_API_KEY!,
signingSecret: process.env.SPURVIK_SIGNING_SECRET!,
contentHash: { mode: "keyed" as const, secret: process.env.SPURVIK_CONTENT_HASH_SECRET! },
baseUrl: process.env.SPURVIK_BASE_URL!,
riskTier: "limited_art50" as const, // your own classification
};
export const graph = new StateGraph(MessagesAnnotation)
.addNode("refund", async () => {
// Sends the action to the review queue and waits (120 s by default).
const decision = await spurvikInterrupt(
{ actionName: "issue_refund", payload: { orderId: "42", amountEur: 40 } },
config,
);
if (!decision.approved) {
// "rejected" and "overridden" both land here: do not act.
return { messages: [new AIMessage(`Refund not issued (${decision.status}).`)] };
}
// ... issue the refund here ...
return { messages: [new AIMessage("Refund issued.")] };
})
.addEdge(START, "refund")
.addEdge("refund", END)
.compile();spurvikInterrupt()checks the halt state, sends the action to the queue and waits.decision.approvedis true only when the status isapproved.- If your graph pauses with LangGraph
interrupt, passinterruptin the request and resolve the paused items withresolveLangGraphInterrupts(); itscommandis the objectnew Command(...)accepts.
Before 1.0.7
Earlier builds of the LangGraph and OpenAI Agents helpers treated overridden as approved. Upgrade to spurvik@1.0.8.
The SDK and MCP server are MIT and run in your own stack.
Try it
- Install:
npm i spurvikand import fromspurvik/channels,spurvik/openai-agentsorspurvik/langgraph
<script src="https://spurvik.com/badge.js" data-system="Your AI" data-attribution="true" defer></script>- A small “Art. 50 · Spurvik” link shows after the notice by default. On Pro (€19/mo) you can hide it with
data-attribution="false"for a white-label badge.
Free €0 (10k events/mo) · Pro €19/mo · Team €79/mo · excl. VAT · B2B only.
FAQ
Do I need a Spurvik account for approval links?
No. createMultiChannelApprovalRouter and verifyChannelApprovalToken from spurvik/channels run in your own code. The OpenAI Agents and LangGraph helpers use the hosted review queue, which needs a console workspace and is on Pro and higher.
What happens on an overridden decision?
From 1.0.7 the helpers continue only on approved. Rejected and overridden both stop the action.
Is there a hosted endpoint for the Approve and Block links?
No. callbackBaseUrl must point at an endpoint you run, which checks the click with verifyChannelApprovalToken.
Can an approval link be used twice?
A link stays valid until it expires (24 hours by default). Record the nonce from each link and refuse a repeat.
Does Monday.com get Approve and Block links?
No. The Monday channel only creates a board item with the gate id.
Sources
- npm: https://www.npmjs.com/package/spurvik (1.0.8;
dist/channels.d.ts,dist/openai-agents.d.ts,dist/langgraph.d.tsanddist/langgraph.js/dist/openai-agents.jsinspected 11 Nov 2026) - Docs: https://spurvik.com/docs
Reviewed 11 November 2026. Not legal advice. Supports parts of your Art. 50 work; responsibility stays with you. EyeTea Solutions ApS · Trekantområdet, Denmark