Docs menu
Human-in-the-loop tool approvals (guard)
A Guard function suspends risky tool calls by returning an interrupt; once an operator approves, the run continues via resumeRun — a gate in front of money or irreversible actions.
What it's for / when to use it#
When an agent calls an irreversible tool — charging a card, sending an email, deleting a record — you want a human to approve the call BEFORE it runs. Guard is a general policy hook that runs before every tool call (after the exactly-once check): it doesn't restrict which tools the LLM can see, it only gates the side effect.
When Guard returns require-approval , the real tool does NOT run; instead an Interrupt is written to the journal as suspended and the run stops. Once the operator approves, the same runId is used to call resumeRun — there's no need to resend the prompt, the input is read back from the journal.
Coming from the symptom rather than the mechanism? Human approval before an AI SDK tool runs, and after a crash walks through it from the problem side.
Setup / import#
import { runDurable, resumeRun } from '@gnldev/durable';
import type { Guard, GuardCall, GuardDecision, Interrupt } from '@gnldev/durable';No extra sub-package is required — Guard is a type, and resumeRun is a core @gnldev/durable export. Import a journal adapter (e.g. @gnldev/durable/sqlite) separately, as needed.
Step-by-step usage#
1) Define a Guard — it looks at the tool name and args and returns allow / deny / require-approval :
const guard: Guard = ({ toolName, args }) =>
toolName === 'chargeCard' && (args as any).amount > 1000
? { action: 'require-approval' }
: { action: 'allow' };2) Give runDurableyour guard. If a risky call is suspended, the real tool never runs at all, and the returned result comes back with interrupts populated:
const r1 = await runDurable({
runId: 'o1',
journal,
model: makeModel(),
tools: makeTools(counter),
guard,
prompt: 'charge 5000',
stopWhen: stepCountIs(6),
});
// counter.charges === 0 → the tool did NOT run
// r1.interrupts.length === 1 → one call is awaiting approval
console.log(r1.interrupts[0]);
// { toolCallId: 'call-c', toolName: 'chargeCard', args: { amount: 5000 }, reason: undefined }3) Once the operator approves, use the same runId to call resumeRun — the prompt is not resent, the input is read from the journal. In the approvals object, mark the suspended toolCallIdas true:
const r2 = await resumeRun('o1', {
journal,
model: makeModel(),
tools: makeTools(counter),
guard,
approvals: { 'call-c': true },
stopWhen: stepCountIs(6),
});
// counter.charges === 1 → the tool has now actually run
// r2.text.includes('Done') → the run completedAPI reference#
Guard(call: GuardCall) => GuardDecision | Promise<GuardDecision> — general policy hook that runs before every tool call.
GuardCall{ toolName, args, toolCallId, runId } — the call context passed to the Guard function.
GuardDecision{ action: 'allow' } | { action: 'deny'; reason? } | { action: 'require-approval'; reason? } — the body of the decision.
Interrupt{ toolCallId, toolName, args, reason? } — a tool call suspended by require-approval, waiting on human approval.
SuspendSentinel{ __gnl_suspend: Interrupt } — the suspend signal returned in place of the tool executing; the loop stops as soon as it sees this, via stopWhen.
resumeRun(runId, { journal, model, tools, guard?, approvals?, stopWhen?, replay? }) => Promise<DurableResult> — reads the input back from the journal and calls runDurable again.
approvals['call-c'] is set to false , the call is permanently journaled as denied ; the model sees this result and can correct itself. If no approval is ever given (the field is absent), the call stays suspended — resume suspends it again rather than looping forever.toolCallId already has a succeeded/denied record, Guard is not asked again — the same result is returned from the journal, so resume never double-executes.