Guide
Human-in-the-loop approval for AI agents
An agent that runs unattended will eventually reach a step that cannot be undone. A post goes out, a message is sent, a record is deleted. Human-in-the-loop approval is the decision about where a person stands relative to that step.
This page covers three common placements and what each one costs. Then it shows one implementation in full, down to the line that parks the run.
Three placements
Where the person sits in the loop
The same requirement produces three different systems. They differ in what a mistake costs, and in what every run costs.
01Act first, review the log
The agent executes everything and appends each step to an event log. A person reads the log afterwards. Nothing blocks, so the run stays cheap.
Fine for reversible actions: a draft, a branch, a staging change. It fails on the irreversible ones, because a published post turns the review into a record rather than a decision.
02Approve before the step runs
The run stops at the risky step and waits for a person. Nothing happens until a decision arrives, so the irreversible action is the one that waits.
Costs latency and someone on call. If every step waits, the agent becomes a form and people start approving without reading.
03Approve in a separate queue
Risky steps are lifted out of the run into a queue that a person works through. This scales better than blocking one run at a time.
Costs a second state machine. The queue and the run have to agree on what is pending, and a resumed run may act on a world that moved while it waited.
One implementation
How the gate is built in Autopilot
Autopilot suspends the run instead of queueing it. The gate is five pieces: a flag on the plan, the rule that sets it, the promise that parks the run, the decision that resolves it, and the event log that records both. Paths below are repository-relative.
The plan is data. Every task carries a lane, a day range and an approval flag, so the gate is decided when the plan is made rather than improvised while it runs.
lane: "research" | "draft" | "publish" | "reply" | "report";
reason: string; // why this task exists, in the model's words
estimated_days: [number, number]; // [start day, end day], e.g. [1, 3]
requires_approval: boolean;The flag is set where the plan is normalised: anything in the publish lane requires approval, whatever the model returned.
tasks: parsed.tasks.map((t, i) => ({
...t,
id: t.id || `t-${i + 1}`,
requires_approval: t.lane === "publish" || t.requires_approval === true,
})),At that step the request is raised with its evidence, and then the runner awaits a decision. The await is the gate: the process keeps serving other requests, this run does not move.
// HITL pause, once on day 12, approving a high-risk post with an image.
if (day === 12) {
emit("hitl_required", day, {
task_id: "t-publish-1",
preview_body:
"5 AI tools I actually kept using — number three saved me two hours a week, with a step-by-step signup guide",
image_desc: "side-by-side comparison of 5 AI tools · meeting notes / Claude / ChatGPT / image gen / Cursor",
reason: "Image plus multiple brand mentions — approval_mode=risky_only sends it to you",
});
const decision = await ctx.waitForApproval();The wait is a promise stored on the run record. Nothing polls and no worker is held, so the run is parked until something resolves it.
waitForApproval: () =>
new Promise<ApprovalDecision>((resolve) => {
rec.pendingApproval = resolve;
}),A decision resolves that promise, and false means nothing was waiting. approveGoal and rejectGoal are the two ways out.
function decide(id: string, decision: ApprovalDecision): boolean {
const rec = goals.get(id);
const resolve = rec?.pendingApproval;
if (!rec || !resolve) return false;
rec.pendingApproval = null;
rec.summary.status = "running";
resolve(decision);
return true;
}
export function approveGoal(id: string): boolean {
return decide(id, "approved");
}The pause in this build lands on a scripted day. The runner checks `day === 12` and raises the request against a fixed publish task (`goal-runner.ts:296-303`). The flag is real and travels with the plan, but the timeline that plays it is compressed and scripted; it is not a scheduler that scans every task for a flag.
The promise lives inside the process. The store is an in-memory map and a restart resets it (`db.ts:5-6`), so an approval that is pending across a deploy is lost rather than recovered. That is a boundary of the demo, not a property to build on.
The decision arrives as an HTTP call: a POST that returns 409 when the run is not waiting (`Autopilot/src/app/api/goals/[id]/approve/route.ts:19-22`). Every event, approvals included, is appended with a sequence number (`goal-manager.ts:54-69`), which is what makes the run replayable afterwards.
The other direction
An agent that cannot approve
Blocking the run is one answer. The other is removing the capability: the agent may read the queue and recommend, but the tool that approves is not in its toolset.
CompanyBrain does this for fact review. The review tool sits in a separate high-risk list (`apps/agent-gateway/src/agent/admin-review.ts:7-16`), the system prompt forbids approving on its own (`:27-33`), and the policy type pins auto-approval to false (`:38`).
The approving tool is not in the agent's allowed set; it sits in a high-risk list used by the surrounding code. The capability is removed rather than discouraged.
export const ADMIN_REVIEW_AGENT_ALLOWED_TOOLS = [
'nano_admin_list_fact_submissions',
'nano_get_fact_submission',
'nano_save_fact_candidates',
'nano_get_dream_status',
'nano_get_dream_run',
'nano_list_facts',
] as const;
export const ADMIN_REVIEW_HIGH_RISK_TOOLS = ['nano_admin_review_fact_submission'] as const;The allowed-tools list is what removes the capability; the prompt only repeats it. A prompt can be talked around, a missing tool cannot.
Boundaries
When the gate costs more than it saves
A gate is a cost on every run, so it has to be selective. Three cases where it should be narrow or off.
- Reversible steps
- A draft, a branch, a staging deploy. Being wrong costs a revert, and the log already covers it. In Autopilot the mode is a field on the input, including `none` (`goal-input.ts:19`).
- Decisions the agent could already make
- Waiting helps only when the decision needs something the agent cannot supply: a policy call, a brand judgement, a spend. If the reviewer would click approve without reading, the gate is theatre.
- High-volume, low-variance steps
- A hundred identical steps a day turn a per-step queue into a rubber stamp. Batch the review, or move the decision earlier into the plan.
One cost to state plainly: while the run waits, it is not doing anything else. The status becomes `awaiting_approval` and stays there (`goal-manager.ts:83`).
Terms
What people call it
- English uses both `human in the loop` and `human-in-the-loop`; the hyphenated form works as the adjective.
- German- and French-language results mostly keep the English term, which is why this page is published in English first.
- Japanese searchers type ヒューマンインザループ and Korean searchers type 휴먼 인 더 루프. Both are transliterations rather than translations.
- Spanish-language pages use supervisión humana, and this page uses the same term so a translated version stays consistent.
Next
See it running
Two projects carry the pattern, in opposite directions.
Autopilot
The run suspends, the decision lands in the approval inbox, and the whole run is replayable afterwards.
CompanyBrain
The agent reviews facts but cannot approve them; the capability is withheld at the tool boundary.
The catalogue
Retrieval, document review, analytics and the rest of the store.