The last three chapters described agents, frameworks and protocols in the abstract. This one builds a real agent from an empty directory to a deployed API, and then rebuilds it twice, once with LangGraph and once on top of MCP, so you can see exactly what each layer adds.
The agent is a data analyst: a business user asks a question in plain English ("what were our top five products by revenue last month, and how does that compare with September?"), and the agent finds the right tables, writes SQL, runs it, checks the result and answers in prose. No SQL knowledge is required of the user. It is a good teaching example because it needs every part of the stack: tools, multi-step reasoning, error recovery, memory for follow-up questions, guardrails (the agent must never modify data) and evaluation (wrong numbers are worse than no numbers).
Step 1: Project Setup
mkdir analyst-agent && cd analyst-agent
python -m venv .venv && source .venv/bin/activate
pip install anthropic fastapi uvicorn
export ANTHROPIC_API_KEY=... # or sign in with the provider's CLI
The warehouse is a SQLite file so everything runs on a laptop. In production it would be Postgres, Snowflake, BigQuery or Athena; only the query function changes.
analyst-agent/
warehouse.db # sample data: customers, products, orders
tools.py # what the agent can do
agent.py # the loop
api.py # HTTP interface
evals.py # how we know it works
Step 2: Define the Tools
Tools are the most important design decision. They determine what the agent can do, and their descriptions determine what it will do. Three tools are enough: discover tables, inspect one, run a query.
# tools.py
import sqlite3
DB_URI = "file:warehouse.db?mode=ro" # read-only at the driver: the real guardrail
MAX_ROWS = 200
def _query(sql: str, params=()):
with sqlite3.connect(DB_URI, uri=True, timeout=5) as conn:
cur = conn.execute(sql, params)
cols = [c[0] for c in cur.description]
rows = cur.fetchmany(MAX_ROWS + 1)
return cols, rows
def list_tables() -> str:
_, rows = _query("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
return ", ".join(r[0] for r in rows)
def describe_table(table: str) -> str:
_, rows = _query("SELECT name, type FROM pragma_table_info(?)", (table,))
if not rows:
return f"No table named '{table}'. Call list_tables to see what exists."
return "\n".join(f"{name} {typ}" for name, typ in rows)
def run_query(sql: str) -> str:
if not sql.lstrip().lower().startswith(("select", "with")):
return "Rejected: only SELECT queries are allowed."
cols, rows = _query(sql)
truncated = len(rows) > MAX_ROWS
lines = [",".join(cols)] + [",".join(map(str, r)) for r in rows[:MAX_ROWS]]
if truncated:
lines.append(f"... truncated at {MAX_ROWS} rows; aggregate or add LIMIT")
return "\n".join(lines)
TOOLS = [
{
"name": "list_tables",
"description": "List every table in the analytics warehouse. Call this first if you don't know which table holds the data you need.",
"input_schema": {"type": "object", "properties": {}},
},
{
"name": "describe_table",
"description": "Return the column names and types of one table. Always call this before writing SQL against a table you haven't described in this conversation.",
"input_schema": {
"type": "object",
"properties": {"table": {"type": "string", "description": "Exact table name"}},
"required": ["table"],
},
},
{
"name": "run_query",
"description": "Run one read-only SQLite SELECT (or WITH ... SELECT) and return up to 200 rows as CSV. Prefer aggregates over raw rows. Dates are stored as ISO-8601 text.",
"input_schema": {
"type": "object",
"properties": {"sql": {"type": "string"}},
"required": ["sql"],
},
},
]
HANDLERS = {"list_tables": list_tables, "describe_table": describe_table, "run_query": run_query}
Three details carry most of the weight:
- The guardrail is in the connection string, not the prompt.
mode=ro makes the database refuse writes no matter what SQL the model produces. The SELECT prefix check is a friendlier error message, not the protection: a prompt can be talked around, a read-only file handle cannot.
- Errors are information. A missing table returns a sentence telling the model what to do next instead of raising. The model reads tool results the way a programmer reads compiler output.
- Output is bounded. Every row returned is tokens in the next request. Capping rows and saying so ("truncated; aggregate instead") keeps cost bounded and teaches the model to write better queries.
Key idea: The descriptions are written for the model, not for you. "Always call this before writing SQL" in describe_table is an instruction, and it measurably changes how often the agent guesses column names.
Step 3: The System Prompt
SYSTEM = """You are a careful data analyst for an online retailer. You answer
questions using the analytics warehouse, which you can explore with tools.
How to work:
- If you don't know the schema, list tables and describe the ones you need first.
- Write one focused query at a time. Prefer aggregates; never select every row.
- If a query errors or returns nothing surprising, investigate before answering.
- State the time range and any filters you assumed.
Never invent numbers. If the data can't answer the question, say what is missing.
Today's date is {today}."""
Note what the prompt doesn't do: it doesn't repeat the tool list (the API sends that separately) or try to enforce read-only access (the connection does). It sets working habits and the one rule that matters most for this domain: no invented numbers. Including today's date matters because "last month" is otherwise ambiguous, and the model has no clock.
Step 4: The Agent Loop, by Hand
This is the whole agent. Everything a framework does is a more elaborate version of these lines.
# agent.py
import datetime, json, time
import anthropic
from tools import TOOLS, HANDLERS
client = anthropic.Anthropic()
MODEL = "claude-opus-5-5"
MAX_STEPS = 12
def run_tool(name: str, args: dict) -> tuple[str, bool]:
try:
return HANDLERS[name](**args), False
except Exception as e: # bad SQL, unknown table, timeout...
return f"{type(e).__name__}: {e}", True
def ask(messages: list, trace: list | None = None) -> str:
"""Run the agent until it answers. `messages` is the conversation so far
and is extended in place, so follow-up questions keep their context."""
system = SYSTEM.format(today=datetime.date.today().isoformat())
for step in range(MAX_STEPS):
started = time.monotonic()
response = client.messages.create(
model=MODEL,
max_tokens=16000,
system=system,
tools=TOOLS,
messages=messages,
)
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason == "refusal":
return "The request was declined."
if response.stop_reason != "tool_use":
return "".join(b.text for b in response.content if b.type == "text")
results = []
for block in response.content:
if block.type != "tool_use":
continue
output, is_error = run_tool(block.name, block.input)
results.append({
"type": "tool_result",
"tool_use_id": block.id, # ties the result to its request
"content": output,
"is_error": is_error,
})
if trace is not None:
trace.append({"step": step, "tool": block.name, "input": block.input,
"error": is_error, "ms": int((time.monotonic() - started) * 1000)})
messages.append({"role": "user", "content": results}) # all results in one message
return "I couldn't finish within the step limit. Try a narrower question."
Walk through one run of "top five products by revenue last month":
- The model sees the question and three tool definitions and replies with a
tool_use block: list_tables. The stop_reason is tool_use.
- The loop runs it, appends
customers, order_items, orders, products as a tool_result, and calls the model again with the whole conversation. The API is stateless; the messages list is the agent's short-term memory.
- The model asks to describe
orders and order_items, possibly in one turn as two parallel tool calls. Both results go back in a single user message.
- The model writes a join with a date filter and calls
run_query. Suppose it guessed a column name wrong: the result is OperationalError: no such column: price with is_error: true. The model reads it, re-checks the schema it already has, and rewrites the query with unit_price.
- The query returns five rows. The model replies with text instead of a tool call,
stop_reason is end_turn, and the loop returns the answer.
Five model calls, four tool calls, one recovered error, typically a few seconds. Notice what the code never does: parse free text for tool calls, or let the model execute anything. The model proposes structured calls; the loop decides whether to run them.
Pitfall: Append the model's entire response.content to the history, not just its text. Tool-use blocks, and the reasoning blocks newer models emit before them, must be sent back unchanged, or the next request fails or loses the model's chain of thought.
Step 5: Memory and Follow-ups
Because ask extends the list it is given, follow-ups work for free:
def chat(history: list, text: str) -> str:
history.append({"role": "user", "content": text})
return ask(history)
history = []
print(chat(history, "Top 5 products by revenue last month?"))
print(chat(history, "Now break the top one down by region.")) # "the top one" resolves from history
This is buffer memory from the LangChain chapter, and it has the same failure: a long session eventually overflows the context window and gets expensive, since every request resends everything. The fixes, in the order to reach for them:
- Prompt caching. The system prompt, tools and earlier turns form an unchanging prefix. Marking it cacheable makes repeated prefixes much cheaper and faster. This is free and should be on by default.
- Clear stale tool results. A 200-row CSV from ten turns ago is rarely needed again. Replace old results with a one-line note.
- Summarize. When the conversation passes a threshold, replace the oldest turns with a model-written summary. Some APIs can do this server-side.
- Persist. Store
history per session in Redis or a database so it survives restarts and works across server replicas.
Step 6: Serve It
# api.py
from fastapi import FastAPI
from pydantic import BaseModel
from agent import ask
app = FastAPI()
sessions: dict[str, list] = {} # use Redis or Postgres in production
class Question(BaseModel):
session_id: str
message: str
@app.post("/ask")
def post_ask(q: Question):
history = sessions.setdefault(q.session_id, [])
history.append({"role": "user", "content": q.message})
trace: list = []
answer = ask(history, trace)
return {"answer": answer, "steps": trace}
uvicorn api:app and any front end (a web page, a Slack bot, a spreadsheet add-in) can call it. Package it in a container and deploy it like any other service, behind the same load balancers, timeouts and autoscaling as everything else. Three agent-specific concerns:
- Latency. A five-step run takes several seconds. Stream progress ("Looking at the orders table…") over Server-Sent Events or a WebSocket instead of leaving the user staring at a spinner.
- Timeouts and cost caps. Bound the number of steps (done), the wall-clock time per request and the tokens per session. A loop that never terminates is a bill that never stops.
- Per-user authorization. If different users may see different data, the tools must enforce it, using the caller's identity, never the model's judgment.
Step 7: Trace Everything
When an agent gives a wrong answer, the question is always which step went wrong. The trace list above is the minimum: every tool, its input, whether it failed and how long it took. In production, emit one span per model call and per tool call (OpenTelemetry works well, and agent-specific platforms such as LangSmith or Langfuse add replay and diffing), linked by a run ID, with token counts. You will use it daily: "it answered about Q3 instead of October" becomes obvious when you can see the WHERE clause it wrote.
In practice: Log the SQL the agent ran next to every answer it shows a user, and show it to the user too ("Based on: SELECT …"). Analysts trust numbers they can check, and the habit catches wrong filters before they reach a slide deck.
Step 8: Evaluate
Unit tests check functions. Agents need evals: a fixed set of questions with known-correct answers, run against the whole agent, scored automatically and tracked over time. For a data agent, correctness is checkable because you can compute the true answers yourself.
# evals.py
from agent import ask
from tools import _query
CASES = [
("How many orders were placed in October 2026?",
"SELECT COUNT(*) FROM orders WHERE order_date BETWEEN '2026-10-01' AND '2026-10-31'"),
("Which product had the highest revenue last month?",
"SELECT p.name FROM order_items i JOIN products p ON p.id = i.product_id "
"JOIN orders o ON o.id = i.order_id WHERE o.order_date >= '2026-10-01' "
"AND o.order_date < '2026-11-01' GROUP BY p.name ORDER BY SUM(i.quantity * i.unit_price) DESC LIMIT 1"),
("What was revenue from customers in Antarctica?", None), # no such data: must say so
]
passed = 0
for question, truth_sql in CASES:
trace = []
answer = ask([{"role": "user", "content": question}], trace)
if truth_sql:
expected = str(_query(truth_sql)[1][0][0])
ok = expected in answer.replace(",", "")
else:
ok = any(w in answer.lower() for w in ("no data", "no orders", "doesn't contain", "not available"))
passed += ok
print(f"{'PASS' if ok else 'FAIL'} steps={len(trace):2} {question}")
print(f"{passed}/{len(CASES)}")
Real eval sets have dozens to hundreds of cases drawn from actual user questions, and they score more than the final answer: did it describe tables before querying, how many steps did it take, did it ever try a forbidden statement? Run the set on every change to the prompt, the tools or the model version. Without evals, every prompt tweak is a guess, and a model upgrade is a leap of faith.
The Same Agent with LangGraph
Here is the agent rebuilt on the framework from the LangChain chapter, reusing the same tool functions:
from langchain.agents import create_agent
from langchain_core.tools import tool
from langgraph.checkpoint.memory import InMemorySaver
import tools as t
@tool
def list_tables() -> str:
"""List every table in the analytics warehouse."""
return t.list_tables()
@tool
def describe_table(table: str) -> str:
"""Return the columns and types of one table. Call before writing SQL."""
return t.describe_table(table)
@tool
def run_query(sql: str) -> str:
"""Run one read-only SELECT and return up to 200 rows as CSV."""
return t.run_query(sql)
agent = create_agent(
model="anthropic:claude-opus-5-5",
tools=[list_tables, describe_table, run_query],
system_prompt=SYSTEM.format(today="2026-11-01"),
checkpointer=InMemorySaver(),
)
cfg = {"configurable": {"thread_id": "analyst-7"}}
agent.invoke({"messages": [{"role": "user", "content": "Top 5 products last month?"}]}, cfg)
What you gained: the loop, per-thread persistence, streaming of intermediate steps and a place (middleware) to add approval or history trimming. What you gave up: a layer between you and the API that you must learn and keep up to date. For this agent the hand-written loop is arguably clearer; add human approval for writes, long-running jobs or multi-agent routing, and the framework starts paying for itself.
The Same Agent over MCP
Now move the tools out of the agent entirely. The warehouse_server.py from the MCP chapter exposes list_tables, describe_table and run_query as an MCP server. The agent becomes a host: it starts the server, asks for its tools, translates them into the model's tool format, and forwards each tool call:
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
async def mcp_tools(session):
listed = await session.list_tools()
return [{"name": t.name, "description": t.description, "input_schema": t.inputSchema}
for t in listed.tools]
async def run_tool_mcp(session, name, args):
result = await session.call_tool(name, args)
return "".join(c.text for c in result.content if c.type == "text"), result.isError
The loop is unchanged except that run_tool now sends tools/call instead of calling a local function. The payoff is organizational: the data platform team owns the warehouse server, its credentials and its read-only enforcement; every agent in the company (this one, the IDE assistant, the Slack bot) connects to it; and nobody else ever holds the database password.
What You Built
| Layer |
In this agent |
Chapter |
| Model |
A frontier model behind one API call |
How LLMs Work |
| Tools |
Three functions with careful descriptions |
AI Agents |
| Loop |
Twenty-five lines of while |
this chapter |
| Memory |
The message list, cached and trimmed |
LangChain & LangGraph |
| Integration |
Local functions, then an MCP server |
MCP in Depth |
| Guardrails |
Read-only connection, row caps, step limits |
this chapter |
| Serving |
FastAPI, sessions, streaming |
Scalable Systems |
| Quality |
Traces and an eval set |
this chapter |
Key idea: The intelligence is in the model, but the reliability is in everything you built around it. Good tools, enforced limits, traces and evals are what separate an agent that demos well from one people trust with real numbers.