Assignments and mail
The assignment protocol: ask, answer, done, handoff, and the durable mail that carries each message.
An assignment is the unit of crew work: a task, declared file ownership, action grants, at most one pending question, and an explicit report that ends it. Native idle is not completion.
The shape of an assignment#
A new recruit receives separate crew-incarnation, assignment, and message IDs. --owns PATH and --allow ACTION record ownership and grants; repeat either flag as needed. Overlapping active ownership between crew is refused outright.
captain crew Gibbs --task "Rewrite the docs site content" \
--owns src/content --owns src/app/docs \
--allow edit --allow buildThe protocol commands#
Here check, ask, and done run in the crew's own environment; answer and assign run as captain.
captain check Jack edit src/example.py
captain ask Jack "Which behaviour is intended?"
captain answer Jack <question-id> "Keep existing behaviour" --assignment <assignment-id>
captain done Jack --report "Changed example.py; focused checks pass"
captain assign Jack --task "Next task" --handoff <old-assignment-id> \
--owns docs/example.md --allow edit| Command | Run by | Purpose |
|---|---|---|
check | Crew | Validate a grant. Executes nothing. |
ask | Crew | Record one pending question, then stop. |
done | Crew | Record explicit completion with a required report. |
answer | Captain | Answer that pending question. |
tell | Captain | Append a follow-up to an active assignment. |
assign | Captain | Start the next assignment after acknowledged completion. |
Implicit assignment IDs#
New launches export CAPTAIN_ASSIGNMENT, letting crew omit --assignment on ask, done, and check. The crew name, incarnation, and active assignment must all match; missing or stale context fails. Captain calls still require explicit IDs, and a replacement assignment requires its own new explicit ID.
Finishing#
done requires a report, an answered question if one is pending, and no unread mail. Reassignment additionally requires acknowledgement of prior notifications and the exact handoff ID. Stale assignment, incarnation, and question IDs are refused.
A handoff carries the retired assignment's grant forward: omit --owns or --allow and the new assignment inherits that assignment's paths or actions, so only what changes needs restating.
Mail: the message is a file#
assign, tell, and answer all reach a crew as mail. Each writes the message to a durable file first, then rings the pane. The ring is a doorbell, not the delivery.
captain assign/tell/answer ──▶ mail/<crew>/<id>.json ──▶ (doorbell) ──▶ crew pane
▲ │
└──────── captain inbox ────────────┘
(read receipt)For a crew whose CLI has no delivery hook, the first ring is a Herdr agent prompt carrying the mail body plus one line telling the crew to run captain inbox NAME. A later ring, after that first one landed, is the inbox line alone, so it can be repeated safely.
A Claude Code crew is hook-delivered instead, and its ring carries no content at all: one fixed wake line, mail from the captain is waiting; keep working. The crew's own hook injects the mail body into its context and stamps the read receipt, so the body never crosses the terminal. That makes the wake line safe to repeat and safe to drop, dropping one delays a turn without losing a message, and inbox there is a manual re-read rather than the delivery.
captain inbox Jackinbox prints that crew's queued mail oldest first and writes the read receipt. That receipt is what proves delivery, moving the message from queued to read on the assignment it names. done refuses while mail is unread.
Why it works this way#
- Delivery is proven, not inferred. Nothing reads a TUI to guess whether a message landed.
- A landed retry is idempotent. The slow re-ring types only the inbox line, so an uncertain nudge is retried rather than escalated.
- A blocked composer delays a nudge, never loses a message. The mail is already durable before the pane is touched.
Gates on the doorbell#
The ring fires only when unread mail exists whose head was not already rung, the pane is quiescent, the launch grace window has passed, no native approval prompt is waiting, there is no unsubmitted human draft on the composer, and the per-crew cooldown has elapsed.
A gate that does not clear is not an error: the mail stays queued, status says which gate is holding it, and the next ring tries again. If the human-draft gate holds one message for 300 seconds, the next drain appends a single held notice so the captain's next wait surfaces it. The nudge is never typed over a draft, and a draft is never cleared for us.
Waiting#
captain wait Jack
captain wait Jack --timeout 300
captain wait Jack --json
captain wait Jack --json --ack <delivery-id> --timeout 0A notification carries status, delivery_id, crew, assignment_id, and a summary capped at 1,600 characters. --json returns that as a stable envelope. Statuses distinguish working, idle, asked, awaiting_approval, done, held, bounced, timeout, and error.
waitreturns only when a notification carries a delivery ID. Nativeworkingnever notifies, and a native idle notifies at most once per message delivered — so quiet work runs the timeout out instead of waking the captain every turn.- A notification is returned once. Waiting again without
--ack IDfails and names the unacknowledged delivery, its status, and its summary. - Acknowledging may return the next notification; consume that too.
--ack IDon the finaldonereturns oneidleinstead. - Timeouts carry no delivery ID and do not imply completion.
--timeout 0polls once. Omitted, it falls back to[crew] wait_timeout, 86400 seconds (a full day) by default: quiet stays quiet, so only a real event returns. - Approval notifications never approve the underlying native prompt, and neither waiting nor timing out retries a terminal prompt.
Claude Code crew are read from their event file alone, since their approvals arrive as hooks. Codex and pi crew report no approval, so their pane is also checked for a modal once when a wait starts and then every 30 seconds.
Follow-ups#
captain tell Jack "also update the changelog" --assignment <assignment-id>tell prompts an existing crew in place, keeping its pane, model, and running conversation. The message is appended without replacing the original task, ownership, or grants. A message whose text repeats the last message on the assignment is refused by that message's ID. Dismissed crew are refused — recruit new crew instead.
Stop hooks, per provider#
For Claude Code crew the CLI's own Stop hook returns a blocking decision while the crew has unread mail, or while its active assignment is unfinished with no question pending. Reading the mail, finishing with done, or raising a question releases it. The hook fails open, so no crew is ever wedged by it.
Codex and pi cannot block, so for them reading mail and finishing with done remain instructions rather than enforcement.
Bounced mail#
If a crew cannot be rung at all — its pane is gone, its agent unregistered — the mail is marked bounced with the reason, and the next wait returns it as a notification naming the crew, the message, and why. Undeliverable mail is reported, never parked.
Every notification also carries the queued_at of the message that prompted it, and a crew signal older than the message it answers is ignored. Stale notices die by construction.