"Add a tool, add just one handler" — The loop stays the same. Register the new tool in the dispatch map and you're done.
Harness Layer: Tool Dispatch — Expanding the model's reach.
Only One Tool: Bash
The s01 Agent has only one tool: bash. To read a file, cat; to write, echo "..." > file.py; to edit, sed.
The model thinks "read this file" but has to spell out cat path/to/file. An extra layer of translation that wastes tokens and invites errors.
Overview: Tool Dispatch
The s01 loop is fully preserved (LLM call, tool_use block check, message append — not a single word changed). The only change is in that one line of tool execution: run_bash() is replaced with TOOL_HANDLERS[block.name]() dispatch lookup.
Adding a tool to the Agent requires just two things:
- Define the tool: Add one entry to the
TOOLSarray - Register the handler: Add one mapping in the
TOOL_HANDLERSdict
From 1 Tool to 5 Tools
s01 had only bash:
TOOLS = [{"name": "bash", ...}]
def run_bash(command): ...
s02 expands to 5 tools, each independently defined:
TOOLS = [
{"name": "bash", "description": "Run a shell command.", ...},
{"name": "read_file", "description": "Read file contents.", ...},
{"name": "write_file", "description": "Write content to file.", ...},
{"name": "edit_file", "description": "Replace text in file once.", ...},
{"name": "glob", "description": "Find files by pattern.", ...},
]
Each tool has its own implementation function:
def run_read(path, limit=None):
lines = safe_path(path).read_text(encoding="utf-8").splitlines()
if limit:
lines = lines[:limit]
return "\n".join(lines)
def run_write(path, content):
safe_path(path).write_text(content, encoding="utf-8")
return f"Wrote {len(content)} bytes to {path}"
def run_edit(path, old_text, new_text):
text = safe_path(path).read_text(encoding="utf-8")
if old_text not in text:
return "Error: text not found"
safe_path(path).write_text(text.replace(old_text, new_text, 1), encoding="utf-8")
return f"Edited {path}"
def run_glob(pattern):
import glob as g
matches = sorted(set(g.glob(
pattern, root_dir=WORKDIR, recursive=True)))
shown = matches[:200]
if len(matches) > 200:
shown.append("... (more matches omitted; narrow the pattern)")
return "\n".join(shown)
Tool Dispatch
TOOL_HANDLERS = {
"bash": run_bash,
"read_file": run_read,
"write_file": run_write,
"edit_file": run_edit,
"glob": run_glob,
}
# Only one line changed in the loop — from hardcoded run_bash to dispatch lookup:
for block in tool_calls:
handler = TOOL_HANDLERS[block.name] # lookup
output = handler(**block.input) # call
results.append(...)
Adding a tool = one entry in TOOLS array + one line in TOOL_HANDLERS dict. The loop stays the same.
Multiple Tool Calls
The model often returns multiple tool_use calls at once — "read a.py and b.py, then list all .py files".
Calls are executed one by one in their original response.content order.
Quick Reference
| Concept | One-Liner |
|---|---|
| TOOL_HANDLERS | Tool name → handler function dict. Add a tool = add one mapping line |
| Tool Definition | JSON schema telling the model "what I can do" |
| Multiple tool calls | Model may return multiple tool_use at once; calls execute in their original order |
| Loop Unchanged | s01's while True loop — not a single line changed |
Changes from s01
| Component | Before (s01) | After (s02) |
|---|---|---|
| Tool count | 1 (bash) | 5 (+read, write, edit, glob) |
| Tool execution | Hardcoded run_bash() | TOOL_HANDLERS dispatch lookup |
| Path safety | None | safe_path validation (file tools only) |
| Loop | while True + tool_use block | Identical to s01 |
Try It
cd learn-claude-code
python s02_tool_use/code.py
Try these prompts:
Read the file README.md and tell me what this project is aboutCreate a file called test.py that prints "hello", then read it backFind all Python files in this directoryRead both README.md and requirements.txt, then create a summary file
What to watch for: When does the model call just one tool, and when does it call multiple at once? Are multiple tool calls executed in the correct order?
#!/usr/bin/env python3
"""
s02_tool_use.py - Tools
The agent loop from s01 does not change. This lesson adds four tools
and a dispatch map:
+----------+ +-------+ +--------------------------+
| User | ---> | LLM | ---> | Tool Dispatch |
| prompt | | | | bash -> run_bash |
+----------+ +---+---+ | read_file -> run_read |
^ | write_file -> run_write |
| | edit_file -> run_edit |
+----------+ glob -> run_glob |
tool_result+--------------------------+
+ run_read / run_write / run_edit / run_glob
+ TOOL_HANDLERS instead of a hard-coded run_bash call
+ safe_path to keep file tools inside the workspace
Key insight: the loop stays the same; only tool registration and dispatch grow.
"""
import os
import subprocess
from pathlib import Path
try:
import readline
readline.parse_and_bind('set bind-tty-special-chars off')
readline.parse_and_bind('set input-meta on')
readline.parse_and_bind('set output-meta on')
readline.parse_and_bind('set convert-meta off')
except ImportError:
pass
from anthropic import Anthropic
from dotenv import load_dotenv
load_dotenv(override=True)
if os.getenv("ANTHROPIC_BASE_URL"):
os.environ.pop("ANTHROPIC_AUTH_TOKEN", None)
WORKDIR = Path.cwd()
client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL"))
MODEL = os.environ["MODEL_ID"]
ENVIRONMENT_PROMPT = (
"Windows: the bash tool runs through cmd.exe; use cmd.exe syntax, not Unix "
"Bash or PowerShell syntax, and prefer dedicated file tools for file operations"
if os.name == "nt"
else "Unix-like: the bash tool runs the system shell"
)
SYSTEM = (
f"You are a coding agent at {WORKDIR}. Environment: {ENVIRONMENT_PROMPT}. "
"Use tools to solve tasks. Act, don't explain."
)
# -- From s01 (unchanged) --
def run_bash(command: str) -> str:
dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]
if any(d in command for d in dangerous):
return "Error: Dangerous command blocked"
try:
r = subprocess.run(command, shell=True, cwd=WORKDIR,
capture_output=True, text=True, errors="replace",
timeout=120)
out = (r.stdout + r.stderr).strip()
return out[:50000] if out else "(no output)"
except subprocess.TimeoutExpired:
return "Error: Timeout (120s)"
except (FileNotFoundError, OSError) as e:
return f"Error: {e}"
# -- New in s02: four tools --
def safe_path(p: str) -> Path:
path = (WORKDIR / p).resolve()
if not path.is_relative_to(WORKDIR):
raise ValueError(f"Path escapes workspace: {p}")
return path
def run_read(path: str, limit: int | None = None) -> str:
try:
lines = safe_path(path).read_text(encoding="utf-8").splitlines()
if limit and limit < len(lines):
lines = lines[:limit] + [f"... ({len(lines) - limit} more lines)"]
return "\n".join(lines)
except Exception as e:
return f"Error: {e}"
def run_write(path: str, content: str) -> str:
try:
file_path = safe_path(path)
file_path.parent.mkdir(parents=True, exist_ok=True)
file_path.write_text(content, encoding="utf-8")
return f"Wrote {len(content)} bytes to {path}"
except Exception as e:
return f"Error: {e}"
def run_edit(path: str, old_text: str, new_text: str) -> str:
try:
file_path = safe_path(path)
text = file_path.read_text(encoding="utf-8")
if old_text not in text:
return f"Error: text not found in {path}"
file_path.write_text(text.replace(old_text, new_text, 1), encoding="utf-8")
return f"Edited {path}"
except Exception as e:
return f"Error: {e}"
def run_glob(pattern: str) -> str:
import glob as g
try:
matches = sorted({
match for match in g.glob(
pattern, root_dir=WORKDIR, recursive=True)
if (WORKDIR / match).resolve().is_relative_to(WORKDIR)
})
shown = matches[:200]
if len(matches) > 200:
shown.append("... (more matches omitted; narrow the pattern)")
return "\n".join(shown) if shown else "(no matches)"
except Exception as e:
return f"Error: {e}"
# -- New in s02: tool definitions (one tool in s01, five in s02) --
TOOLS = [
{"name": "bash", "description": "Run a shell command.",
"input_schema": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}},
{"name": "read_file", "description": "Read file contents.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "limit": {"type": "integer"}}, "required": ["path"]}},
{"name": "write_file", "description": "Write content to a file.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
{"name": "edit_file", "description": "Replace exact text in a file once.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}},
{"name": "glob", "description": "Find files matching a glob pattern; ** matches recursively.",
"input_schema": {"type": "object", "properties": {"pattern": {"type": "string"}}, "required": ["pattern"]}},
]
# -- New in s02: dispatch map (replaces s01's hard-coded run_bash call) --
TOOL_HANDLERS = {
"bash": run_bash, "read_file": run_read, "write_file": run_write,
"edit_file": run_edit, "glob": run_glob,
}
# -- The agent loop keeps the same shape as s01; only dispatch changes --
# s01: output = run_bash(block.input["command"])
# s02: output = TOOL_HANDLERS[block.name](**block.input)
def agent_loop(messages: list):
while True:
response = client.messages.create(
model=MODEL, system=SYSTEM, messages=messages,
tools=TOOLS, max_tokens=8000,
)
messages.append({"role": "assistant", "content": response.content})
tool_calls = [
block for block in response.content if block.type == "tool_use"
]
if not tool_calls:
return
results = []
for block in tool_calls:
print(f"\033[33m> {block.name}\033[0m")
handler = TOOL_HANDLERS.get(block.name)
output = handler(**block.input) if handler else f"Unknown: {block.name}"
print(str(output)[:200])
results.append({"type": "tool_result", "tool_use_id": block.id, "content": output})
messages.append({"role": "user", "content": results})
if __name__ == "__main__":
print("s02: Tool Use - four tools added to s01")
print("Enter a question, press Enter to send. Type q to quit.\n")
history = []
while True:
try:
# \001/\002 tell Readline the ANSI escapes have zero display width.
query = input("\001\033[36m\002s02 >> \001\033[0m\002")
except (EOFError, KeyboardInterrupt):
break
if query.strip().lower() in ("q", "exit", ""):
break
history.append({"role": "user", "content": query})
agent_loop(history)
for block in history[-1]["content"]:
if getattr(block, "type", None) == "text":
print(block.text)
print()
What's Next
The Agent now has 5 specialized tools. File tools are protected by safe_path, but bash is unrestricted — rm -rf / still runs.
Source and license
- Adapted from shareAI-lab/learn-claude-code / s02_tool_use/README.md (shareAI-lab contributors, License MIT, ce8f9f1)