Built-in Tools
Four default tools; the rest is extension territory
“Operations interfaces behind every tool; mutations serialized”
Deep Dive
Four default tools; everything else is extension territory
Learning Objectives
After this chapter you will be able to:
- Explain how the Operations interface makes tools "wrappable"
- Describe what concurrency problem withFileMutationQueue solves
- Explain edit-diff's dual role (user review + model feedback)
- Understand why pi ships only 4 built-in tools instead of 20
Key Source Files
| File | Lines | Responsibility |
|---|---|---|
packages/coding-agent/src/core/tools/read.ts | ~120 | Read-only file/directory access |
packages/coding-agent/src/core/tools/bash.ts | 505 | Shell command execution + session env injection |
packages/coding-agent/src/core/tools/edit.ts | 437 | Precise replacement editing |
packages/coding-agent/src/core/tools/edit-diff.ts | 560 | Unified diff/patch generation |
packages/coding-agent/src/core/tools/file-mutation-queue.ts | ~80 | Same-file write serialization |
Design Motivation: Why Only 4?
Problem: Competitors (Cursor, Windsurf) ship 15-30 built-in tools. Why does pi only provide 4?
pi's philosophy: "Minimal core + self-extension" (→ Chapter 13). Built-in tools only cover operations that cannot be safely implemented by extensions:
read: Extensions can read, but the built-in has unified line numbers/truncation/binary detectionbash: Must be built-in because spawn permission control is needededit: Must be built-in because mutation queue serialization is requiredwrite: Same as edit — needs queue protection
Everything else (search, browser, database, API calls) → extension territory. The smaller the kernel, the narrower the security audit surface.
Operations Interface: The Tool's Extension Point
// Tools don't touch fs directly — they go through injected Operations
interface EditOperations {
read(path: string): Promise<string>
write(path: string, content: string): Promise<void>
}
interface BashOperations {
spawn(cmd: string, opts: SpawnOptions): ChildProcess
}
// Tool factory parameterized over Operations
export function createEditTool(ops: EditOperations): AgentTool {
return defineTool({
name: "edit",
params: Type.Object({
path: Type.String(),
old: Type.String(),
new: Type.String(),
}),
execute: (args, ctx) => withFileMutationQueue(args.path, async () => {
const current = await ops.read(args.path)
const next = current.replace(args.old, args.new)
await ops.write(args.path, next)
return { patch: generateUnifiedPatch(args.path, current, next) }
}),
})
}
Why inject instead of directly importing fs?
- Extensions can wrap Operations: add audit logs, forward to sandbox, record screenshots
- Testing can mock: no real filesystem needed
- Server mode can replace with remote fs (→ Chapter 19 RPC)
withFileMutationQueue: Serializing Writes
Problem: The model may call edit("auth.ts", ...) and edit("auth.ts", ...) in one turn. If executed in parallel:
Parallel disaster:
edit-1 reads auth.ts (v1) → modifies → writes auth.ts (v2)
edit-2 reads auth.ts (v1) → modifies → writes auth.ts (v2') ← overwrites edit-1's change!
Solution: Per-file mutex queue
const queues = new Map<string, Promise<void>>()
export function withFileMutationQueue<T>(path: string, fn: () => Promise<T>): Promise<T> {
const prev = queues.get(path) ?? Promise.resolve()
const next = prev.then(fn)
queues.set(path, next.catch(() => {})) // errors don't block subsequent
return next
}
Same-file operations are strictly serialized; different files can still run in parallel. The result is predictable: "last edit finishes before this one starts."
edit-diff: Dual Role
edit-diff.ts (560 lines) generates unified diff/patch serving two consumers:
- User review: TUI displays colored diff; user confirms whether the change is reasonable
- Model feedback: Patch written back to messages as tool_result; model knows "what changed"
edit tool_result:
{
"patch": "--- a/src/auth.ts\n+++ b/src/auth.ts\n@@ -3 +3 @@\n- if (pw === pw)\n+ if (await bcrypt.compare(pw, hash))",
"matches": 1
}
After seeing the patch, the model can judge: was the right location changed? Are further modifications needed?
bash Tool: Session Env Injection
bash.ts is more than child_process.spawn — it injects session context:
const env = {
...process.env,
PI_SESSION_ID: session.id, // child process knows its session
PI_MODEL: model.id, // knows the current model
PI_REASONING_LEVEL: thinking, // knows reasoning depth
}
This lets users' shell scripts do conditional logic based on session context.
Engineering Insights
-
TypeBox's dual purpose: Parameter schemas do runtime validation (rejecting invalid args the model hallucinated) AND auto-generate JSON Schema fed to the model (so it knows how to call the tool).
-
bash's 505-line cost: Most is safety logic — timeout kills, output truncation, working directory restrictions, dangerous command detection.
spawnitself is 3 lines. -
find/grep/ls exist but aren't default: They're in the code but not registered by default. Because the model can use
bash("grep ...")to achieve the same — fewer built-ins means a narrower maintenance surface.
Cross-Chapter Links
| Direction | Chapter | Relationship |
|---|---|---|
| Prerequisite | Ch01 Agent Loop | Tools execute in the loop's executeTools phase |
| Next | Ch13 Extension System | Non-built-in tools register via extensions |
| Next | Ch18 Settings & Trust | Tool permissions gated by Trust |
| Next | Ch19 RPC Server | Server mode executes tools remotely via RPC |
Check Your Understanding
- If withFileMutationQueue were removed and two edits to the same file ran in parallel, what's the worst case?
- Why do tools operate on files through the Operations interface rather than directly
import { readFileSync } from "fs"? - Why doesn't pi make grep/find/ls default built-in tools? What design principle does this reflect?
Key Takeaways
- Default tools are minimal (read/bash/edit/write) — only what extensions can't safely provide
- Operations interface lets extensions wrap everything: audit, sandbox, remote
- Mutation queue serializes same-file writes, preventing concurrent overwrites
- TypeBox schema kills two birds: runtime validation + model JSON Schema
This article is based on source analysis of earendil-works/pi main branch.