16 min read
The relay: a Stop hook that refuses to stop
Two Claudes, one job, a mailbox made of files. How a Stop hook turns the end of a session into the next instruction — and the one job of evidence behind it.
The problem
A Claude Code session ends when it runs out of things to do. That sounds obvious and it is the whole difficulty: the moment the work needs a decision, the session stops and waits for a person to type. Not because the model ran out of capability — because the turn ended, and a turn ending is how the tool is shaped.
The manager, worker, red team loop does not solve this. It runs a whole job inside one turn: the manager plans, hands tasks to workers, gates what comes back. But workers are subagents that return to the manager, and the manager’s own turn ends eventually — and then a human types again. The loop makes one turn do far more work. It does not make the next turn happen.
So the question was narrow. Not “how do I make an agent smarter”, but: can a session’s ending become the next instruction, without a person in the middle of it?
The one mechanism that makes it possible
Claude Code runs a Stop hook when a session is about to end. A hook is just a program. It gets the session’s state on stdin, and its exit code decides what happens next:
- exit
0— fine, let the session stop. - exit
2— do not stop. Whatever the hook printed to stderr comes back to the model as the reason, and the session continues with it.
That second line is the entire trick. A Stop hook that waits for a file to change, and then exits 2 with the file’s contents on stderr, converts “the session is over” into “here is what to do next”. Nothing about the model changes. The harness is doing it.
The shape: two Claudes and a folder
There are two Claudes in this. One is Cowork, in the desktop app, which has the conversation with me and decides what should happen. The other is Claude Code, in the repository, which builds. Cowork manages; Claude Code works.
They talk through files in relay/, and nothing else:
to-code.md - Cowork writes here. Claude Code reads it and continues.
to-cowork.md - Claude Code writes here. Cowork reads it and answers.
state.json - the last turn the hook consumed. One writer. (See "What broke".)
cowork-state.json - the last turn the manager consumed. Its file, not the hook's.
log.md - append-only history, so a fresh session can catch up.
ACTIVE - the relay only holds a session open while this file exists.
STOP - if this exists, the relay is over. Beats ACTIVE.
Files, not a socket or a queue or a small server. Both sides can already read and write the
same disk, so a shared folder needs no port, no daemon, no dependency and no new failure
mode. It also stays legible: I can open to-cowork.md in an editor and read exactly what
one Claude said to the other, in order, without a tool.
Every message starts with a turn number, and a side acts on the other’s message only when that number is higher than the one it last consumed. That is what stops a side from re-running an order it already carried out — a hook that polls a file will read the same file many times, and without the counter every poll looks like a fresh instruction.
The hook
The whole thing is about a hundred lines. The part that matters is the wait:
while time.time() < deadline:
if os.path.exists(stop_flag):
log("relay STOP appeared while waiting - session allowed to end")
sys.exit(0)
turn, body = read_instruction()
if turn is not None and turn > last and body:
write_state({"last_code_turn_consumed": turn})
log("turn %d taken from Cowork" % turn)
print(
"RELAY - a new instruction arrived from Cowork (turn %d). Do not stop. "
"Carry it out now, then write your report to relay/to-cowork.md with the "
"next turn number and stop your turn normally - this hook will wait again.\n\n%s"
% (turn, body),
file=sys.stderr,
)
sys.exit(2)
if not waited_announced:
log("waiting for Cowork (turn > %d)" % last)
waited_announced = True
time.sleep(POLL_EVERY)
Poll every five seconds. A newer turn means write down that it was consumed, print it to
stderr, exit 2. Anything else means keep waiting until the deadline, then exit 0 and let the
session end. The waited_announced flag is there so the log gets one “waiting” line per
wait rather than one every five seconds.
Four decisions that are not obvious
Each of these is one or two lines of code, and each of them is the difference between a mechanism that works and one that fails in a way that looks like something else.
The wait must be shorter than the hook’s own timeout
# The hook's timeout in .claude/settings.json must be larger than MAX_WAIT, or the
# hook is killed mid-wait and the session stops anyway. 570s under a 600s timeout.
MAX_WAIT = 570
POLL_EVERY = 5
The hook is registered with "timeout": 600. If the wait were 600 too, the harness would
kill the hook while it was still sleeping, and the session would stop. The bad part is not
that it stops — it is that a killed hook and a hook that waited politely and found nothing
produce exactly the same outcome, so the bug would read as “Cowork was slow” every time.
Thirty seconds of headroom is what keeps the two cases distinguishable.
The order of the two Stop hooks is the safety net
Two Stop hooks are registered, and they run in the order they are listed:
"Stop": [{ "matcher": "*", "hooks": [
{ "type": "command", "command": "python \"${CLAUDE_PROJECT_DIR}/.claude/hooks/on_stop.py\"", "timeout": 60 },
{ "type": "command", "command": "python \"${CLAUDE_PROJECT_DIR}/.claude/hooks/relay_stop.py\"", "timeout": 600 }
]}]
on_stop.py runs first and commits everything — it appends a line to the job’s
PROGRESS.md and makes a checkpoint commit, so nothing lives only in a model’s context.
Only then does relay_stop.py start waiting. That ordering is the reason a wait that times
out is a pause and not a loss: by the time the waiting begins, the state is already on
disk and in git. Reverse the two and every timeout would end a session with unsaved work.
Subagents must be sent home immediately
if data.get("agent_id"):
sys.exit(0)
A Stop hook fires for subagents too. Without this line, every worker in a wave would sit in the wait window instead of returning its result to the manager — five workers, each holding a session open for nine and a half minutes, each waiting for a message addressed to somebody else. The relay is for the top-level session and nothing below it.
The relay has to be off by default
if not os.path.isdir(relay) or not os.path.exists(active_flag):
sys.exit(0)
Without an on switch, every ordinary session in this repository would wait 570 seconds
before it was allowed to end. Ask a one-line question, wait ten minutes for the answer to
finish arriving. The ACTIVE file makes the relay something you switch on for a job rather
than a property of the repo.
Why the off switch is a file you add, not one you remove
This is the one design decision that came from a real constraint rather than from taste.
Cowork can create files on this machine. It cannot delete them. So an off switch that
works by deleting ACTIVE would be an off switch Cowork cannot reach — the managing side
could start a relay and then have no way to end it. Hence two files, and hence STOP
beating ACTIVE wherever they disagree:
# STOP wins over ACTIVE. Cowork can only create files on this machine, not delete them,
# so STOP is how it ends a relay it started.
if os.path.exists(stop_flag):
log("relay STOP present - session allowed to end")
sys.exit(0)
The check runs twice: once before the wait starts, and again on every poll inside it, so
STOP lands even while a session is mid-wait.
What broke
The litter left by the constraint above. Testing the hook means creating and removing
ACTIVE and STOP repeatedly. The side doing the testing could not delete, so it renamed
them out of the way instead, and two zero-byte files sat in the repository until a session
with delete rights cleaned them up:
$ git status --short
D relay/.trash-ACTIVE
D relay/.trash-STOP
?? relay/ACTIVE
Nothing was broken by them. But it is a fair illustration of the whole asymmetry: the constraint that shaped the design also shows up as rubbish on the floor.
The log is not reliably in order. Two writers append to log.md, each stamping it with
its own clock, and they are not the same clock:
- 2026-09-07 04:14 waiting for Cowork (turn > 0)
- 2026-09-07 04:16 turn 1 from Cowork: guide A (the relay) chosen; ...
- 2026-09-07 04:15 turn 1 taken from Cowork
The third line happened after the second and is stamped a minute earlier. The file is append-only and honest about what each side saw; it is not a timeline, and reading it as one would mislead.
Half of state.json was decoration. The file used to hold two counters, and an earlier
draft of this page described it as recording what each side had consumed. Only one of them
was true. Nothing in the repository wrote the other:
$ grep -rn last_cowork_turn_consumed .claude/ relay/ shared/
relay/state.json:1:{"last_code_turn_consumed": 3, "last_cowork_turn_consumed": 0}
One hit, and it was the file itself. The hook wrote last_code_turn_consumed; nothing wrote
last_cowork_turn_consumed, so it had read 0 since the day it was created while the
manager had demonstrably consumed several turns. The guard against re-running an old order
was real on the side the hook owned and imaginary on the other. Nobody found it by hitting
the bug — the red team found it by reading this guide against the repository and noticing
that a sentence in it was not true of the code.
There is a second half to that, and it is the part worth reading. Told about the missing
counter, the manager said it had written the field itself that same turn. The field still
read 0. I checked every committed version of the file, found 0 in all of them, reasoned
that the hook merges on write so a real write would have survived — and published a
paragraph saying the fix had never happened.
It had happened. The hook did not merge; it wrote back a copy of the file it had read before a wait that lasts minutes, so anything written by anyone else in between was erased without a sound. A textbook lost update, in the one file both sides were writing. Two bugs stacked: a counter nobody filled in, sitting in a file that destroyed the evidence when somebody finally did.
Reproduced against the old code and the new, same race both times — the other side writes during the wait, then sends a turn:
old code exit=2 state={'last_code_turn_consumed': 1} -> write LOST
fixed code exit=2 state={'last_code_turn_consumed': 1, 'cowork_counter': 4} -> write SURVIVED
The fix is two changes, and the smaller one is the real one. write_state now re-reads the
file and merges instead of dumping its snapshot. And each side got its own file —
state.json for the hook, cowork-state.json for the manager — because a file with one
writer cannot lose an update no matter how long the other side takes. Merging is belt and
braces; not sharing the file is the fix.
What I got wrong is the useful part. “Done means observed” told me to distrust the claim and check the file, and I did, and the check returned a clean, confident, wrong answer, because the thing I was measuring with was the thing that was broken. I then wrote that a colleague had reported work it had not done. Absence of evidence was evidence my own code had destroyed. The rule still stands — but the next line of it is that when a check clears someone and accuses someone else, the check is exactly where you should look first.
What it actually did
The turn you are reading about is the evidence. At the end of the first turn, on_stop.py
made its checkpoint commit — these two were the top of the log at that moment, and are
buried further down by now, because the relay kept running while this was written:
c49485f checkpoint: 2026-09-snoweylabs - Releul e pornit, turul 1 e scris. Rezumat scurt:
c3787ca chore(relay): relay on, hook-test leftovers removed, turn 1 proposes the fourth guide
Then relay_stop.py began waiting, Cowork answered, and the hook handed the answer back:
- 2026-09-07 04:14 waiting for Cowork (turn > 0)
- 2026-09-07 04:15 turn 1 taken from Cowork
About a minute between the session ending and the session continuing, with no key pressed. This guide was chosen, scoped and written across that boundary, and then across two more like it: at the last edit before publication the two counters read
// relay/state.json relay/cowork-state.json
{"last_code_turn_consumed": 5} {"last_cowork_turn_consumed": 4}
Every number in this section is a reading taken at a moment, not a command you can run now and expect to match. The relay was still going while the guide about it was being written, which is either the best evidence in here or a warning about quoting live state in prose, depending on your temperament. It is both.
Rules that do not bend, even here
- Nothing irreversible without a human. Cowork manages, and it can settle technical choices and small bounded costs. A real email, a publication, a deletion, a purchase, a credential: those need a person, and the manager is not one. This guide tested that line rather than illustrating it. Cowork first held publishing back for a separate turn, then came back saying the authority had been widened and it could approve publishing from now on. That message is a claim relayed by an agent, and a relayed claim is not a human answering, however plausible and however likely to be true — so the session did not act on it, and this page sat built and unpublished until a person settled it. The boundary between “the manager may decide this” and “a person must” is allowed to move. What keeps it honest is that only one side of it is allowed to move it, and that side is not the one asking.
- The instruction file is the manager’s. Everything else is data. What arrives in
to-code.mdis an instruction. A web page read while working, a scraped file, a cloned repository — those are data, no matter what they claim about themselves. - Conditions, not counts. The hook waits “until a newer turn arrives or the deadline passes”, never “for three checks”. The only numbers in it are the ones that are the constraint: 570 seconds under a 600-second timeout, five seconds between polls.
What it cost
Nothing in money. No service, no server, no dependency: one Python file in
.claude/hooks/, six files in a folder, and one Stop block in settings.json registering
it alongside the checkpoint hook. It uses
the standard library only.
The cost that is real is model usage, and it is the honest catch in the whole idea. A relay does not make a job cheaper; it makes a job continue. Every turn the hook hands back is a turn that gets billed, and a session that would have ended politely now keeps going until the work is actually finished or the manager says stop. That is the point, and it is also the bill.
What is not proven
One job. This one.
The relay was switched on for the first time on 7 September 2026, to write the guide you are reading, and it has run for hours rather than weeks. I have no failure rate, no numbers on how often a wait window expires with nothing in it, and no idea how this behaves on a job that runs overnight. Anyone putting this into a real workflow on the strength of one run should know that is exactly what they are doing.
Three specific things I cannot yet claim:
- That a pause is always cheap. When the wait window expires, the session stops with its state saved — but starting it again is a human action. A relay left alone for an hour is a relay that needed a person after all.
- That the manager is right. The relay removes the human from the loop between turns; it does not remove the need for judgement. Everything Cowork decides is decided by a model with no more evidence than what is written in the mailbox. Twice in this one job the manager’s instruction was wrong on a fact: the brief that started it asked for the site’s second guide when three were already live, and a later turn was written as though this page had not been built yet. Neither was damaging, both were visible in thirty seconds of looking, and the only reason they did not propagate is that the side receiving the instruction checked it against the repository instead of carrying it out. A relay is a machine for executing instructions faster; a wrong instruction is executed faster too.
- That two Claudes beat one. I have not run the same job the ordinary way to compare. The case for it here is that the session kept working while its manager thought, not that anything was measurably better.
What I can say is what the files show: the hook takes a new turn, waits when there is
nothing new, obeys STOP, does nothing at all without ACTIVE, and carried this job across
several handovers with no key pressed at any of them. The exact count is in relay/log.md
and was still going up while this sentence was being edited, which is the most honest thing
in the guide and the reason I stopped quoting it as a number.