Chapter 2 · The core loop
Tools
Tools let a model change things in the world, but the model never touches them. It writes a request, and your code decides whether to run it.
What a tool really is
Patch has a tool for searching code. The spec describes it like this:
name: search_files
input:
query: string
output:
matching files
The model sees only a short entry like this: a name, a sentence about what the tool does, and the arguments with their types. Your code holds the rest: the function that searches the files, and rules such as "read-only, and only inside the repository".
The model picks tools from the visible part alone, so a tool's description works like a prompt. A vague description leads to wrong calls.
The life of one call
Say the model wants to find where Patch checks token expiry. It replies with a request instead of text:
// the model's reply
{"role": "assistant", "tool_calls": [
{"id": "call_1", "name": "search_files",
"arguments": {"query": "expires_at"}}]}
// what your runtime adds to the conversation after running it
{"role": "tool", "tool_call_id": "call_1",
"content": "auth/session.py:5: return token.expires_at >= now - ..."}
Your runtime (the code that runs the agent loop) checks the request and runs the search. It adds the output to the conversation with the same id. The id tells the model which result answers which request. The model is not waiting meanwhile, because its call has already ended. Your runtime calls it again later, with the result in the history.
Vendors name these fields differently, but the flow is the same:
Runtime tells model: "These tools exist." ↓ Model outputs: "Call search_files(query='authentication')" ↓ Runtime validates the request ↓ Runtime executes code (the tool touches the world) ↓ Result returned to the model (it reads it on its next call)
Schemas: the contract for arguments
Patch sends this definition of search_files with every call:
{"name": "search_files",
"description": "Search for literal text in files under the workspace. ...",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Text to find, not a regex"},
"path": {"type": "string", "description": "File or folder. Default: all"},
"max_results": {"type": "integer", "minimum": 1, "maximum": 50}},
"required": ["query"],
"additionalProperties": false}}
The parameters part is a JSON Schema, a standard format for describing JSON data. Here it says that query is a required string.
Schema choices prevent mistakes. A fixed list of allowed values, such as "open" or "closed", stops the model from inventing a third. Rejecting unknown fields catches arguments your code would otherwise ignore without a word. But a schema checks only form: it cannot tell that ../deploy/prod.env points outside the repository.
Validating arguments
Each request in this table fails a different check:
| Check | Question | A request that fails it |
|---|---|---|
| Parse | Is it complete JSON? | A reply cut off mid-argument |
| Schema | Right fields and types? | max_results: "all" |
| Current state | Does it still make sense? | An edit whose old text is gone |
| Policy | Is it allowed? | Reading ../deploy/prod.env |
Treat arguments like a request body from the internet, because they are input you did not write. A library can do the first two checks, but the last two need your own rules. When a check fails, your runtime skips the tool and returns an error result with the same call id, so the model can try again.
The third row is a stale object. The model read a file at step 3, and someone changed it before the edit at step 9. A good edit tool takes the old text and refuses if it no longer matches, like a compare-and-swap. So the edit fails instead of overwriting a teammate's change (chapter 3).
When a call fails
Suppose the model asks for auth/tokens.py, but the file is called auth/token_utils.py. Your runtime returns this result: "No such file: auth/tokens.py. Similar: auth/token_utils.py." On its next call, the model asks for the right file.
So return errors to the model as tool results, instead of crashing or hiding the failure, and say what to try next.
| Failure | Who handles it |
|---|---|
| Network timeout, rate limit | Your runtime retries with growing waits (backoff) |
| Wrong arguments, denial, missing file | The model, from an error result with a hint |
| A bug in the tool | The model gets an error result, and you get an alert |
| The same failure again and again | Your runtime stops the run or asks a person |
Every call needs exactly one result before the next model call, and some APIs reject a history with gaps. So after a crash or a cancel, your runtime still records a result, such as "cancelled".
Big outputs
Patch runs pytest -v, and pytest prints 1,227 lines. Your runtime saves the full output to a file and returns a short preview instead:
exit code 1
============================= test session starts =============================
collected 1200 items
[... 1,220 lines truncated. Full output saved to .patch/outputs/call_1.log ...]
FAILED tests/test_auth.py::test_login_expired_token - assert 200 == 401
FAILED tests/test_billing.py::test_refund_rounding - assert ...
================== 2 failed, 1198 passed in 48.31s ==================
The preview keeps the first lines, which show what ran, and the last lines, which hold the summary. Its marker says how much was cut and where the rest is, so the model knows the preview is incomplete.
Structured output needs more care, because cutting JSON at a character limit leaves invalid JSON. Drop whole items instead, and say how many you dropped. Chapter 4 covers what to do when the context still fills up.
Several calls at once
The model asks to read auth/tokens.py and auth/session.py in one reply. Your runtime runs both reads at once. The second finishes first, and then the first fails because the file does not exist.
So your runtime matches each result to its request by call id, not by finishing order. It returns every result, including the error.
Which calls can run together?
Calls can depend on each other in two ways, and each one is handled in a different place.
One call needs another's result. To read the file a search finds, you need the search result first. This kind sorts itself out, because the model writes every argument before any call in its reply runs. It cannot name the file before the search returns, so the read comes in the next step. Calls in one reply never need each other's output.
Two calls touch the same thing. Say the model asks to edit auth/session.py and run the tests, in one reply. Both calls have complete arguments, but the tests read the file the edit changes. Run them at once, and the tests might check the old code. The model can miss a clash like this, so your runtime decides from what each tool does, not from what the model meant:
- Calls that only read, such as
read_fileandsearch_files, can run at the same time. - A call that changes something runs alone, in the order the model listed it. Writes to different files can share a batch, but two writes to the same file cannot.
- When you cannot tell what a call changes, treat it as a change.
run_command("pytest")can touch anything the tests touch, so it runs alone.
One call at a time, in the listed order, is always correct. Running reads together only makes it faster. You can also tell the model in the system prompt to put only independent calls in one reply. That helps, but the rule in your runtime is what makes a wrong guess safe. The effect classes later in this chapter give each tool the label this rule needs.
Step through five calls
The widget follows five calls through a runtime. Watch the count of blocked calls: a blocked call never counts as run.
Structured outputs
Patch can end a run by calling a tool instead of writing a final message:
submit_result(
summary="Removed the 10-minute grace period in auth/session.py",
files_changed=["auth/session.py"],
tests_passed=True, # a claim, not evidence
)
Your runtime offers submit_result with a schema for the final answer, so the run ends with data your code can read. Some providers offer constrained decoding, which restricts what the model can generate so the output always matches the schema. Without it, your runtime checks the output and asks for a retry. Either way, the content can still be false, so your runtime should check the last test run itself.
How the model finds tools
Patch sends all four tool definitions with every call. An assistant with 300 tools cannot, because each definition costs tokens on every call, and similar tools make the choice harder.
Runtimes handle this in four ways. A static list sends the same tools every time. A filtered list hides tools that cannot work right now. A tool server lists its tools on request. Tool search lets the model search a large catalog, so the runtime loads only the definitions it needs.
MCP (Model Context Protocol) is a standard way for a server to offer tools. The server lists its tools and runs the calls your runtime sends. It is not an agent loop, because your runtime still decides what runs. It is not a security boundary either. The spec says the protocol cannot enforce its own security rules, so your runtime must. Treat a server's descriptions and results as untrusted unless you trust the server.
Effect classes and destructive tools
run_command("ls tests") only lists files, and run_command("rm -rf build") deletes them. run_command("pytest") runs project code, which can do whatever the sandbox and test setup allow. The tool name is the same in all three, so it says nothing about the risk.
So give each tool, or each call, an effect class:
| Class | Examples | Typical treatment |
|---|---|---|
| Read-only | search_files, read_file | Runs freely, within its scope |
| Reversible write | edit_file in a clean checkout | Runs, because the original can be restored |
| External or irreversible | Send an email, push to main, DROP TABLE | Needs approval, or gets no tool |
An edit is reversible only if you can restore the original. Git cannot bring back uncommitted work that an edit overwrote, so give the agent a clean checkout or save each original first. Read-only does not mean harmless either, because reading a secret and sending it somewhere is a leak (chapter 10).
Who reads the effect class?
Your code reads it, not the model. You set the class once when you define the tool, and several parts of your runtime act on it:
- The permission check lets read-only calls through and asks a person before external ones (next section and chapter 11).
- The scheduler runs read-only calls at the same time, and the rest one at a time.
- The retry code retries reads freely. It retries an external action only with an idempotency key, so a repeat does no harm (chapter 12).
- The undo support saves the original before a reversible write, so the write really can be undone.
For run_command, the class depends on the command, so your runtime has to classify each call. When it cannot tell, it should pick the riskier class.
"Effect class" is this course's name. Real systems use the same idea under other names:
Permissions
Suppose the rule is "never delete files outside the repository". Written in the system prompt, it is only a request that the model can misread and injected text can argue against. Written in the code that dispatches tool calls, it runs on every call:
def dispatch(call):
tool = TOOLS.get(call.name)
if tool is None:
return error(call.id, "unknown tool: " + call.name)
problems = tool.schema.validate(call.arguments)
if problems:
return error(call.id, "invalid arguments: " + "; ".join(problems))
verdict, reason = policy(tool, call.arguments) # "allow" | "deny" | "ask"
if verdict == "deny":
return error(call.id, "denied by policy: " + reason)
if verdict == "ask":
return pause_for_approval(call) # chapter 11
output = tool.run(**call.arguments)
return result(call.id, limit(output, artifact_for=call.id))
A permission check answers allow, deny, or ask a person (chapter 11). But it covers only the paths it intercepts: if a shell tool can delete the same files, the gate has a hole. It also cannot stop a process that finds another way, which takes a sandbox (chapter 10).
Code as the action
# one model call, many tool calls, one short result
hits = tools.search_files(query="expires_at")
for path in sorted({h.path for h in hits}):
text = tools.read_file(path=path)
if "timedelta(minutes=10)" in text:
print(path, "has a 10-minute grace period")
Some agents let the model write a short script like this instead of one call per step. Your runtime runs it in a sandbox and returns only what it prints, so many steps cost one model call. But the model now writes programs, so the sandbox must be tight, and the script's tool calls should pass the same checks.
Why tools make models so much more useful
Ask a model with no tools whether Patch's auth test passes, and it can only guess from the code you pasted. Give it run_command, and it runs the test and reads the result.
Tools give the model fresh facts and a way to act. They also give it feedback, so it can try a fix, test it, and try again. Coding suits agents because tests give fast, honest feedback (chapter 9).
Running example: Patch gets real tools
Patch version 2 gets four tools, each with an effect class. Its runtime checks every call before it runs, returns blocked calls as errors, and saves long outputs to files.
# Patch v1: one tool, no checks
- tools = [run_command]
# Patch v2: four tools, each with an effect class
+ tools = [
+ Tool("search_files", effect="read-only"),
+ Tool("read_file", effect="read-only"),
+ Tool("edit_file", effect="reversible write"), # clean checkout, originals saved
+ Tool("run_command", effect="depends on the command",
+ allow=["pytest", "git status", "git diff"]),
+ ]
Patch still cannot tell you which files it changed. Chapter 3 gives it that record.
What goes wrong
Vague or overlapping tools
- Why it happens
- The model picks tools from their descriptions alone, so similar tools get mixed up.
- How to spot it
- Traces show the same wrong tool or argument mistake again and again.
- What to do
- Say when to use each tool and when not to, and merge tools that overlap.
Errors that end the run or vanish
- Why it happens
- The runtime crashes on a tool error or returns an empty string, so the model never learns what went wrong.
- How to spot it
- Runs end in exceptions, or the model repeats a failing call.
- What to do
- Return every error as a result with a hint, and retry network errors in code.
Rules that live only in the prompt
- Why it happens
- "Never touch files outside the repo" is only a request, which a confused model or injected text can ignore.
- How to spot it
- Ask the agent to break the rule. If clever wording gets it through, nothing enforces the rule.
- What to do
- Check paths and commands in the dispatcher, and run the agent in a sandbox.
You decide, AI builds
Decide yourself
- Which tools exist, and which actions get no tool at all.
- The effect class of each tool, what each class requires, and which calls may run at the same time.
- Output limits, and where full outputs are kept.
- Which failures code retries, and which go to the model.
Delegate to AI
- JSON Schema generated from typed function signatures.
- The validation and dispatch code.
- Retries with backoff, truncation, and saved output files.
- Tool wrappers around existing APIs.
Check yourself
How does a tool call actually work, from the model's output to its next call? (Q9)
Answer
The runtime lists the tools in the context, and the model replies with a request: a tool name, arguments, and an id. The runtime checks it, runs the function, and adds the output to the history with the same id.
The model calls read_file with the path ../.env. The schema says the path is a string. What should happen?
Answer
The schema only checks that the path is a string. The policy finds the path outside the workspace and denies it, and the model still gets a result.
Two parallel calls finish in reverse order, and the first one fails. What does the model receive?
Answer
Results attach by id, not by finishing order, so the model gets the error for one call and the result of the other.
Takeaways
- Check every call in code before it runs. A blocked call never runs, but it still gets a result.
- Send failures and big outputs back as results the model can act on.
- Give each tool an effect class. Enforce permissions in the dispatcher and the sandbox, not in the prompt.
Going deeper
- Writing effective tools for agents (Anthropic, September 2025). How to design tools a model uses well: fewer tools, readable output, truncation by default, and errors that suggest a fix.
- Code execution with MCP (Anthropic, November 2025). Why calling tools from code can save context, and why that code needs a sandbox.
- Model Context Protocol specification (version 2026-07-28). This version is stateless: no session or handshake, and each request carries its own protocol version.