A Single Missing Curly Brace Broke a 200-Line AI Coding Agent for Two Hours
Written in front: Today I finally got the "mini Cursor" — an Agent that can create a React project, write code, install dependencies, and start a server — working. But the process was extremely painful: less than 200 lines of code in total, and I spent 2 hours finding a bug. In the end, the problem was in the data structure — the format of the parameters passed into
tool.invoke(args)was inconsistent with the format expected when thetoolfunction was defined. A single bracket cost me two hours. The furthest distance in coding is not from nothing to something, but from "it should run" to "it actually runs."
1. Today's Goal: Hand-write a "Mini Cursor"
1.1 What to implement?
The teacher said:
"Use Claude Code to complete the following task: Use Vite to create a React TodoList project and get it running."
In plain terms, let the AI do this by itself:
"Create a React TodoList for me using Vite, with full functionality, nice styling, and make it run."
To do this, what capabilities does the Agent need?
| Required Capability | Corresponding Tool | Analogy |
|---|---|---|
| Create project directories and files | write_file | AI's hands, can write files |
| Modify existing files | read_file + write_file | AI's eyes + hands |
| View project structure | list_directory | AI's navigation |
| Install dependencies / start project | exec_command | AI's feet, can run commands |
The teacher said an Agent needs to complete these steps:
"Programming task planning is three steps: ① Vite creates the project (write file Tool) → ② LLM's programming ability can write it → ③ Project runs (Tool that calls CLI commands)."
1.2 Four tools, none can be missing
In all-tools.mjs, four tools are defined:
import { tool } from '@langchain/core/tools';
import fs from 'node:fs/promises';
import path from 'node:path';
import { spawn } from 'node:child_process';
import z from 'zod';
readFileTool: Read file
const readFileTool = tool(
async ({filePath}) => {
const content = await fs.readFile(filePath, 'utf-8');
return content;
},
{
name: 'read_file',
description: 'Read file content',
schema: z.object({
filePath: z.string().describe('File path'),
}),
}
)
writeFileTool: Write file (auto-create directories)
const writeFileTool = tool(
async ({filePath, content}) => {
const dir = path.dirname(filePath);
await fs.mkdir(dir, { recursive: true }); // Auto-create directories
await fs.writeFile(filePath, content, 'utf-8');
return `Successfully wrote ${filePath}`;
},
{
name: 'write_file',
description: 'Write file content, auto-create directories',
schema: z.object({
filePath: z.string().describe('File path'),
content: z.string().describe('File content to write'),
}),
}
)
fs.mkdir(dir, { recursive: true }) is the magic for "auto-creating parent directories" — if dir doesn't exist, it will create all parent directories.
ListDirectoryTool: List directory
const ListDirectoryTool = tool(
async ({workingDirectory}) => {
const files = await fs.readdir(workingDirectory);
return `Directory contents:\n ${files.join('\n')}`;
},
{
name: 'list_directory',
description: 'List all files and folders in the specified directory',
schema: z.object({
workingDirectory: z.string().describe('Directory path'),
}),
}
)
execCommandTool: Execute command
const execCommandTool = tool(
async ({command, workingDirectory}) => {
const cwd = workingDirectory || process.cwd();
return new Promise((resolve, reject) => {
const [cmd, ...args] = command.split(' ');
const child = spawn(cmd, args, {
cwd,
stdio: 'inherit',
shell: true,
});
// ... error handling and return
});
},
{
name: 'exec_command',
description: 'Execute system command, supports specifying working directory',
schema: z.object({
command: z.string().describe('Command to execute'),
workingDirectory: z.string().describe('Working directory'),
}),
}
)
2. Pitfall Diary: A data structure error took two hours to find
2.1 Where was the problem?
Look at the core code of the ReAct loop in mini-cursor.mjs:
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);
// ↑ args object is passed here
messages.push(new ToolMessage({
content: toolResult,
tool_call_id: toolCall.id,
}));
}
}
What toolCall.args passes in is an object: { filePath: "src/tool.mjs" }.
And in all-tools.mjs, the tool's async function uses destructured parameters:
async ({filePath}) => { ... }
// Destructure filePath from the object
This is correct.
But when I first wrote it, I wrote it like this:
// ❌ Wrong way
async (filePath) => { ... }
// filePath is an object { filePath: "..." }, not a string "..."
// Then I tried to use fs.readFile(filePath)
// But actually filePath = { filePath: "src/tool.mjs" }
// Reading file → error → two hours of debugging
The difference between async (filePath) and async ({filePath}) is just one curly brace, but that one curly brace cost me two hours of debugging.
2.2 Why does one bracket make such a big difference?
// What invoke passes in is: { filePath: "src/tool.mjs", content: "..." }
// ✅ Destructured parameter: directly extract filePath from the object
async ({filePath}) => {
await fs.readFile(filePath, 'utf-8'); // filePath = "src/tool.mjs" ✅
}
// ❌ Regular parameter: filePath is the entire object
async (filePath) => {
await fs.readFile(filePath, 'utf-8');
// filePath = { filePath: "src/tool.mjs" }
// fs.readFile({ filePath: "..." }) → Error ❌
}
invoke passes an object; the function must use destructuring to get the specific value inside.
2.3 A lesson learned in blood
The teacher said:
"
langchain invokeoutputs as-is, and also carefully prepares tools to append afterward. The convenience and readability of LLM engineering development help."
LangChain does a lot for you, but the format of parameter passing must be consistent with it. The second parameter of tool() defines the schema, and invoke will pass parameters according to the schema's structure.
When writing code, parameter types are more error-prone than logic. Logic errors can run but produce wrong results; parameter type errors directly throw errors — and the error messages are often vague.
3. The "Unwritten Rules" in the System Prompt: Prompts more important than code
There is a particularly important section in the System Prompt of mini-cursor.mjs:
new SystemMessage(`
...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 fail to find the directory
- Correct example: { command: "pnpm install", workingDirectory: "react-todo-app" }
Important rules - File paths:
- Project files are in the react-todo-app/src/ directory
- When reading/writing files, paths must include the react-todo-app/ prefix
Important rules - Execution order:
- Starting the dev server (pnpm run dev) must be the last step
- Because it is a long-running process that won't exit
`)
These "unwritten rules" written in the System Prompt are more important than those written in code.
Because code is fixed, but LLM behavior is malleable. If you don't tell the LLM:
- Don't
cdagain insideworkingDirectory— it will writecd dir && pnpm install, thencd diragain inside thedirdirectory, and fail to find the path. - File names need a prefix — it will write
src/App.tsxinstead ofreact-todo-app/src/App.tsx. - The dev server must be started last — it will start the server first, then install dependencies, resulting in the server running before dependencies are installed.
The System Prompt is the Agent's "operation manual"; the more detailed it is, the fewer mistakes the AI makes.
4. Complete ReAct Loop + Promise.all
The ReAct loop in mini-cursor.mjs is more refined than the version from the previous lesson:
async function runAgentWithTools(query, maxIterations = 30) {
const messages = [
new SystemMessage(`...`),
new HumanMessage(query),
];
for (let i = 0; i < maxIterations; i++) {
const response = await modelWithTools.invoke(messages);
messages.push(response);
// No tool calls → return answer directly
if (!response.tool_calls || response.tool_calls.length === 0) {
console.log(`\n AI Final Reply:\n ${response.content} \n`);
return response.content;
}
// Has tool calls → execute each tool
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,
}));
}
}
}
}
Recap the three actions of the ReAct loop learned in the previous lesson:
| Step | Code | Description |
|---|---|---|
| Reason | modelWithTools.invoke(messages) |
LLM thinks about the next step |
| Act | foundTool.invoke(toolCall.args) |
Execute the tool |
| Observe | new ToolMessage({ tool_call_id }) |
Observe the tool result |
Loop condition: response.tool_calls is not empty.
Termination condition: The LLM decides no more tools are needed and directly generates an answer.
5. Case Study: Creating a TodoList Project from Scratch
The content of case1 is:
Create a feature-rich React TodoList application:
1. npm create vite@latest react-todo-app -- --template react-ts
2. Modify src/App.tsx to implement a fully functional TodoList
- Add, delete, mark complete
- Category filtering (All / In Progress / Completed)
- Statistics display
- localStorage data persistence
3. Add complex styling (gradient background, card shadows, rounded corners, hover effects)
4. Add animations (transition animations for add/delete, using CSS Transitions)
5. List directory to confirm
Note: Use pnpm, functionality must be complete, styling must be beautiful, must have animation effects
The Agent's workflow roughly looks like this:
| Round | Thought | Action | Observation |
|---|---|---|---|
| 1 | Need to create the project first | exec_command: "npm create vite@latest..." |
Project created successfully |
| 2 | Need to write code | write_file: "react-todo-app/src/App.tsx" |
File written successfully |
| 3 | Need to write styles | write_file: "react-todo-app/src/App.css" |
Written successfully |
| 4 | Need to install dependencies | exec_command: "pnpm install" |
Installation successful |
| 5 | Check directory structure | list_directory: "react-todo-app/src" |
Files are all there |
| 6 | Start the server | exec_command: "pnpm run dev" |
Server started successfully |
Every step is automatically completed within the ReAct loop — no human intervention needed at all.
6. Summary: Architecture of the Mini Cursor
| Layer | File | What it does |
|---|---|---|
| Tool Layer | all-tools.mjs |
Defines 4 tools (read/write/list/execute command) |
| Agent Layer | mini-cursor.mjs |
ReAct loop + tool invocation |
| Child Process Layer | node-exec.mjs |
spawn executes CLI commands |
| System Prompt | Inline in mini-cursor | Operating rules for the LLM |
The architecture of the "mini Cursor" follows the same idea as the real Cursor/Claude Code. It's just that real products have more robust error handling, a richer toolset, and more complex task orchestration.
Written at the End
Today's two biggest gains. The first is finally writing a programming Agent that can do work on its own — from creating a project to starting the server, fully automated. The second is spending two hours wrestling with a bracket — the difference between async (filePath) and async ({filePath}) gave me a deep understanding of "parameter destructuring."
The teacher said there's more to learn later, probably covering multi-agent collaboration (LangGraph) and more complex task orchestration. I'm ready — next time I encounter a bug, I'll check the brackets first.
Next time an interviewer asks you: "How does an Agent execute multiple tool calls?"
You can calmly say:
"The Agent registers a tool list via LangChain, and
model.bindTools(tools)binds the tools to the LLM. In the ReAct loop, thetool_callsarray returned by the LLM may contain multiple tool calls, just iterate and execute them. Each tool call is executed viafoundTool.invoke(toolCall.args). The parameters must match the structure defined by the schema intool()— what's passed is an object, and the function must use destructured parameters to receive it (async ({filePath})notasync (filePath)). The tool execution result is pushed into the messages context vianew ToolMessage({ content, tool_call_id }), then the LLM is called again untiltool_callsis empty."
Then look at the interviewer's satisfied expression and silently think: This round, nailed it again.
All code examples in this article are from classroom learning materials and are genuinely runnable.