A ReAct Loop with Four File-System Tools Makes a Minimal Coding Agent
In the previous article, we discussed ReAct:
Reason: The model determines the next step based on the current messages.
Act: The Runtime executes the tool selected by the model.
Observe: The tool's result is written back into the message history.
Loop: The model continues to decide based on the new results.
The weather query demo already ran a minimal loop, but it could only call a simple function.
If an Agent is to handle programming tasks like Cursor, it needs to actually interact with the project environment:
Read files
Write files
List directories
Execute commands
This article directly uses the code from the course project hello-langchain to see how the same ReAct loop, when connected to these tools, becomes a minimal coding Agent.
What task needs to be completed this time?
The task the project gives the Agent is to create a React TodoList:
Create a Vite + React + TypeScript project
Implement adding, deleting, and toggling completion status for TodoList
Add filtering for All, In Progress, and Completed
Add task statistics and localStorage persistence
Add a gradient background, card styles, and transition animations
Install dependencies and start the development server
These steps are not written out one by one as fixed JavaScript calls.
The program only provides the model with tools and the task. In each round, the model decides which tool to call next based on the current messages.
The Core Structure of Mini Cursor
This diagram expresses a layered relationship, not a fixed execution order.
The four tools are independent of each other. The model might first execute a command to create the project, or it might first check the existing directory; after getting the real result, it decides the next step.
LLM: Understands the task and generates tool_calls
Runtime: Matches tools, executes functions, maintains messages
Tools: Operate files and child processes via Node.js
Local Workspace: Saves code, directories, and command execution results
The model does not directly gain computer permissions.
What actually reads/writes the disk and executes commands are still the tools registered in the Node.js Runtime.
Project Structure
The course project is located at:
hello-langchain/
├── package.json
├── .env
├── react-todo-app/
└── src/
├── all-tools.mjs
├── mini-cursor.mjs
├── node-exec.mjs
└── tool.mjs
This article mainly looks at two files:
all-tools.mjs: Defines the file and command tools
mini-cursor.mjs: Binds the tools and runs the Agent Loop
The project dependencies come from the current package.json:
{
"dependencies": {
"@langchain/core": "^1.2.1",
"@langchain/openai": "^1.5.3",
"chalk": "^5.6.2",
"dotenv": "^17.4.2",
"zod": "^4.4.3"
}
}
Tool 1: Read File
Below is the readFileTool from the project's all-tools.mjs:
const readFileTool = tool(
async({ filePath }) => {
const content = await fs.readFile(filePath, 'utf-8');
console.log(`[Tool Call] read_file(${filePath})
Successfully read ${content.length} bytes`)
return content;
},
{
name: 'read_file',
description: `Use this tool to read file content. Call this tool when the user asks to read a file, view code, or analyze file content. Input the file path (can be a relative or absolute path)`,
schema: z.object({
filePath: z.string().describe('The file path to read')
})
}
)
A LangChain Tool consists of two parts:
Function: Actually executes fs.readFile()
Description object: Tells the model the tool's name, purpose, and parameter structure
After the model selects read_file, the Runtime calls fs.readFile() to read the file and returns the content as the tool result.
Tool 2: Write File
The write file tool in the project first gets the parent directory, then recursively creates the directory and writes the content:
const writeFileTool = tool(
async({ filePath, content}) => {
try {
const dir = path.dirname(filePath);
console.log(`Directory: ${dir}`);
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(filePath, content, 'utf-8');
console.log(`[Tool Call] write_file(${filePath})
Successfully wrote ${content.length} bytes`)
return `Successfully wrote ${filePath}`
} catch (err) {
console.log(`[Tool Call] write_file(${filePath})
File write failed: ${err.message}`)
return `File write failed: ${err.message}`
}
},
{
name: 'write_file',
description: `Write file content to a specified path, automatically creating directories`,
schema: z.object({
filePath: z.string().describe('File path'),
content: z.string().describe('Content to write to the file')
})
}
)
The key code here is:
await fs.mkdir(dir, { recursive: true });
If a multi-level directory like src/components/todo doesn't exist yet, recursive: true will recursively create the parent directories before writing the file.
After the tool executes successfully, it returns:
Successfully wrote file path
This text then enters the ToolMessage, letting the model know whether the previous write action was successful.
Tool 3: List Directory
The listDirectoryTool in the project is used to view a specified directory:
const listDirectoryTool = tool(
async ({ directoryPath }) => {
try{
const files = await fs.readdir(directoryPath);
console.log(`[Tool Call] list_directory(${directoryPath})
Successfully listed ${files.length} files and folders`)
return `Directory contents:\n ${files.join('\n')}`
} catch (err) {
console.log(`[Tool Call] list_directory(${directoryPath})
Directory read failed: ${err.message}`)
return `Directory read failed: ${err.message}`
}
},
{
name: 'list_directory',
description: `List all files and folders in a specified directory`,
schema: z.object({
directoryPath: z.string().describe('Directory path')
})
}
)
A coding Agent needs to know what files are in the current project to decide what to read or modify next.
For example, after a Vite project is created, the model can call the directory tool to confirm:
package.json
src/
public/
vite.config.ts
The directory result becomes context for the model's next decision round, rather than just being printed for the user.
Tool 4: Execute Command
The project uses Node.js's child_process.spawn() to start a command child process:
const executeCommandTool = tool(
async ({ command, workingDirectory }) => {
const cwd = workingDirectory || process.cwd();
console.log(`[Tool Call] execute_command(${command})
Working directory: ${cwd}`);
return new Promise((resolve, reject) => {
const [cmd, ...args] = command.split(' ');
const child = spawn(cmd, args, {
cwd,
stdio: 'inherit',
shell: true,
})
let errorMsg = '';
child.on('error', (err) => {
errorMsg = err.message
});
child.on('close', (code) => {
if (code === 0) {
console.log(`[Tool Call] execute_command(${command})
Successfully executed`)
const cwdInfo = cwd?
`\n\nImportant note: Command executed in directory "${cwd}"`:
'';
resolve(`Command line executed successfully ${command}${cwdInfo}`);
} else {
console.log(`[Tool Call] execute_command(${command})
Exit code: ${code}`),
resolve(`Command line execution failed, exit code: ${code}\n Error message: ${errorMsg}`)
}
})
})
},
{
name: 'execute_command',
description: 'Execute a system command, supports specifying a working directory, displays output in real-time',
schema: z.object({
command: z.string().describe('The command to execute'),
workingDirectory: z.string().describe('Working directory (recommended to specify)')
})
}
)
spawn() creates a child process to execute the command.
stdio: 'inherit'
This means the child process inherits the current terminal's input and output, so execution logs for installing dependencies or building the project will be displayed directly.
When the command finishes, the close event triggers, and the Runtime organizes the tool result based on the exit code:
code === 0: Command successful
code !== 0: Command failed, returns exit code and error message
This way, the model knows in the next round whether the command actually executed successfully.
Handing the Four Tools to the Model
mini-cursor.mjs registers the four tools defined in the project:
const tools = [
readFileTool,
writeFileTool,
listDirectoryTool,
executeCommandTool,
]
const modelWithTools = model.bindTools(tools);
bindTools() converts the tool names, descriptions, and Schemas into tool definitions the model can understand.
But it does not execute the tools directly.
The actual invocation still requires the subsequent Agent Loop to read response.tool_calls.
The Task the Course Project Gives the Model
Below is the original task in mini-cursor.mjs:
const case1 = `
Create a feature-rich react todolist application:
1. Create the project:
echo -e "n\nn" | pnpm create vite react-todo-app --template react-ts
2. Modify src/App.tsx to implement a fully functional Todolist:
- Add, delete, mark as complete
- Category filtering (All/In Progress/Completed)
- Statistics display
- localStorage data persistence
3. Add complex styles:
- Gradient background (blue to purple)
- Card shadows, rounded corners
- Hover effects
4. Add animations:
- Transition animations for adding/deleting
- Use CSS transitions
5. List the directory to confirm
Note:
Use pnpm, functionality must be complete, styles must be beautiful, must have animation effects
Afterwards, in the react-todo-app project:
1. Use pnpm install to install dependencies
2. Use pnpm run dev to start the server
`
This task provides both result requirements and some execution constraints.
The model still needs to decide for itself:
When to create the project
When to read App.tsx
What content to write to the file
In which working directory to install dependencies
Whether to check the directory again after completion
SystemMessage Constrains the Command Working Directory
The SystemMessage in the project explicitly tells the model what tools are available and emphasizes the usage rules for workingDirectory:
new SystemMessage(`You are a project management assistant, use tools to complete tasks.
Current working directory: ${process.cwd()}
Tools:
1. read_file: Read file content
2. write_file: Write file content
3. list_directory: List all files and folders in a directory
4. execute_command: Execute command line operations (supports workingDirectory parameter)
Important rules
- execute_command:
- The workingDirectory parameter will automatically switch to the specified directory
- When using workingDirectory, absolutely do not use cd in the command
- Wrong example: { command: "cd react-todo-app && pnpm install", workingDirectory: "react-todo-app" }
This is wrong! Because workingDirectory is already in the react-todo-app directory, cd react-todo-app again will not find the directory
- Correct example: { command: "pnpm install", workingDirectory: "react-todo-app" }
This is correct! workingDirectory has already switched to react-todo-app, just execute the command directly
Keep replies concise, just state what was done
`)
If the tool has already set the working directory to react-todo-app, the command does not need to execute:
cd react-todo-app
Otherwise, it might enter a non-existent nested directory.
Mini Cursor's ReAct Loop
The runAgentWithTools() function in the project continues the ReAct structure from the previous article:
async function runAgentWithTools(query, maxIterations = 30) {
const messages = [
new SystemMessage(`You are a project management assistant, use tools to complete tasks.
Current working directory: ${process.cwd()}
Tools:
1. read_file: Read file content
2. write_file: Write file content
3. list_directory: List all files and folders in a directory
4. execute_command: Execute command line operations (supports workingDirectory parameter)
Important rules
- execute_command:
- The workingDirectory parameter will automatically switch to the specified directory
- When using workingDirectory, absolutely do not use cd in the command
- Wrong example: { command: "cd react-todo-app && pnpm install", workingDirectory: "react-todo-app" }
This is wrong! Because workingDirectory is already in the react-todo-app directory, cd react-todo-app again will not find the directory
- Correct example: { command: "pnpm install", workingDirectory: "react-todo-app" }
This is correct! workingDirectory has already switched to react-todo-app, just execute the command directly
Keep replies concise, just state what was done
`),
new HumanMessage(query),
];
for(let i = 0; i < maxIterations; i++) {
console.log(chalk.bgGreen(`Waiting for AI, thinking round ${i+1}...`))
const response = await modelWithTools.invoke(messages);
messages.push(response);
if(!response.tool_calls || response.tool_calls.length === 0) {
console.log(`\n: AI Final Reply\n: ${response.content}`);
return response.content;
}
for(const toolCall of response.tool_calls) {
const foundTool = tools.find(t => t.name === toolCall.name);
if(foundTool) {
const toolResult = await foundTool.invoke(toolCall.args);
messages.push(new ToolMessage({
content: toolResult,
tool_call_id: toolCall.id
}))
}
}
}
return messages[messages.length - 1].content;
}
Each execution round can be broken down into four steps:
1. modelWithTools.invoke(messages): The model decides the next step
2. response.tool_calls: Reads the tool call requests
3. foundTool.invoke(toolCall.args): The Runtime executes the tool
4. ToolMessage: Writes the execution result back into the context
When the model no longer returns tool_calls, it means it considers the task complete, and the function returns the final content.
maxIterations = 30 adds a stop boundary for the Agent to prevent infinite loops.
What did this project actually generate?
The current course project already contains the Agent-generated react-todo-app:
react-todo-app/
├── package.json
├── vite.config.ts
├── src/
│ ├── App.tsx
│ ├── App.css
│ └── index.css
└── ...
App.tsx has already implemented:
- Adding and deleting tasks;
- Toggling completion status;
- Filtering for All, In Progress, and Completed;
- Total and status statistics;
- localStorage data persistence;
- Transition states for adding and deleting.
This shows the Agent didn't just output a suggestion; it actually wrote the results into the project workspace through file tools.
Code Details
Tool names, export names, and Schema parameters need to be consistent across different files:
listDirectoriesTool → listDirectoryTool
executeCommndTool → executeCommandTool
directoryPath → workingDirectory
The directory tool was also changed to directly output the filename strings returned by fs.readdir():
const files = await fs.readdir(directoryPath);
return files.join('\n');
This way, the parameters generated by the model can be correctly received by the tool function, and mini-cursor.mjs can also import the corresponding tool.
Still needs attention: The development server will not exit on its own
The command tool waits for the child process to end via the close event, but pnpm run dev usually runs continuously. When executing the step to start the server, the tool might wait indefinitely. It's necessary to confirm with the actual classroom operation method whether another terminal was opened or if there is additional process management code.
This isn't missing course knowledge, but an engineering problem that needs to be handled later when doing process management.
What production capabilities is Mini Cursor still missing?
The Demo has run through the core workflow, but a mature Coding Agent needs more engineering boundaries:
Workspace Restrictions
File tools should only be allowed to access the specified project; the model cannot be allowed to arbitrarily read the user's other directories.
Command Confirmation
High-risk commands like deleting files, modifying Git history, or uploading content need to be prohibited or require user confirmation before execution.
Patch Modifications
Overwriting an entire file can easily affect existing code. Mature tools typically use structured Patches to modify only the necessary fragments.
Build and Test
"Write successful" doesn't mean the functionality is correct. The Agent also needs to verify results through builds, tests, or browser checks.
Process Management
Long-running processes like development servers need the ability to view logs, check ports, and actively stop them, not just start them.
Summary
This Mini Cursor didn't change the basic ReAct loop from the previous article; it just expanded the tool capabilities from weather queries to the local development environment:
read_file: Read code
write_file: Create and modify files
list_directory: View project structure
execute_command: Execute install, build, and start commands
The model is responsible for choosing the next step based on the task and observation results, the Runtime is responsible for executing tools and maintaining messages, and the local workspace is responsible for providing real file and command results.
LLM Decision-making
+ ReAct Loop
+ File and Command Tools
= Minimal Coding Agent
After understanding this chain, you can see the common foundation of Coding Agents like Cursor, Claude Code, and Codex: the model doesn't directly operate the computer, but continuously acts, observes, and corrects through controlled tools.