ESC

Type to search the knowledge base.

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.

intermediate5 min read
  • 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

  1. 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.
  2. Parked run. Keep the transcript, tool schemas, and the pending id. Drop in-flight stream buffers.
  3. Policy. Caps, ownership, and “this SKU is non-refundable” are checked again at execute time. Inventory moves while humans think.
  4. 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 guides