DEV Community

Cover image for How to Build Your Own Claude Code?
Preecha
Preecha

Posted on

How to Build Your Own Claude Code?

TL;DR

The Claude Code source leak exposed a 512,000-line TypeScript codebase on March 31, 2026. The architecture boils down to a loop that calls the Claude API, dispatches tool calls, and feeds tool results back into the conversation. You can recreate the core behavior with Python, the Anthropic SDK, and a small set of filesystem tools.

Try Apidog today

Introduction

On March 31, 2026, Anthropic shipped a 59.8 MB source map file inside version 2.1.88 of the @anthropic-ai/claude-code npm package. Source maps map minified JavaScript back to original source. Because Anthropic’s build tool generated them by default, the TypeScript source was recoverable.

Developers quickly mirrored and analyzed the codebase. The most useful takeaway was not the leaked implementation itself, but the architecture: a model loop, a tool system, context compaction, project memory, and permissions.

This guide shows how to build a minimal Claude Code-style coding agent from scratch. The examples use Python and the Anthropic SDK, but the same design works with any model that supports tool/function calling.

What the leak revealed about Claude Code’s architecture

Codebase structure

Claude Code, internally codenamed “Tengu,” spans about 1,900 files. Its modules break down into clear layers:

cli/          - Terminal UI using React + Ink
tools/        - Tool implementations
core/         - System prompts, permissions, constants
assistant/    - Agent orchestration
services/     - API calls, compaction, OAuth, telemetry
Enter fullscreen mode Exit fullscreen mode

The CLI is a React app rendered in the terminal with Ink. It uses Yoga for layout and ANSI escape codes for styling.

You do not need that level of UI complexity for your own agent. A simple REPL is enough to implement the core behavior.

The master agent loop

At its core, Claude Code is a loop:

  1. Send the system prompt, tool definitions, and messages to the Claude API.
  2. Receive text and/or tool_use blocks.
  3. Execute requested tools using a name-to-handler dispatch map.
  4. Append tool results to the conversation.
  5. Repeat until the model returns text with no tool calls.

Here is the minimal Python version:

import anthropic

client = anthropic.Anthropic()
MODEL = "claude-sonnet-4-6"

def agent_loop(system_prompt: str, tools: list, messages: list) -> str:
    """Run the agent until Claude stops requesting tools."""
    while True:
        response = client.messages.create(
            model=MODEL,
            max_tokens=16384,
            system=system_prompt,
            tools=tools,
            messages=messages,
        )

        messages.append({
            "role": "assistant",
            "content": response.content,
        })

        if response.stop_reason != "tool_use":
            return "".join(
                block.text
                for block in response.content
                if hasattr(block, "text")
            )

        tool_results = []

        for block in response.content:
            if block.type == "tool_use":
                result = execute_tool(block.name, block.input)

                tool_results.append({
                    "type": "tool_result",
                    "tool_use_id": block.id,
                    "content": result,
                })

        messages.append({
            "role": "user",
            "content": tool_results,
        })
Enter fullscreen mode Exit fullscreen mode

That loop is the foundation. Most of the engineering work lives in the tools, context management, memory, and permissions.

Building the tool system

Use dedicated tools instead of one generic shell tool

Claude Code uses dedicated tools for file and search operations:

  • Read instead of cat
  • Edit instead of sed
  • Grep instead of raw grep
  • Glob instead of find

That design has practical advantages:

  • Structured output: the model receives predictable results.
  • Safety: dangerous shell behavior is easier to restrict.
  • Token efficiency: tool output can be truncated or formatted before being sent back to the model.

For a small agent, start with five tools:

  • read_file
  • write_file
  • edit_file
  • run_command
  • search_code

Define the tools

TOOLS = [
    {
        "name": "read_file",
        "description": "Read a file from the filesystem. Returns contents with line numbers.",
        "input_schema": {
            "type": "object",
            "properties": {
                "file_path": {
                    "type": "string",
                    "description": "Absolute path to the file",
                },
                "offset": {
                    "type": "integer",
                    "description": "Line number to start reading from, 0-indexed",
                },
                "limit": {
                    "type": "integer",
                    "description": "Maximum lines to read. Defaults to 2000.",
                },
            },
            "required": ["file_path"],
        },
    },
    {
        "name": "write_file",
        "description": "Write content to a file. Creates the file if it does not exist.",
        "input_schema": {
            "type": "object",
            "properties": {
                "file_path": {
                    "type": "string",
                    "description": "Absolute path",
                },
                "content": {
                    "type": "string",
                    "description": "File content to write",
                },
            },
            "required": ["file_path", "content"],
        },
    },
    {
        "name": "edit_file",
        "description": "Replace a specific string in a file. The old_string must be unique.",
        "input_schema": {
            "type": "object",
            "properties": {
                "file_path": {
                    "type": "string",
                    "description": "Absolute path",
                },
                "old_string": {
                    "type": "string",
                    "description": "Text to find",
                },
                "new_string": {
                    "type": "string",
                    "description": "Replacement text",
                },
            },
            "required": ["file_path", "old_string", "new_string"],
        },
    },
    {
        "name": "run_command",
        "description": "Execute a shell command and return stdout/stderr.",
        "input_schema": {
            "type": "object",
            "properties": {
                "command": {
                    "type": "string",
                    "description": "Shell command to run",
                },
                "timeout": {
                    "type": "integer",
                    "description": "Timeout in seconds. Default 120.",
                },
            },
            "required": ["command"],
        },
    },
    {
        "name": "search_code",
        "description": "Search for a regex pattern across files in a directory.",
        "input_schema": {
            "type": "object",
            "properties": {
                "pattern": {
                    "type": "string",
                    "description": "Regex pattern",
                },
                "path": {
                    "type": "string",
                    "description": "Directory to search",
                },
                "file_glob": {
                    "type": "string",
                    "description": "File pattern filter, for example '*.py'",
                },
            },
            "required": ["pattern"],
        },
    },
]
Enter fullscreen mode Exit fullscreen mode

Implement tool dispatch

The model returns a tool name and JSON input. Your agent maps that name to a Python function.

import os
import subprocess

def execute_tool(name: str, params: dict) -> str:
    """Dispatch tool calls to their handlers."""
    handlers = {
        "read_file": handle_read_file,
        "write_file": handle_write_file,
        "edit_file": handle_edit_file,
        "run_command": handle_run_command,
        "search_code": handle_search_code,
    }

    handler = handlers.get(name)
    if not handler:
        return f"Error: Unknown tool '{name}'"

    if not check_permission(name, params):
        return f"Error: Permission denied for tool '{name}'"

    try:
        return handler(params)
    except Exception as e:
        return f"Error: {str(e)}"
Enter fullscreen mode Exit fullscreen mode

Implement file tools

def handle_read_file(params: dict) -> str:
    path = params["file_path"]
    offset = params.get("offset", 0)
    limit = params.get("limit", 2000)

    with open(path, "r") as f:
        lines = f.readlines()

    selected = lines[offset:offset + limit]
    numbered = [
        f"{i + offset + 1}\t{line}"
        for i, line in enumerate(selected)
    ]

    return "".join(numbered)

def handle_write_file(params: dict) -> str:
    path = params["file_path"]

    os.makedirs(os.path.dirname(path), exist_ok=True)

    with open(path, "w") as f:
        f.write(params["content"])

    return f"Successfully wrote to {path}"

def handle_edit_file(params: dict) -> str:
    path = params["file_path"]

    with open(path, "r") as f:
        content = f.read()

    old = params["old_string"]
    new = params["new_string"]

    count = content.count(old)

    if count == 0:
        return f"Error: target string not found in {path}"

    if count > 1:
        return f"Error: target string matches {count} locations. Be more specific."

    new_content = content.replace(old, new, 1)

    with open(path, "w") as f:
        f.write(new_content)

    return f"Successfully edited {path}"
Enter fullscreen mode Exit fullscreen mode

Implement command execution

Keep command execution behind explicit approval. Also truncate large outputs before sending them back to the model.

def handle_run_command(params: dict) -> str:
    cmd = params["command"]
    timeout = params.get("timeout", 120)

    blocked = [
        "rm -rf /",
        "mkfs",
        "> /dev/",
    ]

    for pattern in blocked:
        if pattern in cmd:
            return f"Error: Blocked dangerous command pattern: {pattern}"

    result = subprocess.run(
        cmd,
        shell=True,
        capture_output=True,
        text=True,
        timeout=timeout,
        cwd=os.getcwd(),
    )

    output = ""

    if result.stdout:
        output += result.stdout

    if result.stderr:
        output += f"\nSTDERR:\n{result.stderr}"

    if not output.strip():
        output = f"Command completed with exit code {result.returncode}"

    if len(output) > 30000:
        output = (
            output[:15000]
            + "\n\n... [truncated] ...\n\n"
            + output[-15000:]
        )

    return output
Enter fullscreen mode Exit fullscreen mode

Implement code search

def handle_search_code(params: dict) -> str:
    pattern = params["pattern"]
    path = params.get("path", os.getcwd())
    file_glob = params.get("file_glob", "")

    if file_glob:
        cmd = ["grep", "-rn", "--include", file_glob, pattern, path]
    else:
        cmd = ["grep", "-rn", pattern, path]

    result = subprocess.run(
        cmd,
        capture_output=True,
        text=True,
        timeout=30,
    )

    if not result.stdout.strip():
        return f"No matches found for pattern: {pattern}"

    lines = result.stdout.strip().split("\n")

    if len(lines) > 50:
        return (
            "\n".join(lines[:50])
            + f"\n\n... ({len(lines) - 50} more matches)"
        )

    return result.stdout
Enter fullscreen mode Exit fullscreen mode

Context management

Why context matters

The leaked source shows Claude Code spends significant engineering effort on context management. For a practical DIY agent, implement two patterns first:

  1. Auto-compaction: summarize long conversations before they exceed the context window.
  2. Project context re-injection: reload project instructions on every turn.

Add conversation compaction

This example uses a rough character-based token estimate.

def maybe_compact(
    messages: list,
    system_prompt: str,
    max_tokens: int = 180000,
) -> list:
    """Compact conversation when it gets too long."""
    total_chars = sum(
        len(str(message.get("content", "")))
        for message in messages
    )

    estimated_tokens = total_chars // 4

    if estimated_tokens < max_tokens * 0.85:
        return messages

    summary_response = client.messages.create(
        model=MODEL,
        max_tokens=4096,
        system=(
            "Summarize this conversation. Keep all file paths, "
            "decisions made, errors encountered, and current task state. "
            "Be specific about what changed and why."
        ),
        messages=messages,
    )

    summary_text = summary_response.content[0].text

    compacted = [
        {
            "role": "user",
            "content": f"[Conversation summary]\n{summary_text}",
        },
        {
            "role": "assistant",
            "content": (
                "I have the context from our previous conversation. "
                "What should I work on next?"
            ),
        },
    ]

    compacted.extend(messages[-4:])

    return compacted
Enter fullscreen mode Exit fullscreen mode

Re-inject project guidelines every turn

Claude Code reads project instructions such as .claude/CLAUDE.md and keeps them available. Rebuild the system prompt before each agent loop.

def build_system_prompt(project_dir: str) -> str:
    """Build a system prompt with project-specific context."""
    base_prompt = """You are a coding assistant that helps with software engineering tasks.
You have access to tools for reading, writing, editing files, running commands, and searching code.
Always read files before modifying them.
Prefer edit_file over write_file for existing files.
Keep responses concise and focus on the code."""

    claude_md_path = os.path.join(project_dir, ".claude", "CLAUDE.md")

    if os.path.exists(claude_md_path):
        with open(claude_md_path, "r") as f:
            project_context = f.read()

        base_prompt += f"\n\n# Project guidelines\n{project_context}"

    root_md_path = os.path.join(project_dir, "CLAUDE.md")

    if os.path.exists(root_md_path):
        with open(root_md_path, "r") as f:
            root_context = f.read()

        base_prompt += f"\n\n# Repository guidelines\n{root_context}"

    return base_prompt
Enter fullscreen mode Exit fullscreen mode

Add memory

Claude Code uses a layered memory architecture. You can implement a smaller version with:

  1. An always-loaded memory index.
  2. Topic files loaded when relevant.
  3. Session logs that are searched but not loaded wholesale.

Example memory index

- [User preferences](user-prefs.md) - prefers TypeScript, uses Vim keybindings
- [API conventions](api-conventions.md) - REST with JSON:API spec, snake_case
- [Deploy process](deploy.md) - uses GitHub Actions, deploys to AWS EKS
Enter fullscreen mode Exit fullscreen mode

Minimal memory implementation

import os

MEMORY_DIR = ".agent/memory"

def load_memory_index() -> str:
    """Load the memory index for system prompt injection."""
    index_path = os.path.join(MEMORY_DIR, "MEMORY.md")

    if os.path.exists(index_path):
        with open(index_path, "r") as f:
            return f.read()

    return ""

def save_memory(key: str, content: str, description: str):
    """Save a memory entry and update the memory index."""
    os.makedirs(MEMORY_DIR, exist_ok=True)

    filename = f"{key.replace(' ', '-').lower()}.md"
    filepath = os.path.join(MEMORY_DIR, filename)

    with open(filepath, "w") as f:
        f.write(
            f"---\n"
            f"name: {key}\n"
            f"description: "{description}\n\""
            f"---\n\n"
            f"{content}"
        )

    index_path = os.path.join(MEMORY_DIR, "MEMORY.md")
    index_lines = []

    if os.path.exists(index_path):
        with open(index_path, "r") as f:
            index_lines = f.readlines()

    new_entry = f"- [{key}]({filename}) - {description}\n"
    updated = False

    for i, line in enumerate(index_lines):
        if filename in line:
            index_lines[i] = new_entry
            updated = True
            break

    if not updated:
        index_lines.append(new_entry)

    with open(index_path, "w") as f:
        f.writelines(index_lines)
Enter fullscreen mode Exit fullscreen mode

You can expose save_memory as another tool so the agent can persist project knowledge between sessions.

Add a permission system

The leaked source shows multiple permission modes, including interactive prompts and bypass modes. For a DIY agent, start with risk levels:

RISK_LEVELS = {
    "read_file": "low",
    "search_code": "low",
    "edit_file": "medium",
    "write_file": "medium",
    "run_command": "high",
}
Enter fullscreen mode Exit fullscreen mode

Then require approval for medium- and high-risk operations:

def check_permission(
    tool_name: str,
    params: dict,
    auto_approve_low: bool = True,
) -> bool:
    """Ask the user before running risky tool calls."""
    risk = RISK_LEVELS.get(tool_name, "high")

    if risk == "low" and auto_approve_low:
        return True

    print(f"\n--- Permission check ({risk.upper()} risk) ---")
    print(f"Tool: {tool_name}")

    for key, value in params.items():
        display = str(value)[:200]
        print(f"  {key}: {display}")

    response = input("Allow? [y/n/always]: ").strip().lower()

    if response == "always":
        RISK_LEVELS[tool_name] = "low"
        return True

    return response == "y"
Enter fullscreen mode Exit fullscreen mode

This protects users from accidental file rewrites and arbitrary command execution.

Testing your agent’s API calls with Apidog

Building a coding agent means sending many multi-turn API requests with tool definitions, tool calls, and tool results. Raw logs become hard to inspect quickly.

Image

Use Apidog during development to test and replay requests to the Anthropic Messages API.

Capture and replay API requests

Create an Apidog project for your agent and add the endpoint:

POST https://api.anthropic.com/v1/messages
Enter fullscreen mode Exit fullscreen mode

Then configure the request body with:

  • model
  • max_tokens
  • system
  • tools
  • messages

This lets you replay one agent turn without running your full REPL. When the model returns an unexpected tool call or invalid parameter, edit the request body and resend it.

Debug multi-turn conversations

Agent bugs often depend on conversation state. Use environment variables in Apidog to store message snapshots:

  • Save the full messages array after a turn.
  • Replay from a specific conversation state.
  • Compare tool results across runs.
  • Test how different system prompts affect tool selection.

Validate tool schemas

Tool schemas control what the model can call. If your schema is malformed or too vague, the model may skip the tool or pass bad arguments.

Import your tool definitions into Apidog and validate the JSON Schema before sending it to the model.

Put everything together: complete REPL

Here is the agent tied together as a working command-line REPL:

#!/usr/bin/env python3
"""A minimal Claude Code-style coding agent."""

import anthropic
import os
import subprocess

client = anthropic.Anthropic()
MODEL = "claude-sonnet-4-6"
PROJECT_DIR = os.getcwd()

def main():
    messages = []

    print("Coding agent ready. Type 'quit' to exit.\n")

    while True:
        user_input = input("> ").strip()

        if user_input.lower() in ("quit", "exit"):
            break

        if not user_input:
            continue

        messages.append({
            "role": "user",
            "content": user_input,
        })

        current_system = build_system_prompt(PROJECT_DIR)

        memory = load_memory_index()
        if memory:
            current_system += f"\n\n# Memory\n{memory}"

        messages = maybe_compact(messages, current_system)

        result = agent_loop(
            system_prompt=current_system,
            tools=TOOLS,
            messages=messages,
        )

        print(f"\n{result}\n")

if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

With the previous sections included, this gives you a small but functional coding agent that can:

  • Read files
  • Edit files
  • Write files
  • Run commands with approval
  • Search a codebase
  • Compact long conversations
  • Load project instructions
  • Persist memory across sessions

What to build next

Once the core loop works, add these features.

Sub-agents for isolated work

Claude Code can spawn sub-agents for independent tasks. The pattern is simple:

  1. Copy the parent context.
  2. Give the sub-agent a focused task.
  3. Limit its tools if needed.
  4. Run a separate agent_loop().
  5. Return the result to the main conversation.

This keeps exploratory work from polluting the main context.

File-read deduplication

Track files the agent has read, along with modification times. If a file has not changed, avoid re-reading it and return a short message such as:

File unchanged since last read.
Enter fullscreen mode Exit fullscreen mode

This saves tokens during long sessions.

Better output truncation

Large command outputs can consume the entire context window. Add truncation to every tool that can return large data:

  • grep
  • test runners
  • build logs
  • package manager output
  • generated files

Always tell the model when output was truncated.

Auto-compaction with file re-injection

After compaction, re-inject recently accessed files. This helps the model retain working knowledge of the current codebase even after old conversation turns are summarized.

Key takeaways

The Claude Code leak confirmed several practical agent-building patterns:

  • The core loop is simple. The basic agent loop fits in a few dozen lines.
  • Dedicated tools are better than raw shell access. They produce safer and more predictable output.
  • Context management matters more than prompt length. Compaction and re-injection make long sessions viable.
  • Memory needs layers. Keep a small index always loaded and load detailed files only when needed.
  • Permissions are mandatory. Never let an agent write files or execute commands without guardrails.
  • The harness is the product. The model provides reasoning, but the harness provides perception, action, memory, and safety.

If you are building your own agent, debug the API layer early. Multi-turn tool-use conversations are easier to inspect when you can replay exact requests, validate schemas, and compare responses outside your application loop.

FAQ

Can I legally use patterns from the Claude Code leak?

The leak exposed architectural patterns, not proprietary algorithms. A loop that calls a model, dispatches tools, and feeds results back is a standard agent pattern documented in model API docs. Do not copy proprietary source code verbatim. Reimplementing the architecture with your own code is the safer path.

What model should I use for a DIY coding agent?

Claude Sonnet 4.6 offers a balance of speed and capability for coding tasks. Claude Opus 4.6 can be better for complex architecture work but costs more and is slower. Claude Haiku 4.5 can work for simpler edits and searches.

How much does it cost to run your own coding agent?

A typical coding session with 30-50 turns using Claude Sonnet 4.6 can cost around $1-5 in API fees. The biggest cost driver is context size. Compaction and output truncation help control cost.

Why does Claude Code use React for a terminal app?

Ink lets a terminal UI use React’s component model and state management. That helps with permission dialogs, streaming output, and tool-call displays. For a DIY build, input() and print() are enough.

What is the most important feature after the core loop?

Build the permission system first. Without it, the model can overwrite files or run arbitrary commands without user oversight.

How should tool errors work?

Return tool errors as text in the tool_result. The model can then decide whether to retry, use another tool, or ask the user for help.

Can I use this pattern with models other than Claude?

Yes. The architecture works with any model that supports function calling or tool use. You will need to adapt the API request and response format, but the loop, tools, memory, and permissions are model-agnostic.

How do I prevent dangerous commands?

Use multiple layers:

  • Block known dangerous patterns.
  • Require approval for all shell commands.
  • Run commands in the project directory, not arbitrary paths.
  • Prefer dedicated tools over shell commands.
  • Add risk levels for every tool.

Top comments (0)