The 4 Tools That Let Claude Code Actually Modify and Run Your Code
🚀 Welcome to the fourth installment of the "No Framework, Hand-Built AI Agent" series.
Whether it's Claude Code, Cursor, or Codex, these wildly popular AI coding agents seem omnipotent—they can read code, fix bugs, and even run tests on their own.
But if you completely dismantle their underlying architecture, you'll discover: the core secret of an AI agent that can actually get work done is hidden in just the four most basic tools we're about to discuss.
If this is your first time seeing this series, you can absolutely start right here. You just need to remember the agent's underlying formula first:
Agent Loop = Model decides the next step → Program executes the tool → Results are returned to the model → RepeatToday, we're going to hand-build a "read-modify-run" closed loop and a tool registry, just like the one inside Claude Code!
First, a little "teaser" to show you the "complete form" we'll eventually build by the end of this series
In this chapter, we'll complete this minimal toolkit.
Previously, our agent could already call read_file to read files within a project. Today, we'll also give it the ability to create files, make partial modifications, and execute commands, while solving a more critical problem:
When the number of tools grows from 1 to 4, 10, or even dozens, how do we define, register, and execute them all in the same way?
Once we're done, the agent won't just read code; it will be able to complete a small development task:
User: Create hello.ts, make it output Hello, power-code, then run it
Round 1: AI calls write_file
✓ hello.ts has been written
Round 2: AI calls bash
Hello, power-code
AI: The file has been created and run successfully.
From this moment on, what we're writing is no longer just a "chatbot that can read a project," but an AI programming assistant that can actually get its hands dirty and do work.
💡 Supplementary Note: In real-world engineering, specialized capabilities like Git commit management, browser interaction, and MCP extensions are equally indispensable. In this chapter, let's focus on getting the core minimal closed loop of "read, modify, run" working first!
🚀 Source code for this chapter: powercode 👈Click it
If you run into problems midway, you can check the source code for troubleshooting. The code for subsequent chapters will also be continuously updated. If this project helps you, a Star ⭐ is welcome.
The Hard-Coding Dilemma: When Tools Explode from 1 to 10
Before we continue refactoring, let's first explain what a "tool" is here.
A tool is a program capability we expose for the model to call. The model cannot directly read files, modify code, or execute terminal commands; it can only tell our program: "I want to call this tool, and here are the parameters."
The actual execution is still performed by the program code we write.
Currently, this agent has only one tool:
read_file: Reads text files within the project.
Today, we'll add three more tools:
write_file: Creates a file, or overwrites an existing file with complete content;edit_file: Replaces only a small segment of content within a file, suitable for precise modifications;bash: Executes terminal commands within the project directory.
bash can be initially understood as "letting the Agent use the terminal." Bash is a common command-line interpreter through which we can run commands within a project.
For example, it can run:
npm run build # Compile the project
npm test # Run tests
node hello.js # Run a JavaScript file
git status # View changes in the current project
In this chapter, we'll mainly use bash for building, testing, and running verification; reading and writing files will be preferentially delegated to the dedicated file tools.
However, bash has the broadest scope of capabilities: it can theoretically execute any command the current user has permission to execute. We'll specifically address its security boundaries later.
With these concepts in mind, let's look back at the agent.ts from the previous chapter. Back then, with only the read_file tool, the code for handling calls looked something like this:
if (toolCall.function.name !== "read_file") {
throw new Error(`Tool not yet supported: ${toolCall.function.name}`);
}
const result = await readFileTool(toolCall.function.arguments);
When we only have one tool, writing it this way is very straightforward.
But as tools multiply, problems arise: every time we add a tool, we have to keep adding conditionals to agent.ts.
if (name === "read_file") {
// Read file
} else if (name === "write_file") {
// Write file
} else if (name === "edit_file") {
// Modify file
} else if (name === "bash") {
// Run command
}
Later, if we add search, Git, browser, database, we just keep appending.
The problem isn't just a long chain of if...else. For every new tool, we need to modify at least three places:
- Write a tool description for the model to see;
- Write the function that actually executes the tool;
- Add a branch in the Agent loop.
In other words, the Agent is responsible for both the "loop" and knowing the implementation details of every single tool.
This is like a boss having to personally remember every new employee's phone number, desk location, and workflow. It might hold up with a few people, but chaos is guaranteed when the team grows.
So, we'll add a tool management center:
Agent: I want to call edit_file, here are the parameters
↓
Registry: Let me look up who is called edit_file
↓
EditFileTool: I'm responsible for actually modifying the file
↓
Registry: Return the result to the Agent
This management center is called the Registry in code.
Step 1: First, Define a Standard Shape for All Tools
To manage tools uniformly, the first step isn't writing the Registry, but establishing a set of rules.
Regardless of whether a tool reads files, runs commands, or queries a database, it must answer at least three questions:
What is your name?
How do you introduce yourself to the model?
When actually called, how do you execute?
Create src/tools/types.ts:
import type OpenAI from "openai";
export interface Tool {
name: string;
definition: OpenAI.Chat.Completions.ChatCompletionTool;
execute(argumentsJson: string): Promise<string>;
}
This is the unified interface for tools.
You can think of an interface as an onboarding form: anyone who wants to join the tool center must submit these three items.
nameis the tool's unique identifier, e.g.,read_file;definitionis the instruction manual for the model, telling it what the tool does and how to pass parameters;executeis the execution entry point for the program to call, responsible for doing the actual work.
There's an important distinction here:
definition: Shown to the AI, helping it decide whether to call
execute: Used by our program, actually operating the computer
The model seeing the manual doesn't mean it has gained file system permissions. The real permissions remain within the execute function we write.
Step 2: Write a Registry for Unified Registration and Execution
Now create src/tools/registry.ts:
import type OpenAI from "openai";
import type { Tool } from "./types.js";
export class Registry {
// Tool registry
// Key: tool name
// Value: tool instance
private readonly tools = new Map<string, Tool>();
/**
* Register a tool
* @param tool The tool
*/
register(tool: Tool): void {
if (this.tools.has(tool.name)) {
throw new Error(`Duplicate tool registration: ${tool.name}`);
}
this.tools.set(tool.name, tool);
}
/**
* Get definitions for all tools
* @returns Definitions for all tools
*/
getDefinitions(): OpenAI.Chat.Completions.ChatCompletionTool[] {
return [...this.tools.values()].map((tool) => tool.definition);
}
/**
* Execute a tool
* @param name Tool name
* @param argumentsJson Tool arguments JSON string
* @returns Tool execution result
*/
async execute(name: string, argumentsJson: string): Promise<string> {
const tool = this.tools.get(name);
if (!tool) {
throw new Error(`Tool not found: ${name}`);
}
return tool.execute(argumentsJson);
}
}
The Registry only does three things:
register Accept a tool
getDefinitions Hand all tool descriptions to the model
execute Find the tool by name and execute it
A Map is used here because it's well-suited for storing "name → tool" correspondences:
read_file → ReadFileTool
write_file → WriteFileTool
edit_file → EditFileTool
bash → BashTool
From now on, the Agent no longer needs to know which file ReadFileTool or BashTool is located in. It just hands the name and parameters to the Registry.
Step 3: Prepare a Secure Project Path Tool
When reading, writing, or modifying files, the model will generate paths.
These parameters cannot be used directly. Suppose the model sends:
../../some-secret.txt
This means "go up two directory levels, then access some-secret.txt". If we don't check this, the Agent could read or write files outside the project.
So, let's first write a common method to confine all file tools' activity to the current project.
Create src/tools/path.ts:
import { resolve, sep } from "node:path";
/**
* Resolve a path to an absolute path and ensure it stays within the working directory.
*/
export function resolveInWorkDir(
workDir: string,
relativePath: string,
): string {
if (!relativePath.trim()) {
throw new Error("File path cannot be empty.");
}
const root = resolve(workDir);
const target = resolve(root, relativePath);
const isInside =
target === root || target.startsWith(`${root}${sep}`);
if (!isInside) {
throw new Error("Can only operate on files within the current project.");
}
return target;
}
The most critical part here is resolve.
resolve comes from Node.js's node:path. It calculates a path into a normalized absolute path: automatically handling path segments like . and .., but it doesn't actually read the file or check if it exists.
Assume the project directory is:
/Users/me/power-code
Then:
resolve("/Users/me/power-code", "src/index.ts");
// /Users/me/power-code/src/index.ts
resolve("/Users/me/power-code", "src/../package.json");
// /Users/me/power-code/package.json
resolve("/Users/me/power-code", "../some-secret.txt");
// /Users/me/some-secret.txt
In the second example, src/../package.json can be broken down into three segments:
src: First, enter thesrcdirectory;..: Go back to the parent directory ofsrc, which is the project root;package.json: Then accesspackage.jsonwithin the project root.
Therefore:
src/../package.json
and:
package.json
ultimately point to the same file.
resolve automatically cleans up this kind of "enter a directory, then immediately back out" path:
resolve("/Users/me/power-code", "src/../package.json");
// is equivalent to
resolve("/Users/me/power-code", "package.json");
// /Users/me/power-code/package.json
In the third example, .. means "parent directory":
/Users/me/power-code
→ ../
→ /Users/me
→ /Users/me/some-secret.txt
That is, although the model passed in the relative path ../some-secret.txt, resolve first calculates it into the final absolute path. This way, we can discover that it has already escaped the project directory.
The isInside check in the code determines if the final path is still within the project scope:
const isInside =
target === root || target.startsWith(`${root}${sep}`);
This breaks down into two legal cases:
target === root: The final path is exactly the project root, e.g., passing in.orsrc/..;target.startsWith(${root}${sep}): The path is located under the project root.
sep is the path separator also imported from node:path:
macOS / Linux: /
Windows: \
Assuming root is:
/Users/me/power-code
Then:
`${root}${sep}`;
// /Users/me/power-code/
Why can't we just check:
target.startsWith(root);
Suppose the project root is:
/Users/me/power-code
And the target path is:
/Users/me/power-code-backup/secret.txt
It also starts with /Users/me/power-code, but is not actually inside the project directory.
By adding ${root}${sep}, only paths truly located under the project directory will pass the check.
The three file tools that follow will all go through this single entry point, eliminating the need to repeat the safety check individually.
What's done here is still just the most basic path traversal check. resolve does not handle symbolic links: a symlink that appears to be inside the project directory could still point to a file outside the project. A strict production environment would need to further handle symlinks, file permissions, and other issues; this small piece of code should not be considered a complete file system sandbox.
Step 4: Refactor read_file into a Standard Tool
The previous chapter already implemented file reading. Now, its capability doesn't need to be rewritten from scratch; it just needs to put on a uniform "work suit" and become a class that implements the Tool interface.
Create src/tools/read-file.ts:
import { readFile } from "node:fs/promises";
import type { Tool } from "./types.js";
import { resolveInWorkDir } from "./path.js";
// Maximum bytes to read
const MAX_BYTES = 8_000;
/**
* Parse path
* @description Parse the path, ensuring it is within the working directory.
* @param workDir Working directory
* @param relativePath Relative path
* @returns Resolved path
*/
function parsePath(argumentsJson: string): string {
const input: unknown = JSON.parse(argumentsJson);
if (typeof input !== "object" || input === null || Array.isArray(input)) {
throw new Error("Tool arguments must be an object.");
}
const path = (input as Record<string, unknown>).path;
if (typeof path !== "string") {
throw new Error("path must be a string.");
}
return path;
}
/**
* Read file tool
*/
export class ReadFileTool implements Tool {
readonly name = "read_file"; // Tool name
readonly definition = {
type: "function" as const, // Tool type
// Tool function definition
function: {
name: this.name,
// Tool description
description: "Read a text file within the current project.",
// Tool parameter definition
parameters: {
type: "object",
properties: {
path: {
type: "string", // File path parameter type
description: "File path relative to the project root directory", // File path parameter description
},
},
required: ["path"], // Must include the path parameter
additionalProperties: false, // No other additional properties allowed
},
},
};
constructor(private readonly workDir: string) {}
/**
* Execute the tool
* @param argumentsJson Tool arguments JSON string
* @returns Tool execution result
*/
async execute(argumentsJson: string): Promise<string> {
const path = parsePath(argumentsJson);
const targetPath = resolveInWorkDir(this.workDir, path);
const content = await readFile(targetPath);
if (content.length > MAX_BYTES) {
return `${content.subarray(0, MAX_BYTES).toString("utf8")}
...[File too long, only returning the first ${MAX_BYTES} bytes]...`;
}
return content.toString("utf8");
}
}
Compared to the previous chapter, the core of actually reading the file remains:
await readFile(targetPath);
The only change is that the original constants and functions have been encapsulated into ReadFileTool, which uniformly provides name, definition, and execute.
Step 5: Add write_file, Enabling AI to Create Files
Reading files is just observation; writing files is where the project truly starts to change.
Create src/tools/write-file.ts:
import { mkdir, writeFile } from "node:fs/promises";
import { dirname } from "node:path";
import type { Tool } from "./types.js";
import { resolveInWorkDir } from "./path.js";
/**
* Parse the tool arguments for write_file.
*/
function parseArguments(argumentsJson: string): {
path: string;
content: string;
} {
const input: unknown = JSON.parse(argumentsJson);
if (typeof input !== "object" || input === null || Array.isArray(input)) {
throw new Error("Tool arguments must be an object.");
}
const { path, content } = input as Record<string, unknown>;
if (typeof path !== "string" || typeof content !== "string") {
throw new Error("path and content must be strings.");
}
return { path, content };
}
/**
* Create or completely overwrite a file in the project.
*/
export class WriteFileTool implements Tool {
readonly name = "write_file";
readonly definition = {
type: "function" as const,
function: {
name: this.name,
description: "Create or completely overwrite a file in the current project. Directories will be automatically created if they don't exist.",
parameters: {
type: "object",
properties: {
path: { type: "string", description: "Path relative to the project root directory" },
content: { type: "string", description: "Complete file content" },
},
required: ["path", "content"],
additionalProperties: false,
},
},
};
constructor(private readonly workDir: string) {}
async execute(argumentsJson: string): Promise<string> {
const { path, content } = parseArguments(argumentsJson);
const targetPath = resolveInWorkDir(this.workDir, path);
await mkdir(dirname(targetPath), { recursive: true });
await writeFile(targetPath, content, {
encoding: "utf8",
flag: "w",
});
return `File written: ${path}`;
}
}
There are two noteworthy points here.
First is:
await mkdir(dirname(targetPath), { recursive: true });
dirname(targetPath) extracts the directory containing the target file.
For example, if the target path is:
src/utils/date.ts
Then its containing directory is:
src/utils
recursive: true means: if the AI wants to create src/utils/date.ts, but src/utils doesn't exist yet, the program will first create the missing directories.
Second is the flag when writing the file:
flag: "w"
w stands for write:
- File doesn't exist: Create a new file;
- File already exists: First clear the old content, then write the new complete content.
Actually, even without writing flag: "w", writeFile defaults to using "w".
It's explicitly written here not because the program requires it to run, but to make the "create or completely overwrite" semantics of write_file directly visible in the code.
So write_file is suitable for two situations:
- Creating a new file;
- Rewriting an existing file when the model already has the complete content.
If a file has 500 lines and the model only wants to change one or two of them, having it regenerate the entire file not only wastes tokens but could also accidentally alter other content.
This is precisely why we also need edit_file.
Step 6: Add edit_file for Precise Small-Scale Modifications
Suppose a file has 500 lines, and you only want to change the port from 3000 to 8080.
If the model regenerates the complete 500 lines, it not only wastes tokens but could also accidentally alter other content.
A more reliable approach is to tell the tool:
In this file
find old_text
replace with new_text
Create src/tools/edit-file.ts:
import { readFile, writeFile } from "node:fs/promises";
import type { Tool } from "./types.js";
import { resolveInWorkDir } from "./path.js";
function parseArguments(argumentsJson: string): {
path: string;
oldText: string;
newText: string;
} {
const input: unknown = JSON.parse(argumentsJson);
if (typeof input !== "object" || input === null || Array.isArray(input)) {
throw new Error("Tool arguments must be an object.");
}
const value = input as Record<string, unknown>;
const path = value.path;
const oldText = value.old_text;
const newText = value.new_text;
if (
typeof path !== "string" ||
typeof oldText !== "string" ||
typeof newText !== "string"
) {
throw new Error("path, old_text, and new_text must be strings.");
}
if (!oldText) {
throw new Error("old_text cannot be empty.");
}
return { path, oldText, newText };
}
export class EditFileTool implements Tool {
readonly name = "edit_file";
readonly definition = {
type: "function" as const,
function: {
name: this.name,
description:
"Replace a uniquely occurring segment of old text in a file with new text. Read the file before modifying; if old_text appears multiple times, include surrounding context and retry.",
parameters: {
type: "object",
properties: {
path: { type: "string", description: "Path of the file to modify" },
old_text: { type: "string", description: "The exact existing text in the file" },
new_text: { type: "string", description: "The replacement text" },
},
required: ["path", "old_text", "new_text"],
additionalProperties: false,
},
},
};
constructor(private readonly workDir: string) {}
async execute(argumentsJson: string): Promise<string> {
const { path, oldText, newText } = parseArguments(argumentsJson);
const targetPath = resolveInWorkDir(this.workDir, path);
const content = await readFile(targetPath, "utf8");
const matches = content.split(oldText).length - 1;
if (matches === 0) {
throw new Error("old_text not found. Please re-read the file and try again.");
}
if (matches > 1) {
throw new Error(
`old_text appeared ${matches} times. Please provide a longer string with surrounding context.`,
);
}
await writeFile(targetPath, content.replace(oldText, newText), {
encoding: "utf8",
flag: "w",
});
return `File modified: ${path}`;
}
}
The meaning of the parameters here is straightforward:
path Which file to modify
old_text The text that originally exists in the file
new_text The new text to replace it with
old_text cannot be empty, because an empty string would match every position in the file, and the tool couldn't determine exactly where you want to change.
new_text can be an empty string. This way, edit_file can also be used to delete a uniquely occurring segment of text.
Why Calculate the Number of Matches?
This line of code calculates how many times old_text appears in the file:
const matches = content.split(oldText).length - 1;
split can be understood as "cutting the original string apart based on a certain piece of text."
Note: it doesn't return the matched oldText, but the content left before and after oldText.
For example:
"hello hello".split("hello");
// ["", " ", ""]
The original string can be broken down into:
"" + "hello" + " " + "hello" + ""
Where:
- The first
"": Nothing before the firsthello; " ": A space between the twohellos;- The last
"": Nothing after the secondhello.
After the two hellos are removed as delimiters, three segments remain:
["", " ", ""]
So the array length is 3.
As long as oldText is not an empty string, if a piece of text appears n times, it will cut the original string into n + 1 segments:
Appears 0 times → 1 segment
Appears 1 time → 2 segments
Appears 2 times → 3 segments
Therefore:
content.split(oldText).length - 1
gives the number of occurrences of oldText.
Next, the tool handles three cases:
0 matches: The model's content might be outdated, or old_text was written incorrectly
1 match: Safe to replace
Multiple matches: Unsure which one to change, refuses to modify
Note that the error occurs before writeFile, so the original file is not modified when a match fails.
What Situation Qualifies as Safe to Modify?
The success condition for edit_file is just one:
old_text appears exactly 1 time in the file
"One time" here means this complete segment of text appears once in the file, not that only one line of code can be modified.
For example, if the model wants to change the development server's port, it can use the entire relevant code block as old_text:
{
"path": "src/server.ts",
"old_text": "function startDevServer() {\n const port = 3000;\n app.listen(port);\n}",
"new_text": "function startDevServer() {\n const port = 8080;\n app.listen(port);\n}"
}
This call will simultaneously modify multiple lines of code within the function, but this complete old_text appears only once in the file, so it can be safely executed.
The rule can be understood like this:
| What you want to do | old_text match count |
Current tool's result |
|---|---|---|
| Modify one or more lines at a unique location | 1 time | Successfully replaced |
| Delete a unique code block | 1 time, new_text is an empty string |
Successfully deleted |
Modify two different locations where the short old_text is identical |
2 times | Refuse to modify |
| Replace all identical text occurrences | Multiple times | Not supported by the current tool |
content.replace(oldText, newText) itself only replaces the first match. By first confirming matches === 1, we guarantee it's replacing the unique target, not accidentally hitting the first occurrence.
If old_text Appears Multiple Times, What Does the Agent Do Next?
Suppose a file has two identical sections:
function startDevServer() {
const port = 3000;
app.listen(port);
}
function startTestServer() {
const port = 3000;
startTestRunner(port);
}
If the model only passes in:
{
"path": "src/server.ts",
"old_text": "const port = 3000;",
"new_text": "const port = 8080;"
}
The tool will find that old_text appears twice, refuse to modify, and return:
Tool execution failed: old_text appeared 2 times. Please provide a longer string with surrounding context.
This error is returned to the Agent as a tool result. In the next round, the model can see why its previous call failed.
But note: the error doesn't automatically "stuff more file content" into the model.
"Provide more context" really means: the model should make old_text more specific, including the code near the target location. For example, if it only wants to modify the dev server, it can change to:
{
"path": "src/server.ts",
"old_text": "function startDevServer() {\n const port = 3000;\n app.listen(port);\n}",
"new_text": "function startDevServer() {\n const port = 8080;\n app.listen(port);\n}"
}
This complete text appears only once, so the modification can succeed.
If the model doesn't have enough file content to construct a unique old_text, it should first call read_file, then retry:
read_file
↓
edit_file: multiple matches, fails
↓
Model sees the error result
↓
read_file (re-read if necessary)
↓
edit_file: retry with surrounding context
If the user genuinely wants to modify two independent locations simultaneously, this current version of the tool requires the Agent to call edit_file twice, each time providing a unique old_text.
More mature agents can put multiple independent modifications into a single tool call. For example, Pi's edit tool supports an edits array; however, each oldText within it must still be a unique match, and multiple edit blocks cannot overlap. That is, "modifying multiple places at once" and "allowing the same text to match multiple times" are two different things. Pi edit source code
This is a very practical principle:
When modifying code, it's better to explicitly fail than to silently change the wrong thing.
Got it, and besides the conceptual explanation here, the code also suggests a small improvement: when a command fails, the terminal output should be brought back to the model. Otherwise, after npm test fails, the Agent might only see "command failed" without seeing the specific error.
Step 7: Add bash, Enabling AI to Verify Results
Writing code doesn't mean the task is done.
A programming assistant must at least be able to run builds, tests, and the program itself to confirm that the modification just made actually works:
npm run build
npm test
node hello.js
This is the role of the bash tool: letting the Agent execute terminal commands in the project directory and return the results to the model.
Create src/tools/bash.ts:
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import type { Tool } from "./types.js";
const execFileAsync = promisify(execFile);
const TIMEOUT_MS = 30_000;
const MAX_BUFFER = 8_000;
function parseCommand(argumentsJson: string): string {
const input: unknown = JSON.parse(argumentsJson);
if (typeof input !== "object" || input === null || Array.isArray(input)) {
throw new Error("Tool arguments must be an object.");
}
const command = (input as Record<string, unknown>).command;
if (typeof command !== "string" || !command.trim()) {
throw new Error("command must be a non-empty string.");
}
return command;
}
function formatOutput(stdout: string, stderr: string): string {
return [stdout, stderr].filter(Boolean).join("\n").trim();
}
export class BashTool implements Tool {
readonly name = "bash";
readonly definition = {
type: "function" as const,
function: {
name: this.name,
description:
"Execute a terminal command in the current project directory. Used for building, testing, running code, and viewing command results.",
parameters: {
type: "object",
properties: {
command: { type: "string", description: "The command to execute" },
},
required: ["command"],
additionalProperties: false,
},
},
};
constructor(private readonly workDir: string) {}
async execute(argumentsJson: string): Promise<string> {
const command = parseCommand(argumentsJson);
try {
const { stdout, stderr } = await execFileAsync(
"bash",
["-c", command],
{
cwd: this.workDir,
timeout: TIMEOUT_MS,
maxBuffer: MAX_BUFFER,
},
);
const output = formatOutput(stdout, stderr);
return output || "Command executed successfully with no terminal output.";
} catch (error) {
const details = error as {
stdout?: string;
stderr?: string;
};
const reason = error instanceof Error ? error.message : String(error);
const output = formatOutput(
details.stdout ?? "",
details.stderr ?? "",
);
throw new Error(
output
? `Command execution failed: ${reason}\n\n${output}`
: `Command execution failed: ${reason}`,
);
}
}
}
First, look at these two lines:
import { execFile } from "node:child_process";
import { promisify } from "node:util";
execFile is a Node.js function used to launch external programs.
For example, if we want to run npm test, it essentially means letting Node.js start a terminal program and hand npm test to it for execution.
However, execFile's original signature uses a callback function to receive results:
execFile("bash", ["-c", "npm test"], (error, stdout, stderr) => {
if (error) {
console.error(error);
return;
}
console.log(stdout);
});
The callback function here can be understood as: "While the command is still executing, don't process further yet; call me back when the command finishes."
But our execute method is already using async and await. If we continue using the callback style, the code style will be inconsistent and prone to deeper nesting.
So, we use promisify to convert execFile into a Promise version:
const execFileAsync = promisify(execFile);
After conversion, we can use await to wait for the command to finish executing:
const { stdout, stderr } = await execFileAsync(
"bash",
["-c", "npm test"],
);
When the command succeeds, stdout and stderr will contain the terminal output; when the command fails, await will throw an error, and the code will enter the subsequent catch. Node.js official documentation also states that after promisify, the error object on failure will additionally carry stdout and stderr, which is precisely why we read them in the catch block. Node.js child_process documentation
What Exactly Does bash -c Mean?
The part that actually executes the command is this:
execFileAsync("bash", ["-c", command], ...)
It can be broken down as:
bash Start the Bash terminal
-c Execute the command string passed afterwards
command The command the model actually wants to run, e.g., npm test
So if the model passes in:
{
"command": "npm test"
}
From a usage perspective, it's equivalent to a person typing in the terminal within the project directory:
npm test
Internally, the program actually executes:
bash -c "npm test"
That is, Node.js first starts Bash, then hands npm test as a command to Bash for execution.
command doesn't have to be just a single simple command.
Besides executing:
npm test
The Agent can also combine multiple commands to complete a task.
For example:
npm run build && npm test
This means:
First execute npm run build
Only if the build succeeds, then execute npm test
The && here can be understood as:
After the previous command succeeds, execute the next command
If npm run build fails, the subsequent npm test will not execute.
Look at this example:
node hello.js | grep Hello
grep is a terminal command for finding text within content.
grep Hello
Can be understood as:
Only keep lines of text that contain Hello
Therefore, the execution process of the entire command is:
node hello.js runs hello.js
↓
hello.js produces normal output via console.log
↓
This output is handed to grep Hello via |
↓
grep only keeps lines containing Hello, then prints them to the terminal
The | here can be understood as an "output pipe":
The normal output continuously produced by the previous command is handed to the next command for further processing.
It differs from &&:
&&: After the previous command succeeds, execute the next command
| : Hand the normal output of the previous command to the next command for processing
When using |, both sides usually run concurrently. The left side doesn't need to completely finish; as soon as it has new output, the right side can start receiving and processing.
Bash is the program that understands terminal syntax like && and |. Only after we hand the model's command to Bash for execution can the Agent combine multiple commands like a person in the terminal.
This also means bash is very powerful: it can not only run npm test but also execute various terminal commands. When we discuss security boundaries later, we'll specifically restrict this capability.
What are stdout and stderr?
Terminal output is usually divided into two categories:
stdout: Normal output
stderr: Error output or warning output
For example, suppose there's a hello.js in the project:
console.log("Hello, power-code");
When the Agent executes:
node hello.js
node will run this JavaScript file.
The file's:
console.log("Hello, power-code");
will print the text to the terminal. This normal print result goes into stdout:
Hello, power-code
So, stdout can initially be simply understood as: "the content printed to the terminal when a command runs normally."
And when npm test fails, the testing framework usually outputs error information to stderr.
So we merge both:
const output = [stdout, stderr].filter(Boolean).join("\n").trim();
Whether the command produces success output, warning output, or failure output, the model can get the result.
Why Also Handle the catch?
The role of catch is not to hide the error, but to preserve the terminal logs of the failed command and hand them to the outer Agent Loop for processing.
When a terminal command finishes, it returns an exit code:
0: Command succeeded
Non-0: Command failed
For example, if npm test has any test failures, it usually returns a non-zero exit code. At this point, execFileAsync will throw an error, directly entering catch, and will not execute the success branch:
return output || "Command executed successfully with no terminal output.";
When a command times out or its output exceeds the maxBuffer limit, it also enters catch.
On failure, the terminal usually has already output test failure reports or build logs. Node.js places these stdout and stderr on the error object; we first append them to the error message:
throw new Error(
output
? `Command execution failed: ${reason}\n\n${output}`
: `Command execution failed: ${reason}`,
);
Note: this throw doesn't yet directly hand the content to the model.
It throws the error containing the terminal logs back to the Agent Loop. Later, when we refactor agent.ts, the Agent will uniformly catch all tool errors and place the error information as a tool result back into the conversation history.
The entire process is:
Model: Run npm test
↓
BashTool: Execute the command
↓
Test fails, BashTool throws an error with logs
↓
Agent: Catches the error and returns it as a tool result to the model
↓
Model: Sees the specific error, decides the next step for fixing
Three Basic Limits
When executing commands, we added three limits:
{
cwd: this.workDir,
timeout: TIMEOUT_MS,
maxBuffer: MAX_BUFFER,
}
cwd stands for current working directory, meaning "which directory the command starts executing from."
cwd: this.workDir
means:
npm test
will run inside the current project directory, not in an arbitrary directory.
timeout is the maximum execution time:
const TIMEOUT_MS = 30_000;
Here, 30_000 is 30 seconds. If a command hasn't finished beyond this time, Node.js will send a termination signal to the child process, subsequently causing the tool to return a failure.
maxBuffer is the maximum amount of output allowed to be collected:
const MAX_BUFFER = 8_000;
This is approximately 8 KB.
If stdout or stderr exceeds this limit, Node.js will terminate the child process and cause this execution to fail; the error object might only retain the portion of output already collected. Its purpose is to prevent a massive log from filling up memory and the Agent's context at once, rather than "silently truncating and treating it as success." Node.js child_process documentation
These Limits Are Not a Sandbox
It must be clearly stated:
cwd,timeout, andmaxBufferare just basic limits, not a security sandbox.
cwd only dictates where the command starts executing; it cannot prevent it from accessing other locations.
For example, the command can still be written as:
cat /etc/hosts
cd ..
More dangerous commands could also still be executed.
bash gets the command execution permissions of the current user. Therefore, this version is only suitable for running locally, under supervision, within your own practice projects.
Additionally, this code assumes Bash is present on the system:
macOS / Linux: Usually available directly
Windows: Recommended to use via WSL or Git Bash
Later, we will continue to add failure recovery, duplicate call detection, and dangerous operation restrictions to this version, so that when the Agent encounters errors or high-risk commands, it knows to stop, retry, or request confirmation.
In a real production launch, command whitelists, container isolation, permission approval, and stricter resource limits are usually added further. These are not problems solvable with just a few lines of code, but for now, just remember one thing:
Being able to execute commands does not mean you can safely execute arbitrary commands.
Step 8: Let ChatClient Accept a Tool List
The chat.ts from the previous chapter still hardcoded the tools:
tools: [READ_FILE_TOOL];
Back then, with only the read_file tool, writing it this way was fine.
But now that tools are managed by the Registry, ChatClient should no longer import or know about any specific tool. It's only responsible for one thing: receiving tool descriptions and sending them to the model.
Modify src/chat.ts to:
import OpenAI from "openai";
import type { ProviderConfig } from "./config.js";
export class ChatClient {
private readonly client: OpenAI;
constructor(private readonly config: ProviderConfig) {
this.client = new OpenAI({
apiKey: config.apiKey,
baseURL: config.baseURL,
});
}
async complete(
messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[],
tools: OpenAI.Chat.Completions.ChatCompletionTool[],
) {
const response = await this.client.chat.completions.create({
model: this.config.model,
messages,
tools,
});
return response.choices[0]?.message;
}
}
The most critical change is that complete now has an additional tools parameter:
async complete(
messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[],
tools: OpenAI.Chat.Completions.ChatCompletionTool[],
)
Here, tools is a set of "tool descriptions for the model to see."
For example, the Registry will later provide content like this:
read_file: Can read files
write_file: Can create or overwrite files
edit_file: Can precisely replace a segment of text
bash: Can execute terminal commands
ChatClient doesn't need to know how these tools are specifically executed, nor does it need to import ReadFileTool or BashTool.
It's only responsible for putting the tool descriptions into the request:
const response = await this.client.chat.completions.create({
model: this.config.model,
messages,
tools,
});
Only after seeing these descriptions can the model decide whether it needs to call a tool, which tool to call, and what parameters to pass.
Step 9: Let the Agent Accept the Registry
Now the Agent needs not only ChatClient but also to obtain tool descriptions and execute tools through the Registry.
The Agent constructor from the previous chapter had only one parameter:
constructor(private readonly client: ChatClient) {}
Now it needs to accept an additional registry parameter:
constructor(
private readonly client: ChatClient,
private readonly registry: Registry,
) {}
Finally, refactor src/agent.ts.
Delete:
import { readFileTool } from "./readFile.js";
Change to importing the Registry:
import type OpenAI from "openai";
import { ChatClient } from "./chat.js";
import { Registry } from "./tools/registry.js";
const MAX_STEPS = 8;
export class Agent {
constructor(
private readonly client: ChatClient,
private readonly registry: Registry,
) {}
async run(prompt: string): Promise<string> {
const messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [
{
role: "system",
content:
"You are power-code, a development assistant. Prioritize reading real files; after modifications, proactively run builds or tests to verify results. Please answer in Chinese.",
},
{ role: "user", content: prompt },
];
for (let step = 1; step <= MAX_STEPS; step += 1) {
const message = await this.client.complete(
messages,
this.registry.getDefinitions(),
);
if (!message) {
throw new Error("The model did not return a message.");
}
messages.push(message);
const toolCalls = message.tool_calls ?? [];
if (toolCalls.length === 0) {
return message.content ?? "The model did not return text content.";
}
for (const toolCall of toolCalls) {
if (toolCall.type !== "function") {
throw new Error(`Tool type not yet supported: ${toolCall.type}`);
}
const name = toolCall.function.name;
console.log(`Round ${step}: AI calls ${name}`);
let result: string;
try {
result = await this.registry.execute(
name,
toolCall.function.arguments,
);
console.log(`✓ ${result}\n`);
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
result = `Tool execution failed: ${reason}`;
console.log(`✗ ${result}\n`);
}
messages.push({
role: "tool",
tool_call_id: toolCall.id,
content: result,
});
}
}
throw new Error(`Execution exceeded ${MAX_STEPS} rounds, stopped.`);
}
}
There are three key changes here.
First, the Agent no longer hardcodes any specific tool:
this.registry.getDefinitions()
will retrieve all tool descriptions from the Registry and hand them to ChatClient to send to the model.
Second, after the model initiates a tool call, the Agent no longer writes a long chain of if...else, but uniformly delegates to the Registry:
await this.registry.execute(name, toolCall.function.arguments);
Whether the model calls read_file, edit_file, or any tool added in the future, the Agent only goes through this single line.
Finally, all tool errors are also uniformly handled here:
try {
result = await this.registry.execute(
name,
toolCall.function.arguments,
);
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
result = `Tool execution failed: ${reason}`;
}
For example, when the bash tool fails to execute a test, it puts the test logs into the error message; after catching the error here, it turns it into a tool result:
messages.push({
role: "tool",
tool_call_id: toolCall.id,
content: result,
});
In the next round, the model can see the failure reason and decide to re-read the file, modify the code, run the test again, or stop the task.
This is the failure recovery within the Agent Loop:
Model calls a tool
↓
Tool succeeds: Returns the result
Tool fails: Throws an error
↓
Agent uniformly catches it
↓
Returns the success result or error information to the model
↓
Model decides the next step
Step 10: Register All Tools and Create the Agent
Now go back to src/main.ts, create the Registry, register all tools, and then hand the Registry to the Agent.
import { resolve } from "node:path";
import { Agent } from "./agent.js";
import { ChatClient } from "./chat.js";
import { loadConfig } from "./config.js";
import { BashTool } from "./tools/bash.js";
import { EditFileTool } from "./tools/edit-file.js";
import { ReadFileTool } from "./tools/read-file.js";
import { Registry } from "./tools/registry.js";
import { WriteFileTool } from "./tools/write-file.js";
function getPrompt(args: string[]): string {
const promptIndex = args.indexOf('-prompt');
if (promptIndex === -1) {
throw new Error('Please pass a question via -prompt, e.g., -prompt "Hello"');
}
const prompt = args[promptIndex + 1];
if (!prompt) {
throw new Error('The content after -prompt cannot be empty.');
}
return prompt;
}
async function main() {
const prompt = getPrompt(process.argv.slice(2));
const config = await loadConfig();
const client = new ChatClient(config);
const workDir = resolve(process.cwd());
const registry = new Registry();
registry.register(new ReadFileTool(workDir));
registry.register(new WriteFileTool(workDir));
registry.register(new EditFileTool(workDir));
registry.register(new BashTool(workDir));
const agent = new Agent(client, registry);
console.log("AI is thinking...\n");
const answer = await agent.run(prompt);
console.log(`AI: ${answer}`);
}
main().catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
console.error(`Startup failed: ${message}`);
process.exit(1);
});
Here:
const agent = new Agent(client, registry);
corresponds to the new constructor parameter added in the previous step:
constructor(
private readonly client: ChatClient,
private readonly registry: Registry,
) {}
When adding a new tool in the future, the entry point only needs one more registration line:
registry.register(new SearchTool(workDir));
The Agent Loop doesn't need any new branches.
At this point, responsibilities are clearly separated:
Tool: Describes and executes a capability
Registry: Stores, looks up, and dispatches all tools
ChatClient: Sends tool descriptions to the model
Agent: Maintains the "think → do → see result" loop
Let's Run It and See
First, compile:
npm run build
Then give it a task involving "create + run":
npm run start -- -prompt "Create hello.js, make it output Hello, power-code, then run it to verify the result"
You might see:
Try a partial modification again:
npm run start -- -prompt "Change power-code in hello.js to AI Agent, read the file first, only modify the necessary content, then run to verify"
Ideally, it will complete on its own:
read_file
→ edit_file
→ bash
→ Final answer
The effect is as follows:
Note that the specific number of rounds is decided by the model. It might also apply for multiple mutually independent tools in a single round. As long as the tool results are correctly returned, the Agent Loop can continue.
Why Can't the Four Tools Be Merged Into One?
Seeing this, some might ask: since bash can execute any command, couldn't reading and writing files also be done with cat and sed? Why write three separate file tools?
Technically, it's possible, but providing separate tools has several benefits:
- Parameters are more structured; the model doesn't need to handle complex Shell quoting itself;
- Each tool can have its own path checks and error prompts;
- Logs are clearer; you can tell at a glance whether the AI is reading, writing, or modifying;
- When implementing permission control later, you can allow reading files but forbid writing files and executing commands.
You can think of bash as a universal wrench: very powerful, but not always the most stable choice for every task. For operations that can be completed with dedicated tools, prioritize the dedicated tools.
Where Exactly Are the Security Boundaries Now?
The stronger the tools, the more we can't just look at "can it run?". Let's clearly state the boundaries that exist and don't exist in this version.
File tools currently have these basic restrictions:
- Refuse paths that resolve to locations outside the project directory;
read_filewill truncate overly long content;write_filecan create or completely overwrite files, but there's no human confirmation yet;edit_filerequires the old text to be a unique match;- Tool parameters are type-checked; the JSON generated by the model is not blindly trusted.
Command tools currently only have basic brakes:
- Fixed to start from the project directory;
- Maximum execution time of 30 seconds;
- Limit the size of
stdoutandstderr; if the limit is exceeded, the command will fail.
But it still lacks true isolation. Whatever the current user can do, bash can theoretically also do.
So please treat this implementation as a local educational harness. Don't write API Keys into the project, don't test casually in directories containing important uncommitted changes, and definitely don't expose it to unknown users without isolation.
Later, we will continue to add failure recovery, duplicate call detection, and dangerous operation restrictions. Real production systems typically also place commands inside containers or restricted sandboxes and add human confirmation for actions like writing, deleting, and publishing.
What Have We Gained Here?
On the surface, this chapter added four tools, but the truly important change isn't "the number of tools increased," but that tools finally have a unified structure:
┌─ read_file
Model → Agent → Registry ├─ write_file
├─ edit_file
└─ bash
Each tool provides the same three things:
name What am I called
definition How do I introduce myself to the model
execute How do I actually execute
To add a new capability in the future, there's no need to dismantle the Agent Loop. Just:
- Implement the
Toolinterface; - Register it with the Registry;
- Let the model learn when to use it from the tool description.
This is also a layer found at the bottom of many Agent frameworks. Frameworks might wrap the names in more complex ways, but the core idea isn't mysterious: a unified protocol, plus a tool table that dispatches by name.
Preview of the Next Chapter
Now the AI can read, write, modify, and run; short tasks look fairly decent.
But as tasks get longer, a new problem immediately arises: the chat history grows larger and larger, it might forget the original goal, or it might be working without knowing which steps are still unfinished.
In the next chapter, we'll give it Sessions, working memory, and a task list, so the AI can remember its goals during long tasks, see its progress, and continue working after an interruption.