Building a Claude Agent from Scratch

Checkout the official github repository link at: Learn Claude Code

Before diving in, here’s the mental model that anchors everything: an agent is not the LLM. The LLM is the brain — it reasons and decides. The agent is the body — the system that gives the brain hands, eyes, and a nervous system to interact with the real world. The LLM’s knowledge and reasoning capacity are fixed; what you expand through agent design is its ability to act.


S01 — The Agent Loop

A language model can reason about code, but it cannot touch the real world on its own. It can’t read files, run tests, or check error output. Without a loop, every tool call requires you to manually copy-paste results back into the conversation. You become the loop. S01 automates exactly that.

The architecture is two nested loops. The outer loop waits for user input — if the query is empty or a quit command, the program exits. Otherwise it hands the query off to agent_loop(). The inner loop drives a single query to completion: send the full message history to the LLM, receive a response, execute any requested tools, feed the results back, and repeat — until stop_reason is no longer "tool_use".

+--------+    +-------+    +---------+
|  User  | -> |  LLM  | -> |  Tool   |
| prompt |    |       |    | execute |
+--------+    +---+---+    +----+----+
                  ^              |
                  |  tool_result |
                  +--------------+
      (loop until stop_reason != "tool_use")
def agent_loop(query):
    messages = [{"role": "user", "content": query}]

    while True:
        response = client.messages.create(
            model=MODEL, system=SYSTEM,
            messages=messages, tools=TOOLS,
            max_tokens=8000,
        )
        messages.append({"role": "assistant", "content": response.content})

        if response.stop_reason != "tool_use":
            return  # task complete, back to outer loop

        results = []
        for block in response.content:
            if block.type == "tool_use":
                output = run_bash(block.input["command"])
                results.append({
                    "type": "tool_result",
                    "tool_use_id": block.id,
                    "content": output,
                })
        messages.append({"role": "user", "content": results})

The client.messages.create() call packages everything the API needs: the model identifier, a system prompt defining the agent’s role, the full conversation history in messages[], the tool definitions in tools (a schema the LLM reads to decide which tool to call), and max_tokens capping the output size per turn — not the total session cost. Critically, the API is stateless — system and tools must be resent with every request.

The LLM itself never executes a tool. It returns a structured response saying “I want to call bash with this command”. Your Python code reads that intent, runs the actual subprocess, and sends the result back. The LLM only sees text in, text out — execution always happens on your side.

stop_reason tells you why the LLM stopped generating this turn. It can be "tool_use" (keep going), "end_turn" (task done), or "max_tokens" (output was truncated). In practice the only branch that matters is whether it equals "tool_use" or not.


S02 — Tools

S01 works, but relying solely on bash is a problem. Shell commands are untyped strings — the LLM can issue rm -rf just as easily as cat. There’s no layer to validate paths, normalize output, or enforce safety rules. Every bash call is an unconstrained security surface.

S02 introduces a dispatch map — a dictionary that maps tool names to Python functions. The loop becomes generic: it looks up block.name in the map and calls whatever handler it finds. The loop structure is identical to S01; only the map grows.

TOOL_HANDLERS = {
    "bash":       run_bash,
    "read_file":  read_file,
    "write_file": write_file,
    "list_dir":   list_dir,
}

# In the loop:
for block in response.content:
    if block.type == "tool_use":
        handler = TOOL_HANDLERS[block.name]  # dispatch
        output  = handler(block.input)        # execute

One response can contain multiple tool_use blocks — the loop executes all of them and returns all results together in a single user message. Each result carries a tool_use_id so the LLM can match every output back to the specific call that produced it.

Dedicated tools also enable path sandboxing — restricting file access to the project directory, sometimes called a directory jail. Without it, a read_file call with path ../../etc/passwd would quietly traverse outside the project. The fix resolves the absolute path first, then rejects anything that escapes the working directory. This is an instance of the Principle of Least Privilege: give the agent only the access it actually needs.

def read_file(input):
    path = os.path.abspath(input["path"])
    if not path.startswith(os.getcwd()):
        return "Error: path outside sandbox"
    with open(path) as f:
        return f.read()

To add any new tool, three things are needed: write the handler function, register it in the dispatch map, and append its schema to TOOLS so the LLM knows the tool exists. That’s the entire contract — zero changes to the loop.


The broader takeaway from these two sessions is that agent development is mostly tool design. The loop is just the nervous system connecting intent to action. Once written, it never needs to change — everything else layers on top.