Clivern

𝙰 πš‚πš˜πšπšπš πšŠπš›πšŽ π™΄πš—πšπš’πš—πšŽπšŽπš› πšŠπš—πš π™ΎπšŒπšŒπšŠπšœπš’πš˜πš—πšŠπš• πš†πš›πš’πšπšŽπš›.

Building AI Coding Agent from Scratch

25 August 2026

I wanted to understand how a coding agent actually works, not by reading another architecture diagram, but by building one. No framework, no magic. Each piece is small enough to hold in your head.

What a coding agent does?

A chatbot only talks. You type, it types back. An agent can also do things: open a file, search your project, run a command, look something up online.

You give it a job, like β€œwhat’s in app.py?” A chatbot would guess from memory. An agent can go look.

Here is what actually happens, in order:

  1. Your program sends the question to the model. The model cannot open files by itself. So it replies with a request: β€œrun read_file on app.py.” That is not the answer yet.
  2. Your program runs that request, it opens the file on disk and sends the contents back to the model. Now the model has seen the file.
  3. Your program asks the model again, with that extra information. This time the model can answer in words: β€œHere’s what app.py does…” Or it can request another tool (edit the file, run a test). If it requests another tool, you go back to step 2. If it answers in words, you print that and stop.

The model only talks. Your program is what reads files, runs commands, and searches. The β€œagent” is this back-and-forth until the model is done asking for help.

The Components

The Shell. A terminal loop. It waits for a line, hands it to the agent, prints the reply. /q for quit.

The Brain. A language model behind. You send the conversation; it returns a Thought: thought can be words to show you, optional thinking, and (later) a list of tools it wants to run.

Brain Switching. The brain should not be glued to one model but it should allow models switching, that’s why i will use openrouter to be able to switch to any model easily.

The Tools. Small Python classes the brain can ask for: read a file, write one, search the repo, search the web, run a command, save a note. The model never runs them. It requests them. Your code runs and sends the result back.

Plan and Act. Plan mode can only look around and write a plan. Act mode can write files and run commands. /mode act unlocks writing.

Wiring. Last step is wiring everything together to get the final working agent.

you
  β†’ shell (type a line)
       β†’ Agent (loop)
            β†’ Brain (OpenRouter)
            β†’ tools (only if the brain asked)
       β†’ reply printed in the shell

The Shell

Before it can write code or call a model, the agent needs a place to live: a loop that waits for you, hands the line to a handler, and prints the reply. Empty input is ignored. /q (or Ctrl+C) is a stop signal that ends the process.

class AgentStop(Exception):
    """Raised when the agent should stop processing."""
    pass


class Agent:
    def handle_input(self, user_input):
        if user_input.strip() == "/q":
            raise AgentStop()
        if not user_input.strip():
            return ""
        return f"You said {user_input}\n (Agent not yet connected)"


def main():
    agent = Agent()
    print("⚑ Leif v0.1 initialized.")
    print("Type '/q' to quit.")
    while True:
        try:
            user_input = input("\n❯ ")
            output = agent.handle_input(user_input)
            if output:
                print(output)
        except (AgentStop, KeyboardInterrupt):
            print("\nExiting...")
            break


if __name__ == "__main__":
    main()

Nothing talks to a model yet. That is the point. You can sit in the loop and quit it before any API exists.

The Brain

The shell can type. It cannot think. The brain is the language model that does the thinking part.

We reach it through OpenRouter. One URL, one API key, many models - Claude, GPT, DeepSeek. You do not need a separate client for each vendor.

import os
import json
import requests
from dotenv import load_dotenv

load_dotenv()
api_key = os.getenv("OPENROUTER_API_KEY")

url = "https://openrouter.ai/api/v1/chat/completions"
headers = {
    "Authorization": f"Bearer {api_key}",
    "Content-Type": "application/json",
    "HTTP-Referer": "https://github.com/Clivern/Leif",
    "X-OpenRouter-Title": "Leif",
}
payload = {
    "model": "anthropic/claude-sonnet-4.6",
    "max_tokens": 4096,
    "reasoning": {"effort": "low"},
    "messages": [{"role": "user", "content": "Hello, are you ready to code?"}],
}

response = requests.post(url, headers=headers, json=payload, timeout=120)
print(json.dumps(response.json(), indent=2) if response.ok else response.text)

Then wrap the same POST so the rest of the program never sees raw JSON. A Thought is text, optional thinking, and a list of ToolCall - parsed now so the shape does not change when tools arrive.

class ToolCall:
    def __init__(self, id, name, args):
        self.id = id
        self.name = name
        self.args = args


class Thought:
    def __init__(self, text=None, tool_calls=None, thinking=None):
        self.text = text
        self.tool_calls = tool_calls or []
        self.thinking = thinking


class Brain:
    def __init__(self):
        self.api_key = os.getenv("OPENROUTER_API_KEY")
        self.model = "anthropic/claude-sonnet-4.6"
        self.url = "https://openrouter.ai/api/v1/chat/completions"

    def think(self, conversation):
        payload = {
            "model": self.model,
            "max_tokens": 16000,
            "reasoning": {"effort": "low"},
            "messages": conversation,
        }
        response = requests.post(self.url, headers=headers, json=payload, timeout=120)
        response.raise_for_status()
        return self._parse_response(response.json())

    def _parse_response(self, data):
        message = data["choices"][0]["message"]
        tool_calls = []
        for tc in message.get("tool_calls") or []:
            args = tc["function"]["arguments"]
            if isinstance(args, str):
                args = json.loads(args)
            tool_calls.append(ToolCall(id=tc["id"], name=tc["function"]["name"], args=args))
        return Thought(
            text=message.get("content") or None,
            tool_calls=tool_calls,
            thinking=message.get("reasoning") or None,
        )

Brain Switching

A second provider should not mean a second HTTP client. OpenRouter is one door. Claude, DeepSeek, and GPT are slugs in a dict. /switch flips the slug and the brain.

We also need to stop dying on the first 429: request_with_retry backs off on rate limits, 5xx, and network errors.

def request_with_retry(url, headers, payload, max_retries=10):
    """POST with retry on 429, 5xx, and network failures."""
    for attempt in range(max_retries):
        try:
            response = requests.post(url, headers=headers, json=payload, timeout=120)
        except requests.exceptions.RequestException as e:
            wait_time = 2 ** attempt
            print(f"Network error: {e}. Retrying in {wait_time}s...")
            time.sleep(wait_time)
            continue

        if response.status_code == 429 or response.status_code >= 500:
            retry_after = response.headers.get("retry-after")
            try:
                wait_time = int(retry_after) if retry_after else 2 ** attempt
            except (ValueError, TypeError):
                wait_time = 2 ** attempt
            print(f"Error {response.status_code}. Retrying in {wait_time}s...")
            time.sleep(wait_time)
            continue

        if response.status_code >= 400:
            try:
                error_msg = response.json()["error"]["message"]
            except (KeyError, ValueError, requests.exceptions.JSONDecodeError):
                error_msg = response.text
            raise Exception(f"API error ({response.status_code}): {error_msg}")

        return response

    raise Exception(f"Request failed after {max_retries} retries")

MODELS = {
    "claude": "anthropic/claude-sonnet-4.6",
    "deepseek": "deepseek/deepseek-chat",
    "gpt": "openai/gpt-4o",
}


class Brain:
    def __init__(self, name="claude"):
        self.api_key = os.getenv("OPENROUTER_API_KEY")
        if name not in MODELS:
            raise ValueError(f"Unknown brain: {name}")
        self.name = name
        self.model = MODELS[name]
        self.url = "https://openrouter.ai/api/v1/chat/completions"

    def think(self, conversation):
        payload = {
            "model": self.model,
            "max_tokens": 16000,
            "reasoning": {"effort": "low"},
            "messages": conversation,
        }
        response = request_with_retry(self.url, headers, payload)
        return self._parse_response(response.json())


def _switch_brain(self):
    names = list(MODELS.keys())
    new_name = names[(names.index(self.brain.name) + 1) % len(names)]
    self.brain = Brain(new_name)
    return f"Switched to: {new_name}"

The Tools

The model does not execute anything. It asks. Each tool is a class with four things: name, description, input_schema, execute. Each tool also has a plan_safe flag. The next step uses it.

def tool_definitions(tools):
    return [{
        "type": "function",
        "function": {
            "name": t.name,
            "description": t.description,
            "parameters": t.input_schema,
        },
    } for t in tools]

Every tool looks like read_file. The rest is the same pattern with different arguments.

Files

read_file returns the file with line numbers. write_file overwrites. edit_file finds a string and replaces it once, that’s the right tool for a one-line fix.

class ReadFile:
    name = "read_file"
    plan_safe = True
    description = "Reads a file from the filesystem. Use this to examine code."
    input_schema = {
        "type": "object",
        "properties": {"path": {"type": "string", "description": "The path to the file"}},
        "required": ["path"],
    }

    def execute(self, context, path):
        with open(path, "r", encoding="utf-8") as f:
            lines = f.readlines()
        return "".join(f"{i+1} | {line}" for i, line in enumerate(lines))


class EditFile:
    name = "edit_file"
    plan_safe = False

    def execute(self, context, path, old_text, new_text):
        with open(path, "r", encoding="utf-8") as f:
            content = f.read()
        if old_text not in content:
            return f"Error: Could not find the specified text in {path}"
        with open(path, "w", encoding="utf-8") as f:
            f.write(content.replace(old_text, new_text, 1))
        return f"Successfully edited {path}"

Look around

Plan mode can read a file if you name it. It cannot find that file. list_files walks the tree (skipping .git, caches, .leif). search_codebase greps for a string. search_web hits DuckDuckGo for what is not in the repo. something like a library that shipped last week or current docs.

class ListFiles:
    name = "list_files"
    plan_safe = True

    def execute(self, context, path="."):
        file_list = []
        for root, dirs, files in os.walk(path):
            # Ideally it should escape the global ignore and local ignore of our coding agent
            dirs[:] = [d for d in dirs if d not in {".git", "__pycache__", "venv", ".venv", ".leif"}]
        return "\n".join(file_list)


class SearchWeb:
    name = "search_web"
    plan_safe = True

    def execute(self, context, query):
        results = DDGS().text(query, max_results=3)
        if not results:
            return "No results found."
        return "\n".join(
            f"Title: {r['title']}\nURL: {r['href']}\nSummary: {r['body']}\n"
            for r in results
        )

Remember, plan, run

The conversation in memory lives only while the process is running. Close the terminal and it is gone. Preferences should survive that: how you like code formatted, what the project is. We keep those in a markdown file, .leif/memory.md. On every request the agent sends that file as the system prompt, so the model still knows who you are next time. save_memory overwrites the whole file. It is a scratchpad, not a database.

A plan is different. Before the agent is allowed to change your code, it should be able to write down what it intends to do. write_plan saves that outline to PLAN.md. That tool is allowed in plan mode, so the agent can think on disk without touching source files.

Then there is the shell. run_command really runs a command on your machine like tests, installers, scripts. Output comes back as text, stdout and stderr together. It stops after LEIF_TIMEOUT seconds (30 by default) so a hung process cannot sit forever. This tool is act-only. It is also not a sandbox: plan mode means β€œdon’t write until I say so,” not β€œthis cannot hurt the machine.”


class Memory:
    def __init__(self, path=".leif/memory.md"):
        self.path = path
        self._ensure_exists()
        self.content = self._load()

    def save(self, content):
        self.content = content
        with open(self.path, "w") as f:
            f.write(content)


class RunCommand:
    name = "run_command"
    plan_safe = False

    def execute(self, context, command):
        result = subprocess.run(
            command, shell=True, capture_output=True, text=True,
            timeout=int(os.environ.get("LEIF_TIMEOUT", "30")),
        )
        return ((result.stdout or "") + (result.stderr or "")).strip() or "(No output)"
Tool Plan? Does
read_file yes File with line numbers
list_files yes Tree of the project
search_codebase yes Substring grep
search_web yes DuckDuckGo, 3 hits
save_memory yes Overwrite .leif/memory.md
write_plan yes Write PLAN.md
write_file no Overwrite a file
edit_file no One find-and-replace
run_command no Shell, with a timeout

Plan & Act

Tools that write or run are dangerous on the first turn. Plan is the default. The agent can read, search, save memory, and write PLAN.md. It cannot overwrite your code. /mode act unlocks writing. /mode plan locks it again.

Each tool has plan_safe. We only send safe tools to the model in plan mode. If it asks for write_file anyway, we refuse. Two layers, because models improvise.

def _tools_for_mode(self):
    if self.mode == "act":
        return tool_definitions(self.tools)
    return tool_definitions([t for t in self.tools if t.plan_safe])


def _execute_tool(self, name, args):
    tool = get_tool(self.tools, name)
    if self.mode == "plan" and not tool.plan_safe:
        return f"Error: '{name}' is not available in PLAN mode. Use /mode act to enable writing."
    return tool.execute(ToolContext(memory=self.memory), **args)

Wiring things together

The parts above do nothing until something calls them in order. That something is the Agent. It owns the conversation and the loop

you type
  β†’ Agent.handle_input
       β†’ Brain.think (calls model through OpenRouter)
       β†’ Thought (text and/or tool_calls)
       β†’ if tools: execute, append role:tool, think again
       β†’ print text

The Agentic Loop

This loop is the agent. After the model replies, we save the whole reply in the conversation, not only the words it would print.

That matters when it asked for a tool. The next call has to show: the model asked for this tool, here is what we ran. If we drop the ask and only send the result, OpenRouter rejects it.

def _agentic_loop(self):
    output_parts = []
    for _ in range(50):
        thought = self.brain.think(self.conversation)
        self.conversation.append(thought.raw_message)
        if thought.text:
            output_parts.append(thought.text)
        if not thought.tool_calls:
            break
        for tool_call in thought.tool_calls:
            result = self._execute_tool(tool_call.name, tool_call.args)
            self.conversation.append({
                "role": "tool",
                "tool_call_id": tool_call.id,
                "content": result,
            })
    else:
        output_parts.append("(Stopped: too many iterations)")
    return "\n".join(output_parts)

Memory on every request

The scratchpad is the system prompt. Tools get a ToolContext so they can reach Memory without a global.

def _build_system_prompt(self):
    parts = [self.memory.content] if self.memory else []
    if self.mode == "plan":
        parts.append(
            "You are in PLAN mode. You cannot write code files. "
            "Use write_plan to save your plans to PLAN.md."
        )
    return "\n".join(parts)

Compaction

Long sessions fill the context window. After each turn, if input tokens pass 75% of the model’s limit, the agent asks the brain to summarize history and starts over from that summary. Tools are stripped for that call so the summary is text, not another tool round. Compaction is lossy. That is the trade.

def _compact_conversation(self):
    history = "\n".join(
        f"{m.get('role', '?')}: {str(m.get('content', m))[:500]}"
        for m in self.conversation
    )
    saved_tools = self.brain.tools
    self.brain.tools = []
    try:
        thought = self.brain.think([{
            "role": "user",
            "content": f"Summarize this conversation...\n\n{history}",
        }])
    finally:
        self.brain.tools = saved_tools
    self.conversation = [
        {"role": "user", "content": f"Previous conversation summary: {thought.text}"},
    ]

Putting it in main.py

The shell is still that while True loop. main builds the parts and hands them to Agent. Run it with uv run python main.py (plan) or uv run python main.py --act.

def main():
    mode = "act" if len(sys.argv) > 1 and sys.argv[1] == "--act" else "plan"
    name = os.getenv("LEIF_BRAIN", "claude")
    memory = Memory()
    brain = Brain(name, memory=memory, tools=tool_definitions(tools))
    agent = Agent(brain=brain, tools=tools, memory=memory, mode=mode)

    print("⚑ Leif v1.0 (Explore)")
    print("Commands: /q quit, /switch toggle brain, /mode [plan|act]")

    while True:
        try:
            user_input = input(f"[{agent.brain.name}:{agent.mode}] ❯ ")
            output = agent.handle_input(user_input)
            if output:
                print(f"\n{output}\n")
        except (AgentStop, KeyboardInterrupt):
            print("\nExiting...")
            break

That is the whole agent: a shell, a brain you can switch, tools, a plan/act gate, and a loop that wires them. This is still not a production coding assistant. There is no approval prompt per tool and no git isolation.

Full source on GitHub.