learn / 05·6 — under agents · human in the loop

a run thatWAITSfor a person

Autonomy is usually binary — babysit every step, or review the damage after. The missing middle is a checkpoint inside a live run: the agent keeps its momentum, but one decision blocks on a human. No notification SaaS, no queue service, no human pretending to be an API — just three small primitives and a command that waits.

human in the loop11 min read
A small spacesuited figure standing at a glowing control console before a monumental wall-sized screen displaying a single rendered button; one bright green COMMIT lever waits under their hand while the machine hums patiently around them — 1970s sci-fi style, warm and luminous, the vast apparatus dwarfing the lone reviewer

approve everything, or APPROVE nothing

This is a sub-lesson, and it answers a promise the agents lesson made and left open: agents, not autopilot — trust is earned in increments. The trouble is that in practice, autonomy is binary almost everywhere. Either you babysit every step — which is a chat window with extra ceremony — or the agent commits blind and you review the damage afterward, which is an autopsy, not a review.

What's missing is a mid-run checkpoint. Not a permission dialog before each tool call, and not a postmortem on a finished branch — a single point inside a live run where the agent keeps its context and momentum, but one decision waits for a person. The reader's real question is mechanical: how do you make a running process literally pause for a human, without standing up a notification service, a queue, or a human pretending to be an HTTP endpoint?

the DEFINITION

hu·man-in-the-loop /ˈhjuː·mən ɪn ðə luːp/ noun

1. a checkpoint inside a live run, built from three primitives: a canvas the agent opens for a human to judge, a commit the human makes that carries the approved state plus a note, and a mailbox — the run's own session — that the agent polls until the verdict arrives.

The reframe that makes it work: the human's verdict is data, not remote control. They never reach in and drive the agent's mouse. They approve a state — a set of values — and leave a note, and the runtime hands that back to the agent as a small JSON event. Judgment is the human's job; the labor of turning that judgment into source edits stays with the agent. The worked example throughout this page is CTK — the canvas toolkit, a render-shape toolkit whose whole tagline is a human-in-the-loop canvas. Everything is experimental; we'll be honest about that at the end.

a run with an ADDRESS

The first primitive is the one nothing else works without: every run can be named, and the agent inside it knows its own name. When you start a run with POST /api/run, the runtime answers 202 immediately with an id like run-4117 and gets to work — you observe it by polling GET /api/run/:id or over a WebSocket stream. Behind that id sits exactly one supervised GenServer — the session — registered by run id in a registry, so any later request carrying the id finds the right run.

The move that makes a checkpoint possible is small and deliberate: when the session starts the agent, it injects the run's own id into the agent's environment as WB_RUN. The comment in the source says it plainly — WB_RUN lets the agent address its OWN run for CTK reviews. The agent can now point a human at a URL that routes a verdict back to this run and no other.

flowchart TD
  req["POST /api/run
→ 202 {id: run-4117, running}"] req --> sess["session — one GenServer
keyed run-4117 in the Registry"] sess --> agent["the spawned agent
env: WB_RUN=run-4117"] agent -. "addresses itself" .-> sess style sess fill:#9fc4e8,stroke:#121316,stroke-width:2.5px style agent fill:#ffffff,stroke:#121316 style req fill:#fbfaf6,stroke:#121316

Read the arrows as a story: a request comes in and the runtime mints a session keyed by run-4117; that session spawns the agent with WB_RUN set to its own id; and the dotted arrow back up is the whole trick — the agent can now hand out a URL that names the very session that owns it. The id is the thread that ties a human's verdict to your run, so the approval comes back to you, not another session.

the review CANVAS

The second primitive is what the human actually looks at. CTK is a toolkit of KIND: render — a fixed shell, never designed per-story: a top bar, a left control panel, a stage. It is deliberately not for shipping apps (that's a workbook) and not for CLI automation — it inserts a human-in-the-loop checkpoint into the agent loop for one isolated artifact.

A story is one Org file doing double duty: a Controls table declares the panel, and a :tangle source block carries the component itself. The shell parses the table to build the controls and imports the tangled source to render the stage. Controls come in three kinds, never blurred — VIEW (a lens, never changes the component), PROPS (shared authored inputs), and STATES (conditions rendered side-by-side, each able to carry its own per-state controls). And the runtime serves the canvas itself, at /ctk, same origin as the commit route — which matters the moment the human clicks.

Here is the real, shipped button.org story — the Controls table is the review UI; nobody designed a form:

| prop    | type   | default  | range/options        |
|---------+--------+----------+----------------------|
| label   | text   | Download |                      |
| variant | select | primary  | primary|ghost|danger |
| accent  | color  | #2f6fe0  |                      |
| size    | range  | 14       | 11..20               |

*** disabled
    :OVERRIDE: disabled:true
    | opacity | range | 0.55 | 0..1:0.05 |

#+begin_src js :tangle component.js
export function render(el, props) { /* ... the component under review ... */ }
#+end_src

The tangle block is the component being judged; the table is the dial the human turns; the disabled state adds its own per-state control. Five control types cover the whole panel — their grammar:

typewhat it isrange / options grammar
texta free string
colora colour swatch— (a hex default)
rangea slidermin..max or min..max:step
togglea boolean
selectone of a seta|b|c

The reference artifact, ctk.html, is self-contained — you can open it bare with no runtime at all. That self-sufficiency is what lets the commit step degrade gracefully, which is the next piece.

one event, two TRANSPORTS

When the human is happy, they hit Commit, write an optional note (what's right, what to change), and confirm. What leaves the canvas is a single ctk.commit event — the verdict on the wire:

{
  "type": "ctk.commit",
  "story": "button",
  "feedback": "ghost variant border is too faint — bump to --line-strong",
  "snapshot": {
    "story": "button",
    "props": { "label": "Download", "variant": "ghost", "accent": "#2f6fe0", "size": 14 },
    "states": { "default": {}, "disabled": { "opacity": 0.55 }, "loading": {} }
  },
  "ts": 1733764800000
}

Two fields carry the meaning. snapshot is the full approved page state — the shared props plus every state's effective props — and feedback is the human's note, which may be empty. CTK sends this over two transports at once, so it works whether it's embedded or standalone: a host postMessage to window.parent when it's living inside another frame, and a connector webhook when it's loaded standalone with ?connect=<url> — it POSTs the JSON, and a 2xx means delivered. If there's neither a host nor a connector, the payload is captured to the console and the button reads shared locally — the bench never blocks.

The piece that routes a verdict to the right run is the query string. The agent hands the human a connect URL ending in ?run=$WB_RUN, and the runtime reads that to find the session:

sequenceDiagram
  participant H as the human
  participant C as ctk.html (the canvas)
  participant R as runtime
  H->>C: tune controls, write a note, Commit
  C->>R: POST /api/ctk/commit?run=run-4117
{ctk.commit event} R->>R: AgentSession.put_review(run-4117, event) R-->>C: 202 {ok, run} Note over C: button reads shared ✓

The story of that exchange: the human commits; the canvas posts the event to the commit route with run=run-4117 in the query; the runtime drops it into that session's mailbox with put_review and answers 202. The route's full contract, in two directions:

route2xx4xx
POST /api/ctk/commit?run=…202 delivered400 missing run id · 404 no such run
GET /api/ctk/review/:id200 the event · 204 nothing yet404 no such run

Note the asymmetry, because it explains the whole design. The POST is an append — it adds a verdict to the run. The GET is a destructive pop — it takes the oldest pending verdict and removes it. The first half feeds a mailbox; the second half drains it.

the run's MAILBOX

Depth rung — skippable, but it's where the verdict stops being ephemeral. The session GenServer is the mailbox. The web side calls put_review; the agent side calls take_review; a FIFO sits between them. But put_review doesn't just enqueue — a single review fans out to four places, each with a different job:

destinationwhat it iswhat it's for
WS subscribersa live {:agent_review, …} pushanyone watching the stream sees it the instant it lands
the reviews FIFOa queue, popped oldest-firstthe poller — exactly-once delivery to the agent
the review_logan append-only list on the status replythe run's record — surfaced as reviews running and finished
a per-run JSONLtmp/wb-ctk-reviews/<run>.jsonla durable trace that outlives the run process — best-effort

The verdict on that table: a single approval is pushed live, queued for the agent, kept on the run's permanent record, and written to disk — it is never lost to the ether. take_review pops the oldest pending review and returns nil when empty; it's a destructive, exactly-once read aimed at the poller. The JSONL append is wrapped so a failure rescues to :ok — it can never crash the session; it's a record, not a recovery mechanism, a distinction the honesty section insists on. This is the same wire the telemetry stream rides: {:agent_review, …} travels beside {:agent_step, …} to live subscribers.

the blocking COMMAND

The third primitive is how the agent waits. Here is the constraint that shapes everything: an agent in this ecosystem has exactly one tool — bash. There's no callback to register, no webhook for the agent to host, no long-poll framework. So "wait for a human" has to collapse into a single blocking command:

wb ctk await $WB_RUN          # blocks ≤ 600s until the human clicks Commit

Under the hood, wb ctk await <run> [timeout_s] polls GET /api/ctk/review/<run> every two seconds: a 204 means keep waiting, a 200 with a body means pretty-print the review and succeed, and when the deadline passes (default 600 seconds) it exits non-zero with timed out waiting for a CTK review. A 404 means no such run. It runs with no app boot and no NIFs — plain :httpc with a bearer token — and resolves its target from WB_RUNTIME_URL + WB_TOKEN, falling back to the local discovery file.

sequenceDiagram
  participant A as wb ctk await
  participant R as runtime
  A->>R: GET /api/ctk/review/run-4117
  R-->>A: 204 (nothing yet)
  Note over A: sleep 2s
  A->>R: GET /api/ctk/review/run-4117
  R-->>A: 204 (still nothing)
  Note over A: sleep 2s … up to 600s
  A->>R: GET /api/ctk/review/run-4117
  R-->>A: 200 {the ctk.commit event}
  Note over A: print review, exit 0
  

That diagram is the whole verb: ask, get 204, sleep two seconds, ask again — until a 200 carries the verdict, or the 600-second deadline turns it into a non-zero exit. Because the agent only has bash, the same contract is expressible as a raw loop, no wb required:

while :; do
  review=$(curl -fsS "$RUNTIME/api/ctk/review/$RUN" -H "authorization: Bearer $WB_TOKEN")
  [ -n "$review" ] && break          # 204 = empty body = keep waiting
  sleep 2
done

An empty body is a 204 is "not yet"; a non-empty body is the event. "Wait for a human" really is just a poll loop wearing a command's clothes.

the whole loop, ONCE

Now the signature end-to-end — the one diagram this page is remembered by. An agent makes a change, opens a canvas, blocks, a human tunes and commits, the verdict routes home, the agent applies it, and the agent's own git tool lands the commit:

sequenceDiagram
  participant A as agent
  participant K as ctk.html (canvas)
  participant R as runtime
  participant H as human
  A->>A: edit component → write the CTK story
  A->>H: print connect URL (?run=run-4117)
  A->>R: wb ctk await run-4117  (blocks)
  H->>K: open canvas, tune props + per-state values
  H->>K: Commit + feedback note
  K->>R: POST /api/ctk/commit?run=run-4117
  R-->>A: await returns the ctk.commit event (200)
  A->>A: translate snapshot + feedback → source edits
  A->>R: git commit (feedback as the message)
  

Walk it as a story: the agent renders its change as a story and prints a URL with ?run=run-4117 baked in, then blocks on await; the human opens the canvas, moves the same controls the author declared, and commits with a note; that POST drops into the mailbox, await unblocks and hands the agent the event; the agent reads the snapshot and the feedback, edits the actual source, and commits. Here is the real recipe the agent runs — bash, the actual URL shape, the actual pitfall:

echo "Review: $WB_RUNTIME_URL/ctk/ctk.html?connect=$WB_RUNTIME_URL/api/ctk/commit?run=$WB_RUN"
review=$(wb ctk await "$WB_RUN")          # blocks ≤600s until the human clicks Commit
echo "$review" | jq -r '.feedback'        # ghost variant border is too faint — bump to --line-strong
echo "$review" | jq '.snapshot'           # approved props + per-state values
# translate the approved STATE into source edits, then:
git add -A && git commit -m "ui: $(echo "$review" | jq -r '.feedback // "apply CTK review"')"

The cardinal rule, in CTK's own words: CTK never edits your files. It returns the human-approved state plus a note; you translate that into source edits and the commit. The snapshot is the approved state — props and per-state values — not the source. And the pitfall worth tattooing on your wrist: drop ?run=$WB_RUN from the connect URL and the commit has nowhere to route — await waits the full 600 seconds and exits 1, for nothing. The run id is load-bearing because it's the only thing tying a verdict to this run.

when the agent moves the SLIDERS

Depth rung. A human moves the controls — but so can the agent, and there's a deliberate design choice hiding here: there is no separate automation API. The controls a human moves and the ones a driver sets are the same props. The agent doesn't propose changes through a back door; it sets the same dials the human will then judge, through a small in-page surface called window.CTK:

callwhat it does
stories · current · load(id)which stories exist, which is showing, switch to one
spec() · controls() · states()read the panel: the declared controls and states
props() · stateProps(name) · get(prop)read current shared props and per-state props
set(prop, v) · setStateProp(…)write a value — coerced by the control's declared type
snapshot()capture the full effective state, for the record

Across frames the same surface speaks postMessage: {ctk:true, action:"load"|"set"|"snapshot", …} in, a {ctk:true, reply:"snapshot", data} back. The runtime human-in-the-loop loop is five steps — serve the canvas, read the spec, propose by setting values, observe (the human sees the stage; a snapshot keeps the record), then approve or iterate. Because proposing and judging touch the identical props, an agent can stage a starting point and a human can adjust from there — no translation layer between "what the driver set" and "what the human saw."

where the loop is THIN

Full honesty — this is the most experimental thing on these pages, marked STATUS: experimental across the manifest and every skill, with the note mark stable once a real run commits an approved change. The thin spots are real and worth naming:

  • The FIFO lives in session memory. If the session process dies while you're mid-review, there's no true resume — the queue went with it.
  • The JSONL is a record, not a recovery. The per-run file in the temp dir is a best-effort trace (it rescues to :ok on failure). It tells you what happened; it doesn't rebuild a lost run.
  • Nobody notifies the human. The agent prints a URL — a person has to actually open it. There is no notification channel; that's a feature gap, stated plainly.
  • The verdict carries no reviewer identity. Auth is the bearer token on the route, not a signature on the verdict. The event records what was approved, not provably who approved it.
  • The env seam is plumbed but unverified end-to-end. The session injects WB_RUN into the agent's run options, and the recipe reads it — but whether it actually expands inside the agent's in-WASM shell today is the open question. The skill's own gap note says the injection is wired and a live round-trip — real run, human commit, agent commit — is the remaining verification. We'd rather flag that than imply it's proven.

None of this contradicts the design; it scopes it. The three primitives are right and small; the maturity is young. Believe the shape, not a "ships itself" story.

questions people actually ASK

What if the human never clicks?

wb ctk await blocks up to 600 seconds, then exits non-zero. The agent decides what that means — re-prompt with a fresh URL, or abort the run. The timeout is a deadline, not a hang.

Can the human edit the code in the canvas?

No — and that's the boundary that keeps responsibility clean. The human approves a state (props plus per-state values) and leaves a note. The agent translates that into source edits and makes the commit. Human = judgment, agent = labor.

Where does the approval live afterwards?

On the run's record. put_review appends every verdict to an append-only review_log surfaced in the run's status — running and finished — and writes a per-run JSONL to disk. Tomorrow you can read the whole episode back.

Can one run have multiple checkpoints?

Yes. The mailbox is a FIFO — commits queue, and the agent's await / take_review pops them oldest-first, exactly once each. Several checkpoints in a single run is just several trips through the same poll loop.

Is this the same as approving each tool call?

No — coarser, and better. A per-tool-call permission dialog interrupts constantly and shows you a verb, not an outcome. A CTK checkpoint shows you a rendered artifact at a meaningful moment and asks one question: is this right? You judge the result, not the mechanism.

Do I need a server for any of this?

For the routed, agent-driven loop, yes — the runtime serves the canvas and hosts the mailbox. But ctk.html is self-contained: open it bare with no host and no connector, and a commit is captured to the console with shared locally. The bench never blocks on infrastructure.

keep GOING

This is the mechanics behind the agents lesson's "agents, not autopilot." Up to the parent, or sideways to the cycle the checkpoint interrupts.