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 not a UI feature bolted on top — it is a decision about where a person stands relative to that step.

There are three common placements. They differ in what a mistake costs and what every run costs.

Three placements

1. Act first, review the log. The agent executes everything and appends each step to an event log. A person reads it afterwards. Nothing blocks, so the run stays cheap. Fine for reversible actions — a draft, a branch, a staging change. It fails on irreversible ones, because a published post turns the review into a record rather than a decision.

2. Approve before the step runs. The run stops and waits. Nothing happens until a decision arrives. This costs latency and someone on call. If every step waits, the agent becomes a form and people start approving without reading.

3. Approve in a separate queue. Risky steps are lifted out of the run into a queue a person works through. Scales better than blocking one run at a time, but 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.

I built the second one, and the interesting part is not the approve button — it is what parks the run.

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.

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 rule is in the normaliser, not the prompt. 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, })), 

This is the difference between a policy and a suggestion. A prompt that says "please be careful" is not a gate.

The wait is a promise, not a poll. Nothing spins, no worker is held:

waitForApproval: () => new Promise<ApprovalDecision>((resolve) => { rec.pendingApproval = resolve; }), 

A decision resolves that promise. Returning false when nothing was waiting is what makes a double-approve a no-op instead of a race:

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; } 

Where this build stops being a reference

The pause lands on a scripted day — the runner checks day === 12 and raises the request against a fixed task. 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, so an approval pending across a restart is lost rather than recovered. That is a boundary of the demo, not a property to build on.

Two things I would keep: the decision is an HTTP call that returns 409 when the run is not waiting, and every event — approvals included — is appended with a sequence number. That is what makes the run replayable afterwards, which matters more than the approval itself. An approval you cannot audit is just a click.

The other direction: remove the capability

Blocking the run is one answer. The other is removing the capability entirely: the agent may read the queue and recommend, but the tool that approves is not in its toolset.

The approving tool sits in a separate high-risk list the surrounding code uses, and the agent's allowed set simply does not contain it. The capability is removed rather than discouraged.

That is a stronger guarantee than any instruction in a system prompt, and it is cheaper — no state machine, no pending promises. It only works when the decision is easy to separate from the judgement. For "should this fact be approved", it does.

Where this comes from

Both systems I quoted are open source, so you can read the rest yourself:

  • DeepSeek Harness Desktop — the desktop agent runtime. Session-level permission presets (Read Only / Workspace Write / Full access) rather than a prompt asking the model to be careful, crash recovery around the loop, and the function-call wiring.
  • Company Brain — the knowledge-graph RAG stack the review tool comes from. Entities and relations in PostgreSQL + Apache AGE, three retrieval engines in one workspace, cited answers.

They are part of a larger collection at aiengstore.com — full source for the systems most teams end up rebuilding. Alongside the open-source ones it covers:

Agents and orchestration — goal-driven runners that plan, pause for human approval and replay every decision; multi-agent routing where the hard part is deciding which agent owns a task boundary, not making them talk; long-running operations agents with event-sourced logs.

Retrieval and knowledge — hybrid retrieval that fuses dense and lexical ranking (and why BM25 tokenisation quietly breaks on product codes and non-Latin text); GraphRAG that survives a real corpus instead of a demo one; agent memory that addresses the two questions tutorials skip — when does it get rebuilt, and what actually remains after a delete.

Documents and extraction — review that returns page coordinates rather than a summary, so a finding can be located; NL-to-SQL with the schema reasoning kept visible; document pipelines that handle scanned PDFs.

Analytics and internal tools — operational dashboards over agent runs, knowledge bases that stay queryable as they grow.

The written guides go deeper than this post in exactly one way: every claim carries a file and line number you can open. If you want the version of this article with all the citations, it is at aiengstore.com/blog/human-approval.