GNL
Docs menu
Core · Free@gnldev/durable

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 completed

API reference#

typeGuard

(call: GuardCall) => GuardDecision | Promise<GuardDecision> — general policy hook that runs before every tool call.

typeGuardCall

{ toolName, args, toolCallId, runId } — the call context passed to the Guard function.

typeGuardDecision

{ action: 'allow' } | { action: 'deny'; reason? } | { action: 'require-approval'; reason? } — the body of the decision.

typeInterrupt

{ toolCallId, toolName, args, reason? } — a tool call suspended by require-approval, waiting on human approval.

typeSuspendSentinel

{ __gnl_suspend: Interrupt } — the suspend signal returned in place of the tool executing; the loop stops as soon as it sees this, via stopWhen.

fnresumeRun

(runId, { journal, model, tools, guard?, approvals?, stopWhen?, replay? }) => Promise<DurableResult> — reads the input back from the journal and calls runDurable again.

Approval denial
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.
Together with exactly-once
Suspension kicks in AFTER the exactly-once check: if a 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.