Skip to content

Course · Pi · The core

The four tools, exactly

Here’s the claim this lesson proves, and it’ll look strange until you’ve seen why: yesterday’s fix swapped one line of parse_date for a six-line list, and the oldText Pi sent carried the entire formats = [...] assignment - variable name and both original format strings - when the conceptual change was just “add two formats.” That wasn’t padding for style. It was the direct, mechanical consequence of one rule buried in a single tool’s description.

By the end of this lesson you’ll be able to point at that rule and explain exactly what a smaller match would have risked.

Go back and read the real diff from the first-fix lesson, not the summary Pi narrated - the git show-able change to stash/extract.py (stash is the course’s running example, a small FastAPI feed reader). It’s small: one function, no drive-by reformatting of the rest of the file. That’s not luck. It’s a consequence of which tool Pi reached for, and what that tool is and isn’t allowed to do. Pi doesn’t give the model forty specialized tools to choose between - it gives it four, and the smallness of that set is exactly what makes each one worth knowing precisely.

read - bounded, and it tells you when it’s held back

Section titled “read - bounded, and it tells you when it’s held back”

read takes a path, and optionally an offset (which line to start at) and a limit (how many lines to return). It is not “give me the whole file, however big” - output is capped at 2,000 lines or 50KB, whichever ceiling it hits first. Hand it a file that’s thousands of lines long and you don’t get silence or an error: you get as much as fits, plus an explicit notice telling the model the next offset to continue from.

The model can always ask for more; it just never gets an unbounded firehose by accident. That’s what you were looking at at the top of yesterday’s session - Pi reading stash’s extractor before touching it, not because a rule said “read before you write,” but because the loop - the model call, tool result, next model call cycle - had nothing else to work from yet:

• Read stash/extract.py (22 lines)

The extractor is a small file, so that read came back whole. If it had been a hundred times longer, you’d have seen the truncation notice instead - and the very next tool call would have been another read at the offset it handed back. Nothing hidden, nothing magic - a stated boundary and a stated way past it. A read either gives you the whole file or tells you exactly where it stopped.

edit - exact text, unique match, or it refuses

Section titled “edit - exact text, unique match, or it refuses”

This is the one whose rules matter most, because it’s the one that will surprise you if you assume it’s smarter than it is. edit takes a path and a list of { oldText, newText } pairs. Its single hard rule, straight from the tool’s own description: every oldText must match a unique, non-overlapping region of the original file.

Two consequences follow directly from that:

  • If the text you’re asking it to replace appears more than once in the file, edit won’t guess which one you meant - it fails outright. The model has to include enough surrounding context in oldText to make the match unambiguous.
  • Matching happens against the file’s original content for every edit in a batch, not against a running, already-edited version. So if you (or the model) send five edits in one call, none of them can overlap with another - there’s no “apply edit one, then match edit two against the result.” Two changes you want in the same block have to arrive as one edit instead - the model merges them before sending.

There’s no replace_all flag anywhere in the picture. If an occurrence isn’t already unique in the file, edit has no built-in way to blast every instance at once - that’s a deliberate omission, not a missing feature. It forces precision instead of offering a blunt instrument.

Now re-derive yesterday’s diff before you look at it. The bug: parse_date tried two fixed strptime formats, neither carrying %z, so "Mon, 03 Feb 2026 09:00:00 +0530" raised “unconverted data remains” and fell through to None. The fix widens the formats list with offset-aware variants - one contiguous change inside a ten-line function. So what does oldText have to carry? The whole assignment, not just the strings being replaced:

• Edit stash/extract.py
- Match: " formats = [\"%a, %d %b %Y %H:%M:%S\", \"%Y-%m-%dT%H:%M:%S\"]"
+ Replace: " formats = [\n \"%a, %d %b %Y %H:%M:%S %z\",\n \"%Y-%m-%dT%H:%M:%S%z\",\n \"%a, %d %b %Y %H:%M:%S\",\n \"%Y-%m-%dT%H:%M:%S\",\n ]"

The change restructures the list from one line to six, so there is no smaller region that is the change - the assignment is the unit. But notice what the match carries besides the structure: the formats = name and both original strings, together. That whole-line shape is doing real work, because the strings on their own are the part most likely to appear twice.

In this 22-line file, even a bare fragment would have matched: "%Y-%m-%dT%H:%M:%S" appears exactly once. But that’s luck of a small file - give the repo a second date helper with its own format list and the fragment matches twice, and edit refuses rather than guess which one you meant. The full assignment line is how the model makes the match unambiguous without hoping the file stays small. The size of an edit’s oldText is a uniqueness proof, not a style choice.

The output you get back isn’t just “done,” either - edit returns a real diff for display and a standard patch underneath it, plus the line number where the change starts. That’s what let the TUI show you a clean before/after instead of a vague confirmation.

write takes a path and full file content, and it does exactly one thing: create the file (making any parent directories along the way) or overwrite it wholesale. No merge, no partial application, no matching logic at all - the opposite end of the spectrum from edit’s precision. That’s why the system prompt steers the model toward write only for brand-new files or genuine full rewrites, and toward edit for anything that’s touching part of an existing one.

You didn’t see write fire during the date fix, and that’s the expected outcome - a targeted bug fix in an existing function is edit’s job, not write’s. A tool that can’t merge is a tool that can’t corrupt a merge, either.

bash - synchronous, capped output, and no background jobs

Section titled “bash - synchronous, capped output, and no background jobs”

bash runs one command in stash’s working directory and waits for it to finish before returning anything - there’s no way to fire a command and get control back immediately. That’s a deliberate omission: Pi’s author, in the post explaining Pi’s design, argues background execution trades away observability for convenience, and points to tmux instead for anything genuinely long-running, since a human can watch and co-debug inside a real terminal session rather than trusting an agent’s notion of “still running.”

Output is capped too - the last 2,000 lines or 50KB, whichever limit hits first. Those are the same two constants read uses; one shared truncation helper serves both tools, with nothing tool-specific about the numbers. Here’s the part worth remembering: when a command’s output gets truncated, the full output isn’t thrown away - it’s written to a temp file, and the path comes back in the result so the model can read or grep the rest itself if it needs to:

• Bash: python -m pytest -v
tests/test_extract.py::test_parse_date_preserves_offset PASSED
tests/test_feeds.py::test_fetch_feed_parses_entries FAILED
1 failed, 1 passed

That’s the run that ended chapter 1 - the focused test green, and the full suite surfacing the feeds failure you deferred. Two tests produce two lines, nowhere near any cap. On a repo where that same command prints fourteen thousand lines, the tail comes back in the result with a marker like [Showing lines 12330-14329 of 14329 (2000-line limit). Full output: /tmp/pi-bash-8f2c1a.log] - the last two thousand lines, the cap shaping the message, and the temp file keeping the evidence. A capped tool and a lossy tool are not the same thing.

Notice what’s absent by default: no dedicated grep, find, or ls in the four you get out of the box - they exist as built-in read-only tools you can switch on through tool options, and the system prompt adjusts its guidance if they’re present (more on that later this module). No specialized “run tests” tool, no “search the web” tool, no fifth thing bolted on for a use case someone thought of once.

Four tools, each doing one job with a stated, learnable boundary. That smallness is what made it possible to just now read every rule governing the four tools you get out of the box - the optional extras are a settings flag away, but the default surface fits in one sitting. Try that with a coding agent that ships forty tools.

You’ve read yesterday’s fix at the level of exact tool mechanics: what got read and how much, how the edit matched and why it matched that much, what the bash call verified and where its overflow would have gone. That’s one layer of the black box opened. The next is the shape of the loop that decided to call those tools in that order - and it has fewer limits than you’d expect. Next: go one level deeper into the loop.