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
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:
| type | what it is | range / options grammar |
|---|---|---|
text | a free string | — |
color | a colour swatch | — (a hex default) |
range | a slider | min..max or min..max:step |
toggle | a boolean | — |
select | one of a set | a|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:
| route | 2xx | 4xx |
|---|---|---|
POST /api/ctk/commit?run=… | 202 delivered | 400 missing run id · 404 no such run |
GET /api/ctk/review/:id | 200 the event · 204 nothing yet | 404 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:
| destination | what it is | what it's for |
|---|---|---|
| WS subscribers | a live {:agent_review, …} push | anyone watching the stream sees it the instant it lands |
the reviews FIFO | a queue, popped oldest-first | the poller — exactly-once delivery to the agent |
the review_log | an append-only list on the status reply | the run's record — surfaced as reviews running and finished |
| a per-run JSONL | tmp/wb-ctk-reviews/<run>.jsonl | a 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:
| call | what 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
:okon 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_RUNinto 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.