Ask a human before your agent runs a tool. Keep the answer when the process dies.
Asking is the easy part. The hard part is the hours between the question and the answer, and the moment right after a yes.
The symptom#
An agent prepares a newsletter and calls sendCampaign, which emails 40,000 people. Nobody wants that to happen without a person saying yes, so the tool asks first. The marketing lead approves the next morning. The tool runs, and the worker is redeployed a moment later, before the conversation was saved. On the next call the agent reads the saved history, sees an approved call with no result, and sends the campaign again.
import { generateText, tool } from 'ai';
import { z } from 'zod';
const sendCampaign = tool({
description: 'Email a campaign to its whole audience',
inputSchema: z.object({ campaignId: z.string() }),
needsApproval: true, // the tool does not run until a person says yes
execute: async ({ campaignId }) => mailer.sendCampaign(campaignId),
});
// Next morning: the marketing lead approved. Append the answer and call again.
messages.push({ role: 'tool', content: [{ type: 'tool-approval-response', approvalId, approved: true }] });
const result = await generateText({ model, tools: { sendCampaign }, messages }); // the campaign goes out
await db.saveMessages(messages.concat(result.response.messages));
// A redeploy before this save → the stored history still says "approved, not run"
// → the next call sends the campaign to 40,000 people again.Why it happens#
The AI SDK's needsApproval is the right place to start: the tool does not run, and the result carries a tool-approval-request. You show it to a person, append their tool-approval-response to the messages, and call generateText again, which runs the tool.
Everything in between is yours. The question and the answer exist only in the message array, so you store it. The tool runs inside the second call, and you save the new messages after it returns. If the process dies between those two moments, the stored history still says approved, not yet run, and the next call runs it again.
Fixing it by hand#
Close the gap and the code grows into a small state machine you write for every risky tool:
- A record of the decision. A table for pending approvals, keyed by the call, that survives restarts and tells a stale request from a fresh one.
- A record that the tool ran. Written in the same step as the effect, or the yes can be used twice — the same window the plain retry has.
- An inbox. Pending approvals spread across many runs need one place where a person sees what each call would do, with its arguments.
With GNL#
Declare the gate on the tool and call runDurable. A fresh call to sendCampaign does not run; the run stops and result.interrupts says which call is waiting, with its arguments and your reason.
import { runDurable, gnlTool } from '@gnldev/durable';
import { PostgresStorage } from '@gnldev/durable/postgres';
const tools = {
// the same tool without needsApproval — confirm takes its place
sendCampaign: gnlTool(sendCampaign, {
sideEffect: true,
confirm: { reason: (args: { campaignId: string }) => `send campaign ${args.campaignId} to its audience?` },
}),
};
const journal = new PostgresStorage({ connectionString: process.env.DATABASE_URL }).runs;
const runId = `campaign:${campaignId}`; // the id of THIS job — never a session id
const first = await runDurable({ runId, journal, model, tools, prompt });
// first.interrupts → [{ toolCallId, toolName: 'sendCampaign', args, reason }] — show it to a person
// Hours later, in any process, with the toolCallId the person answered:
await runDurable({ runId, journal, model, tools, prompt,
approvals: { [toolCallId]: true } }); // false = denyThe answer can come hours later, from another process: call runDurable again with the same runId and the decision in approvals. The finished steps are read back from the journal, the approved call runs once, and the agent continues.
The decision is written to the journal, not to your message array. If the process dies after the yes and before the tool ran, the next call applies the recorded decision without being told again. Once the tool's result is recorded, no later call runs it again.
Approvals from every suspended run land in one place: Studio's inbox shows the tool, the run it paused and the exact arguments it would run with.
false for the call in approvals and a denial is recorded; the tool never runs and the model reads that it was not permitted, so it can tell the user.When GNL does not solve it#
The crash inside the call. If the process dies while sendCampaign is running, nobody knows whether the emails left. GNL does not guess: the resume stops with SideEffectRetryBlockedError, or your tool's recover() asks the email provider.
Who may approve. GNL records the decision; whether this person is allowed to make it is your application's check.
One yes, one call. An approval belongs to one tool call. If the model plans the campaign again as a new call, it asks again. By default a recorded yes survives a crash; set limits.approvalScope: 'attempt' if every attempt must ask again.
Sources#
Every claim on this page has a test in the public repository:
packages/durable/test/approval-persistence.test.ts— approved, crashed before the tool ran, resumed without the approval: the tool runs once; a denial persists toopackages/durable/test/suspend-cross-process.test.ts— one process suspends, another reads the waiting call and approves, the tool runs oncepackages/durable/test/faz3-confirm-thread.test.ts— the confirm gate: the first call suspends, approval executes exactly once, denial never fires the effectpackages/durable/test/approval-scope.test.ts— a journaled approval versus one spent by a single attempt
The mechanism in full: human-in-the-loop approvals. github.com/gnlhq/gnldev