teachyou.ai academy
← All posts
AI Agents

Building a Coding Agent from Scratch: Core Components Explained

Ira Menon · Jun 27, 2026 · 14 min read

Why coding agents feel like magic (and why they aren't)

Watch a coding agent fix a bug for the first time and it feels like sleight of hand. You type a sentence, and a few seconds later there's a diff, a passing test suite, and a commit message that actually makes sense. It's tempting to treat the whole thing as an inscrutable black box powered by a sufficiently large language model.

It isn't. A coding agent is a small number of well-understood parts wired together in a loop: a model that can reason and call tools, a set of tools that touch the real file system and shell, a way to remember what's happened so far, and a controller that keeps looping until the job is done or something goes wrong. That's most of it. The sophistication people attribute to "the AI" is usually sophistication in the scaffolding around the AI — the tool design, the context management, the safety rails.

This article walks through that scaffolding piece by piece. We'll build a minimal but genuinely functional coding agent in Python, explain why each component exists, and point out the failure modes that show up the moment you skip one. By the end you'll understand what's actually happening inside tools like Claude Code, Cursor, and Aider well enough to build your own stripped-down version — which is exactly what we do, hands-on, in the 30 Days of Hermes Agent course.

The core loop: think, act, observe, repeat

Strip away everything else and a coding agent is an implementation of the ReAct pattern — Reason, Act, Observe — run in a loop until a stopping condition is met.

  • The model receives a task and the current conversation state
  • It decides whether to respond directly or call a tool
  • If it calls a tool, the agent framework executes that tool against the real system
  • The tool's output (a file's contents, a command's stdout, an error) gets appended back into the conversation
  • The loop repeats, now with more information than before

The entire agent is this loop plus the discipline to stop. Here's the skeleton, with no tools wired in yet, just to make the loop itself concrete:

def agent_loop(client, model, messages, max_iterations=25):
    for _ in range(max_iterations):
        response = client.messages.create(
            model=model,
            max_tokens=4096,
            messages=messages,
            tools=TOOL_SCHEMAS,
        )

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

        tool_uses = [b for b in response.content if b.type == "tool_use"]
        if not tool_uses:
            # Model produced a final text answer, no more tools requested
            return response

        tool_results = []
        for block in tool_uses:
            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})

    raise RuntimeError("Hit max_iterations without completing the task")

Notice what this loop does *not* contain: no parsing of natural language for intent, no regex matching on "please edit this file." The model decides what to do next; the loop's only job is to execute that decision and feed the result back. Everything interesting about agent behavior — whether it explores before editing, whether it re-reads a file after changing it, whether it runs tests before declaring victory — emerges from this loop plus the tools and prompt you give it, not from extra logic bolted onto the loop itself.

Tool design: the interface between model and machine

Tools are how the model touches reality. Get the tool definitions wrong and the smartest model in the world will misuse them constantly — not because it's confused, but because the interface is ambiguous.

A coding agent needs, at minimum, a small set of primitives:

  • Read — return file contents, ideally with line numbers so the model can reference exact locations
  • Edit — replace an exact string in a file with a new one (far more reliable than "rewrite the whole file")
  • Write — create a new file or fully overwrite one
  • Bash/Shell — run arbitrary commands: tests, linters, git, package managers
  • Grep/Glob — search file contents and find files by pattern, since most codebases are too large to read in full

Here's a minimal but real implementation of the first three, following a pattern deliberately close to how production agents define their edit tool — exact string matching rather than line-number patching, because line numbers drift the moment any edit happens:

import subprocess
from pathlib import Path

def tool_read(path: str, offset: int = 0, limit: int = 2000) -> str:
    lines = Path(path).read_text().splitlines()
    selected = lines[offset:offset + limit]
    return "\n".join(f"{i + offset + 1}\t{line}" for i, line in enumerate(selected))

def tool_edit(path: str, old_string: str, new_string: str) -> str:
    content = Path(path).read_text()
    count = content.count(old_string)
    if count == 0:
        return "Error: old_string not found in file"
    if count > 1:
        return "Error: old_string is not unique, add more context"
    Path(path).write_text(content.replace(old_string, new_string, 1))
    return "Edit applied successfully"

def tool_bash(command: str, timeout: int = 120) -> str:
    try:
        result = subprocess.run(
            command, shell=True, capture_output=True,
            text=True, timeout=timeout,
        )
        output = result.stdout + result.stderr
        return output[-8000:]  # cap output so context doesn't explode
    except subprocess.TimeoutExpired:
        return f"Error: command timed out after {timeout}s"

Two design decisions here are load-bearing, not incidental.

First, tool_edit refuses to apply an ambiguous edit. If old_string appears more than once, it fails loudly instead of guessing. This single check eliminates an entire class of bugs where the model accidentally edits the wrong occurrence of a common string like return None or import os.

Second, tool_bash truncates its output. Without that cap, a single npm install or a verbose test run can dump tens of thousands of tokens into the conversation, and every subsequent turn pays for those tokens again. Tool output discipline is context discipline, and context discipline is what makes an agent affordable to run for more than a few turns.

Giving the model the tool schema

The functions above are just Python — the model can't call them unless you describe them in a format it understands. This is where the "tools" parameter in modern LLM APIs comes in: a JSON Schema description of each tool's name, purpose, and parameters.

TOOL_SCHEMAS = [
    {
        "name": "read_file",
        "description": "Read a file from the local filesystem and return its contents with line numbers.",
        "input_schema": {
            "type": "object",
            "properties": {
                "path": {"type": "string", "description": "Absolute path to the file"},
                "offset": {"type": "integer", "description": "Line to start reading from"},
                "limit": {"type": "integer", "description": "Max lines to read"},
            },
            "required": ["path"],
        },
    },
    {
        "name": "edit_file",
        "description": (
            "Replace an exact string in a file with a new string. "
            "old_string must match exactly one location in the file, "
            "including whitespace. Fails if old_string is not unique."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "path": {"type": "string"},
                "old_string": {"type": "string"},
                "new_string": {"type": "string"},
            },
            "required": ["path", "old_string", "new_string"],
        },
    },
    {
        "name": "bash",
        "description": "Execute a shell command and return combined stdout/stderr.",
        "input_schema": {
            "type": "object",
            "properties": {
                "command": {"type": "string"},
                "timeout": {"type": "integer"},
            },
            "required": ["command"],
        },
    },
]

The description field matters more than most people expect. It's not documentation for humans reading your source — it's the model's only window into what a tool does and when to use it. Vague descriptions ("edits a file") produce vague usage. Specific descriptions that state constraints up front ("must match exactly one location") measurably reduce the error rate, because the model sees the constraint before it ever calls the tool, not after it fails.

Context management: the part everyone underestimates

A coding agent's context window is a shared, finite resource. Every file read, every command's output, every prior turn of reasoning competes for the same budget. Left unmanaged, a long agent session degrades in two predictable ways: it hits the context limit outright, or — more insidiously — it starts performing worse well before the hard limit, because the signal-to-noise ratio in the conversation has collapsed under accumulated tool output.

Three techniques address this, and production agents use all three together.

Selective reading. Never read a whole large file when a targeted grep or a bounded offset/limit read will do. The tool_read function above accepts offset and limit for exactly this reason — a 4,000-line file doesn't need to occupy the same context budget as a 40-line one.

Summarization on threshold. When the conversation crosses a token threshold, compress the older parts into a summary and keep only the summary plus the most recent turns:

def maybe_compact(messages, client, model, token_limit=150_000):
    estimated_tokens = sum(len(str(m)) for m in messages) // 4
    if estimated_tokens < token_limit:
        return messages

    summary_prompt = (
        "Summarize the work done so far in this session: files read, "
        "edits made, decisions made and why, and what remains to be done. "
        "Be specific about file paths and function names."
    )
    summary = client.messages.create(
        model=model,
        max_tokens=1024,
        messages=messages + [{"role": "user", "content": summary_prompt}],
    )
    return [
        {"role": "user", "content": "Session summary so far:\n" + summary.content[0].text},
        messages[-1],
    ]

Tool output truncation, which we already saw in tool_bash. The pattern generalizes: any tool that can plausibly return unbounded output needs an explicit cap, because the model cannot self-regulate the size of a shell command's stdout.

Get context management wrong and you'll observe a specific symptom: an agent that works great for the first ten minutes of a session and then starts forgetting decisions it made five minutes ago, or re-reading files it already has open. That's not the model getting "dumber" — it's the useful signal getting diluted by uncapped tool noise.

The system prompt: encoding judgment, not just instructions

If the loop is the skeleton and the tools are the hands, the system prompt is where you encode judgment — the stuff that's obvious to an experienced engineer and invisible to a model with no other guidance.

A coding agent's system prompt typically needs to cover:

  • What tools exist and roughly when to prefer one over another (grep before reading a whole directory, for instance)
  • How to verify a change actually worked, rather than assuming it did
  • When to ask versus when to proceed autonomously
  • House rules specific to the codebase or organization — commit message style, whether to run tests before finishing, whether destructive operations need confirmation

A short but realistic excerpt:

You are a coding agent operating on a real filesystem and shell.

Rules:
- Before editing a file you have not read in this session, read it first.
- Prefer the smallest edit that correctly fixes the issue. Do not
  refactor unrelated code in the same change.
- After making a change that could plausibly break something, run the
  relevant test command before reporting success.
- If a shell command fails, read the error output carefully before
  retrying. Do not repeat an identical failing command more than once.
- Never run destructive commands (rm -rf, force push, database drops)
  without first stating what you're about to do and why.

This is not decoration. Each line above exists because, without it, models reliably make a specific, observable mistake: editing blind, over-refactoring, declaring success without verification, retrying a failing command unchanged, or running something destructive without warning. The system prompt is where you pay down that risk once, in text, instead of catching it after the fact in every session.

Safety rails: permissions, sandboxing, and human checkpoints

An agent that can run arbitrary shell commands is, by construction, an agent that can delete your repository, exfiltrate secrets, or push to production. Capability and risk scale together here, so real coding agents build in layers of control rather than trusting the model's judgment alone.

Permission gating intercepts specific tool calls before they execute:

DESTRUCTIVE_PATTERNS = ["rm -rf", "git push --force", "DROP TABLE", "git reset --hard"]

def requires_confirmation(command: str) -> bool:
    return any(pattern in command for pattern in DESTRUCTIVE_PATTERNS)

def execute_tool(name: str, tool_input: dict) -> str:
    if name == "bash" and requires_confirmation(tool_input["command"]):
        approved = ask_human(f"Agent wants to run:\n{tool_input['command']}\nApprove?")
        if not approved:
            return "Command rejected by user."
    return TOOL_DISPATCH[name](**tool_input)

Sandboxing limits blast radius even if the permission layer is bypassed or misses a pattern — running the agent in a container or a restricted user account, with the filesystem scope limited to the project directory and network access restricted or logged.

Human checkpoints are the highest-leverage control of all: for anything genuinely irreversible (production deploys, database migrations, force pushes to shared branches), require an explicit human "go" regardless of how confident the model sounds. Confidence in a model's tone is not evidence of correctness, and this is the one rule worth being inflexible about.

The pattern worth internalizing: don't rely on prompt instructions alone to prevent destructive actions. Prompts shape behavior probabilistically; a hardcoded check in execute_tool is deterministic. Use the prompt to guide judgment on ambiguous cases, and use code to make the unambiguous cases unconditional.

Putting it together: a working minimal agent

Here's the loop from earlier, now wired to the tool dispatch and a permission check, forming a coding agent small enough to read end to end but complete enough to actually fix a bug in a real repo:

TOOL_DISPATCH = {
    "read_file": tool_read,
    "edit_file": tool_edit,
    "bash": tool_bash,
}

def execute_tool(name: str, tool_input: dict) -> str:
    if name == "bash" and requires_confirmation(tool_input["command"]):
        if not ask_human(f"Approve: {tool_input['command']}"):
            return "Rejected by user."
    try:
        return TOOL_DISPATCH[name](**tool_input)
    except Exception as e:
        return f"Tool error: {e}"

def run_agent(client, model, task: str):
    messages = [{"role": "user", "content": task}]
    for step in range(25):
        response = client.messages.create(
            model=model, max_tokens=4096,
            messages=messages, tools=TOOL_SCHEMAS,
            system=SYSTEM_PROMPT,
        )
        messages.append({"role": "assistant", "content": response.content})

        tool_uses = [b for b in response.content if b.type == "tool_use"]
        if not tool_uses:
            return response.content[0].text

        results = [
            {"type": "tool_result", "tool_use_id": b.id,
             "content": execute_tool(b.name, b.input)}
            for b in tool_uses
        ]
        messages.append({"role": "user", "content": results})
        messages = maybe_compact(messages, client, model)

    return "Stopped: exceeded max steps without completion."

This is not a toy in the dismissive sense — it's genuinely the architecture, minus scale. Add more tools (git operations, a linter runner, a test-file generator), tighten the permission model, add proper logging and retry handling, and you have something close to what's running inside production coding agents today. The gap between this and a polished commercial tool is mostly engineering hardening, not a different architecture.

Common failure modes and how to debug them

Once you've built the loop, a predictable set of failures shows up, and each one traces back to a specific missing piece above.

  • Infinite tool-call loops — the model calls the same tool with the same arguments repeatedly. Usually means the tool's error message isn't informative enough for the model to change strategy; make errors specific ("file not found at this path" beats "error").
  • Edits landing in the wrong place — almost always an ambiguous old_string in an edit tool that doesn't enforce uniqueness. Fix the tool, not the prompt.
  • Agent declares success without verifying — the system prompt doesn't require running tests, or there's no test-running tool available at all. Add both.
  • Context exhaustion mid-task — no truncation on tool output, no compaction strategy. Revisit the context management section.
  • Agent runs something destructive — no permission gate, or the gate's pattern list doesn't cover the command that got through. Treat this list as living documentation that grows every time something slips past it.

Debugging an agent is different from debugging a normal program because the failure often isn't a crash — it's a plausible-looking wrong answer. The fix is almost always to inspect the full transcript (every tool call and every tool result), not just the final output, because the bug usually happened several turns before the outcome you noticed.

Where to go from here

A coding agent is a loop, a handful of well-specified tools, a context budget you manage deliberately, a system prompt that encodes judgment, and safety rails that don't rely on the model behaving well. None of these pieces is exotic on its own — the skill is in getting all five right at the same time and understanding how they interact when one of them is under strain.

If you want to build this out for real rather than just read about it — wiring up an actual multi-tool agent, adding a proper permission system, handling context compaction on a live long-running session, and debugging the failure modes described above on real code instead of a toy repo — that's exactly the hands-on path we built 30 Days of Hermes Agent for. It walks through building a working coding agent from first principles, day by day, until you've built the same core components this article described, in your own codebase, with your own hands on the keyboard.