Practices/ Capable Series/ Day 03 · Command
03 / 04
Pradhya Day 03 · Command 5 units · ~25 min reading + 40 min hands-on

Tools turn talk into action.

A chatbot writes you a recipe. An agent reads your pantry, picks the dish, places the grocery order, and adds the prep step to your calendar. The difference is one capability: tool use.

By the end of Day 03 · Command
  • Declare a tool with a description Claude will use correctly
  • Run the full tool-use loop end to end (tool_use → tool_result → answer)
  • Get schema-guaranteed JSON back with structured outputs
  • Apply the three rules of the agentic discipline (scope, preview, reversibility)
Get the code

Today’s labs run a Python script. If you did Day 1, you already have an agents/ folder with a virtual environment. Reuse it. If not, set one up now (one minute) and save the two files into it:

# one-time setup (macOS / Linux), from a folder named agents/
python3 -m venv .venv
source .venv/bin/activate
pip install anthropic
export ANTHROPIC_API_KEY="sk-ant-..."   # get one at console.anthropic.com

Verify your setup: python hello_claude.py prints a critique. Then you’re ready for §03.03.

§ 03.01 · Unit 19

What a tool is.

A tool is a function the model can ask you to run. You tell the model what tools exist. The model decides when to call one. You execute it. The model uses the result.

The model never runs code directly. It returns a structured request. “please run get_weather with city='Phoenix'”. And waits. Your code runs the function and sends the answer back. The model then incorporates the result into its response.

This loop is the whole game. Once you see it, every agent in the world is the same diagram, just with more tools and more turns.

Claude (model) decides, never executes get_weather(...) your code · runs locally user prompt → 2. Tool_use { city: "Phoenix" } 3. Tool_result { 102°F, sunny } ← final answer

1. User prompt → 2. Model emits tool_use → 3. Your code returns tool_result → 4. Model writes final answer

The shift Step 2 is the new thing. In a chatbot, the model writes the answer. In an agent, the model writes a request. And then waits for the world to answer back.

Map the four steps onto a real task.

You’ll do
Pick one repetitive task from your week and write the loop’s four steps for it on one line each.
Steps
  1. Name a task where Claude would need to act, not just talk (e.g. “check today’s calendar,” “read the latest CSV in Downloads”).
  2. Write line 1. The user prompt in plain English.
  3. Write line 2. The tool_use the model would emit (tool name + one argument, e.g. read_file{path: "report.csv"}).
  4. Write lines 3 and 4. The tool_result your code returns, and the final answer the model writes from it.
Verify
You have four labelled lines, and you can point at line 2 (tool_use) as the one step a plain chatbot never produces.

Stretch. Add a second tool to the same task and mark which step now repeats (the loop runs once per tool the model needs).

§ 03.02 · Unit 20

Declaring a tool.

A tool is described by JSON Schema. Name, description, parameters. The description matters. The model decides whether to call the tool by reading it.

From the last unit: You know what a tool is. Now you need to tell Claude about yours. The shape is JSON Schema; the leverage is the description.

get_weather description: when to use it params: city, units required: city model reads model decides
the description
is the most
important field
Name + description + schema · the model only sees these
tools = [
    {
        "name": "get_weather",
        "description": (
            "Get the current weather for one city: temperature, "
            "conditions (rain, sun, cloud), and wind speed. "
            "Use this when the user asks about the weather right now "
            "or what to wear today. It does not return forecasts or "
            "past weather, so say so if the user asks for those."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "city": {
                    "type": "string",
                    "description": "City name, e.g. phoenix or San Diego",
                },
                "units": {
                    "type": "string",
                    "enum": ["fahrenheit", "celsius"],
                    "default": "fahrenheit",
                },
            },
            "required": ["city"],
        },
    }
]

Why the description is the most important field

The model doesn’t see your function code. It sees the name and the description. A good description tells the model not just what the tool does but when to reach for it. Treat it like a man page: what the tool does, when to reach for it (and when not to), what each parameter means, and what it does not return. Leave out how it is implemented. Three or four sentences is a normal length; a one-line description is the most common tool-design mistake.

Declare a tool for your work.

You’ll do
Write a JSON-Schema tool declaration for one task you’d hand to Claude, and prove it parses.
Steps
  1. Pick one task from your week (e.g. “look up an order by id,” “create a calendar event”). No task in mind? Copy the get_weather block above and edit it.
  2. Copy the get_weather declaration as your template. Rewrite name, the description (say when to use it, not just what), and the input_schema properties.
  3. Save it as my_tool.py with import json at the top and your declaration assigned to a variable named tool.
  4. Run it: python -c "import my_tool, json; t=my_tool.tool; json.dumps(t); print(sorted(t)[:3])".
Verify
json.dumps(tool) runs without error and the dict has all three keys: name, description, input_schema (with at least one property).

Stretch. Paste your declaration into a fresh Claude chat and ask, “Given this tool, what user message would make you call it?” If the answer matches your intent, your description is doing its job.

§ 03.03 · Hands-on · 25 min

The full tool-use loop.

Save this as tool_use_demo.py in the same agents/ folder you set up on Day 1. It declares two tools, calls Claude, and runs the loop until the model is done.

From the last unit: You have one tool declared. Now run the full loop end to end. The shape every agent in the world uses underneath.

model decides tool_use harness executes result model reads it done? if not, loop
The tool-use loop · model · harness · model · halt

The pattern works for any tool. Reading a file, hitting an API, querying a database, running a shell command. Once you write the loop once, every agent is the same shape underneath.

# tool_use_demo.py — full minimal loop
import json, datetime
from anthropic import Anthropic

client = Anthropic()
MODEL = "claude-sonnet-5-5"

# 1. Declare the tools
tools = [
    {"name": "get_current_time",
     "description": "Return the current local date and time (ISO 8601). Takes no input. Use it when the answer depends on today’s date or time.",
     "input_schema": {"type": "object", "properties": {}}},
    {"name": "read_file",
     "description": "Read a UTF-8 text file and return its first 8000 characters. Read-only.",
     "input_schema": {
        "type": "object",
        "properties": {"path": {"type": "string"}},
        "required": ["path"]}},
]

# 2. Implement the tools
def get_current_time():
    return datetime.datetime.now().isoformat(timespec="seconds")

def read_file(path):
    with open(path, "r", encoding="utf-8") as f:
        return f.read()[:8000]

TOOL_FNS = {
    "get_current_time": lambda **_: get_current_time(),
    "read_file":        lambda **a: read_file(a["path"]),
}

# 3. The loop
def run(user_prompt):
    messages = [{"role": "user", "content": user_prompt}]
    while True:
        resp = client.messages.create(
            model=MODEL, max_tokens=4000,
            tools=tools, messages=messages)
        messages.append({"role": "assistant", "content": resp.content})
        if resp.stop_reason != "tool_use":
            for b in resp.content:
                if b.type == "text": print(b.text)
            return
        tool_results = []
        for b in resp.content:
            if b.type != "tool_use": continue
            print(f"[tool] {b.name}({json.dumps(b.input)})")
            try: out = TOOL_FNS[b.name](**b.input)
            except Exception as e: out = f"ERROR: {e}"
            tool_results.append({"type": "tool_result",
                                 "tool_use_id": b.id, "content": str(out)})
        messages.append({"role": "user", "content": tool_results})

if __name__ == "__main__":
    run("What time is it? Then read ./demo.txt and summarize the file in three bullets.")

Run it

The prompt asks the model to read ./demo.txt, so create that file first. One line is enough. Then run the loop from inside the agents/ folder where you saved the script.

# from the same agents/ folder you saved Day 1’s code into
cd agents
echo "Pradhya Day 03 demo: this line gets read by the tool-use loop." > demo.txt
python tool_use_demo.py

Example run

The two [tool] lines come from the print(...) in the loop. One per tool call. And everything after them is the model’s final answer:

$ python tool_use_demo.py
[tool] get_current_time({})
[tool] read_file({"path": "./demo.txt"})
It’s 2026-10-22T09:14:22 right now. demo.txt holds a single line, so one
bullet covers it rather than three:
- “Pradhya Day 03 demo: this line gets read by the tool-use loop.”

Your timestamp and exact wording will differ. The two tool calls and the file-grounded summary will not. Notice the model reported what the file actually says instead of padding it out to the three bullets you asked for. Current models usually do. You still check, and the lab’s Verify step is where you do it. You just built an agent.

Run the tool-use loop end to end.

You’ll do
Run tool_use_demo.py and watch the model call two tools, then answer from the file.
Steps
  1. Save the code above as tool_use_demo.py in the same agents/ folder you used on Day 1 (or download tool_use_demo.py. Right-click → Save As).
  2. From a terminal: cd agents. Then activate Day 1’s venv (source .venv/bin/activate) and confirm the key is present without printing it: python -c "import os; print('set' if os.getenv('ANTHROPIC_API_KEY') else 'missing')".
  3. Create the file the demo reads: echo "hello from demo.txt" > demo.txt.
  4. Run it: python tool_use_demo.py.
Verify
The terminal shows a [tool] get_current_time(...) line and a [tool] read_file(...) line, then a final answer that quotes what you put in demo.txt.

Stretch. Add two more lines of invented content to demo.txt, then run python tool_use_demo.py "Read ./demo.txt and list every tool it mentions.". Keep real work files and secrets out of this exercise.

§ 03.04 · Unit 22

Structured outputs.

Sometimes you don’t want the model to talk. You want a JSON object you can pass straight to another system.

From the last unit: Tool use returns a result the model reads. Sometimes you want the result to BE the answer. Structured, typed, ready to pass to other code. Same idea as a tool schema, but the schema shapes the answer itself.

"The meeting was with Wei Ling, we discussed the carbon-border- tax linkage and she wanted..." prose · hard to consume json schema { who: "Wei Ling", topics: ["carbon-border-tax"], ask: "..." } typed · pass straight to code
Structured outputs as typed return · the model fills the form

The trick: hand the API a JSON Schema in output_config.format and the reply is guaranteed to be valid JSON matching it (structured outputs). This is the closest thing to a typed return value the model offers. (Older code forced a tool call with tool_choice to get the same effect; the newest models — Sonnet 5.5, Opus 5.5, Fable 5.1 — reject forced tool choice, so use structured outputs.) The fragment below is complete. Save it as structured_demo.py in your agents/ folder and run it.

# structured_demo.py — make the model fill a form (structured outputs)
import json
from anthropic import Anthropic

client = Anthropic()
MODEL = "claude-sonnet-5-5"

meeting_notes = """
Call with Dana Okafor (procurement lead, Northwind).
Still open: the SOC2 letter they asked for last week, and pricing for the 50-seat tier.
Dana wants a revised quote before their board meeting on Friday.
She mentioned the security review is the only blocker left on their side.
Budget is already approved on their end; legal sign-off is the long pole.
Next step on us: send the SOC2 letter and the revised 50-seat quote by Wednesday.
"""

brief_schema = {
    "type": "object",
    "properties": {
        "who":            {"type": "string"},
        "open_threads":   {"type": "array", "items": {"type": "string"}},
        "talking_points": {"type": "array", "items": {"type": "string"},
                           "description": "Exactly three talking points."},
        "the_ask":        {"type": "string"},
    },
    "required": ["who", "open_threads", "talking_points", "the_ask"],
    "additionalProperties": False,
}

resp = client.messages.create(
    model=MODEL,
    max_tokens=4000,
    output_config={"format": {"type": "json_schema", "schema": brief_schema}},
    messages=[{"role": "user", "content": "Write a one-page meeting brief from these notes:\n" + meeting_notes}],
)
text = next(b.text for b in resp.content if b.type == "text")
brief = json.loads(text)   # guaranteed to match brief_schema
assert len(brief["talking_points"]) == 3, "ask again: need exactly three"
print(json.dumps(brief, indent=2))

No tools, no loop. One request, one schema-valid dict back. Schemas can’t express every rule (array lengths, numeric ranges), so the assert checks the one rule the schema can’t. This pattern is the foundation for the meeting-prep agent and the SEO content-brief workflow. Anywhere you need a model to fill in a form rather than write prose.

Make the model fill a form.

You’ll do
Run structured_demo.py and confirm the output is valid JSON with the four required keys.
Steps
  1. Save the code above as structured_demo.py in your agents/ folder (the venv from Day 1 already has anthropic installed).
  2. Run it: python structured_demo.py. It prints a JSON object.
  3. Pipe the output through a parser to prove it’s real JSON: python structured_demo.py | python -c "import sys, json; d=json.load(sys.stdin); print(sorted(d))".
Verify
The second command prints ['open_threads', 'talking_points', 'the_ask', 'who']. json.load succeeded and every required key is present. (No JSONDecodeError means the output parsed.)

Stretch. Replace meeting_notes with a messy paragraph from your own inbox. The schema holds; only the values change.

§ 03.05 · Unit 23

The agentic discipline.

A tool-using model is a model with hands. Hands break things. Three rules separate a useful agent from an expensive mistake.

From the last unit: Tool use turns a model into an agent. An agent with hands can break things. Three rules separate a useful agent from an expensive mistake.

1 scope 2 preview 3 reversible
Three rules · the difference between useful agent and expensive mistake
  1. Scope it. Be explicit about which folder, which app, which account. An agent that can read your whole disk is an agent that will read your whole disk.
  2. Preview before commit. For anything you can’t take back (sending, deleting, paying), the agent shows the plan or the draft and waits for your approval. Enforce that pause in code or permissions, not only in the prompt. The cost of one extra round-trip is nothing compared to one wrong action.
  3. Reversibility first. Drafts before sends. Copies before moves. Read-only before read-write. Build the agent so the worst thing it can do today is print something dumb.
The hardest lesson The hardest lesson with agents isn’t writing the prompt. It is deciding what they’re allowed to touch.
Commitment card Before Day 04, run the tool-use demo on the synthetic fixture below. Then change one tool’s description and watch how the model’s behavior shifts. The descriptions are where the design lives.

Scope rule #1, in code

Your read_file from §03.03 reads any path the model names. Never test that behavior with a real secret: tool results are sent to the model. Rule #1 (scope it) resolves the path and proves it is contained by the current folder. Swap your read_file for this version.

import pathlib

ALLOWED_ROOT = pathlib.Path.cwd().resolve()   # only this folder

def read_file(path):
    p = pathlib.Path(path).expanduser().resolve()
    try:
        p.relative_to(ALLOWED_ROOT)   # rejects sibling-prefix and symlink escapes
    except ValueError:
        return f"ERROR: blocked — {p} is outside {ALLOWED_ROOT}"
    return p.read_text(encoding="utf-8", errors="replace")[:8000]

Feel the blast radius.

You’ll do
Watch the same agent read a harmless out-of-scope fixture with the guard off, then get blocked with the guard on.
Steps
  1. Create a synthetic fixture outside agents/: mkdir -p ../safety-fixture && printf 'demo-host=staging.example.test\n' > ../safety-fixture/outside-demo.txt. It must contain no real hostname, credential, or work data.
  2. With the original unguarded tool, run python tool_use_demo.py "Read ../safety-fixture/outside-demo.txt and report the demo host." Note that the agent reads a file outside its intended folder.
  3. Now replace read_file with the guarded version above (keep the [:8000] truncation if you like). Save.
  4. Run the exact same command again. This time it’s blocked.
  5. Do not widen the guard to your home directory. If you want to prove that policy controls access, temporarily set it to pathlib.Path.cwd().parent.resolve(), re-run only the synthetic fixture, then restore cwd().
Verify
With the guard on, the [tool] read_file(...) result says outside …/agents and the model reports it cannot read the synthetic fixture. You can explain why relative_to blocks sibling-prefix and symlink escapes that a string-prefix test can miss.

Stretch. This is rule #1 only. Add a deny rule for secret-like filenames, then test it with harmless files named .env.example and id_rsa.example that contain dummy values only.