This is Part 2 of a three-part series, Deep Dive: How Claude Code Works Under the Hood. Part 1 covered the agent loop, and Part 3 explores planning and subagents.
In Part 1, we looked at the agent loop — the deceptively simple cycle that powers Claude Code. But there was a black box in that loop: execute_tool. When Claude decides to read a file, edit code, or run a shell command, what actually happens? How does the system know which function to call? And more importantly, how does it prevent the AI from doing something catastrophic?
This is the post where we open that black box.
The Obvious (and Terrible) Approach
Let’s start with what you might build if you were prototyping an AI agent over a weekend. The model says it wants to run a bash command. Cool — you take the command string and pass it to subprocess.run(). Done.
Except now your AI agent has unrestricted shell access. It could rm -rf /. It could curl your private SSH keys to an external server. It could modify your git history. It could install malware. The LLM doesn’t intend to do any of these things, but LLMs can be confused, can hallucinate commands, and — in adversarial scenarios — can be manipulated through prompt injection.
Giving an agent raw, unrestricted bash access is like handing a toddler the car keys. They probably don’t want to crash, but that doesn’t mean you should let them drive.
Claude Code’s solution is elegant: instead of giving the model a single all-powerful tool, it provides a set of purpose-built tools with clear boundaries, routed through a dispatch map.
The Dispatch Map Pattern
A dispatch map is a dictionary (or object, or hash map — whatever your language calls it) that maps tool names to handler functions. When the agent loop receives a tool call from the model, it looks up the tool name in the dispatch map and calls the corresponding handler.
Here’s what it looks like in simplified form:
tool_handlers = {
"read_file": handle_read_file,
"write_file": handle_write_file,
"edit_file": handle_edit_file,
"bash": handle_bash,
"search": handle_search,
}
def execute_tool(tool_name, arguments):
handler = tool_handlers.get(tool_name)
if handler is None:
return f"Error: Unknown tool '{tool_name}'"
return handler(**arguments)
That’s the entire dispatch logic. A dictionary lookup and a function call. But this simple pattern gives you several powerful properties.
Why Not if/else?
You might wonder why this is better than a chain of if/elif statements:
# Don't do this
if tool_name == "read_file":
return handle_read_file(**arguments)
elif tool_name == "write_file":
return handle_write_file(**arguments)
elif tool_name == "edit_file":
return handle_edit_file(**arguments)
# ... and so on
The if/else chain works, but it creates problems at scale:
- Fragile to modify. Adding a new tool means adding a new branch in a growing conditional. Miss one, and it silently fails.
- Hard to compose. You can’t easily enable or disable tools at runtime. With a dispatch map, you just add or remove keys from the dictionary.
- No introspection. With a map, you can iterate over all available tools, generate documentation, validate configurations. With if/else, the tool list is implicit in the code structure.
- Violates open/closed principle. The dispatch function has to change every time you add a tool. With a map, the dispatcher never changes — you just register new entries.
In my work at ZenoLab, I’ve adopted this same pattern for plugin systems in several apps. Any time you have a set of named operations that might grow over time, a dispatch map is almost always the right choice.
Purpose-Built Tools vs. Raw Access
Claude Code doesn’t just give the model a single bash tool and call it a day. It provides a curated set of tools, each designed for a specific task with specific constraints. Let’s look at the main ones:
read_file
Reads the contents of a file and returns them. But it’s not just open(path).read(). The handler validates the file path, checks that it’s within the allowed workspace, handles encoding issues, and can limit how much of large files is returned to avoid blowing up the context window.
def handle_read_file(path, offset=0, limit=2000):
path = resolve_and_validate_path(path) # Security check
with open(path, 'r') as f:
lines = f.readlines()
return ''.join(lines[offset:offset + limit])
write_file
Creates or overwrites a file. Again, path validation happens before any write occurs. The handler can also verify that the model has previously read the file (to prevent blind overwrites of files the model hasn’t seen).
edit_file
This is the surgical tool — it replaces a specific string in a file with a new string. This is much safer than rewriting entire files because: (a) the change is minimal and reviewable, (b) it fails if the target string doesn’t exist or isn’t unique, preventing edits based on hallucinated file contents.
bash
Yes, Claude Code does provide bash access. But it’s sandboxed. The handler validates commands against a set of rules, can restrict which commands are allowed, limits execution time, and captures both stdout and stderr for the write-back.
search
Searches for patterns across files — essentially a controlled grep or ripgrep. This is much better than letting the model run arbitrary grep commands through bash, because the handler can enforce path boundaries, limit result sizes, and format output consistently.
Path Sandboxing: The Invisible Safety Net
Every file-related tool in Claude Code performs path sandboxing — it ensures that the resolved file path is within the allowed workspace directory. This is more nuanced than it sounds.
Consider this seemingly innocent path: ../../etc/passwd. If the workspace is /Users/truong/zenolab, then resolving that path gives you /Users/etc/passwd — outside the workspace. A naive implementation that just concatenates strings would allow this.
Proper path sandboxing resolves the full absolute path (following symlinks), then checks that it starts with the workspace directory:
import os
def resolve_and_validate_path(path, workspace="/Users/truong/zenolab"):
# Resolve to absolute path, following symlinks
resolved = os.path.realpath(os.path.join(workspace, path))
# Check that it's within the workspace
if not resolved.startswith(os.path.realpath(workspace)):
raise SecurityError(
f"Access denied: {path} resolves outside workspace"
)
return resolved
This catches:
- Relative path traversal (
../../etc/passwd) - Symlink attacks (a symlink inside the workspace pointing outside it)
- Absolute paths that bypass the workspace entirely (
/etc/shadow)
I’ll be honest — before studying this, I would have gotten path sandboxing wrong in my own projects. The symlink resolution step is the one most people miss, and it’s critical. If you’re building any system that restricts file access to a directory, resolve symlinks first.
Adding New Tools Without Touching the Core
Remember the agent loop from Part 1? Here’s the beautiful thing about the dispatch map: adding a new tool to Claude Code requires zero changes to the loop. You:
- Define the tool’s schema — its name, description, and parameters — so the LLM knows it exists
- Implement the handler function — the actual logic
- Register it in the dispatch map — add one entry
The loop doesn’t change. The dispatch function doesn’t change. The model automatically discovers the new tool through its system prompt and can start using it.
This is why Claude Code can keep gaining capabilities without its core architecture growing more complex. The tool for creating pull requests, the tool for searching documentation, the tool for managing git — each one is an independent module that plugs into the same dispatch system.
In software architecture terms, this is the Open/Closed Principle in action: the system is open for extension (new tools) but closed for modification (the loop and dispatcher stay the same).
Security Implications of Proper Tool Design
There’s a deeper security lesson here that goes beyond path sandboxing. By designing purpose-built tools instead of relying on raw bash, you get defense in depth:
Layer 1: Tool selection. The model can only call tools that exist in the dispatch map. If there’s no delete_database tool, it can’t delete your database (at least not directly).
Layer 2: Parameter validation. Each handler validates its inputs before acting. A write_file handler can reject paths outside the workspace, files that are too large, or file types that shouldn’t be modified.
Layer 3: Permission boundaries. Even the bash tool can be constrained — blocking certain commands, requiring user confirmation for destructive operations, or running in a restricted shell.
Layer 4: Result filtering. The handler controls what gets written back to the conversation. Sensitive information (API keys in env files, for instance) can be redacted before the model sees them.
Compare this to giving the model a single unrestricted bash tool. You lose all four layers. Every command runs with the same permissions. There’s no parameter validation beyond what bash itself enforces. And the model sees everything, including secrets.
This layered approach is something I think about constantly when building features at ZenoLab. Any time an AI component interacts with user data, I want multiple independent layers of protection, not a single gate that, if breached, exposes everything.
A Real Example: The Edit Flow
Let me trace through how a tool call actually works end to end. Say Claude Code is fixing a bug and decides to edit a file.
The model’s response includes a tool call like:
{
"tool": "edit_file",
"arguments": {
"path": "src/utils/sleep_calculator.py",
"old_string": "return total_hours",
"new_string": "return total_hours if total_hours > 0 else 0"
}
}
Here’s what happens:
- The agent loop extracts the tool call from the response
- It looks up
"edit_file"in the dispatch map and findshandle_edit_file handle_edit_fileis called with the arguments- The handler resolves
src/utils/sleep_calculator.pyto an absolute path and validates it’s in the workspace - It reads the file and searches for
"return total_hours"— it must appear exactly once (if it appears zero times, the model hallucinated; if more than once, the edit is ambiguous) - It performs the replacement and writes the file
- It returns a confirmation: “Successfully edited src/utils/sleep_calculator.py”
- The agent loop writes this result back into the conversation
- The model sees the confirmation and decides its next action
Every step has a clear purpose. Every step can fail safely. And the agent loop didn’t need to know anything about file editing — it just called the handler and wrote back the result.
Building Your Own Tool System
If you’re inspired to build agent-like features into your own applications, here’s the minimal pattern:
- Start with a dispatch map. Even if you only have two tools, use a map. You’ll thank yourself later.
- Make each tool do one thing well. Don’t build a “file” tool that reads, writes, edits, and deletes. Build four separate tools.
- Validate inputs in every handler. Never trust the model’s arguments blindly. Check types, check bounds, check paths.
- Return structured, consistent results. The model will perform better if tool results follow a predictable format.
- Log everything. When an agent does something unexpected, logs of every tool call and result are invaluable for debugging.
This is the same approach I’ve taken when adding AI features to ZenoLab apps. Start simple, validate everything, and keep the core loop clean.
What’s Next
We’ve covered the engine (the agent loop) and the hands (the tool system). But there’s still a critical question: how does Claude Code stay focused on complex, multi-step tasks without drifting off course? How does it manage its limited context window when working on a large codebase?
In the final post of this series, we’ll explore planning with TodoWrite and subagents — the techniques that keep Claude Code coherent even on tasks that span hundreds of tool calls.
Continue to Part 3: Planning & Subagents — How Claude Code Stays Focused on Complex Tasks