跪拜 Guibai
← Back to the summary

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.