"I don't prompt Claude anymore. I have loops running that prompt Claude and decide what to do next. My job is to write those loops."
— Boris Cherny, creator of Claude Code
Disclaimer. This kit is an independent reimplementation of the "loops over prompts" pattern, made for teaching purposes. It is not affiliated with, endorsed by, or distributed by Anthropic or Boris Cherny.
A small open-source Python starter kit that distills the loop Claude Code
runs internally into one screen of code you can read, run, and extend. No
framework, no abstractions you have to learn first — just the Anthropic
SDK, a while loop, and four small tools.
The model doesn't just answer — it can also call functions by
returning a tool_use block. Your code runs the function, sends the
result back, and the model decides what to do next. Repeat until the
model says "I'm done" or you hit a limit. That's the whole pattern.
- You send a task to the model, with a list of tools it can call. Each tool is a name, a description, and a JSON schema of its inputs.
- The model replies with one or more blocks. They can be:
text— the model is just talkingtool_use— the model wants to call a function. You get{name, input, id}.
- Your code dispatches the tool call. Look up the function by
name, call it withinput, catch errors. - You append the result as a
tool_resultblock, with the matchingtool_use_id. Now the conversation has both the assistant turn and the tool's response. - Send the conversation back to the model. It sees what the tool returned and decides what to do next — call another tool, call multiple tools in parallel, or return text saying it's done.
- Special case: there's a
finishtool. When the model calls it, the loop returns the result and exits. The model "knows" this is the signal to stop. - Exit conditions: model calls
finish→ model returns text without any tool call → you hitmax_iters(default 20).
The default demo sends this task:
Read the local file
fixtures/intro.html, extract the 5 most important points, and write them as a markdown report to./report.md. Callfinish("done")whenreport.mdis written.
What the model does (one possible trace):
| Iter | Model says | Loop does |
|---|---|---|
| 1 | tool_use: read_file(path="fixtures/intro.html") |
reads the file, returns the HTML |
| 2 | tool_use: write_file(path="report.md", content=...) |
writes report.md |
| 3 | tool_use: finish(result="done") |
returns "done", loop exits |
The model decided the order and chose which tools — your code just executed whatever it asked for. The model's job is sequencing; your code's job is execution.
This is the same pattern Claude Code uses internally. Every time you ask Claude Code to "find the bug in foo.py and fix it," it might do:
read_file foo.py → grep "TODO" → read_file tests/test_foo.py
→ run_shell pytest → read_file foo.py (with edit)
→ write_file foo.py → run_shell pytest → finish("done")
Your kit lets you read the loop, run it, and add a fifth tool
without learning any framework. The ALL_TOOLS list is a regular
Python list of dicts. Append a new one, add a matching function to
TOOL_FUNCTIONS, and the next loop call sees the new tool.
The rest of this kit is boris.py (one screen) plus four tools in
tools.py plus a demo that wires them up against a real task. Adding a
fifth tool is one function and one schema entry; the loop itself never
changes.
Here is the whole boris_loop function. It is exactly what Claude Code
does internally, on one screen:
def boris_loop(
task: str,
*,
model: str = "claude-sonnet-4-5",
max_iters: int = 20,
client: anthropic.Anthropic | None = None,
verbose: bool = True,
) -> str:
if client is None:
client = make_client()
messages = [{"role": "user", "content": task}]
for i in range(1, max_iters + 1):
response = client.messages.create(
model=model,
max_tokens=4096,
system=SYSTEM_PROMPT,
tools=ALL_TOOLS,
messages=messages,
)
blocks = response.content
# finish short-circuits the loop.
for b in blocks:
if getattr(b, "type", None) == "tool_use" and getattr(b, "name", None) == "finish":
return b.input["result"]
# No tool calls -> the model gave a text answer. Take it.
tool_uses = [b for b in blocks if getattr(b, "type", None) == "tool_use"]
if not tool_uses:
return "".join(b.text for b in blocks if getattr(b, "type", None) == "text")
# Append the assistant turn + a tool_result user message.
messages.append({"role": "assistant", "content": [b.model_dump() for b in blocks]})
results = []
for b in tool_uses:
if verbose:
print(f"iter {i}: tool={b.name}", file=sys.stderr)
try:
obs = TOOL_FUNCTIONS[b.name](**b.input)
except KeyError:
obs = f"error: unknown tool {b.name!r}"
except Exception as e:
obs = f"error: {type(e).__name__}: {e}"
results.append({"type": "tool_result", "tool_use_id": b.id, "content": obs})
messages.append({"role": "user", "content": results})
return f"boris_loop: max_iters ({max_iters}) reached without finish"That's it. The full source is in boris.py — under 120 lines
including imports and the __main__ block.
git clone <this-repo>
cd boris-loop
python -m venv .venv
.venv/Scripts/python -m pip install -r requirements.txt # macOS/Linux: source .venv/bin/activate first
export ANTHROPIC_API_KEY=sk-...
.venv/Scripts/python demo.pyThe default demo reads fixtures/intro.html (bundled, works offline),
extracts the 5 most important points, and writes them to report.md
in the repo directory. To run it against a real URL instead:
.venv/Scripts/python demo.py https://docs.anthropic.com/en/docs/introThe demo exits 0 on a clean finish, 1 on a missing API key, and 1 if
the loop hits max_iters without finishing.
.venv/Scripts/python -m pytest -q37 tests run in under 2 seconds. No API key required — every test
mocks the Anthropic client via a FakeClient fixture that records calls
and serves scripted responses.
A tool is a plain Python function plus a JSON-schema entry in ALL_TOOLS.
The dispatch table is built from the same list, so the loop picks up new
tools without any edit to boris.py. Example: add a list_files tool.
In tools.py:
import os
def list_files(path: str = ".") -> str:
"""List files under `path` in the project directory."""
real = _resolve_within_cwd(path)
return "\n".join(sorted(os.listdir(real)))
ALL_TOOLS.append({
"name": "list_files",
"description": "List files in a project directory. Returns paths, one per line.",
"input_schema": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "Directory to list (default: project root)."},
},
},
})
TOOL_FUNCTIONS["list_files"] = list_filesThat's the whole change. The next loop call exposes list_files to the
model.
Pass model= to boris_loop:
boris_loop("summarize this", model="claude-opus-4-8")The default is claude-sonnet-4-5. Any current Anthropic model works.
This is a teaching kit, not a hardened runner.
run_shellis disabled by default. SetBORIS_LOOP_ALLOW_SHELL=1in the environment to enable it. Even when enabled, a denylist blocks common dangerous patterns (rm -rf /,mkfs, pipe-to-shell, fork bombs, base64-decoded payloads, ad-hocpython -c). The denylist is not a sandbox — it does not cover variable expansion, exotic shells, encoded payloads, or other bypasses. The subprocess runs with a minimal env that lacksANTHROPIC_API_KEY, but it inherits whatever else the parent shell had.read_fileandwrite_fileresolve paths against the cwd and refuse to escape it; NUL bytes are rejected.web_fetchforces HTTPS, restricts to an allowlist (docs.anthropic.com,anthropic.comby default — extend via theBORIS_LOOP_WEB_ALLOWLISTenv var), and rejects hosts that resolve to private/loopback/link-local addresses.
Don't point the loop at anything you don't trust the model to touch.
MIT. See LICENSE.
