Human in the Loop for Agents
Side-effect tools pause for a human. How to design approval, show diffs, expire tokens, and resume the loop without replaying the refund.
- agents
- approvals
- safety
If a refund tool runs because the model emitted refund_order, you did not build an agent. You built a self-serve cash register for a sampler. Human in the loop means the side-effect tool stops, you persist the run, a person sees the exact payload, and only then does your process talk to Stripe.
The model can draft. Your code still owns money, deletes, and outbound email. The loop is a state machine with a parked turn, not a chat that “asks nicely.”
Key takeaways
- Interrupt write tools. Persist the run. Do not execute on the model’s JSON alone.
- Bind the approval to a hash of the tool name plus payload, plus an expiry and an actor role.
- Resume from stored state. Do not let a new model turn replay the refund.
- Idempotency keys on the side effect; agent retries must not double-credit.
- Read tools can run after authorization. Irreversible tools wait.
Human in the loop is a parked run
Treat the agent like a request that can return 202 with a resume token. The UI is an approval inbox: who, what, how much, until when. Users mash Stop while they think. Wire that to AbortController for the generation, not for the pending refund. Cancelling the stream must not cancel a stored approval, and approving must not need the browser tab that started the run.
Put the wait on the server. A Route Handler owns the pending row. The browser polls or subscribes. Secrets stay off the client; see secrets in frontend bundles.
| Tool class | Execute now? | Why |
|---|---|---|
Read (get_order) |
Yes, after authz | No durable side effect |
Draft (compose_reply) |
Preview only | Reversible until send |
| Money, delete, send | No. Interrupt. | Irreversible or noisy |
Bind the approval to the payload
The receipt is not a thumbs-up on a paragraph of prose. It is a signature over the JSON you will execute. If the UI lets someone edit the amount, that is a new pending row, not a mutated one.
import { createHash } from 'node:crypto';
type RefundArgs = { orderId: string; amountCents: number };
type PendingRefund = {
id: string;
runId: string;
tool: 'refund_order';
payload: RefundArgs;
payloadHash: string;
requestedBy: string;
expiresAt: Date;
status: 'pending' | 'approved' | 'rejected' | 'expired' | 'executed';
};
function payloadHash(tool: string, payload: RefundArgs): string {
return createHash('sha256')
.update(tool)
.update(JSON.stringify(payload))
.digest('hex');
}
export async function proposeRefund(opts: {
userId: string;
runId: string;
args: RefundArgs;
}): Promise<{ type: 'interrupted'; pendingId: string }> {
const order = await db.orders.findOwned(opts.userId, opts.args.orderId);
if (!order) throw new Error('not found');
if (opts.args.amountCents > order.paidCents) throw new Error('amount');
const pending = await db.pending.insert({
runId: opts.runId,
tool: 'refund_order',
payload: opts.args,
payloadHash: payloadHash('refund_order', opts.args),
requestedBy: opts.userId,
expiresAt: new Date(Date.now() + 30 * 60 * 1000),
status: 'pending',
});
await agentRuns.park(opts.runId, { pendingId: pending.id });
return { type: 'interrupted', pendingId: pending.id };
}
Show a diff, not a vibes summary: order id, amount, currency, customer, policy reason. The operator with refunds:approve is not the chat user by default. A customer chatting “please refund me extra” must not be the approver.
Who is “the human” is a product decision you encode in IAM, not in the prompt. Support chat: a staff role. Internal coding agent: the developer who started the run, with a timeout so a forgotten modal cannot fire later. Batch jobs: a queue an on-call owns. If you cannot name the role, you are not ready to expose the tool.
Resume without replaying the refund
The dangerous bug is: human approves, you call the model again, the model emits refund_order a second time, and your executor is not idempotent. Execute from the pending row. Feed the tool result back as observation. The model never gets a second chance to fire Stripe.
export async function resumeAfterDecision(opts: {
pendingId: string;
actorId: string;
decision: 'approved' | 'rejected';
approvedHash: string;
}) {
const pending = await db.pending.get(opts.pendingId);
if (!pending) throw new Error('missing');
if (!(await iam.can(opts.actorId, 'refunds:approve'))) {
throw new Error('forbidden');
}
if (pending.status !== 'pending') throw new Error('not pending');
if (pending.expiresAt.getTime() < Date.now()) {
await db.pending.setStatus(pending.id, 'expired');
return agentRuns.resume(pending.runId, {
tool: 'refund_order',
content: 'expired: human did not approve in time',
});
}
if (opts.decision === 'approved' && opts.approvedHash !== pending.payloadHash) {
throw new Error('payload mutated after request');
}
if (opts.decision === 'rejected') {
await db.pending.setStatus(pending.id, 'rejected');
return agentRuns.resume(pending.runId, {
tool: 'refund_order',
content: 'rejected by operator',
});
}
const result = await refundProvider.credit({
orderId: pending.payload.orderId,
amountCents: pending.payload.amountCents,
idempotencyKey: pending.id,
});
await db.pending.setStatus(pending.id, 'executed');
return agentRuns.resume(pending.runId, {
tool: 'refund_order',
content: JSON.stringify({ ok: true, refundId: result.id }),
});
}
If credit already ran for that key, return the original refund id. Do not invent a second one because the agent loop retried.
What expires, what does not
- Pending token. 15–30 minutes is a reasonable default for money. After that, the model gets an error observation. A late click is a 409, not a refund.
- Parked run. Keep the transcript, tool schemas, and the pending id. Drop in-flight stream buffers.
- Policy. Caps, ownership, and “this SKU is non-refundable” are checked again at execute time. Inventory moves while humans think.
- The model’s wording. Never execute a sentence. Execute the stored JSON.
Prompt-only rails fail open. Pair this with guardrails and treat skipped confirmation as an agent failure mode. Tool JSON is still a proposal; function calling does not grant authorization.
Interview answer (out loud)
“Human in the loop is an interrupt on side-effect tools: persist the run, show a diff of the exact payload, require a role, expire the token, then execute with an idempotency key bound to that pending id. I resume by appending the tool result, not by asking the model to ‘do it again.’ Reads can run after authz. Refunds, deletes, and sends wait. If approval is only a system-prompt sentence, that is not a control.”
Further reading (primary sources)
Related on this site
- Function calling and tools
- Guardrails for LLM apps
- Agent failure modes
- AbortController
- Secrets in frontend bundles
Related guides
- Agent Failure ModesAgents loop, lie about tool results, skip confirmation, and get stuck apologizing. Catalog the failures and the circuit breakers.
- Agent HandoffsA handoff passes the conversation to another agent with a slimmer prompt and different tools. What to copy, what to drop, and how to avoid ping-pong.
- Agent MemoryAgents forget when the context window ends. Working memory, session summaries, and long-term stores are three different systems. Do not mix them.
- Agent System Design InterviewDesign a support or coding agent in a loop: tools, approvals, traces, evals, and cost. The frontend surfaces matter as much as the model.
- Coding AgentsA coding agent reads, edits, and runs tests in a repo. Permissions, diffs, and why "it compiles" is not the acceptance test.