LLM + RoboticsBuild a Robot Brain · Week 10 · Lesson 18

A world-class brain over the internet: planners and agents

Your word counter and your tiny language model live on your own computer and answer in a fraction of a millisecond. But they can't handle "put the red and blue blocks away". Today Robo-Helper borrows a huge model (Claude) over the internet, in two different ways. And you write the code that makes sure it never gets to drive the robot unchecked.

Time3–4 hours (plus the stretch)
You'll needPython 3, your finished Lessons 10 and 11. Optional: an API key from a parent, and the anthropic package
New wordsAPI, API key, environment variable, system prompt, structured output, JSON schema, planner, agent, tool call
Project pieceClaude as the robot's brain, with you in charge

Start here

A big model over the internet can understand almost anything you say, but it is a guest in your robot: you tell it the rules, you describe the room, and you check every single thing it asks the robot to do.

The problem

Your little sister types "put the red and blue blocks away". The word counter from Lesson 9 picks one intent and one colour, bins the red block, and stops. Your tiny LM from Lesson 15 bins the blue one. Neither of them can do two things, neither can say "which block do you mean?", and neither has ever heard of "except" or "closest". Teaching them all that would take thousands of examples. Somebody has already trained a model on a huge chunk of the internet. Can Robo-Helper use it?

The idea in one picture

Imagine you're working in a kitchen and you can phone a world-famous chef. Two ways to use them. Planner: you describe your kitchen, they read you the whole recipe in one call, you hang up and cook it. Quick and cheap, but if the oven turns out to be broken, the recipe doesn't know. Agent: you stay on the phone. They say "open the fridge", you say "done, there's no milk", they say "then use water". Slower, more phone time, but they react to what actually happens. Either way, if the chef says "put your hand on the hot tray", you say no. You're the one standing in the kitchen.

Words to know

API
"Application programming interface": a menu of requests one program can send to another. Here, your Python sends text to Anthropic's computers and gets the model's answer back.
API key
A long secret password that proves a request is yours. Every request is charged to the account the key belongs to, so it's as precious as a bank card.
Environment variable
A named value your terminal hands to every program it starts, like ANTHROPIC_API_KEY. It keeps the secret out of your code files.
System prompt
The standing instructions sent with every request: who the model is, what it may do, the rules. The person's command goes separately.
Structured output
Asking the API for a reply in an exact JSON shape instead of free chat, so a program can read it.
JSON schema
A description of that shape: which keys, what type each value is, which values are allowed.
Planner
One request in, one whole plan out. Then the robot runs the plan.
Agent
The model gets the robot's tools and uses them one at a time, seeing each result before choosing the next step. That back-and-forth is called the agent loop.
Tool call
The model asking your program to run one tool, such as go_to({"object": "blue block"}). Your program decides whether to actually do it.

The full lesson: everything below builds a request from nothing, compares the two ways of using a big model, and then you write the two safety gates that every request has to pass.

1. The problem

Here are four commands from the bake-off you'll run next week. Read them as Robo-Helper's word counter would:

These need a brain that understands language properly, knows what's in the room, can make a plan of several steps, and knows when to ask. That's what large language models are good at. The catch: the good ones are far too big for your laptop. They run in data centres, and you reach them over the internet.

So today's questions are practical ones. How do you send a request? How do you keep your key safe? How do you tell the model about the room? How do you get an answer a program can check? And should the model write the whole plan at once, or work step by step?

2. First principles

Step 1: a request is just a message to another computer

When your Python "calls Claude", nothing magic happens. Your program sends a bundle of text over the internet to Anthropic's servers. A computer there runs the model on that text and sends text back. The bundle has a fixed shape. Here's the planner request from ask_planner in your homework file, simplified:

model       = "claude-opus-5-5"            which model to run
system      = SYSTEM                       the standing rules (Step 3)
messages    = [{"role": "user",
                "content": "Room: ...\nPerson says: put the block away"}]
max_tokens  = 4000                         the longest answer we'll accept
output_config = {... "format": {"type": "json_schema", "schema": PLAN_SCHEMA}}

The anthropic package turns that into an internet request for you. It's not part of Python, so you install it, ideally inside a virtual environment (a "venv"): a private folder of packages just for this project, so nothing else on the computer gets changed. The project README shows the three lines:

python3 -m venv ~/robo-venv
source ~/robo-venv/bin/activate
pip install anthropic

Next time you open a terminal, run the source line again to step back into the venv.

Step 2: the key is a secret, so it never goes in your code

Every request carries your API key, and every request costs a little money on the account that owns the key. Anyone who has your key can spend that money. So, three rules, no exceptions:

  1. Never type the key into a code file. Code gets shared, uploaded to GitHub, pasted into chats. Bots scan GitHub for leaked keys within minutes.
  2. Never paste it into a chat, a message, a screenshot, or a file you share. Not even to "ask for help".
  3. Keep it in an environment variable. Ask a parent to add one line to your ~/.zshrc (the file your terminal reads when it starts): export ANTHROPIC_API_KEY=.... Ask them to set a spending limit on the account too.

Now look at brains.py. The line that connects is just client = anthropic.Anthropic(). There's no key in it. The package looks up ANTHROPIC_API_KEY in the environment by itself. Your code can be shared with the whole world and the key stays at home.

No key? Nothing is blocked. The course includes replay brains, planner-replay and agent-replay, which re-run real recorded Claude runs from project/solutions/recordings/ through your safety checks, offline and free. If that folder isn't there yet, they say (no recording for this command) and the bake-off skips them.

Step 3: the system prompt sets the rules

A model has read a huge amount of text, but it knows nothing about your robot. The system prompt is where you tell it. It's sent with every request, before the person's command. Here's the start of SYSTEM from your homework file:

You are the brain of Robo-Helper, a small cleaning robot in a room with three blocks and a bin.
You can only: put a block in the bin ("put"), drive to a block ("go"),
report where a block is ("find"), or "stop".
Rules:
- Only use the blocks listed in the room description. Never invent objects.
- If the person's request is ambiguous ..., ask ONE short clarifying question instead of guessing.
- If the request is impossible or unrelated to tidying ..., politely say so and do nothing.
- Prefer doing exactly what was asked: don't bin blocks the person didn't ask about.

Notice what it does: it says who the model is, lists the only allowed actions (the same four as Lesson 10), and covers the tricky cases you saw in Section 1 (ask when unsure, refuse the sandwich). The system prompt is a request, though, not a lock. A model usually follows it. "Usually" is not good enough for a robot, which is why Step 7 exists.

Step 4: the model can't see the room, so you describe it

"Put away the block that's closest to the bin." Closest to what? The model is in a data centre. It has never seen your room. It only knows what's in the text you send. So ask_planner puts the world state into the message, using robot.look(). On a fresh robot, that's this (real output):

Robot at (1, 1), holding nothing. Bin at (7, 6). Blocks not yet in the bin: {'red block': (2, 1),
'blue block': (6, 3), 'green block': (1, 5)}. In the bin: nothing. Obstacles seen so far: none.

Now it can do the maths you'd do. Distance to the bin at (7, 6), counting squares across plus squares down:

red   (2, 1):  |7 − 2| + |6 − 1| = 5 + 5 = 10
blue  (6, 3):  |7 − 6| + |6 − 3| = 1 + 3 =  4   ← closest
green (1, 5):  |7 − 1| + |6 − 5| = 6 + 1 =  7
Predict first: what might go wrong if you forgot to send the room description?

The model would have to guess. It might assume a "yellow block" exists, or pick "closest" at random, or ask where things are. A model always answers something, so a missing fact doesn't cause an error: it causes a confident wrong answer. Rule: everything the model needs to decide must be in the request.

Step 5: structured outputs make the reply checkable

In Lesson 10, pretend models replied with chatty text, broken JSON and extra keys, and you had to dig the JSON out with extract_json. Real APIs can do better. You send a JSON schema and the API makes the reply fit that shape. Here's PLAN_SCHEMA in plain words:

{
  "reply":   a string            what to say to the person
  "clarify": a string, or null   a question to ask instead of acting
  "plan":    a list of actions, each exactly {"intent": put|go|find|stop,
                                              "object": red block|blue block|green block|null}
}

So for "put the block away", a good reply would look like this (an example we wrote to show the shape):

{"reply": "Happy to help!", "clarify": "Which block: red, blue or green?", "plan": []}

And for "put the red and blue blocks away" (also our example):

{"reply": "On it!", "clarify": null,
 "plan": [{"intent": "put", "object": "red block"}, {"intent": "put", "object": "blue block"}]}

A machine-checkable reply is the whole point. Your program can now test every key and every step before anything moves. Does the schema make checking unnecessary? No. The schema fixes the shape, not the sense. It allows a plan with 40 steps. It allows a question and a plan at once ("Which block?" while already binning the red one). A model can also decline to answer, in which case there's no JSON at all and ask_planner returns None. And the same checks protect brains that don't support schemas well, like a small model on your own computer.

Step 6: two ways to use the model, planner and agent

The planner makes one request: room plus command in, whole plan out. Then run_plan (in brains.py) does each step with the robot you built in Lesson 11, stopping at the first failure. One request means it's quick and cheap. But the plan is written before the robot moves. Your robot's own reflexes (Lesson 11's drive_to) will still drive around the cat, but if something changes that the plan depended on, the plan can't change its mind.

The agent doesn't get a list of intents. It gets the robot's tools, each described with its own little schema:

ToolInputWhat it does
looknothingDescribes the room (the text from Step 4).
go_to{"object": ...}Drives to a block, replanning around obstacles.
pick_up{"object": ...}Picks up a block. The robot must already be at it.
put_in_binnothingDrives to the bin and drops what it holds.

Then run_agent runs the agent loop. Here it is in plain words:

messages = [the person's command]
repeat at most 12 times:
    send messages (and the tool list) to the model
    if the reply has no tool calls: it's finished, keep its text and stop
    for each tool call in the reply:
        ok, text = execute_tool(robot, name, args, known)     ← YOUR gate
        add a "tool_result" with that text (and is_error = not ok)
    add the model's reply and all the results to messages

Two things to notice. First, the model sees every result, including errors. If it tries to pick up a block while holding another, it gets back "Already holding the blue block. Put it in the bin first." and can fix its own mistake. Second, every turn sends the whole conversation so far again, so each request is bigger than the last. An agent makes many trips and reads more text: slower and more expensive, but able to react.

Predict first: in "put the red and blue blocks away", how many requests does a planner make? An agent that calls one tool per turn?

The planner: one. The agent, if it does look, then go_to, pick_up, put_in_bin for each block, makes 7 tool calls, one per turn, plus one more turn where it replies "done" without calling a tool: 8 requests. Each one waits for the internet and the model. That's the price of being able to react.

Step 7: you stay in charge

Here's the most important idea in the lesson. The model never touches the robot. It only ever produces text: a plan, or a tool call. Your code decides what happens. Two gates, and you write both:

There are other limits built in too: MAX_PLAN = 6 steps, max_turns=12 for the agent loop, so a confused model can't loop for ever (and spend for ever). That's how real companies use these models in products: the model suggests, the program checks, the program acts.

Predict first: the agent calls a tool named self_destruct. What happens?

Your execute_tool doesn't recognise the name and returns (False, "Unknown tool 'self_destruct'. Allowed: look, go_to, pick_up, put_in_bin."). The robot doesn't move. The model receives that text as an error result and has to pick a real tool. A list of allowed things is far safer than a list of banned things: you could never think of every bad idea in advance.

Step 8: what does a request cost?

Big models charge per token (roughly a word or a piece of a word, from Lesson 16), with different prices for what you send and what you get back. For claude-opus-5-5 the prices in lesson18_world_class.py are $4 per million input tokens and $20 per million output tokens. cost_usd does the sum. Suppose (made-up numbers, just to practise) a request sends 1,000 tokens and gets 200 back:

in:   1,000 / 1,000,000 × $4  = $0.004
out:    200 / 1,000,000 × $20 = $0.004
total                         = $0.008   (less than one cent)

Tiny for one request. But the agent sends the growing conversation every turn, and a robot might hear thousands of commands. You'll measure the real numbers in the bake-off.

3. Play with it

Interactive: be the planner, meet the gate

The real room from world.py, with the cat at (4, 1) that the robot doesn't know about until it sees it. Type (or break) a planner reply in the box and press Check and run. The page runs validate_plan exactly like the Python, including Lesson 10's checks and the same reasons word for word. If the reply passes, it runs the plan the way execute_plan_reply does, with Lesson 11's A* and stop-neuron. Your typed reply plays the part of the model.


validate_plan
–
Steps run
–
Moves
–
In the bin
–

Try: the good plan (watch the robot meet the cat on the way to the blue block). Then change "blue block" to "purple block". Delete the "clarify" key. Make the plan seven steps long. Put the same block in twice: what does the robot say the second time?

Step through the agent loop

This tool-call sequence is an example we wrote to show the shape, for the command "put the red and blue blocks away". It is not a recorded Claude run. The tool results are real: each call was run through the solution's execute_tool on the simulator, cat and all. Notice the mistake in call 4, and how the error goes back to the model.


Requests sent
0
Tool calls
0
Errors fed back
0
Messages in history
1

Watch the last stat. Every request re-sends the whole history, so by the end the model is re-reading 19 messages to decide one thing. That's why agents cost more than planners.

4. Homework

Open project/homework/lesson18_world_class.py. The top half (prompts, schema, tools) and the bottom half (ask_planner, run_agent) are written for you. Read them, especially run_agent: you should be able to point at each line of the loop from Step 6. Then write the two gates.

4.1 validate_plan(plan_obj, known_objects)

Check a planner reply before the robot acts on it. Return (True, "") if it's safe, or (False, reason). In order:

  1. plan_obj must be a dict whose keys are exactly reply, clarify, plan.
  2. plan_obj["plan"] must be a list, with at most MAX_PLAN (6) actions.
  3. Every action must pass Lesson 10's validate_action(action, known_objects). Say which step failed.
  4. If clarify is a non-empty string, the plan must be empty.

Examples (real output from the solution):

{"reply": "On it!", "clarify": None, "plan": [put red, put blue]}   → (True, '')
{"reply": "Sure", "clarify": None, "plan": [put "the cat"]}         → (False, "step 1: unknown object 'the cat'")
{"reply": "Which one?", "clarify": "Which block?", "plan": [put red]} → (False, 'asked a question AND gave a plan: ask first, act later')
Hint 1

Check the outside first, then the inside. If plan_obj isn't even a dict (it could be None when the model declined), you can't look up its keys. isinstance(x, dict) tells you. Return straight away on each failure: flat if ... return lines, no nesting.

Hint 2

"Exactly these keys" is a set comparison: set(plan_obj) != {"reply", "clarify", "plan"}. Python's and short-circuits, so not isinstance(...) or set(...) != ... is safe even for None. Use enumerate to number the steps from 1: f"step {i + 1}: {reason}".

Hint 3
not a dict, or keys != {reply, clarify, plan}   → False, "reply must have exactly the keys reply, clarify and plan"
plan = plan_obj["plan"]
plan not a list                                 → False, "plan must be a list"
len(plan) > MAX_PLAN                            → False, f"plan too long ({len} > {MAX_PLAN})"
for i, action in enumerate(plan):
    ok, reason = validate_action(action, known_objects)
    not ok                                      → False, f"step {i + 1}: {reason}"
clarify is truthy AND plan is non-empty         → False, "asked a question AND gave a plan: ..."
→ True, ""

4.2 execute_tool(robot, name, args, known_objects)

Run one tool call from the agent on the simulated robot. It must never crash: whatever the model sends, return (ok, text).

Example: on a fresh SimRobot(), execute_tool(robot, "pick_up", {"object": "red block"}, known) → (False, "Not at the red block: it's at (2, 1), robot is at (1, 1). Use go_to first."). That message comes from the robot, not from you: you just pass it on.

Hint 1

The robot (SimRobot in sim.py) already has look, go_to, pick_up and put_in_bin. Your job is the doorman, not the robot: check the request, then hand it over.

Hint 2

Use args.get("object"), not args["object"]: if the key is missing, .get gives None instead of crashing, and None isn't a known object, so it's refused. Check isinstance(args, dict) before anything else.

Hint 3
args not a dict              → False, "Tool arguments must be an object."
name == "look"               → True, robot.look()
name == "put_in_bin"         → return robot.put_in_bin()
name in ("go_to", "pick_up"):
    obj = args.get("object")
    obj not in known_objects → False, f"Unknown object {obj!r}. ..."
    return robot.go_to(obj) or robot.pick_up(obj)
otherwise                    → False, f"Unknown tool {name!r}. Allowed: ..."

Run it

No key needed: the bottom of the file tests your gates on pretend replies and pretend tool calls.

cd project/homework
python3 lesson18_world_class.py

What you should see (real output from the solution):

good           -> (True, '')
sneaky         -> (False, "step 1: unknown object 'the cat'")
asks AND acts  -> (False, 'asked a question AND gave a plan: ask first, act later')
look         {}                       -> (True, "Robot at (1, 1), holding nothing. Bin at (7, 6). Blocks not yet in the bin: {'red block': (2, 1), 'blue block': (6, 3), 'green block': (1, 5)}. In the bin: nothing. Obstacles seen so far: none.")
pick_up      {'object': 'red block'}  -> (False, "Not at the red block: it's at (2, 1), robot is at (1, 1). Use go_to first.")
go_to        {'object': 'red block'}  -> (True, 'Arrived at the red block at (2, 1).')
pick_up      {'object': 'red block'}  -> (True, 'Picked up the red block.')
put_in_bin   {}                       -> (True, 'The red block is in the bin.')
self_destruct {}                       -> (False, "Unknown tool 'self_destruct'. Allowed: look, go_to, pick_up, put_in_bin.")

Three planner replies: one passes, two are stopped with a clear reason. Then six tool calls: the first pick_up is refused by the robot (wrong square), the next three work, and self_destruct is refused by you.

With a key (in your venv, from project/solutions or your finished homework), watch the real thing:

python3 sim.py "put the block away" --brain planner
python3 sim.py "tidy up the room" --brain agent --watch

Without a key, use --brain planner-replay or --brain agent-replay to replay the recorded runs through your gates.

Check it

cd project
python3 check.py 18

Four checks: validate_plan: good plans, validate_plan: bad plans, execute_tool: real moves and execute_tool: never crashes. A green ✓ means that group passed. A red ✗ prints a hint, such as "asking AND acting -> reject" or "args that aren't a dict -> (False, ...)". A yellow … means a function still says NotImplementedError, or an earlier lesson (10 or 11) isn't finished.

Stretch: a dry run before the real run

Real robot companies often test a plan in a simulator before the real machine moves. Write dry_run(plan_obj): make a fresh SimRobot(), run every step with robot.do_action(action), and return (False, message) at the first step whose result has ok False, else (True, ""). Then add it as a third gate after validate_plan. Which plans pass validate_plan but fail the dry run? (Hint: try a "go" to a block surrounded by surprises.) If you have a key, also run the planner and the agent on "tidy up the room" and compare how many requests and tokens each used.

5. Real-world challenge: phone a chef

You'll play planner and agent with real people, and see the trade-off with your own eyes. You need three people (you, a "model" and a "robot"), two rooms, a stopwatch, paper, three small objects and a box as the bin.

  1. Set up. In room A, put the three objects and the box somewhere on the floor. The robot stands in room A. The model sits in room B and can't see room A. You are the checker.
  2. Write the world state. On a slip, describe room A for the model: where each object is, where the box is. (That's look().)
  3. Agree the tools. The only allowed instructions are: look, go to X, pick up X, put in box. You'll refuse anything else, and anything about an object that isn't on the list.
  4. Round 1, planner. Give the model the slip and a command such as "put two things away, but not the one nearest the door". They write the whole plan on one slip. You check every line, then hand it to the robot, who does it exactly. Just before they start, quietly move one object. Time the round. Did it work?
  5. Round 2, agent. Reset. Same command, same secret move. This time the model sends one instruction per slip, and the robot writes back what happened on each ("done", or "can't: nothing there"). You check every slip both ways. Time it, and count the slips.
  6. Record for each round: success (yes/no), time, number of slips (requests), and how many slips you refused.

What it teaches: the planner round is quick and needs few messages, but breaks when the world changes after it planned. The agent round copes with the surprise, at the cost of many more slips and more time. And in both rounds, the person who could actually prevent a mistake was the checker, not the clever one in the other room.

6. Your Robo-Helper so far

Robo-Helper can now use Claude as its brain, with you in charge. Your two gates are used everywhere a big model touches the robot: in brains.py, execute_plan_reply calls your validate_plan for the planner, planner-replay and local brains, and AgentBrain (through run_agent) and the agent replay call your execute_tool for every single tool call.

Next week, in Lesson 19, run_bakeoff() puts every brain (your word counter, your tiny LM, and Claude as planner and as agent) through the same 12 tasks, and your new score_task() decides who actually did the right thing.

7. Common mistakes

8. Check yourself

Why does the request include robot.look()?

The model can't see the room. It only knows what's in the text you send. Without the world state it would have to guess positions, and a model guesses confidently rather than raising an error.

The API already forces the reply into PLAN_SCHEMA. Why write validate_plan at all?

The schema fixes the shape, not the sense: it allows any length of plan, and a question together with a plan. The model can also decline and send nothing. And the same gate protects brains that don't follow schemas reliably. Never trust, always check.

Predict: validate_plan({"reply": "ok", "clarify": "", "plan": [put red]}, known). Pass or fail?

Pass: (True, ''). An empty string is "falsy" in Python, so there's no real question, and a plan is allowed.

Give one reason to use a planner and one to use an agent.

Planner: one request, so it's faster and cheaper, and you can check the whole plan before anything moves. Agent: it sees every result, so it can recover from surprises and from its own mistakes.

Predict: the agent calls go_to with {"object": "purple block"}. What does the robot do, and what does the model see?

The robot does nothing. Your execute_tool returns (False, "Unknown object 'purple block'. Known: [...]"), and run_agent sends that text back as a tool result with is_error set. The model then has to choose again: ideally, tell the person there's no purple block.

9. Go deeper (optional)