A 200-Line Agent That Builds React Apps Explains How Cursor Actually Works
After Writing an AI Programming Agent from Scratch, I Finally Understood the Core Principles of Cursor
This article is suitable for Node.js developers interested in AI Agents. We won't talk about vague concepts—using less than 200 lines of code, we'll implement a programming Agent from scratch that can automatically create projects, write code, install dependencies, and start services. In the process, we'll truly understand "why a for loop can drive complex tasks."
[TOC]
1. An Overlooked Question
Over the past year, AI programming tools like Cursor, Copilot, and Devin have permeated our daily work. Type a requirement, and the AI automatically creates a project, writes code, installs dependencies, and runs it—this is nothing new.
But there's a question most people have used these tools for a long time without seriously considering: How does this "automatic task completion loop" actually work internally?
The first time I seriously thought about this was when I was using Cursor to build a full-stack project. The AI first created the frontend project, then the backend project, then automatically installed dependencies, and even corrected a configuration error for me—the entire process involved about 20 steps, and it completed them on its own. I thought at the time: How does it know what the "next step" should be? What is its "decision-making" mechanism?
Later, I read quite a few Agent-related papers and source code and discovered that the core of this is actually an old pattern born in 2022: ReAct (Reasoning + Acting). Moreover, its implementation skeleton is surprisingly simple—a while loop, less than 50 lines of code.
In this article, I want to share with you the process of "writing a mini-Cursor from scratch." It only has four tools and one ReAct loop, but it completely reproduces the core skeleton of an AI programming Agent. More importantly—after understanding this skeleton, your understanding of all AI programming tools on the market will shift from "magic" to "engineering."
2. First, Establish a Formula: Agent = LLM + Tools + Loop
Any AI Agent, no matter how complex, can be summarized with this formula:
Agent = LLM (Brain) + Tools (Hands) + Loop Invocation (Heartbeat)
- LLM is responsible for "thinking"—analyzing the current state and deciding what to do next. Each invocation is like a "snapshot judgment"; it doesn't remember what happened the moment before—unless you tell it the history.
- Tools are responsible for "acting"—reading files, writing code, executing commands, querying databases. The LLM itself is just a language model; it cannot operate the file system, cannot go online, cannot execute commands. Tools are its tentacles reaching into the external world.
- The Loop is responsible for "continuity"—the LLM stops after thinking once, but tasks usually require multiple steps to complete. The loop keeps this process running continuously: Think → Act → Observe Result → Think Again.
This sounds like a truism—"With a brain, hands, and a heartbeat, of course it can work." But what I want to say is: In this formula, the least intuitive and most critical link is the "loop." Because most discussions about Agents on the market focus on the LLM's capabilities and the richness of tools; very few people think about: "Why can the LLM continuously make correct decisions within a loop?"
I believe the answer is: The LLM does not "plan" the entire task; it only makes one inference at each step based on the "current context + the tool result from the previous step." The global "sense of intelligence" emerges from local, individual micro-decisions.
Therefore, to understand Agents, the focus is not on "what model is used" or "how many tools there are," but rather how this loop is designed, and how the context is passed at each step. Below, we verify this through code.
3. Three Key Trade-offs in Tool Design
Before writing the ReAct loop, we need to equip the Agent with "hands." We define four tools: read_file, write_file, list_directory, execute_command, corresponding to reading, writing, browsing, and executing. Each tool is wrapped using LangChain's tool(), containing three elements: the function, name + description (telling the LLM what it can do), and Zod schema (telling the LLM the parameter types).
I won't expand on all the specific code here (see the end of the article for the complete version); I want to focus on three key trade-offs in tool design—these trade-offs reflect common compromises in Agent development.
Trade-off 1: Robustness vs. Precision—Should directories be automatically created when writing files?
const writeFileTool = tool(
async ({ filePath, content }) => {
const dir = path.dirname(filePath);
await fs.mkdir(dir, { recursive: true }); // Auto-create
await fs.writeFile(filePath, content, 'utf-8');
return `Successfully wrote ${filePath}`;
},
{ /* name, description, schema */ }
);
I hesitated about whether to include the line fs.mkdir(dir, { recursive: true }). The reason is: Adding it means the Agent might create directories where it shouldn't; not adding it means the Agent will repeatedly fail when writing nested paths.
Ultimately, I chose to "add it." Because the scenario for this Agent is "automated programming," the LLM is highly likely to write paths like src/components/Header.tsx—if src/components doesn't exist and throws an error, the Agent will repeatedly trial-and-error between creating directories and writing files, wasting multiple rounds of calls.
The principle behind this trade-off is: Tools should tolerate the LLM's "coarseness"—the LLM excels at generating intent, not at precise operations. When designing Agent tools, you need to proactively judge which operations can be "fault-tolerant" and which must be "strict." This is completely different from traditional API design thinking—traditional APIs emphasize "contracts," while Agent tools emphasize "robustness."
Trade-off 2: Real-time Feedback vs. Result Collection—Use spawn or exec when executing commands?
const child = spawn(cmd, args, {
cwd,
stdio: 'inherit', // Child process output directly passed through to the terminal
shell: true,
});
spawn + stdio: 'inherit' is a deliberate choice. If exec were used, the child process's output would be buffered and only returned after the command finishes executing. For commands with progress bars like pnpm install, the user would stare at the terminal waiting for several minutes, seeing nothing—this experience is terrible.
But stdio: 'inherit' also has a cost: The child process's output does not pass through Node.js, so you "cannot capture" its stdout and cannot analyze the output content afterward to assist decision-making. If the Agent needs to judge the next step based on command output, spawn is not a good choice.
The principle of the trade-off is: If the command's output is for the user to see (installation progress, build logs), prioritize experience and use spawn; if the command's output needs to be parsed and reasoned by the Agent, use exec to collect the complete result. In this mini-Cursor, three of the four tools require the Agent to get the returned result for the next decision; only execute_command is an exception—because its main scenarios are installation and startup, and the user cares more about "seeing it run" than "the Agent analyzing the output."
Trade-off 3: Description Detail vs. Context Consumption—How to write the description for a tool?
Each tool needs a description field to tell the LLM what it does. I initially wrote it very briefly:
description: 'Read file',
Later, I found that writing it this way caused the LLM to choose the wrong tool between "viewing code" and "checking directory"—it couldn't distinguish when to use read_file and when to use list_directory. So I changed the description to a more detailed version:
description: 'Use this tool to read file content. Call this tool when the user requests reading a file, viewing code, or analyzing file content. Input the file path (can be a relative or absolute path)',
But another problem arose—a description that is too long consumes the context window. Four tools, each with a 100-character description, plus the System Prompt, already occupy several hundred tokens. For simple tasks, this is completely wasteful.
The principle is: The length of the description should be proportional to the "call ambiguity." For two easily confused tools (like read_file and list_directory), the description should be written with sufficient differentiation; for tools unlikely to be confused (like execute_command), the description can be brief.
After making these three trade-offs, you'll discover a deeper pattern: Tool design is not about writing an API; it's about "conversing" with the LLM. You need to anticipate the scenarios where the LLM might hesitate and proactively pave the way for it. This "product thinking" is far more important than pure engineering thinking—and this is also what I find the most interesting part of Agent development.
4. The Elegance of the ReAct Loop—Why a for Loop Can Drive Complex Tasks
With the tools defined, now comes the most interesting part: Running this for loop and seeing how the LLM makes decisions at each step.
4.1 Model Binding—Why bindTools is a Bridge, Not Black Magic
const model = new ChatOpenAI({
modelName: 'deepseek-v4-pro',
temperature: 0, // Code generation doesn't need creativity, it needs determinism
});
const tools = [readFileTool, writeFileTool, listDirectoryTool, executeCommandTool];
const modelWithTools = model.bindTools(tools);
bindTools is a method provided by LangChain. What it does is very simple: it converts each tool's name, description, and zod schema into an OpenAI-compatible function-calling format and stuffs it into the API request. After the model receives it, during the continuous reasoning process, it can choose at any moment "I want to call a tool"—it will output a tool_calls array containing the tool name and parameters.
There's a detail worth noting here: temperature: 0. Agent tasks are not about writing poetry—each step should be stable and predictable. If the temperature is set too high, the model might "have a whim" and call a non-existent tool, or fill in the wrong path in the parameters. For code generation and tool calling, determinism is more important than creativity.
4.2 The Loop Body—The Core of the Entire Agent, Less Than 30 Lines
async function runAgent(query, maxIterations = 30) {
const messages = [
new SystemMessage(`You are a project management assistant...
Current working directory: ${process.cwd()}
Available tools: read_file, write_file, list_directory, execute_command
Important rules: Do not use workingDirectory and cd simultaneously...`),
new HumanMessage(query),
];
for (let i = 0; i < maxIterations; i++) {
const response = await modelWithTools.invoke(messages);
messages.push(response);
// No tool_calls → Task complete
if (!response.tool_calls || response.tool_calls.length === 0) {
return response.content;
}
// Has tool_calls → Execute tools, push results back into conversation history
for (const toolCall of response.tool_calls) {
const tool = tools.find(t => t.name === toolCall.name);
if (tool) {
const result = await tool.invoke(toolCall.args);
messages.push(new ToolMessage({
content: result,
tool_call_id: toolCall.id,
}));
}
}
}
}
The heartbeat of this loop can be drawn like this:
┌──────────┐ response.tool_calls exists ┌──────────┐
│ LLM Reasoning │ ────────────────────────────► │ Execute Tool │
│ (invoke) │ │ (invoke) │
└──────────┘ └──────────┘
▲ │
│ ToolMessage pushed back into messages │
└───────────────────────────────────────────┘
│
response.tool_calls does not exist
│
▼
┌──────────┐
│ Task Complete │
└──────────┘
I truly understood the essence of ReAct only after carefully examining the "message passing" mechanism in this loop. Two points are most critical:
First, the "passing back" of ToolMessage is the memory mechanism of the entire system. The LLM itself is stateless—each invoke, its only source of information is the messages array. Therefore, you must push the tool execution result back verbatim. If you forget this step, the LLM will have "amnesia" in the next round of calls—it won't know what was done in the previous round, let alone whether the result was success or failure. And what will it do then? It will most likely repeat calling the same tool, falling into "loop death."
Second, the LLM has no "global planning," only "local reasoning." This is the biggest misunderstanding many people have about Agents. Let's review the actual execution trajectory of the Agent creating a React project:
1st Reasoning: "My task is to create a project, I should first execute the vite command"
→ Call tool: execute_command("pnpm create vite ...")
→ Result: "Successfully executed"
2nd Reasoning: "The project is created, but I don't know what files are inside yet, let me check first"
→ Call tool: list_directory("react-todo-app/src")
→ Result: "App.tsx, main.tsx, App.css, ..."
3rd Reasoning: "There is App.tsx, I should rewrite it to implement TodoList logic"
→ Call tool: write_file("react-todo-app/src/App.tsx", "Complete code...")
→ Result: "Successfully written"
4th Reasoning: "Logic is written, still need a style file to add gradient background and animations"
→ Call tool: write_file("react-todo-app/src/App.css", "Style code...")
→ Result: "Successfully written"
5th Reasoning: "Code and styles are all written, time to install dependencies"
→ Call tool: execute_command("pnpm install", { workingDirectory: "react-todo-app" })
→ Result: "Successfully executed"
6th Reasoning: "Dependencies installed, start it up"
→ Call tool: execute_command("pnpm run dev", { workingDirectory: "react-todo-app" })
→ Result: "Successfully executed"
7th Reasoning: "All steps are complete, the project is already running"
→ No tool_calls, directly return final text
Notice: The LLM did not "think out" all the steps in the first step. It only does one thing at each step, and after finishing, judges the next step based on the new context. The global "sense of intelligence" naturally emerges from individual local reasonings—just like ants don't have a "nest blueprint," but the anthill structure created by thousands of ants collaborating is extremely intricate.
This is the most elegant part of the ReAct pattern: Complexity is borne by the environment (messages history + tool results), not by the model. The model doesn't need to "plan"; it only needs to make the best judgment at each step. And the environment provides all the information it needs to make that judgment.
4.3 System Prompt—The Agent's "Guardrails"
These 30 lines of loop code are the heart, but the System Prompt is the "genes." I did several key things in the System Prompt:
- Told it the current working directory:
${process.cwd()}—without this line, it might write files to unexpected places - Reiterated the tool list: Although
bindToolsalready passed the tool definitions, listing them again in the System Prompt can significantly reduce the chance of the model calling the wrong tool. This is practical experience—dual information (tool definition + prompt description) is more reliable than single information - Highlighted error-prone rules: The conflict between
workingDirectoryandcd.
The third point is worth expanding on. execute_command supports a workingDirectory parameter to specify which directory the command should be executed in. But the LLM has a "thinking inertia"—when it wants to enter a directory to execute a command, it subconsciously writes cd react-todo-app && pnpm install in the command. The problem is: if workingDirectory: "react-todo-app" is also passed, the actual path becomes react-todo-app/react-todo-app—directly throwing an error.
I deliberately wrote correct and incorrect examples in the System Prompt:
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
- Incorrect example: { command: "cd react-todo-app && pnpm install",
workingDirectory: "react-todo-app" }
This is wrong! Because workingDirectory is already in react-todo-app
- Correct example: { command: "pnpm install", workingDirectory: "react-todo-app" }
This seems like "rambling," but in Agent development, for known, high-probability error patterns, the best way is to directly write them in the prompt. Don't expect the LLM to "figure it out" on its own—it has no common sense. The more you treat it like a "smart but not very reliable colleague," the better the results.
5. Practical Verification: Let It Build a Complete React Project
After all this theory, can the Agent actually do the work? The task I gave it was:
Create a feature-rich React TodoList application:
1. Use pnpm create vite react-todo-app --template react-ts to create the project
2. Modify App.tsx to implement full functionality: CRUD, category filtering, statistics, localStorage persistence
3. Add beautiful styles: blue-purple gradient background, card shadows and rounded corners, hover effects, CSS transitions animations
4. pnpm install to install dependencies
5. pnpm run dev to start the development server
Running node mini-cursor.mjs, the terminal started outputting:
Executing 1st AI thinking
[Tool Call] execute_command(pnpm create vite react-todo-app --template react-ts)
Working directory: /Users/xxx/hello-langchain/src
...Vite scaffolding output...
[Tool Call] execute_command(pnpm create vite ...) Successfully executed
Executing 2nd AI thinking
[Tool Call] list_directory(react-todo-app/src)
Successfully listed 5 files and folders
Executing 3rd AI thinking
[Tool Call] write_file(react-todo-app/src/App.tsx)
Successfully wrote 4521 bytes
...
After about 7 rounds, the Agent completed all steps. Opening the browser and visiting http://localhost:5173, a complete TodoList was right there—gradient background, card rounded corners, hover effects, add/delete animations, and localStorage persistence. Refreshing the page, data was not lost.
This demo is not about showing off skills—its sole purpose is: To verify that the formula "LLM + Tools + Loop" holds true in practice. A program of less than 200 lines, paired with a language model, can indeed automatically complete an end-to-end development task. This proves that the core of an Agent is not "how strong the model is," but whether the design of the "loop + tool passing" is reasonable.
6. From 200 Lines to Cursor, What's the Difference?
After understanding mini-Cursor, looking back at the real Cursor, the difference lies not in "what magic is used," but in the following engineering increments:
| Dimension | mini-Cursor | Real Cursor |
|---|---|---|
| Number of Tools | 4 | Possibly hundreds (file operations, Git, terminal, LSP, browser...) |
| Context Management | Simple messages array | Exquisite context compression, file relevance sorting, RAG retrieval |
| Code Editing | Full file overwrite | Diff-level precise editing (applyEdit) |
| Human-Computer Interaction | Pure command line | Diff preview in GUI, one-click accept/reject, inline chat |
| Error Recovery | Simple maxIterations fallback | Automatic retry, rollback, LSP diagnostic feedback |
| Memory | None | Project-level and user-level memory, cross-session persistence |
But the skeleton is the same—the ReAct loop. All the increments are optimizations built on top of the "loop": making tools richer, making context more precise, making interaction more friendly. This is not a dimensionality reduction attack, but the same architecture at different stages of maturity.
This understanding helped me clarify a common misconception: "Cursor is strong because it uses the strongest model." No. Cursor is strong in its toolchain design and context management—these two things are as important as model capability, or even more so. The model is only the quality of "thinking," while tool design and context management determine whether the "direction of thinking" is correct.
7. Summary
Three Core Viewpoints
The essence of an AI Agent is a dialogue loop with tool calls. LLM reasons → calls tool → observes result → reasons again. A 30-line for loop is the heartbeat of the entire system. Understanding this point makes any Agent framework (LangChain, AutoGPT, CrewAI) transparent in your eyes.
Tool design is not about writing an API; it's about "conversing" with the LLM. You need to anticipate the scenarios where the LLM will hesitate and proactively design fault tolerance and guidance. How detailed the description is written, how parameters are defined, whether to automatically create directories—these "human-computer interaction" level decisions affect the final outcome more than algorithm-level decisions.
The "sense of intelligence" of an Agent is emergent, not planned. The LLM only makes one local judgment at each step, but after the message history strings all judgments together, what manifests is "global intelligence." This means your job is not to teach the LLM to plan, but to ensure that the context at each step (tool results + history messages) is sufficiently clear and complete.
If You Want to Try It Yourself
The complete code has been posted above (all-tools.mjs and mini-cursor.mjs); configure the DeepSeek API Key and it will run. If you want to delve deeper, three directions worth exploring:
- Memory System: Add a vector database to the Agent, allowing it to remember previous conversations and project context, maintaining state across sessions
- Multi-Agent Collaboration: One Agent writes the frontend, one writes the backend, one runs tests, collaborating through message passing
- Human-in-the-loop: Require user confirmation before critical operations (deleting files, pushing code), significantly improving security and controllability
After you get mini-Cursor running, you might encounter some "unexpected behaviors"—like the LLM writing a file with a syntax error, or calling the same tool in an infinite loop. These are not bugs, but normal "communication processes" in Agent development. Each debugging session is you teaching the LLM how to better understand your intent.
In this process, your biggest gain might not be a runnable project, but establishing a set of your own, engineering-based understanding of "what AI programming tools are actually doing."