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.
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.