Module 11 · AI Agents & MCP · Drills
Reading the loop is not knowing the loop. Type every one of these — in a scratch file or a real DocChat branch — before you reveal the solution. The agent loop only sticks when your fingers know it.
Drill 1 first call
Make a basic messages.create call to claude-sonnet-4-6 with a system prompt and one user message, then print only the text blocks of the response.
import anthropic client = anthropic.Anthropic() resp = client.messages.create( model="claude-sonnet-4-6", max_tokens=512, system="You are DocChat, a precise document assistant.", messages=[{"role": "user", "content": "Say hello in one line."}], ) for block in resp.content: if block.type == "text": print(block.text)
Remember: content is a list of blocks, not a string. Always guard on block.type.
Drill 2 tool schema
Define a tool dict named get_weather that takes a required city (string). Give it a description that says when to use it.
weather_tool = {
"name": "get_weather",
"description": (
"Get the current weather for a city. Use this whenever the "
"user asks about weather or temperature."
),
"input_schema": {
"type": "object",
"properties": {
"city": {"type": "string", "description": "City name, e.g. Dubai."},
},
"required": ["city"],
"additionalProperties": False,
},
}
Drill 3 tool_use block
Given a resp with stop_reason == "tool_use", pull out the first tool_use block and print its id, name, and input.
tool_calls = [b for b in resp.content if b.type == "tool_use"] if tool_calls: block = tool_calls[0] print(block.id) # toolu_01A... — echo this back as tool_use_id print(block.name) # get_weather print(block.input) # {"city": "Dubai"} — already parsed, a dict
block.input is already a parsed dict — never raw-string-match the serialized JSON.
Drill 4 the loop
Write the agent loop: call the model, break on end_turn, otherwise append the assistant turn, run each tool, append a single user message of tool_result blocks, and repeat — with a 5-step cap.
def agent(question, tools, run_tool): messages = [{"role": "user", "content": question}] for _ in range(5): # max-iteration guard resp = client.messages.create( model="claude-sonnet-4-6", max_tokens=1024, tools=tools, messages=messages, ) if resp.stop_reason == "end_turn": return next(b.text for b in resp.content if b.type == "text") messages.append({"role": "assistant", "content": resp.content}) results = [ {"type": "tool_result", "tool_use_id": b.id, "content": run_tool(b.name, b.input)} for b in resp.content if b.type == "tool_use" ] messages.append({"role": "user", "content": results}) return "Hit the step limit."
Two non-negotiables: append resp.content before the results, and return all results in one user message.
Drill 5 structured JSON
Force the model to return JSON with name (string) and urgent (boolean), then parse it. Use output_config.
import json resp = client.messages.create( model="claude-sonnet-4-6", max_tokens=256, messages=[{"role": "user", "content": "Ticket: Ada says the server is down NOW."}], output_config={"format": { "type": "json_schema", "schema": { "type": "object", "properties": { "name": {"type": "string"}, "urgent": {"type": "boolean"}, }, "required": ["name", "urgent"], "additionalProperties": False, }, }}, ) data = json.loads(next(b.text for b in resp.content if b.type == "text")) print(data["name"], data["urgent"]) # Ada True
Don't prefill the assistant turn with { — that's a 400 on current models. output_config.format is the supported way.
search_documents (stub it to return a fixed string) and calculator (evaluate a simple arithmetic expression safely). Run the full loop with a max-iteration guard and ask a question that needs both — "Find our seat count in the office doc and tell me the cost at 1200 AED each."
Build · two-tool agent
Wire two tools, a run_tool dispatcher, and the capped loop. Validate the calculator input — never eval arbitrary strings.
import anthropic, ast, operator client = anthropic.Anthropic() TOOLS = [ {"name": "search_documents", "description": "Search DocChat for relevant passages. Use for document questions.", "input_schema": {"type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"], "additionalProperties": False}}, {"name": "calculator", "description": "Evaluate an arithmetic expression. Use for any math.", "input_schema": {"type": "object", "properties": {"expr": {"type": "string"}}, "required": ["expr"], "additionalProperties": False}}, ] _OPS = {ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul, ast.Div: operator.truediv} def safe_calc(expr): def ev(node): if isinstance(node, ast.Constant): return node.value if isinstance(node, ast.BinOp): return _OPS[type(node.op)](ev(node.left), ev(node.right)) raise ValueError("unsupported expression") return str(ev(ast.parse(expr, mode="eval").body)) def run_tool(name, args): if name == "search_documents": return "[office.pdf] The Dubai office seats 40 people." # stub if name == "calculator": return safe_calc(args["expr"]) return f"Unknown tool: {name}" def agent(question, max_steps=6): messages = [{"role": "user", "content": question}] for _ in range(max_steps): resp = client.messages.create( model="claude-sonnet-4-6", max_tokens=1024, system="You are DocChat. Use tools, then answer grounded in results.", tools=TOOLS, messages=messages) if resp.stop_reason == "end_turn": return next(b.text for b in resp.content if b.type == "text") messages.append({"role": "assistant", "content": resp.content}) messages.append({"role": "user", "content": [ {"type": "tool_result", "tool_use_id": b.id, "content": run_tool(b.name, b.input)} for b in resp.content if b.type == "tool_use"]}) return "Hit the step limit." print(agent("How many seats in the office, and the cost at 1200 AED each?"))
The model searches first (40 seats), then calls the calculator (40 × 1200) — two tools, one question, all its own decision. Note the AST-based safe_calc: never eval() model-supplied strings.
Click a card to flip it. Say the answer out loud before you flip — that's the rep that builds storage strength.
name, description, and input_schema (JSON Schema).tool_use block with stop_reason == "tool_use"; your code runs it.tool_result block in a user message, with tool_use_id matching the call's id.claude-opus-4-8, claude-sonnet-4-6, claude-haiku-4-5.Tick each only if you can do it without looking:
messages.create call and read text blockstool_use block and return a matching tool_result