Skip to slide 1
01 / 28
VCN #44 · The Loop · 2026-07-25 · Frontier Tower F9
doors 10:00 · sprint 10:15

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.

SAT JUL 25 Frontier Tower F9 Doors 10:00 Sprint 10:15 Off the Leash · #44

$10 early · $20 door · FT members free (FTMEMBER)

doors 10:00 · sprint starts 10:15

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.

01 · wi-fi
network frontiertower
password frontiertower995
02 · install (that is the whole list)
python3 --version needs 3.10 or newer
pip install anthropic
no pytest, no framework, nothing else
03 · get your key NOW, not later
a human approves it, so start it during doors
it will be waiting for you at 10:29
the key, three steps
immersive commons workshop key 5 hours · this event only
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 block
already have a coding agent open?
Point it at https://vcn-44-the-loop.vercel.app/setup.txt and tell it: set me up for this morning. That file is written to an agent, not to you.
join Telegram Luma
the problem

YOU 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.

Claude Code, Cline, Cursor agents. You type a sentence. It reads files, runs commands, fixes the test, comes back and tells you it is done.
Right now that is magic. Magic is unsteerable. When it loops forever, or quits three steps early, or edits the wrong file, you are guessing.
Guessing is expensive. It is also unnecessary, because the thing inside is smaller than you think.
steel front
A vending machine you cannot see into. You put money in, something comes out, and when it jams you hit it. That is your relationship with your agent today.
glass front
Same machine. Same spirals, same motor, same everything. You can watch the spiral turn, so you can see exactly which one is stuck. Nothing got smarter. You just got sight.

Today we delete the magic.

the whole secret, one line

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.

plan act observe repeat
Pull the model. Ask it what to do next, given everything that has happened.
Call a tool. Your code runs the thing it asked for. The model never touches your machine.
Read the result. Hand the real output back, success or failure, unedited.
Loop until done. That is the entire shape every coding agent runs. No exceptions, no hidden sauce, no second system you have not heard of.
jargon · harness
The harness is your code: the plain Python that calls the model, runs the tools, and keeps the conversation. The model is a guest. The harness is the building. Almost everything that goes wrong with an agent is a harness problem, not a model problem, which is good news, because the harness is the part you control.

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.

the unit everything is counted in

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.

reason
It thinks out loud
The model writes plain text about what it believes is going on, then emits one action it wants taken.
act
Your code runs it
Your harness, not the model, actually opens the file or runs the command. You decide whether to obey.
result
The truth goes back
The real output, including the ugly failure, goes back into the conversation. That round-trip is one turn.
turn
One round-trip: model speaks, tool runs, result comes back. When you hear "it took 12 turns", that is 12 of these.
ReAct
Reason plus Act. A 2022 research pattern that says: do not plan everything first. Think one step, act one step, look at what happened, think again.
tool_use
The model does not run anything. It cannot. It emits a small block that says "please call read_file with path=src/thing.py". Your code decides whether to obey.
context window
The model has no memory between calls. Every single turn you resend the entire conversation. A whiteboard of fixed size that you rewrite from scratch every time you speak.

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

what we are about to build

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.

part / 01
The model
The brain. It decides. It cannot do anything.
part / 02
The tools
read_file, write_file, run_command. The hands. They do things. They decide nothing.
part / 03
The harness
The while-loop that wires brain to hands and holds the conversation. Yours. About 60 lines.
part / 04
The stop
How it knows the task is finished, or hopeless, or that you have had enough. Three separate mechanisms, and you need all three.
checkpoint A · 10:29 · before anyone writes a loop
smoke test one real call
python smoke.py

reply:       LOOP OK
VCN44 SETUP READY
No smoke.py yet? It is section 5 of /setup.txt, ready to paste.
"Everybody got a response back? Hands up."
Nobody proceeds into the labs on an unproven key. If you are red, pair with a green neighbour right now and share a screen. You will still write every line yourself. You will not spend the sprint debugging a key.
one word, whole morning
If your key came from the Immersive Commons flow it is a gateway key, so build the client with auth_token=, not api_key=. The SDK sends api_key as an X-Api-Key header and the gateway reads only Authorization Bearer. A perfectly valid key sent the wrong way answers malformed token.
lab stage 0 / 5 · 10:33 · 12 min

THE 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.

lab/stage0_skeleton.py · config
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 _CLIENT
lab/stage0_skeleton.py · the loop
def 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.

lab stage 1 / 5 · 10:45 · 12 min

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.

lab/stage1_tools.py · what the model sees
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"],
        },
    },
]
lab/stage1_tools.py · what actually runs
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 fixtureexitchars on stdout ONLYchars you would have lost
python test_math.py101530 on stderr
python -m unittest -q101172 on stderr
python -c "import nope"10134 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.

lab stage 2 / 5 · 10:57 · 18 min · the longest block, on purpose

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.

lab/stage2_toolcall.py · act and observe
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 results

1. 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.

and the loop body gains one line
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 + observe
checkpoint B · 11:09
"Everybody seen the model actually run a tool and get the output back? Hands up."
Run python 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.
lab stage 3 / 5 · 11:15 · 12 min

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.

1 · natural stop

stop_reason != "tool_use". Nothing left to ask for. The good ending. Fails when the model is confused and just starts talking.

2 · safety stop

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.

3 · explicit stop

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.

lab/stage3_stop.py · the done tool
    {
        "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 None
lab/stage3_stop.py · all three stops in one loop
def 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."
    )
checkpoint C · 11:24 · also the cut decision point
"Everybody's loop stop on its own, without you hitting Ctrl-C? Hands up."
Run 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.
lab stage 4 / 5 · 11:27 · 12 min

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.

lab/stage4_context.py · read the count, do not estimate it
def est_tokens(messages, exact: int | None = None) -> int:
    if exact is not None:
        return exact
    return len(json.dumps(messages, default=str)) // 4
lab/stage4_context.py · the compaction helper
def 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
lab/stage4_context.py · and it is actually CALLED
    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.

the replay · 11:39 · 12 min

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.

run it
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 · one function is wrong
# 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)
what the trace looks like · recorded before doors
[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
checkpoint D · 11:48 · this is the moment
"Everybody watched a red test go green? Hands up."

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.

the swap · second endpoint · 12:40, after the demos

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.

lab/nebius.py · python nebius.py --show-swaps
  [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)
lab/nebius.py · the batching INVERTS
    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_messages
lab/nebius.py · the loop, unchanged
def 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.

gotchas · trust gates

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.

01 · the shell is real
Your agent has a shell. It will use it.
Jail every path to a workspace, and put destructive commands behind a human yes. Never point this at a repo you have not committed. The gate in your code fails closed: no human and no answer both mean no.
02 · loops that will not quit
Bad stop logic burns tokens at machine speed.
The turn ceiling is a seatbelt. You have one because you already watched what happens without one. max_turns is not a performance setting, it is a spend limit.
03 · context drift
Compact too hard and it forgets the goal.
Compact too little and you overflow mid task. The budget is a dial, not a constant. 60,000 is a starting point, not an answer.
04 · errors are signal
Feed the traceback back in.
The model usually self corrects. This only works because your 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.
lab/agent.py:91-93 · the gate fails closed
# 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.")
what you ran
stdout only
stdout + stderr
a test run that reports to stdout
859 chars
859 chars
a script that crashes
0 chars
the whole traceback
an import error, a typo in argv
0 chars
the whole traceback

Measured this morning against the fixture. The stdout only bug survives every rehearsal and detonates the first time you run your own script.

leave with

WHAT YOU NOW HAVE WORKING.

Plain language: here is the list of things that were opaque two hours ago and are not any more.

01
A loop you wrote
The reason then act turn, the harness around it, tool calls in both directions, three stop conditions, and a context policy. Built by hand, not copied.
02
A gate and a write tool
A workspace jail and a destructive command gate that refuses without a human. The refusal goes back to the model as an ordinary result, so the loop keeps its footing.
03
A test that decides
fixtures/test_math.py. You watched it go red, then green, driven by your own code. Keep it. Wednesday needs it.
04
A real mental model
You now know what Claude Code and Cline are doing underneath. You stop guessing and start steering.
34
The loop itself is 34 lines. That is 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.
resources · take this home

WHAT YOUR TICKET INCLUDES.

Plain language: the model access is provisioned for you. The code and the test are yours to keep.

model access
z.ai behind your loop, provisioned for this session through the Immersive Commons workshop key flow from the arrival slide. Event scoped, five hours, minted for you.
nebius builders
$50 Token Factory plus $50 AI Cloud plus $25 Tavily. Self serve at dev.nebius.com/builders. Yours to keep, not scoped to today. It runs the second endpoint challenge, and it is your hosted fallback whenever a laptop cannot hold the model.
starter repo
The assembled reference agent.py, the five stage files stage0_skeleton.py through stage4_context.py, the fixture fixtures/mathlib.py and fixtures/test_math.py, the safety gate safety.py, the second endpoint port nebius.py, and verify.py which checks the whole thing offline.
the room
vibecodingnights.com for the commands and the next event. Telegram for the gallery.
the second endpoint delta · only one row is the base url
what changesAnthropic shapeOpenAI shape
packageanthropicopenai
endpointapi.z.ai/api/anthropicapi.tokenfactory.nebius.com/v1/
tool schemaname, description, input_schematype, function{...parameters}
max_tokensrequiredoptional
system prompta top level argumenta role system message
stop signalstop_reason == tool_usefinish_reason == tool_calls
arguments arrive asa parsed dicta JSON string, needs json.loads
tool resultsALL in ONE user messageONE 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.

credit · no logos

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.

tool_use and tool_result
The block pair you wrote this morning is the Anthropic Claude Messages API contract. It is called that because that is what the API calls it, not because anybody paid for this slide. The loop you wrote is the loop that shape is designed for, which is why it fit together so cleanly.
the other contract
A 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.
ReAct
A paper, not a product. Yao et al., ICLR 2023, 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.
frameworks
Plenty of them ship this loop pre built. Worth knowing they exist. Now that you have written the harness by hand, you can read one and tell whether its opinions are opinions you want.

Nobody sponsored this event. Nothing on this slide is an endorsement, and nothing on it is a logo.

community · off the leash, #41 to #50

KEEP BUILDING WITH VCN.

Plain language: two things happen next and both of them use the thing you just built.

wed jul 29 · 19:00 · floor 10
VCN #45: Bench
You cannot measure an agent you do not have. Now you have one. We write evals for it. Keep the signature. On Wednesday we import your agent and call it exactly like this, so a bench written for one loop scores everybody's.
def agent(task: str, max_turns: int = 25) -> str:
Bring fixtures/test_math.py. Today it went red then green once. Wednesday we run it twenty times and find out how often. Running once is a demo. Running consecutively is reliability. That gap is the whole night.
luma.com/vcn-45-bench
thu jul 30 · 19:30 · floor 9
Loop-fest
Show off your best loop. Five minute slots. Registration asks for a link to your loop, which is exactly why we put yours somewhere linkable at 12:00.
You just did four minutes in this room. Thursday is five. You are already ready. A loop that spun twenty five turns and gave up is a better slot than one that worked first try, because the room learns more from it.
luma.com/loop-fest
jargon · oracle
The test that decides pass or fail, with no opinion involved and no model in the judging seat. 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.

vcn #44 · the loop

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.

act 2 · loops in claude code

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.

your loop, mapped onto the documented three phase loop
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
repeats
A model call plus the tool round trip it triggers. One turn.
advances
Every tool result is appended to the transcript, so the next call sees a different world.
observes
The tool results, handed back as one user message per turn. agent.py:203.
terminates
No tool call, or the done tool, or the ceiling. Same three you wrote.

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.

rung 1 · steer

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.

01 · the cheapest habit on this page
Esc and typing are different moves.
Esc stops it now and cancels the running tool call. Typing a correction and pressing Enter does not kill the tool call. It lands as soon as the current action finishes, and the loop adjusts before it decides what to do next. Most people only know Esc, so they kill a good turn to add one sentence.
02 · context
It fills in two stages, not one.
Older tool outputs get cleared first. Summarizing the conversation is the second move, not the first. Those are exactly the two levers you built at Stage 4: truncate a result at agent.py:138, fold the middle at agent.py:192, and run it at the top of the turn at agent.py:227.
03 · aim the summarizer
Do not let it guess what mattered.
/compact focus on the auth bug fix
Run it before a long new task, not once you are already wedged. Compacting a nearly full context is itself a large expensive request, and your own Stage 4 code is the proof: the summarizer is a model call. Compaction is not free. When you want a clean start instead of continuity, /clear costs nothing.
04 · plan mode
Explore with the write tools withheld.
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.
one correction for the internet
Every blog post tells you to lean on 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.
rung 2 · the pivot

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.

life/events/oracle.yaml · a real one, running in this repo
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"
ran on this laptop this morning
$ python main.py verify probe events/oracle.yaml
  deterministic: True
  oracle: PASS
An oracle is a file the thing being graded never writes. It lists commands, and their exit codes decide done. No opinion, no model in the judging seat. why cmd is a list It runs with no shell. Nothing is interpolated and nothing is interpretable. That is the point, not a style choice. what probe adds 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.

rung 2 · loop on the 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.

orchestration/loop/verify_loop.py:10-16 · the whole config
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
green
The oracle passes. on_green fires, which is where the commit lives.
max_iters
Six tries and out. A count, not a hope.
max_seconds
Twenty minutes of wall clock. The spend limit.
stalled
The same checks failed twice running, so nothing is advancing. Question two, enforced.

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.

k
Green once is a demo. The pass^k gate runs the whole oracle k times in a row with nothing changing in between, and passes only if every run is green. It exists because agents that succeed once routinely fail on an identical rerun. Ran here this morning against the same oracle: 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.
rung 3 · the hook that fought this deck

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.

~/.claude/hooks/atomic-commit-gate.js:325 · the line that refuses
process.stdout.write(JSON.stringify({ decision: "block", reason }));
and the reason template it hands back, atomic-commit-gate.js:314
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>"
evidence, not a story. Checked on this machine this morning.
.claude/atomic-commit-writes/96e15c99.txt exists. 96e15c99 is this session's id.
git log shows session tagged commits: WIP[f21a293c], WIP[2657cfca].
The count is not decoration. It is git status --porcelain, re read on every Stop.
A Stop hook is a program the harness runs when Claude tries to finish its turn. If the program says no, the turn does not end, and the reason string is handed back to the model as its next instruction. So the agent cannot walk away from a mess. It commits, or the session does not end. the four questions Repeats: the attempt to end the turn.
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.
rung 3 · the contract

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 wantyou do
allowexit 0, print nothing
block, with a reasonexit 0 and print {"decision":"block","reason":"..."} on stdout
block, the quick waywrite the reason to stderr, then exit 2
warn, do not blockprint hookSpecificOutput with additionalContext
deny a tool callPreToolUse, permissionDecision "deny" plus a reason
exit 1nothing at all. the most common silent failure.
brake one · the fuse
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.
brake two · a clearable predicate
The commit gate has no fuse anywhere in its 329 lines and does not need one, because the condition is genuinely clearable. Commit the files and it goes away. Never build a Stop gate on a judgement call. If the model cannot reliably clear it, the fuse is the only thing between you and a session you cannot exit.
brake three · fail open
} catch (e) {
  process.exit(0);
}
The last three lines of the gate. A gate that crashes closed bricks every session on the machine. Ray hit exactly that once, and the fix is documented in the source.

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.

rung 4 · it turns without you

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.

the clock
A schedule, on a cron expression.
/loop 5m /babysit-prs
/loop 30m check the deploy
Advances is wall clock time only, so every tick has to re read the world from scratch. Terminates is external: Esc, or deleting the job, or the session ending. Ray really runs this. His agent protocol file says the heartbeat is one minute and every agent is launched with /loop 1m.
Drop the interval and it paces itself, printing the delay and a one sentence reason so you can follow a cadence you did not set.
the doorbell
A condition, checked by the shell.
Bash({ command: 'until grep -q "Ready in" dev.log; do sleep 0.5; done',
       run_in_background: true })
The cheapest wake in the whole toolkit and the one people reach past. It exits the moment the condition is true, and that exit is the notification. One notification, nothing armed left behind. Repeats is a shell loop invisible to the conversation. Observes is grep. Terminates in seconds, not at a timeout.
For a stream of occurrences rather than one, that is what a monitor is for.

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.

Session only. Read off the shipped tool schema this morning: jobs live only in this session, nothing is written to disk, and they are gone when Claude exits. durable has no effect.
Idle only. Jobs fire while the prompt is idle, never mid query. There is no catch up.
Recurring jobs expire after 7 days. They fire one final time, then delete themselves.
Pick an off minute. Every user on the planet who asks for 9am gets 0 9. Write 57 8 * * *. Jitter is up to 10 percent of the period, capped at 15 minutes.
rung 5 · outside the session

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.

config/crons_canonical.yaml is the source of truth
python main.py cron bootstrap          # preview, this is the default
python main.py cron bootstrap --apply  # commit
python main.py cron                    # the dashboard
ran on this laptop this morning
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.
rungs 6 and 7 · take this home

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.

formatwhat it costs youwhat it can do
skillabout 100 words always, body loads on triggerthe default packaging unit
CLAUDE.mdits entire length, every session, forevercontext, and it is advisory
hookzero contextENFORCEMENT. the only layer that can block.
subagentits own fresh contexta bounded inner loop, own tools, own cap
MCP servertool schemasthe 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.

rung 6 · one loop becomes many When one context window is not enough: spawn N fresh agents, collect, decide, spawn again. The pattern actually worth stealing is loop until dry. Run rounds. Each round is told what earlier rounds already found and asked to hunt what they missed. A round that finds nothing new is dry. Two dry rounds and you stop. Dryness is a measurement. A round count is a guess. Two dry rounds, not one, because a single dry round can be one unlucky sample. And when it exits on the cap instead, it must say so. naming honesty There is no 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.