A Minimal Claude Code Clone in 200 Lines
Foreword
You say "help me build a react+vite todolist," and Claude Code sets up the project, writes the code, and runs npm run dev all by itself.
It looks like magic, but when you take it apart, it's just one sentence: LLM + Tool (fs + cli).
Today, following this idea, we'll go through the entire process of getting the first runnable coding Agent up and running, from task planning, workflow orchestration, the Message protocol, to the while loop.
Demo Task Breakdown: A Three-Step Plan
The first thing the Agent does when it receives the task "create a react+vite todolist" is not write code, but plan.
js
// Task: Create a react+vite todolist
The LLM breaks it down into three steps, each corresponding to a tool:
| Step | Task | Required Tool |
|---|---|---|
| 1 | Create project scaffold with vite | Write file Tool (output package.json, vite.config) |
| 2 | Write components, styles, logic | Write file Tool (output App.jsx, TodoList.jsx) |
| 3 | Install dependencies + run it | CLI command Tool (npm install + npm run dev) |
This is called planning—the LLM breaks down the task itself and decides which tool to use for each step. The tool palette for a minimal Claude Code is just fs (read/write files) + cli (execute commands). Two tools support the entire coding Agent.
LangChain: The Workflow Orchestration Framework for the LLM World
LangChain was born even earlier than the OpenAI SDK, with a clear positioning: an LLM application development framework.
It solves a core pain point—there are too many LLM providers. Using OpenAI today, switching to DeepSeek tomorrow, and Qwen the day after, do you have to rewrite everything every time you switch?
LangChain connects them with a unified abstraction. @langchain/openai is just an adapter; changing providers is a one-line configuration change.
Workflow orchestration is like building with blocks, or like connecting nodes in Coze:
ChatOpenAI (model)
↓
tools (declare tools, async fn + zod schema)
↓
bindTools (bind tools to the model, switch to "tool mode")
↓
invoke (feed messages, run it)
The bindTools step is the key switch—once tools are bound, the LLM knows "I can take action on this," otherwise it will only give you back pseudocode.
4 Message Types: The Agent's Conversation Protocol
The Agent communicates with the LLM via a messages array. LangChain divides messages into four derived classes:
| Message Type | Said by | Contains | Key Field |
|---|---|---|---|
| SystemMessage | Developer set | Who the AI is, what it can do, behavioral norms | System prompt |
| HumanMessage | User | Task instruction | User input |
| AIMessage | LLM | Reasoning result + tool call intent | tool_calls |
| ToolMessage | Your code | Tool execution result | tool_call_id |
tool_call_id is the ID card for this round. The LLM might call multiple tools at once; each tool_calls carries an id. When the tool finishes running and returns a ToolMessage, it must carry the same id—the LLM uses it to reconcile "which of my previous calls produced this result."
The four Message types are arranged in chronological order in the array, forming the Agent's complete conversation context.
Native OpenAI vs LangChain: Return Differences
The tool calls returned by the native OpenAI SDK are stuffed inside additional_kwargs.tools, in a raw state for you to dig out yourself.
LangChain's invoke preserves this information as-is, and thoughtfully elevates tool_calls to the top level, making it easy for you to iterate directly.
| Dimension | Native OpenAI | LangChain |
|---|---|---|
| Tool call location | additional_kwargs.tools |
Top-level tool_calls + preserved kwargs |
| Engineering convenience | Parse it yourself | Framework prepares it for you |
| Readability | Average | High |
| Cost of switching providers | Rewrite | Change one line of config |
This is the value of a "framework"—it's not that it does things you couldn't do, it's that it makes what needs to be done more convenient, more readable, and more maintainable.
The Simplest Agent Loop: Getting the While Loop Running
Everything before was just parts. What truly brings the Agent to life is this loop:
js
let messages = [
new SystemMessage('You are a code assistant, available tools: write_file, run_cli'),
new HumanMessage('Help me create a react+vite todolist'),
];
// The simplest loop
while (true) {
const response = await modelWithTools.invoke(messages);
messages.push(response);
// No tool_calls → LLM has already got the results, ready to give the final answer
if (!response.tool_calls?.length) {
break; // Loop exits, task complete
}
// Has tool_calls → execute all tools in parallel, feed results back
const toolResults = await Promise.all(
response.tool_calls.map(call => tools[call.name].invoke(call.args))
);
toolResults.forEach((result, i) => {
messages.push(new ToolMessage({
tool_call_id: response.tool_calls[i].id,
content: result,
}));
});
}
Breakdown by section:
| Section | Purpose | Why it's written this way |
|---|---|---|
while(true) |
Continuous operation | An Agent is a loop, it doesn't stop until done |
invoke + push |
Push AI reply back into the array each round | LLM is stateless, relies on the messages array to maintain context |
if (!tool_calls) |
Exit if no tool calls | The LLM only gives a direct answer when it thinks "it's enough" |
Promise.all(map) |
Run all tools in parallel | One round might call multiple tools, serial is too slow |
ToolMessage + tool_call_id |
Feed results back | So the LLM can match them up |
This is what the notes mean by "the simplest loop has tool calls"—if there are calls, keep spinning; if not, do one last invoke to get the result.
The Position of async/Promise in the Agent
Almost the entire Agent consists of async functions, because every step is waiting—waiting for the LLM, waiting for tools, waiting for file IO.
A few key points to remember:
| Feature | Usage in the Agent |
|---|---|
async function = Promise instance |
The entire main() is async, the return value is the resolve value |
await |
Wait for LLM reply, wait for tool to finish |
Promise.all |
Multiple tools in one round run in parallel, no waiting for each other |
Array.find/map |
Find tools by name in the tools array, map out tool results |
try/catch |
Tools can fail (file not found, CLI error), must be handled |
A special mention for the tools[call.name] pattern—make tools a map from name to function. The LLM outputs call.name, you look up the table and execute directly. Much cleaner than a long chain of if/else checks.
5 Pitfall Reminders
1. Forgetting to push the response back into messages. The LLM is stateless; it doesn't know what it said in the previous round. If you don't push the AI reply back into the array, it gets "amnesia" in the next round, and the loop goes haywire.
2. ToolMessage without tool_call_id. When multiple tools run in parallel, the LLM relies on the id to reconcile. Lose the id, and the result becomes a headless corpse; the LLM doesn't know which call this result corresponds to.
3. No exit protection in the while loop. If the LLM keeps spitting out tool_calls, the loop never exits. Add a max rounds counter and force a break when the limit is reached.
4. Not using try/catch for tool execution. File not found, CLI error, the Promise rejects directly, and the entire Agent crashes. Wrap try/catch inside each tool, feed the error message back to the LLM as a result, and let it decide whether to retry or change its approach.
5. Using async functions as synchronous. const result = someAsyncTool(args) without await gets you a Promise, not data. The Agent is full of traps; develop the muscle memory of "async must be awaited."
Afterword
A runnable coding Agent, at its core, is just these: LLM breaks down tasks → LangChain orchestrates tools → 4 Message types maintain context → while loop spins it up.
Not many parts; the hard part is connecting them stably and running them stably. Claude Code looks powerful, but when you strip it down, it's this same structure, just with more complete tools and more rigorous engineering.