This is the lesson where everything so far becomes one working program. Type it out rather
than copying it. You want the loop in your fingers.

Set up

Make a folder, put llm.py in it, and give Rover something to read:

mkdir rover && cd rover
cp /path/to/llm.py .
echo "Buy milk. Call the plumber. Finish the report by Friday." > notes.txt

The whole agent

Save this as agent.py:

from llm import connect

llm = connect("ollama:gemma4")          # the only line that names a provider

# 1. The menu we hand to the model.
TOOLS = [
    {
        "name": "read_file",
        "description": (
            "Return the full text contents of a file. "
            "Use this whenever you need to know what is inside a file."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "path": {
                    "type": "string",
                    "description": "The name of the file, for example notes.txt",
                }
            },
            "required": ["path"],
        },
    }
]


# 2. The code that actually does the thing. This is the part the model cannot do.
def read_file(path):
    with open(path) as f:
        return f.read()


def run_tool(name, arguments):
    if name == "read_file":
        return read_file(arguments["path"])
    return f"No tool named {name}."


# 3. The loop.
def main():
    messages = [
        {"role": "user", "content": "What is in notes.txt? Summarise it in one line."}
    ]

    for turn in range(10):
        reply = llm.send(messages, TOOLS)

        if reply.text:
            print(reply.text)

        # Keep the reply whole: the text and the tool calls.
        messages.append({"role": "assistant", "content": reply.text,
                         "tool_calls": reply.tool_calls})

        if reply.stop in ("length", "max_tokens"):
            print("[reply was cut off — raise max_tokens]")
            break

        if not reply.wants_tool:
            break

        # Run every tool it asked for, and collect the results.
        results = []
        for call in reply.tool_calls:
            print(f"[running {call.name} with {call.arguments}]")
            output = run_tool(call.name, call.arguments)
            results.append({"id": call.id, "content": output, "is_error": False})

        messages.append({"role": "tool_results", "results": results})


if __name__ == "__main__":
    main()

Run it:

python3 agent.py

What you should see

Something close to this:

[running read_file with {'path': 'notes.txt'}]
The notes contain three tasks: buy milk, call the plumber, and finish the report by Friday.

Look at the order. The model asked for the file before it answered. Nobody told it to do
that. It read the tool description, decided the tool was relevant to the question, and asked.

That decision is the thing you just built.

Read your own program again

Four pieces, and you now know why each one is there:

  1. TOOLS — the menu. Lesson 1.4.
  2. run_tool — your code, doing the work the model cannot do. Lesson 1.4.
  3. messages — the memory, appended to whole. Lesson 1.6.
  4. reply.wants_tool — the control flow. Lesson 1.5.

And one line naming a provider. Change it to connect("anthropic:claude-opus-5") or
connect("gemini:gemini-2.5-flash") and everything else runs unchanged. Try it if you have a
second option available — watching the identical program run on a different company’s model is
worth the thirty seconds.

One honest warning

read_file will open any file your user account can open. Ask Rover about /etc/passwd and
it will read it out.

That is a genuine security hole, and we do not fix it here. We fix it in Lesson 3.5, where
you will get to exploit it first and then close it. Keep this code in a folder you do not
mind poking at until then.

Every agent product you have used is this loop wearing a coat

Sixty lines. A model, a menu, and a loop. Now think about the agent tools you have actually
used. They feel enormously bigger than what you just wrote. Here is what the difference is
made of:

What the product does What that actually is Where we build it
Reads, writes, edits, searches your project More entries in TOOLS Module 3
Runs terminal commands One more tool, and a lot of care Module 3
Asks “allow this?” before acting An if before you call the function Module 3
Recovers when a command fails Sending the error back as a result Module 4
Stops when it goes in circles A counter on the loop Module 4
Connects to Slack, GitHub, your database Somebody else’s menu, in a standard shape Module 5
Stays sharp in a two-hour session Managing the messages list Module 6
Remembers your project between sessions Writing notes to a file Module 6

Not one row on that list is a different mechanism. Every row is an addition to the loop in
agent.py.

What is genuinely hard is not the loop. It is deciding what the agent is allowed to do,
knowing whether it actually did the job, keeping it useful in a long session, and knowing
whether your change made it better. Those are Modules 3, 4, 6 and 7 — and they are the course.

There is no second, more advanced kind of agent. There is this loop, and there is everything
people have carefully built around it.

Try this before the next module

Three experiments, in order:

  1. Ask a question that needs no file at all: "What is 12 times 12?" A strong model answers
    directly. A small local model may call read_file anyway — that is a real limitation and
    we deal with it properly in Module 2.
  2. Ask about a file that does not exist. Watch it crash, and read the traceback. That crash is
    Lesson 2.5.
  3. Add a write_file tool by copying the shape of read_file. Ask Rover to write a summary
    into summary.txt. You now have an agent that changes your disk.

Then answer this in one sentence: what does the model do that your code cannot, and what does
your code do that the model cannot?
If you can answer that cleanly, you understand agents
better than most people using them.