Appendix A: Python Essentials for This Course
This is not a full Python tutorial. It covers exactly the Python features you'll use to build AI agents in this course — nothing more, nothing less. If you can read through this appendix comfortably, you're ready for M01.
Developers who know another language (JavaScript, Java, Go, C#) and want a fast-track into Python syntax. Or Python beginners who want to see which features matter most for AI agent work before diving into the full course.
What You'll Learn
- Set up a Python environment with virtual environments and pip
- Work with Python's core data types: strings, lists, dicts, and tuples
- Write functions, classes, and handle errors with try/except
- Use async/await for non-blocking API calls (critical for agent work)
- Manage packages, environment variables, and type hints
- Read/write JSON and make HTTP requests — the building blocks of every API call
Python Skills Used in This Course
Every concept below appears in the agent code you'll write. The animation shows which modules use each skill most heavily.
Setup & Installation
Installing Python
This course requires Python 3.10 or later. Check your version:
python --version
# Python 3.12.3 (anything >= 3.10 works)
If you don't have Python installed, download it from python.org. On macOS you can also use brew install python. On Linux, use your package manager (apt install python3 on Ubuntu/Debian).
Virtual Environments
Before: Imagine sharing one kitchen pantry between every recipe you ever cook — your Thai curry spices clash with your Italian sauces, and updating one ingredient spoils another dish. Pain: In Python, installing packages globally means Project A's dependencies can break Project B. Mapping: A virtual environmentAn isolated Python installation with its own packages. Changes inside it don't affect other projects or the system Python. is like giving each recipe its own private pantry — totally isolated, no cross-contamination.
Create and activate a virtual environment for every project in this course:
# Create a virtual environment
python -m venv .venv
# Activate it
# macOS/Linux:
source .venv/bin/activate
# Windows:
.venv\Scripts\activate
# Install the Anthropic SDK (you'll use this in every module)
pip install anthropic
# Verify it works
python -c "import anthropic; print('Ready!')"
If python doesn't work, try python3. On some systems, python still points to Python 2. Similarly, use pip3 instead of pip if needed.
Variables & Data Types
Python is dynamically typedYou don't declare variable types — Python figures them out at runtime. This makes code shorter but means type errors show up when you run the code, not when you compile it. — you don't need to declare types, but you should know what types exist.
Strings
"hello" or 'hello'
Prompts, API responses, tool names
Integers & Floats
42 · 3.14
Token counts, temperature, costs
Booleans
True · False
Flags, stream mode, stop conditions
None
None
Missing values, optional params
Lists
[1, 2, 3]
Messages, tool results, embeddings
Dictionaries
{"key": "value"}
API payloads, tool definitions, config
# Variables — no type declaration needed
model = "claude-sonnet-4-6" # str
max_tokens = 1024 # int
temperature = 0.7 # float
stream = True # bool
system_prompt = None # NoneType
# Check a type at runtime
print(type(model)) # <class 'str'>
print(type(max_tokens)) # <class 'int'>
# Python uses indentation (not braces) for blocks
if temperature > 1.0:
print("High creativity mode")
elif temperature > 0.5:
print("Balanced mode")
else:
print("Deterministic mode")
Every API call you make to Claude requires you to set variables like model, max_tokens, and temperature. Knowing your types prevents bugs like passing a string "1024" where an integer 1024 is expected — a mistake that causes silent errors in API calls.
String Formatting (f-strings)
You'll build prompts by injecting variables into strings constantly. Python's f-stringsFormatted string literals (f"...") let you embed Python expressions inside curly braces within a string. Available since Python 3.6. are the cleanest way to do this.
# f-strings — prefix the string with f
user_name = "Alice"
query = "What is MCP?"
# WHAT: Build a prompt by injecting variables
# WHY: Every agent prompt is a template filled with runtime data
prompt = f"User {user_name} asks: {query}"
print(prompt) # "User Alice asks: What is MCP?"
# Multi-line f-strings — use triple quotes
system_prompt = f"""You are a helpful assistant.
The user's name is {user_name}.
Always respond in 3 sentences or fewer.
Current date: {2026}"""
# Expressions inside braces
tokens_used = 1847
max_tokens = 4096
print(f"Usage: {tokens_used}/{max_tokens} ({tokens_used/max_tokens:.1%})")
# "Usage: 1847/4096 (45.1%)"
# Common pattern: joining a list into a prompt
tools = ["search", "calculator", "weather"]
tool_list = ", ".join(tools)
prompt = f"Available tools: {tool_list}"
# "Available tools: search, calculator, weather"
Never inject user input directly into prompts without sanitization. In M16 (Input Guardrails), you'll learn how to prevent prompt injection attacks. For now, just know that f-strings are the mechanism — the safety layer comes later.
Lists, Dictionaries & Tuples
These three data structures are the backbone of every API interaction in this course.
Lists
Ordered, mutable collections. You'll use lists for message histories, tool definitions, and batched results.
# WHAT: Lists hold ordered sequences of items
# WHY: The Claude API expects messages as a list of dicts
messages = [
{"role": "user", "content": "Hello!"},
{"role": "assistant", "content": "Hi there!"},
{"role": "user", "content": "What is an agent?"},
]
# Add to the end
messages.append({"role": "assistant", "content": "An agent is..."})
# Access by index (0-based)
first_message = messages[0] # {"role": "user", "content": "Hello!"}
last_message = messages[-1] # last item
# Length
print(len(messages)) # 4
# Iterate
for msg in messages:
print(f"{msg['role']}: {msg['content'][:30]}...")
# List comprehension — transform every item
roles = [msg["role"] for msg in messages]
# ["user", "assistant", "user", "assistant"]
# Filter with comprehension
user_msgs = [m for m in messages if m["role"] == "user"]
Dictionaries
Key-value pairs. Every API request and response is a dictionary (or an object that behaves like one).
# WHAT: Dicts map keys to values
# WHY: API payloads, tool schemas, and config are all dicts
tool_definition = {
"name": "get_weather",
"description": "Get current weather for a city",
"input_schema": {
"type": "object",
"properties": {
"city": {"type": "string", "description": "City name"},
"units": {"type": "string", "enum": ["celsius", "fahrenheit"]},
},
"required": ["city"],
},
}
# Access values
print(tool_definition["name"]) # "get_weather"
print(tool_definition.get("timeout", 30)) # 30 (default if key missing)
# GOTCHA: tool_definition["missing_key"] raises KeyError
# Use .get() when the key might not exist
# Add/update
tool_definition["cache_ttl"] = 300
# Check if key exists
if "description" in tool_definition:
print("Has description")
# Iterate
for key, value in tool_definition.items():
print(f" {key}: {value}")
# Nested access (very common with API responses)
city_type = tool_definition["input_schema"]["properties"]["city"]["type"]
# "string"
Tuples
Like lists, but immutable. You'll see tuples when a function returns multiple values.
# WHAT: Tuples are immutable sequences
# WHY: Used for returning multiple values from functions
def parse_response(response):
text = response.content[0].text
tokens = response.usage.output_tokens
return text, tokens # returns a tuple
# Unpack the tuple
answer, token_count = parse_response(response)
# Also used for fixed collections
SUPPORTED_MODELS = ("claude-sonnet-4-6", "claude-haiku-4-5-20251001", "claude-opus-4-7")
if model not in SUPPORTED_MODELS:
raise ValueError(f"Unknown model: {model}")
Control Flow
Python uses indentation (4 spaces) instead of braces. This is non-negotiable — wrong indentation is a syntax error.
# If / elif / else
stop_reason = "tool_use"
if stop_reason == "end_turn":
print("Claude finished responding")
elif stop_reason == "tool_use":
print("Claude wants to call a tool") # ← this runs
elif stop_reason == "max_tokens":
print("Response was truncated")
else:
print(f"Unknown stop reason: {stop_reason}")
# For loops
tools = ["search", "calculator", "weather"]
for tool in tools:
print(f"Registering tool: {tool}")
# For loop with index
for i, tool in enumerate(tools):
print(f" Tool {i+1}: {tool}")
# While loop — the agent loop pattern you'll use in M12
max_iterations = 10
iteration = 0
while iteration < max_iterations:
response = get_next_response() # hypothetical
if response.stop_reason == "end_turn":
break # exit the loop early
iteration += 1
else:
# This runs ONLY if the while condition became False
# (not if we broke out)
print("Warning: hit max iterations")
# Ternary expression (one-line if)
mode = "streaming" if stream else "batch"
The while loop with a break condition is the core pattern for every agent loop (M12). Your agent keeps calling Claude, checking if it wants to use a tool, executing the tool, and feeding results back — until Claude says "I'm done." Knowing while/break fluently makes M12 much easier.
Functions
# WHAT: Functions are defined with `def`
# WHY: You'll wrap every tool as a function
def get_weather(city: str, units: str = "celsius") -> dict:
"""Get weather for a city. Returns a dict with temp and conditions."""
# Default parameter: units defaults to "celsius" if not provided
fake_data = {"city": city, "temp": 22, "units": units, "conditions": "sunny"}
return fake_data
# Call with positional args
result = get_weather("Paris")
# Call with keyword args (clearer for complex calls)
result = get_weather(city="Tokyo", units="fahrenheit")
# *args and **kwargs — you'll see these in wrapper functions
def call_with_retry(func, *args, max_retries=3, **kwargs):
"""Call a function with automatic retry on failure."""
for attempt in range(max_retries):
try:
return func(*args, **kwargs)
except Exception as e:
if attempt == max_retries - 1:
raise # re-raise the last exception
print(f"Attempt {attempt+1} failed: {e}. Retrying...")
# Lambda — short anonymous functions (used in sorting, filtering)
tools = [{"name": "search", "priority": 2}, {"name": "calc", "priority": 1}]
tools.sort(key=lambda t: t["priority"])
# Now sorted by priority: [{"name": "calc", ...}, {"name": "search", ...}]
Classes & Objects
Before: Imagine you're filling out a form by hand every time you need to describe a car — make, model, year, color, every single time. Pain: Without classes, you'd pass dozens of loose variables around your agent code: the model name, the system prompt, the message history, the tools list, all separately. Mapping: A classA blueprint for creating objects that bundle data (attributes) and behavior (methods) together. Classes let you organize complex state like agent configuration into a single, reusable structure. is like a pre-printed form — it bundles related data together so you only pass one thing around.
import anthropic
# WHAT: A class bundles data and behavior together
# WHY: In M05-M06 you'll build tool registries; in M12, an agent loop class
class Agent:
"""A minimal agent that wraps the Claude API."""
def __init__(self, model: str = "claude-sonnet-4-6", system: str = ""):
# __init__ runs when you create an instance
# self refers to the instance being created
self.client = anthropic.Anthropic()
self.model = model
self.system = system
self.messages = [] # conversation history
def chat(self, user_message: str) -> str:
"""Send a message and get a response."""
self.messages.append({"role": "user", "content": user_message})
response = self.client.messages.create(
model=self.model,
max_tokens=1024,
system=self.system,
messages=self.messages,
)
assistant_text = response.content[0].text
self.messages.append({"role": "assistant", "content": assistant_text})
return assistant_text
# Create an instance
agent = Agent(system="You are a helpful coding assistant.")
# Use it
answer = agent.chat("What is a decorator in Python?")
print(answer)
# The agent remembers conversation history
follow_up = agent.chat("Can you show me an example?")
print(follow_up)
__init__ is the constructor — it runs once when you write Agent(). self is how Python refers to the current instance (like this in JavaScript/Java). Every method receives self as its first argument. Attributes like self.messages persist across method calls, which is how the agent remembers conversation history.
Error Handling
API calls fail. Networks time out. Rate limits hit. Every agent you build in this course uses try/except to handle these gracefully.
import anthropic
client = anthropic.Anthropic()
# WHAT: try/except catches errors so your program doesn't crash
# WHY: API calls can fail for many reasons — you need to handle each one
try:
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=1024,
messages=[{"role": "user", "content": "Hello"}],
)
print(response.content[0].text)
except anthropic.AuthenticationError:
# WHAT: API key is invalid or missing
# WHY: Catches config errors early before you debug the wrong thing
print("Check your ANTHROPIC_API_KEY")
except anthropic.RateLimitError:
# WHAT: You've sent too many requests
# WHY: In production agents, you'll retry with exponential backoff
print("Rate limited — wait and retry")
except anthropic.APIConnectionError:
# WHAT: Network issue
print("Can't reach the API — check your connection")
except anthropic.APIError as e:
# WHAT: Catch-all for other API errors
# GOTCHA: Always put specific exceptions BEFORE generic ones
print(f"API error: {e.status_code} — {e.message}")
except Exception as e:
# WHAT: Catch anything else
print(f"Unexpected error: {type(e).__name__}: {e}")
finally:
# WHAT: This block ALWAYS runs, error or not
# WHY: Use for cleanup — closing files, logging, etc.
print("Request complete")
# Raising your own errors
def validate_temperature(temp: float) -> float:
if not 0.0 <= temp <= 2.0:
raise ValueError(f"Temperature must be 0.0-2.0, got {temp}")
return temp
Async & Await
Before: Imagine you're at a restaurant and the waiter takes your order, walks to the kitchen, stands there watching the chef cook, and only after your food is ready does the waiter go take the next table's order. Pain: In synchronous code, your program waits idle during every API call — if Claude takes 3 seconds to respond, your program does nothing for 3 seconds. With multiple tools, that waiting compounds. Mapping: AsyncAsynchronous programming lets your code start a long-running task (like an API call) and continue doing other work while waiting for the result, instead of blocking. is like a good waiter — they drop off your order and immediately go serve another table while your food cooks.
import asyncio
import anthropic
# WHAT: async functions can pause while waiting for I/O
# WHY: Agents make many API calls — async lets them overlap
async def ask_claude(question: str) -> str:
"""Ask Claude a question asynchronously."""
client = anthropic.AsyncAnthropic() # Note: AsyncAnthropic, not Anthropic
response = await client.messages.create( # await pauses HERE, not the whole program
model="claude-sonnet-4-6",
max_tokens=1024,
messages=[{"role": "user", "content": question}],
)
return response.content[0].text
# WHAT: Run multiple async calls in parallel
# WHY: In M06 and M14, your agent calls multiple tools at once
async def parallel_research(questions: list[str]) -> list[str]:
"""Ask Claude multiple questions simultaneously."""
tasks = [ask_claude(q) for q in questions]
results = await asyncio.gather(*tasks) # runs ALL tasks concurrently
return results
# WHAT: The entry point for async code
# WHY: You can't use `await` in regular (sync) code
async def main():
questions = [
"What is RAG?",
"What is function calling?",
"What is MCP?",
]
answers = await parallel_research(questions)
for q, a in zip(questions, answers):
print(f"Q: {q}\nA: {a[:100]}...\n")
# Run the async main function
asyncio.run(main())
You can only use await inside an async def function. And you can only call an async def function with await (or by passing it to asyncio.run() / asyncio.gather()). If you forget await, you'll get a coroutine object instead of the actual result — a very common beginner mistake.
Packages & Imports
# Import an entire module
import json
import os
# Import specific items from a module
from pathlib import Path
from typing import Optional
# Import with an alias (convention for popular libraries)
import anthropic
# Import from the Anthropic SDK — you'll see this pattern often
from anthropic import Anthropic, AsyncAnthropic
# Install third-party packages with pip
# pip install anthropic httpx pydantic python-dotenv
# Common packages used in this course:
# anthropic — Claude API client (every module)
# pydantic — Data validation (M04, M16, M21 + capstones)
# httpx — Modern HTTP client, async-capable (M05, M07, M21)
# python-dotenv — Load .env files for secrets (all modules)
# fastapi — Deploy agents as APIs (M21, M22B)
# json — Built-in, parse/create JSON (no install needed)
# asyncio — Built-in, async programming (no install needed)
Environment Variables
You'll store your API key as an environment variable. Never hardcode API keys in your source code.
import os
# WHAT: Read an environment variable
# WHY: API keys must NEVER be in your source code
api_key = os.environ.get("ANTHROPIC_API_KEY")
if not api_key:
raise RuntimeError(
"Missing ANTHROPIC_API_KEY. "
"Set it with: export ANTHROPIC_API_KEY='sk-ant-...'"
)
# The Anthropic SDK reads ANTHROPIC_API_KEY automatically
# so you usually just do:
import anthropic
client = anthropic.Anthropic() # picks up the env var
# --- Using .env files (popular for local development) ---
# Create a file called .env (add it to .gitignore!)
# ANTHROPIC_API_KEY=sk-ant-api03-xxxx
# SOME_OTHER_SECRET=abc123
# Load it with python-dotenv
# pip install python-dotenv
from dotenv import load_dotenv
load_dotenv() # reads .env into os.environ
# Now os.environ["ANTHROPIC_API_KEY"] works
Always add .env to your .gitignore. If you accidentally commit an API key to Git, rotate it immediately in the Anthropic Console. This course enforces this rule in every module.
Type Hints
Python doesn't enforce types at runtime, but type hintsOptional annotations that describe what types a function expects and returns. They're used by editors and tools like mypy for error checking, but Python itself ignores them at runtime. make code much easier to read and catch bugs in your editor. All code in this course uses type hints.
from typing import Optional
# Basic type hints
def create_message(
content: str, # required string
role: str = "user", # string with default
max_tokens: int = 1024, # integer with default
temperature: float = 1.0, # float with default
tools: list[dict] | None = None, # list of dicts, or None
) -> dict: # returns a dict
"""Build a message payload for the Claude API."""
payload = {
"role": role,
"content": content,
"max_tokens": max_tokens,
}
if tools is not None:
payload["tools"] = tools
return payload
# Common type hint patterns you'll see in this course
from typing import Any
MessageList = list[dict[str, Any]] # type alias
def format_history(messages: MessageList) -> str:
return "\n".join(f"{m['role']}: {m['content']}" for m in messages)
# Optional — means "this type OR None"
def find_tool(name: str, tools: list[dict]) -> Optional[dict]:
for tool in tools:
if tool["name"] == name:
return tool
return None # explicitly returns None if not found
File I/O & JSON
Agents read configuration files, cache results, and log interactions. JSON is the universal format for all of this.
import json
from pathlib import Path
# === READING FILES ===
# WHAT: Read a text file
# WHY: Loading system prompts, config files, cached data
content = Path("system_prompt.txt").read_text(encoding="utf-8")
# Classic approach with context manager (auto-closes the file)
with open("config.json", "r", encoding="utf-8") as f:
config = json.load(f) # parse JSON file → Python dict
# === WRITING FILES ===
# WHAT: Write data to a file
# WHY: Caching API responses, saving conversation logs
results = {"query": "test", "response": "Hello!", "tokens": 42}
with open("results.json", "w", encoding="utf-8") as f:
json.dump(results, f, indent=2) # Python dict → JSON file
# === JSON STRINGS (not files) ===
# WHAT: Convert between Python objects and JSON strings
# WHY: API payloads are JSON — you parse responses and build requests
# Python dict → JSON string
tool_input = {"city": "Paris", "units": "celsius"}
json_string = json.dumps(tool_input)
# '{"city": "Paris", "units": "celsius"}'
# JSON string → Python dict
parsed = json.loads(json_string)
print(parsed["city"]) # "Paris"
# Pretty-print JSON (great for debugging API responses)
print(json.dumps(tool_input, indent=2))
json.load() reads from a file. json.loads() reads from a string. json.dump() writes to a file. json.dumps() writes to a string. The "s" stands for "string." This naming trips up everyone at first — now you know.
HTTP Requests
While the Anthropic SDK handles API calls for you, you'll sometimes need raw HTTP requests — for tool implementations (calling external APIs), MCP servers, and webhooks.
import httpx
# WHAT: Make HTTP requests to external APIs
# WHY: Agent tools call external services — weather, databases, search
# Synchronous GET request
response = httpx.get("https://api.example.com/weather?city=Paris")
response.raise_for_status() # raises an exception if status >= 400
data = response.json() # parse JSON response → dict
print(data["temperature"])
# POST request with JSON body
result = httpx.post(
"https://api.example.com/search",
json={"query": "Python async tutorial", "limit": 5},
headers={"Authorization": "Bearer your-token-here"},
timeout=10.0, # seconds
)
# === ASYNC version (used in agent tools) ===
import httpx
async def fetch_weather(city: str) -> dict:
"""Async HTTP call — doesn't block the event loop."""
async with httpx.AsyncClient() as client:
response = await client.get(
"https://api.example.com/weather",
params={"city": city},
timeout=10.0,
)
response.raise_for_status()
return response.json()
# Error handling for HTTP calls
async def safe_fetch(url: str) -> dict | None:
try:
async with httpx.AsyncClient() as client:
response = await client.get(url, timeout=10.0)
response.raise_for_status()
return response.json()
except httpx.TimeoutException:
print(f"Request to {url} timed out")
return None
except httpx.HTTPStatusError as e:
print(f"HTTP {e.response.status_code}: {e.response.text[:100]}")
return None
In M05 (Function Calling), every tool you give Claude is backed by a Python function. Many of those functions make HTTP calls to external services. In M07 (MCP), you'll build servers that handle HTTP requests. Knowing httpx (or requests) is essential for making your agent actually do things in the real world.
Pydantic & Data Validation
Before: Imagine you're a bouncer at a concert checking tickets. You need to verify the name matches the ID, the date is today, and the seat number is real — for every single attendee. Pain: Without a validation library, you'd write dozens of if checks for every piece of data your agent handles: is the temperature a float? Is it between 0 and 2? Is the model name valid? That's tedious and error-prone. Mapping: PydanticA Python library that validates data using type hints. You define a model class with typed fields, and Pydantic automatically checks that incoming data matches the expected types and constraints. is like a smart bouncer — you describe the rules once (in a class), and it automatically rejects anything that doesn't match.
Pydantic is used in 17 modules and capstones across this course. You'll first encounter it in M04 (Structured Output), then use it heavily in M16 (Input Guardrails), M21 (API deployment with FastAPI), and multiple capstone projects.
# Install pydantic (v2+)
pip install pydantic
BaseModel — Your First Validator
from pydantic import BaseModel
# WHAT: Define a model with typed fields
# WHY: In M04, Claude's output gets validated against a schema like this
class WeatherResult(BaseModel):
city: str
temperature: float
units: str
conditions: str
# Valid data — works fine
result = WeatherResult(
city="Paris",
temperature=22.5,
units="celsius",
conditions="sunny",
)
print(result.city) # "Paris"
print(result.temperature) # 22.5
# Invalid data — Pydantic raises ValidationError automatically
try:
bad = WeatherResult(
city="Paris",
temperature="not-a-number", # wrong type!
units="celsius",
conditions="sunny",
)
except Exception as e:
print(e)
# temperature: Input should be a valid number
# Convert to dict (useful for API responses)
print(result.model_dump())
# {"city": "Paris", "temperature": 22.5, "units": "celsius", "conditions": "sunny"}
# Get the JSON Schema (used in M04 for Claude's structured output)
print(WeatherResult.model_json_schema())
# {"properties": {"city": {"type": "string"}, ...}, "required": [...], "type": "object"}
You defined a class that inherits from BaseModel. Pydantic reads the type hints (str, float) and automatically validates any data you pass in. If the data doesn't match, it raises a ValidationError with a clear message. No manual if checks needed.
Field Constraints
from pydantic import BaseModel, Field
# WHAT: Field() adds constraints beyond just the type
# WHY: In M16, you'll validate agent inputs with rules like these
class AgentRequest(BaseModel):
message: str = Field(
..., # ... means required (no default)
min_length=1, # can't be empty
max_length=10000, # prevent absurdly long inputs
description="The user's message to the agent",
)
model: str = Field(
default="claude-sonnet-4-6",
description="Which Claude model to use",
)
max_tokens: int = Field(
default=1024,
ge=1, # greater than or equal to 1
le=4096, # less than or equal to 4096
)
temperature: float = Field(
default=1.0,
ge=0.0, # min 0.0
le=2.0, # max 2.0
)
# Valid
req = AgentRequest(message="What is MCP?")
print(req.max_tokens) # 1024 (default)
# Invalid — caught immediately
try:
bad_req = AgentRequest(message="", max_tokens=99999)
except Exception as e:
print(e)
# message: String should have at least 1 character
# max_tokens: Input should be less than or equal to 4096
Custom Validators
from pydantic import BaseModel, Field, field_validator
# WHAT: @field_validator lets you write custom validation logic
# WHY: In M16, you'll catch prompt injection and bad tool names
class ToolCall(BaseModel):
tool_name: str
arguments: dict
@field_validator("tool_name")
@classmethod
def validate_tool_name(cls, v: str) -> str:
allowed = {"get_weather", "search", "calculator", "send_email"}
if v not in allowed:
raise ValueError(
f"Unknown tool: '{v}'. Allowed: {', '.join(sorted(allowed))}"
)
return v
# Valid
call = ToolCall(tool_name="get_weather", arguments={"city": "Paris"})
# Invalid — custom error
try:
bad_call = ToolCall(tool_name="delete_database", arguments={})
except Exception as e:
print(e)
# tool_name: Unknown tool: 'delete_database'. Allowed: calculator, get_weather, ...
Optional Fields & Nested Models
from pydantic import BaseModel
# WHAT: Models can contain other models — nesting is natural
# WHY: API responses often have nested structures
class Usage(BaseModel):
input_tokens: int
output_tokens: int
class ContentBlock(BaseModel):
type: str
text: str | None = None # optional — can be None
class AgentResponse(BaseModel):
content: list[ContentBlock] # list of nested models
model: str
stop_reason: str
usage: Usage # nested model
# Parse a nested structure (like an API response)
data = {
"content": [{"type": "text", "text": "Hello!"}],
"model": "claude-sonnet-4-6",
"stop_reason": "end_turn",
"usage": {"input_tokens": 12, "output_tokens": 8},
}
response = AgentResponse(**data) # ** unpacks the dict as keyword args
print(response.content[0].text) # "Hello!"
print(response.usage.input_tokens) # 12
Pydantic does three things you'll use constantly: (1) In M04, you'll pass model_json_schema() to Claude so it returns structured data that matches your schema. (2) In M16, you'll validate every user input before it reaches your agent, blocking prompt injection and malformed requests. (3) In M21, Pydantic powers FastAPI's automatic request validation when you deploy your agent as an API. One library, three critical use cases.
Putting It All Together: A Complete API Call
Here's how every Python concept in this appendix comes together in a single Claude API call. The animation walks through the flow step by step.
import anthropic, read env var, create client
client.messages.create(**payload) (sync or async)
response.content[0].text, check stop_reason
stop_reason == "tool_use", execute tool, append result, call again
"""
Complete example combining every concept from this appendix.
This is the pattern you'll use starting from M01.
"""
import os
import json
import anthropic
from dotenv import load_dotenv
# 1. Setup
load_dotenv() # load .env file
client = anthropic.Anthropic() # reads ANTHROPIC_API_KEY from env
# 2. Build the payload (dicts, lists, strings, type hints)
def ask_claude(
question: str,
system: str = "You are a helpful assistant.",
model: str = "claude-sonnet-4-6",
max_tokens: int = 1024,
temperature: float = 1.0,
) -> dict:
"""Send a question to Claude and return the full response."""
messages: list[dict] = [
{"role": "user", "content": question}
]
# 3. Make the API call
try:
response = client.messages.create(
model=model,
max_tokens=max_tokens,
temperature=temperature,
system=system,
messages=messages,
)
# 4. Handle the response
result = {
"text": response.content[0].text,
"model": response.model,
"tokens_in": response.usage.input_tokens,
"tokens_out": response.usage.output_tokens,
"stop_reason": response.stop_reason,
}
# Log the usage
cost_estimate = (result["tokens_in"] * 0.003 + result["tokens_out"] * 0.015) / 1000
print(f"Tokens: {result['tokens_in']} in, {result['tokens_out']} out")
print(f"Estimated cost: ${cost_estimate:.4f}")
return result
# 5. Error handling
except anthropic.RateLimitError:
print("Rate limited — try again in a moment")
return {"text": "", "error": "rate_limited"}
except anthropic.APIError as e:
print(f"API error: {e}")
return {"text": "", "error": str(e)}
# Run it
if __name__ == "__main__":
result = ask_claude("What are the 3 most important Python concepts for building AI agents?")
print(f"\nClaude says:\n{result['text']}")
# Save the result (JSON + file I/O)
with open("response.json", "w") as f:
json.dump(result, f, indent=2)
Knowledge Check
Test your understanding of the Python essentials covered in this appendix.
1. What command creates an isolated Python environment for a project?
pip install venv
python -m venv .venv
python --create-env .venv
virtualenv --system .venv
python -m venv .venv uses Python's built-in venv module to create an isolated environment in the .venv directory.python -m venv .venv. The -m flag runs a module, and venv is the built-in virtual environment module.2. What's the difference between json.load() and json.loads()?
load() is async, loads() is sync
loads() loads multiple JSON objects, load() loads one
load() reads from a file, loads() reads from a string
loads() is just an alias
loads() stands for "string." load(f) reads from a file object, loads(s) parses a string.load() reads from a file object, while loads() reads from a string. The "s" stands for "string."3. What happens if you forget await when calling an async def function?
SyntaxError immediately
None
await, Python returns a coroutine object (like a "promise to run later") instead of executing the function. You'll see a warning like RuntimeWarning: coroutine was never awaited.await, Python returns a coroutine object — a placeholder that represents "this function hasn't actually run yet." It's one of the most common async bugs.4. Why should you use os.environ.get("API_KEY") instead of hardcoding the key?
5. What does self refer to in a Python class method?
self is like this in JavaScript or Java. When you write agent = Agent() and call agent.chat("hi"), inside the chat method, self refers to that specific agent object.self refers to the specific instance (object) that called the method — similar to this in JavaScript or Java. It lets each instance have its own data.6. Which line correctly accesses a nested dictionary value?
tool.input_schema.properties.city
tool["input_schema"]["properties"]["city"]
tool->input_schema->properties->city
tool.get("input_schema.properties.city")
tool["input_schema"]["properties"]["city"]. Dot notation (option A) works on objects/classes, not plain dicts. Option D's .get() doesn't support dotted key paths.7. What does asyncio.gather(*tasks) do?
asyncio.gather() runs all coroutines concurrently (not in parallel threads — it's single-threaded but non-blocking) and returns a list of all results once every task completes.asyncio.gather() runs all async tasks concurrently and waits until every one finishes, returning all results as a list. It uses cooperative multitasking on a single thread, not separate threads.8. What does a Pydantic BaseModel do when it receives data with the wrong type?
None
ValidationError with a clear message
ValidationError immediately, telling you exactly which field failed and why. This is what makes it so useful for input guardrails (M16) and structured output validation (M04).ValidationError with a detailed error message. It won't silently ignore bad data — that's the whole point. You define the rules, and Pydantic enforces them.Summary
Key Takeaways
- Virtual environments — always create one per project with
python -m venv .venv. - f-strings — your primary tool for building prompts dynamically.
- Dicts and lists — every API request is a dict, every message history is a list of dicts.
- try/except — API calls fail; handle auth errors, rate limits, and timeouts gracefully.
- async/await — lets your agent make multiple API calls concurrently instead of waiting one at a time.
- Environment variables — never hardcode API keys; use
.envfiles andos.environ. - Pydantic —
BaseModelandField()validate data automatically; used in structured output, guardrails, and API deployment. - JSON —
json.loads()for strings,json.load()for files; you'll use these constantly.
Ready? Start the Course!
If you understood the code in this appendix, you have all the Python you need. Head to M00: Course Overview to see the full agent lifecycle, then M01: The LLM Mental Model to make your first Claude API call.