THE LOOP.
Every coding agent is a while-loop. This morning you build yours.
The thing that makes an AI coding assistant work is a loop you could write on a napkin. This morning you write it.
$10 early · $20 door · FT members free (FTMEMBER)
WHILE YOU SETTLE IN.
Three things to do while you find a seat. Do them now and you will not lose the first twenty minutes.
1. Connect your agent to Immersive Commons (once, about 60s)
POST https://www.immersivecommons.com/api/agent/signup/start
scopes: ["read:public", "membership:read", "keys:request"]
Open the verify URL it prints, sign in, done.
2. File the request
ic_request_workshop_key(
event_id="https://luma.com/vcn-44-the-loop")
-> request_id, status "pending"
An IC operator approves it. That operator is in this room.
3. Pick it up, once, within ~15 min of approval
ic_get_my_workshop_key(request_id)
-> bundle.copy_paste, a paste-and-go setup blockYOU USE ONE EVERY DAY. YOU CANNOT SEE INSIDE IT.
You already trust a piece of software to edit your code. You have no idea how it decides what to do next.
Today we delete the magic.
PLAN, ACT, OBSERVE, REPEAT.
Ask the AI what to do. Do it. Show it what happened. Ask again. Stop when it says it is finished. That is the whole design.
The rest of this morning is filling those four verbs in with real code, one at a time, in the order you would actually write them.
A TURN IS REASON, THEN ACT, THEN SEE THE RESULT.
One round-trip. The AI thinks out loud, asks for one thing to be done, you do it, and you hand back what happened. That round-trip is the unit everything else is counted in.
Honest footnote, and say it out loud. ReAct is not magic either. In the original paper, on the HotpotQA question-answering benchmark, plain ReAct scored 27.4 and plain chain-of-thought scored 29.4. ReAct lost. What it won is the thing you care about here: it hallucinated 0 percent of the time versus 56 percent, and on long multi-step interactive tasks like ALFWorld it scored 71 versus 45. ReAct is a pattern for doing work in the world, not for answering trivia. That is exactly why it is the right shape for a coding agent. Yao et al., ICLR 2023, arXiv 2210.03629
FOUR MOVING PARTS. NOTHING ELSE.
A brain that decides, hands that touch things, a loop that connects them, and a rule for when to stop.
python smoke.py
reply: LOOP OK
VCN44 SETUP READYTHE WHILE-LOOP HARNESS.
Write the loop before you write anything for it to do. Get the skeleton standing, then hang the muscles on it.
What you are about to do, and why it matters: you are going to write the dumbest possible version of the loop, the one that cannot use a single tool yet, because getting the shape right first means every later stage is a small addition instead of a rewrite.
import os
import sys
from anthropic import Anthropic
BASE_URL = os.environ.get("ANTHROPIC_BASE_URL",
os.environ.get("VCN_BASE_URL", "https://api.z.ai/api/anthropic"))
MODEL = os.environ.get("ANTHROPIC_MODEL", os.environ.get("VCN_MODEL", "glm-4.6"))
def client():
global _CLIENT
if _CLIENT is None:
key = os.environ.get("ANTHROPIC_AUTH_TOKEN") or os.environ.get("ZAI_API_KEY")
if not key:
sys.exit(MISSING_KEY)
# auth_token=, NOT api_key=. The SDK sends api_key as the X-Api-Key
# header and auth_token as "Authorization: Bearer". The workshop
# gateway reads only Authorization; z.ai accepts either. One word.
_CLIENT = Anthropic(base_url=BASE_URL, auth_token=key)
return _CLIENTdef agent(task: str) -> str:
messages = [{"role": "user", "content": task}]
while True: # <- THE LOOP
resp = client().messages.create(
model=MODEL,
max_tokens=2048,
messages=messages,
)
messages.append({"role": "assistant", "content": resp.content})
# No tools exist yet, so the model can never ask for one and this
# always fires on turn 1. Stage 2 gives it something else to do.
if resp.stop_reason != "tool_use": # <- STOP CONDITION
return text_of(resp)
def text_of(resp) -> str:
return "\n".join(b.text for b in resp.content if b.type == "text").strip()auth_token, not api_key. One word, whole morning. The SDK sends api_key= as the X-Api-Key header and auth_token= as Authorization: Bearer. Your workshop gateway reads only Authorization, so the wrong keyword returns "malformed token" on a perfectly valid key.
JARGON: stop_reason. Every response tells you why the model stopped talking, like a courier handing back a delivery slip. tool_use means "I stopped because I want you to run something". Anything else means "I stopped because I am done".
This file does not run yet. TOOLS and run_tools arrive in stages 1 and 2. Run it now and Python says NameError, which is the correct outcome. You are looking at the shape.
GIVE THE MODEL HANDS.
A tool is a job description plus an actual worker. The model only ever reads the job description. If the description lies, the model makes bad decisions and nothing crashes.
What you are about to do, and why it matters: you are going to discover that a tool is two separate things that must agree, because when they disagree the model goes blind and you never see an error message.
TOOLS = [
{
"name": "read_file",
"description": "Read a UTF-8 text file inside the workspace and return its contents.",
"input_schema": {
"type": "object",
"properties": {"path": {"type": "string"}},
"required": ["path"],
},
},
# write_file is the same shape: path + content, both required.
{
"name": "run_command",
"description": (
"Run a shell command in the workspace directory. Returns the exit code "
"plus stdout and stderr. Destructive commands are blocked unless a human "
"approves them."
),
"input_schema": {
"type": "object",
"properties": {"cmd": {"type": "string"}},
"required": ["cmd"],
},
},
]def read_file(path: str) -> str:
return safe_path(WORKSPACE, path).read_text(encoding="utf-8")[:8000]
def write_file(path: str, content: str) -> str:
target = safe_path(WORKSPACE, path)
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(content, encoding="utf-8")
return f"wrote {len(content)} chars to {path}"
def run_command(cmd: str) -> str:
blocked = gate_command(cmd)
if blocked:
return blocked
proc = subprocess.run(
cmd, shell=True, capture_output=True, text=True, cwd=WORKSPACE, timeout=120
)
return (
f"exit code: {proc.returncode}\n"
f"--- stdout ---\n{proc.stdout}\n"
f"--- stderr ---\n{proc.stderr}"
)[-8000:]JARGON: schema. A small piece of JSON saying what arguments a tool takes and which are required. It is the label on the socket that tells you which plug fits. The model reads only the schema, never your Python.
JARGON: stdout and stderr. Two separate output channels. Normal output goes to stdout. Crashes, warnings and tracebacks go to stderr. Your terminal blends them, so you have probably never noticed they are different. Python does not blend them.
| command, run against this repo's own fixture | exit | chars on stdout ONLY | chars you would have lost |
|---|---|---|---|
| python test_math.py | 1 | 0 | 1530 on stderr |
| python -m unittest -q | 1 | 0 | 1172 on stderr |
| python -c "import nope" | 1 | 0 | 134 on stderr |
Measured, not guessed. The obvious version of run_command returns .stdout only. On this fixture that hands the model an empty string for every single failure, and it will then confidently tell you the problem is fixed. That is worse than a crash, because a crash is honest.
JARGON: traceback. The multi-line error report Python prints when something breaks. It goes to stderr. It is the most useful thing you can hand a model, and it is exactly what the naive version throws away.
RUN THE CALL, FEED THE RESULT BACK.
The model asks for something. You do it. You hand back what happened, in a shape it can read. This one function is the entire bridge between thinking and the real world.
What you are about to do, and why it matters: this single function is both "act" and "observe", which is why it is the piece people get wrong.
def run_tools(resp, verbose: bool = True) -> list:
"""ACT and OBSERVE. Returns the single user message carrying every result."""
results = []
for block in resp.content:
if block.type != "tool_use":
continue
if verbose:
print(f" [tool] {block.name}({block.input})")
try:
out = DISPATCH[block.name](**block.input) # ACT
is_error = False
except Exception as exc:
# Do NOT crash the loop on a bad call. The error text goes back to
# the model, which usually self-corrects. Tool errors are signal.
out = f"ERROR: {type(exc).__name__}: {exc}"
is_error = True
results.append({ # OBSERVE
"type": "tool_result",
"tool_use_id": block.id,
"content": str(out),
"is_error": is_error,
})
return [{"role": "user", "content": results}] # ONE message, all results1. The result comes back as a user message. Not a special third role. There are exactly two roles: assistant is what the model produced, user is everything that entered from outside. There is no role for "the world". Reality answering back and you talking look identical to the model. That is the design, not a hack.
2. A tool that fails must still return a tool_result. That is what the try and except are for. Let the exception escape and your loop dies on a typo. Skip the block and the model waits forever for an answer it never got. Catch it, hand back the error text, and the model usually fixes itself.
3. All results from one turn go back in ONE message. The model is allowed to ask for several tools at once. Split the answers across several messages and you quietly teach it to stop asking for more than one at a time. Your agent gets slower and you never find out why.
def agent(task: str) -> str:
messages = [{"role": "user", "content": task}]
turn = 0
while True: # <- still no ceiling
turn += 1
print(f"[turn {turn}]")
resp = client().messages.create(
model=MODEL, max_tokens=2048, tools=TOOLS, messages=messages
)
messages.append({"role": "assistant", "content": resp.content})
if resp.stop_reason != "tool_use":
return text_of(resp)
messages += run_tools(resp) # <- NEW: act + observepython stage2_toolcall.py "read test_math.py and tell me what it checks". Recovery: the working reference is on this deck at /lab/stage2_toolcall.py. Copy it, diff yours against it, keep moving. Nobody debugs alone.KNOWING WHEN TO QUIT.
Left alone, this thing keeps going. You need a way it stops when finished, a way you stop it when it is not, and a way it can announce it is done on purpose.
What you are about to do, and why it matters: three independent stops, because a loop that knows only one way to stop is one bad response away from spending your whole budget while you are in the bathroom.
stop_reason != "tool_use". Nothing left to ask for. The good ending. Fails when the model is confused and just starts talking.
A hard turn ceiling. This is you, refusing to let it spin. It never fails, which is why it exists. A circuit breaker, not a nice-to-have.
A done tool the model calls on purpose. The cleanest signal there is, and the one real agents lean on most. Fails when the model forgets to call it, which is why it never replaces the other two.
{
"name": "done",
"description": (
"Call this when the task is complete. Pass a one-line summary of what "
"you did. Calling this ends the run."
),
"input_schema": {
"type": "object",
"properties": {"summary": {"type": "string"}},
"required": ["summary"],
},
},
]
def done(summary: str) -> str:
return summary
def called_done(resp):
"""Return the summary if the model called the done tool this turn."""
for block in resp.content:
if block.type == "tool_use" and block.name == "done":
return block.input.get("summary", "done")
return Nonedef agent(task: str, max_turns: int = 25) -> str:
global LAST_TRANSCRIPT
messages = [{"role": "user", "content": task}]
LAST_TRANSCRIPT = messages
for turn in range(1, max_turns + 1): # <- NEW: hard ceiling
print(f"[turn {turn}/{max_turns}]")
resp = client().messages.create(
model=MODEL, max_tokens=2048, tools=TOOLS, messages=messages
)
messages.append({"role": "assistant", "content": resp.content})
if resp.stop_reason != "tool_use": # 1. natural stop
return text_of(resp)
summary = called_done(resp)
if summary is not None: # 3. done tool
return summary
messages += run_tools(resp)
# 2. safety stop. Say what happened and hand back something usable,
# because "stopped" with no context gives you nothing to debug.
return (
f"stopped: hit max_turns ({max_turns}) without finishing. "
f"{len(messages)} messages in the transcript. "
f"Inspect stage3_stop.LAST_TRANSCRIPT, raise max_turns, or narrow the task."
)python stage3_stop.py "run python test_math.py and report which test fails". Recovery: set max_turns=5 and run it again. A loop that stops for the wrong reason still stops, and you can debug why over coffee. The runaway is documented, not hypothetical: the ReAct paper describes a model that "repetitively generates the previous thoughts and actions" and cannot break out.THE HARD PART NOBODY SHOWS YOU.
The model has no memory. Every turn you resend the whole conversation. Read three files and you have filled it up. Deciding what to throw away is your job, and it is the job nobody writes a tutorial about.
Picture a whiteboard of fixed size. Every turn you write more on it. You will never get a bigger whiteboard, so you get good at erasing: the task stays pinned in the corner, the last few lines stay because they are what you are working on, and the crowded middle becomes one line that says what happened there.
1. Truncate at the source. Already done in stage 1: every read and every command is capped at 8000 characters. A 40,000-line log teaches the model nothing its last 8000 characters do not.
2. Compact old turns. Once the transcript crosses a token budget, fold the middle into one line.
3. Keep the task pinned. The original instruction stays at position zero forever. Twenty turns deep, that one message is the only reason the agent still knows what it is doing.
def est_tokens(messages, exact: int | None = None) -> int:
if exact is not None:
return exact
return len(json.dumps(messages, default=str)) // 4def maybe_compact(messages, budget: int = 60_000, exact: int | None = None) -> list:
if est_tokens(messages, exact) < budget:
return messages
if len(messages) <= 7: # nothing meaningful in the middle
return messages
print(f" [compact] transcript over {budget} tokens, folding the middle")
head = messages[:1] # the task, pinned forever
tail = messages[-6:] # recent turns, verbatim
summary = summarize(messages[1:-6]) # one LLM call
folded = [{"role": "user", "content": f"[earlier turns summary] {summary}"}]
return head + folded + tail for turn in range(1, max_turns + 1):
messages = maybe_compact(messages, budget, last_input_tokens) # <- NEW, and actually called
LAST_TRANSCRIPT = messages
print(f"[turn {turn}/{max_turns}] ~{est_tokens(messages, last_input_tokens)} tokens")
resp = client().messages.create(
model=MODEL, max_tokens=2048, tools=TOOLS, messages=messages
)
last_input_tokens = resp.usage.input_tokens # the exact count, free
messages.append({"role": "assistant", "content": resp.content})JARGON: token. Roughly three quarters of a word. The unit models count in and bill in. A 60,000-token budget is maybe 45,000 words. JARGON: compaction. Deliberately shrinking the conversation you resend, so you stay under the limit without losing the plot.
Two honest notes. Do not reach for tiktoken: that is OpenAI's tokenizer, it miscounts Claude-shaped models, and you will overflow while your own counter says you are fine. And summarize() is a second model call: it costs tokens, adds latency, and can fail mid-task. It shares your key and your model, so if your loop is rate limited, so is your compaction. Say that out loud rather than hiding it behind a helper name.
SAME SHAPE AS THE BIG TOOLS.
Point your loop at a broken test and watch it fix itself. Everything on screen is code you typed in the last hour.
python agent.py --demo # --demo copies fixtures/ into .work/ first, so the # start state is identical every single time you run it. # That is not tidiness. It is what makes this task # reusable as an eval at #45 on Wednesday.
# lab/fixtures/mathlib.py
def median(values):
"""Middle value. For an even-length list, the mean of the middle two."""
ordered = sorted(values)
return ordered[len(ordered) // 2]
# lab/fixtures/test_math.py
class TestMedian(unittest.TestCase):
def test_median_odd_length(self):
self.assertEqual(median([3, 1, 2]), 2)
def test_median_even_length(self):
# The middle two are 2 and 3, so the median is 2.5.
self.assertEqual(median([1, 2, 3, 4]), 2.5)
def test_median_unsorted_even(self):
self.assertEqual(median([10, 2, 8, 4]), 6.0)[turn 1/25] read_file(test_math.py) [turn 2/25] read_file(mathlib.py) [turn 3/25] run_command(python test_math.py) exit code: 1 FAILED (failures=2) AssertionError: 3 != 2.5 [turn 4/25] write_file(mathlib.py) wrote 412 chars to mathlib.py [turn 5/25] run_command(python test_math.py) exit code: 0 Ran 5 tests OK [turn 6/25] stop_reason end_turn fixed median() for even-length lists
Read, run, observe, edit, re-run, stop. That is exactly what Claude Code prints, and you just wrote the engine that prints it. There was never any magic. There was a while-loop, three tools, and a rule about when to stop. If the network is slow, the recorded trace is at /lab/trace.txt and python verify_loop.py drives the real loop with no key and no network at all.
SAME LOOP, DIFFERENT WIRE.
Point the agent you just built at a completely different provider. Six things change. The loop is not one of them.
What you are about to do, and why it matters: "just change the base URL" is true for an Anthropic-shaped endpoint and false for an OpenAI-shaped one. Doing the port yourself is how you learn which parts of your agent were real and which were just the vendor's spelling.
[SWAP 1] SDK anthropic -> openai
[SWAP 2] tool schema {name, description, input_schema}
-> {type:"function", function:{name, description, parameters}}
[SWAP 3] the call max_tokens required, system= top level
-> max_tokens optional, system is a message
[SWAP 4] stop signal resp.stop_reason == "tool_use"
-> resp.choices[0].finish_reason == "tool_calls"
[SWAP 5] arguments block.input is a parsed DICT
-> call.function.arguments is a JSON STRING (json.loads it)
[SWAP 6] tool results ONE user message holding all tool_result blocks
-> ONE {"role":"tool"} message PER call
(note this INVERTS: batched becomes per-call) out_messages = []
for call in message.tool_calls:
args = json.loads(call.function.arguments) # [SWAP 5] JSON STRING, not a dict
if verbose:
print(f" [tool] {call.function.name}({call.function.arguments[:160]})")
try:
out = DISPATCH[call.function.name](**args)
except Exception as exc:
out = f"ERROR: {type(exc).__name__}: {exc}"
out_messages.append({
"role": "tool", # [SWAP 6] one per call
"tool_call_id": call.id,
"name": call.function.name,
"content": str(out),
})
return out_messagesdef agent(task: str, max_turns: int = 25) -> str:
"""Identical shape to agent.py. Only the wire format moved."""
messages = [{"role": "user", "content": task}]
for turn in range(1, max_turns + 1):
print(f"[turn {turn}/{max_turns}]")
resp = client().chat.completions.create( # [SWAP 3] no max_tokens required
model=MODEL, tools=TOOLS, messages=messages)
message = resp.choices[0].message
messages.append(message) # append the object itself
if resp.choices[0].finish_reason != "tool_calls": # [SWAP 4] different field, different string
return text_of(resp)
messages += run_tools(message)
return f"stopped: hit max_turns ({max_turns})."SWAP 5 is the one that bites. Copy DISPATCH[name](**block.input) straight across and you get TypeError: argument after ** must be a mapping, not str. Over there the arguments arrive as a parsed dict. Over here they arrive as a JSON string.
Now count what did not change: the while-loop, the turn ceiling, the natural stop, the tools themselves, the safety gate, the compaction policy, and agent(task, max_turns) -> str. The loop is the invariant. The wire format is the accident. That is the whole reason this morning was worth two hours of your Saturday.
WHERE IT BREAKS, AND WHERE YOU STAY IN THE LOOP.
Plain language: four ways this bites people. You have already met three of them this morning, which is why they will stick.
max_turns is not a performance setting, it is a spend limit.run_command returns stderr. The version that returns stdout alone makes this card a lie, which is exactly why we fixed it at Stage 1.# No human, or no answer, means no approval. Fail closed, always.
return (f"BLOCKED by the safety gate ({label}, not approved by a human). "
f"Do not retry this command. Find a non-destructive route.")
Measured this morning against the fixture. The stdout only bug survives every rehearsal and detonates the first time you run your own script.
WHAT YOU NOW HAVE WORKING.
Plain language: here is the list of things that were opaque two hours ago and are not any more.
fixtures/test_math.py. You watched it go red, then green, driven by your own code. Keep it. Wednesday needs it.agent() at 20 and run_tools() at 14. The whole runnable agent, safety gate and write tool and command line included, is 168. Both numbers were counted with a parser, not rounded up. On a morning about not guessing, we were not going to guess at our own line count.WHAT YOUR TICKET INCLUDES.
Plain language: the model access is provisioned for you. The code and the test are yours to keep.
| what changes | Anthropic shape | OpenAI shape |
|---|---|---|
| package | anthropic | openai |
| endpoint | api.z.ai/api/anthropic | api.tokenfactory.nebius.com/v1/ |
| tool schema | name, description, input_schema | type, function{...parameters} |
| max_tokens | required | optional |
| system prompt | a top level argument | a role system message |
| stop signal | stop_reason == tool_use | finish_reason == tool_calls |
| arguments arrive as | a parsed dict | a JSON string, needs json.loads |
| tool results | ALL in ONE user message | ONE role tool message PER call |
The last two invert. That is why this is a fifteen minute port and not a one line change, and why it lives after 12:00 instead of inside the sprint. The inner JSON Schema is identical on both sides, which is the one mercy. Run python nebius.py --show-swaps offline to see all six marked in the code.
WHERE THESE SHAPES CAME FROM.
Plain language: you did not invent these formats this morning. Here is who did, and why that is worth knowing.
role: tool message and finish_reason == "tool_calls" is the OpenAI chat completions contract. Nebius Token Factory speaks it, and so does most of the open weights hosting world. Two contracts, one loop.arXiv:2210.03629. It is where reason, act, observe comes from. Its own numbers are more modest than the folklore: plain ReAct scores 27.4 exact match on HotpotQA against 29.4 for plain chain of thought. It wins on long horizon interactive work, not one shot question answering. Read the table, not the tweet.Nobody sponsored this event. Nothing on this slide is an endorsement, and nothing on it is a logo.
KEEP BUILDING WITH VCN.
Plain language: two things happen next and both of them use the thing you just built.
def agent(task: str, max_turns: int = 25) -> str:
fixtures/test_math.py is one. #45 is a night about building four more.
Off the Leash runs #41 to #50: local models, sandboxes, VMs, evals, self hosting your whole rig. The Loop is the core, the rest of the season wires the world around it. Teach a night. Twenty minutes, we have a projector, you do not need slides, and #46 Bake-Off on Aug 1 and #47 Fast Local on Aug 5 both have open speaker slots. Talk to Facilitator Rayyan Zahid before you leave, or find him in the Telegram.
STOP TREATING YOUR AGENT AS A BLACK BOX.
Plain language: you walked in using one of these. You walk out owning one.
You came in using a coding agent. You leave owning one. Same loop. Thirty four lines of it are yours, and you can read every one of them.
plan → act → observe → repeat
Hosted by Vibe Coding Nights: Rayyan Zahid (Immersive Commons), Michalis Vasileiadis (Hacker Bob), Eric Mockler (AI Geneticist), Devinder Sodhi (Learning Layer Labs). Facilitator: Rayyan Zahid.
YOU BUILT THE ENGINE. HERE IS THE CAR.
Plain language: this hour is not more theory. It is the eight loops you can actually run tonight, in the tool you already have open, ordered by how many people really use them.
lab/agent.py:226 for turn in range(1, max_turns + 1): the loop lab/agent.py:231 client().messages.create(..., tools=TOOLS) PLAN, gather context lab/agent.py:242 messages += run_tools(resp) ACT and OBSERVE lab/agent.py:236 if resp.stop_reason != "tool_use": return NATURAL STOP
This is a faithful teaching model, not a decompilation. We did not read Claude Code's source. What we have is the documented three phase loop and the public Messages API shape your agent.py already speaks. Same shape is true. Literally this code would be made up.
STEER THE LOOP YOU ARE ALREADY IN.
Plain language: you run this loop every day and you are probably steering it with one blunt instrument. Here are the other three.
agent.py:138, fold the middle at agent.py:192, and run it at the top of the turn at agent.py:227./compact focus on the auth bug fix
/clear costs nothing.Shift+Tab cycles the permission mode, or start with claude --permission-mode plan. Repeats is an explore and propose loop. Advances is the plan getting sharper. Observes is you, reading it. Terminates is your approval. It is the one loop on this page where the stop condition is a human on purpose.TodoWrite. On this build it is disabled by default, in favour of the Task tools, and they carry dependencies now, so the list is a small graph rather than a flat checklist. And the deeper point, which Ray's own framework states outright at orchestration/ARCHITECTURE.md:147: the shared task list is advisory. Task ownership is unenforced, so it is for dispatch and claiming, never authoritative state and never a completion gate. A checked box is a claim. Which is the whole of the next slide.
GIVE THE LOOP A FINISH LINE IT CANNOT FAKE.
Plain language: every loop up to here stops when the model decides it is finished. That is fine while you are watching. It stops being fine the second you are not.
checks:
- id: events-invariants
cmd: ["C:/Users/jtole/Documents/2026/life/.venv/Scripts/python.exe",
"check_events_invariants.py"]
cwd: "."
expect_rc: 0
stdout_contains: "EVENTS-INVARIANTS: PASS"
$ python main.py verify probe events/oracle.yaml deterministic: True oracle: PASS
verify probe runs the oracle twice and compares the verdict and a hash of which checks failed. A check that is not stable across two runs is not an oracle, and the system gives that its own error kind so a flaky check can never be mistaken for a real failure.
the trap
An oracle that reads a side effect can stay green while the real thing is broken. There is a script in this repo that exists because of an eighteen day bug: the recording file existed and the log said recording, so everything looked green, and every sample was silence. The fix was to measure decibels.
If your check can pass while the thing is broken, it is a proxy, not an oracle.
THEN PUT THE LOOP AROUND IT.
Plain language: generate, then check, then repeat until the checker says green. The generate step can be a headless Claude Code, or any command at all. The loop does not care.
generate: ["claude", "-p", "<the task>", "--permission-mode", "acceptEdits"] oracle: "oracle.yaml" # path relative to this config's dir max_iters: 6 max_seconds: 1200 stall_limit: 2 # stop if the SAME checks fail this many times running on_green: ["git", "commit", "-m", "<msg>", "--", "mathlib.py"] # name the file
Four named stops, never silence. Its own docstring at line 18 says it: all logged, never silent. On any non green stop it reports residual_failing, so nothing is quietly dropped. A loop that exits on its safety cap without saying so is a lie.
pass true, passk 2, green 2, stable true. k defaults to 2. Do not oversell 2 as rigor. It is the floor, and it is the bridge to Wednesday.MAKE THE LOOP REFUSE TO STOP.
Plain language: the session that built these slides kept trying to end this morning. It could not. Something on this laptop kept saying no, and that something is one small program that gets a vote on whether the session is allowed to end.
process.stdout.write(JSON.stringify({ decision: "block", reason }));
UNCOMMITTED CHANGES (${sourceLines.length} files). Commit atomically before ending.
Start a WIP commit to accumulate across turns (recommended for
multi-turn work):
git commit -m "WIP: <short title of the logical unit>"
Or commit atomically now with a real message:
git commit -m "<message>"
Advances: the model runs
git commit. Real state on disk changes.Observes:
git status --porcelain, re read on every single Stop, minus this session's baseline, intersected with this session's own write ledger.Terminates: the tree is clean of source files this session touched. Then the hook exits 0, says nothing, and the session ends. why this beats writing the rule down A rule written in prose can be summarized away by a compaction. A rule written as a hook cannot, because hooks run as code, not context. Ray's atomic commit rule lives in both places, which is the right pattern. The prose explains why. The hook makes it true.
FIVE ANSWERS. ONE OF THEM DOES NOTHING.
Plain language: this is the slide that makes the rest of the rung buildable. All five styles are live in one folder on this laptop right now.
| you want | you do |
|---|---|
| allow | exit 0, print nothing |
| block, with a reason | exit 0 and print {"decision":"block","reason":"..."} on stdout |
| block, the quick way | write the reason to stderr, then exit 2 |
| warn, do not block | print hookSpecificOutput with additionalContext |
| deny a tool call | PreToolUse, permissionDecision "deny" plus a reason |
| exit 1 | nothing at all. the most common silent failure. |
if (input.stop_hook_active) return;
verify-weekday-dates.js:82. The harness sets that flag once it has already forced one correction pass, so this hook can block at most once, ever.} catch (e) {
process.exit(0);
}
And the bit worth putting on a slide about trusting documentation. Ray's own global CLAUDE.md says seven hooks. We counted the file this morning: it is 18 hook commands across 6 events, and 6 of them are Stop hooks. Six separate programs get a vote on whether this session is allowed to end. The doc is stale. The file is the truth. One thing we could not answer, and we looked: how two parallel Stop hooks resolve when both block with different reasons.
EXACTLY TWO THINGS CAN WAKE A LOOP.
Plain language: a clock, or a doorbell. Almost every wasted dollar in agent automation comes from reaching for a clock when you were holding a doorbell.
/loop 5m /babysit-prs /loop 30m check the deploy
/loop 1m.Bash({ command: 'until grep -q "Ready in" dev.log; do sleep 0.5; done',
run_in_background: true })
grep. Terminates in seconds, not at a timeout.Do not poll for something you will be told about. A CI run that takes eight minutes deserves one 480 second check, not eight 60 second ones.
durable has no effect.0 9. Write 57 8 * * *. Jitter is up to 10 percent of the period, capped at 15 minutes.EVERYTHING SO FAR DIES WHEN YOU CLOSE THE TERMINAL.
Plain language: the fix is boring on purpose. One declarative file, applied by a command that previews before it commits.
python main.py cron bootstrap # preview, this is the default python main.py cron bootstrap --apply # commit python main.py cron # the dashboard
Crons: 27 active | 7 paused | 3 failing | 0 firing now
Show the failing ones. A real fleet always has some. That is what an incident ledger is for, and we did not stop to diagnose them.
Twenty seven unattended loops, and not one of them is an agent.
why that is the lesson This layer is deterministic Python. Where judgement is genuinely needed, the cron's entire job is to ping Ray to go run the agents himself, in a session, where he can see the bill. The job's own notes say the reason out loud: the agents meter when driven headless. The cron is the trigger. The human starts the expensive loop. and the honest failure One engine in this repo has a state file reading cycle 3, last run in June. A self paced session loop, dead for a month. Its own README predicted exactly why, because only an external process survives a session or a reboot. And its heartbeat is still sitting in the YAML marked paused. A session loop plus an unapplied cron equals a dead loop.MANY LOOPS, AND THE SAME WAY TWICE.
Plain language: we are not talking through this one. Photograph it. Two rungs, one table, and the sentence the whole hour was built to land.
| format | what it costs you | what it can do |
|---|---|---|
| skill | about 100 words always, body loads on trigger | the default packaging unit |
| CLAUDE.md | its entire length, every session, forever | context, and it is advisory |
| hook | zero context | ENFORCEMENT. the only layer that can block. |
| subagent | its own fresh context | a bounded inner loop, own tools, own cap |
| MCP server | tool schemas | the action surface beyond files and shell |
Skill versus CLAUDE.md is the one people get wrong. A skill is nearly free until invoked. CLAUDE.md is not. Ray's life CLAUDE.md is far over the documented target, which is an honest live example of that trade rather than something to hide.
loopUntilDry(). These are plain while loops written inside a script. If you go home and grep for the function, you will not find it.
rung 7 · the gate belongs at the tool layer
Straight out of Ray's MCP config: a hosted server whose write tools are ungated is used for reads only, and every spend enabling write routes through his own server, which defaults to dry run and requires an explicit submit. When you hand a loop a write tool, gate it in the tool, not in a prompt.
Every rung answered the same four questions. The ladder is really about the third one. Rung 1 observes with your eyes. Rung 2 observes with an exit code. Rung 3 makes that exit code something the model cannot talk its way past. Everything above Rung 2 is only worth building if Rung 2 is real. Write the check first.