Skip to content

AnandSundar/boris-loop

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

8 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

boris-loop

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

boris-loop — one screen of Python, four tools, the loop closes on its own

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.

What this is

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.

How the loop works

  1. 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.
  2. The model replies with one or more blocks. They can be:
    • text — the model is just talking
    • tool_use — the model wants to call a function. You get {name, input, id}.
  3. Your code dispatches the tool call. Look up the function by name, call it with input, catch errors.
  4. You append the result as a tool_result block, with the matching tool_use_id. Now the conversation has both the assistant turn and the tool's response.
  5. 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.
  6. Special case: there's a finish tool. When the model calls it, the loop returns the result and exits. The model "knows" this is the signal to stop.
  7. Exit conditions: model calls finish → model returns text without any tool call → you hit max_iters (default 20).

A real run

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. Call finish("done") when report.md is 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.

Why this matters

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.

The loop

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.

Install & run

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

The 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/intro

The demo exits 0 on a clean finish, 1 on a missing API key, and 1 if the loop hits max_iters without finishing.

Run the tests

.venv/Scripts/python -m pytest -q

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

How to add a tool

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_files

That's the whole change. The next loop call exposes list_files to the model.

How to swap 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.

Safety

This is a teaching kit, not a hardened runner.

  • run_shell is disabled by default. Set BORIS_LOOP_ALLOW_SHELL=1 in 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-hoc python -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 lacks ANTHROPIC_API_KEY, but it inherits whatever else the parent shell had.
  • read_file and write_file resolve paths against the cwd and refuse to escape it; NUL bytes are rejected.
  • web_fetch forces HTTPS, restricts to an allowlist (docs.anthropic.com, anthropic.com by default — extend via the BORIS_LOOP_WEB_ALLOWLIST env 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.

License

MIT. See LICENSE.

About

Starter kit distilling the 'loops over prompts' pattern into ~100 lines of runnable Python. Teaching reimplementation — not affiliated with Anthropic or Boris Cherny.

Resources

License

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors