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.
- 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)
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:
- tool_use_demo.py. The full tool-use loop (§03.03). Right-click → Save As into
agents/. - hello_claude.py and requirements.txt. The Day 1 starter, if you skipped 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.
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.
1. User prompt → 2. Model emits tool_use → 3. Your code returns tool_result → 4. Model writes final answer
Map the four steps onto a real task.
- Name a task where Claude would need to act, not just talk (e.g. “check today’s calendar,” “read the latest CSV in Downloads”).
- Write line 1. The user prompt in plain English.
- Write line 2. The tool_use the model would emit (tool name + one argument, e.g.
read_file{path: "report.csv"}). - Write lines 3 and 4. The tool_result your code returns, and the final answer the model writes from it.
Stretch. Add a second tool to the same task and mark which step now repeats (the loop runs once per tool the model needs).
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.
is the most
important field
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.
- 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_weatherblock above and edit it. - Copy the
get_weatherdeclaration as your template. Rewritename, thedescription(say when to use it, not just what), and theinput_schemaproperties. - Save it as
my_tool.pywithimport jsonat the top and your declaration assigned to a variable namedtool. - Run it:
python -c "import my_tool, json; t=my_tool.tool; json.dumps(t); print(sorted(t)[:3])".
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.
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.
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.
tool_use_demo.py and watch the model call two tools, then answer from the file.- Save the code above as
tool_use_demo.pyin the sameagents/folder you used on Day 1 (or download tool_use_demo.py. Right-click → Save As). - 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')". - Create the file the demo reads:
echo "hello from demo.txt" > demo.txt. - Run it:
python tool_use_demo.py.
[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.
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 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.
structured_demo.py and confirm the output is valid JSON with the four required keys.- Save the code above as
structured_demo.pyin youragents/folder (the venv from Day 1 already hasanthropicinstalled). - Run it:
python structured_demo.py. It prints a JSON object. - 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))".
['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.
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.
- 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.
- 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.
- 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.
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.
- 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. - 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. - Now replace
read_filewith the guarded version above (keep the[:8000]truncation if you like). Save. - Run the exact same command again. This time it’s blocked.
- 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 restorecwd().
[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.