A Tool-Heavy Research Assistant
Lesson 04 · Building Your Own Agents · ~12 min read
What makes an agent “tool-heavy”
Working definition: a tool-heavy agent is one with four or more tools, where at decision time the LLM doesn’t have a fixed order to call them in — the next tool depends on what the previous tool returned. The model is doing real planning between turns, not following a recipe you wrote in code. It’s worth contrasting that shape with three things it isn’t:
-
Single-tool agents — a chatbot with one
searchtool. Trivial. There is no “which tool next” question, so there’s no real planning happening. The Claude Agent SDK runs them fine, but you don’t need it. -
Fixed-pipeline tool use — always
search, then alwayssummarize, then alwaysemail. That’s a pipeline, not an agent. Write it as four lines of Python and call it a day; you don’t need a loop or an LLM-in-the-driver-seat. - Multi-agent crews — covered in Module 5 (CrewAI). Many agents coordinating, not many tools inside one agent. Different shape, different framework choice.
The Claude Agent SDK is at its sweet spot when (a) tools are many, (b) order is data-driven, and (c) one model handles the whole flow. This lesson’s research assistant ticks all three boxes by construction.
Why this is the Claude Agent SDK’s sweet spot
Lesson 03’s closing section listed five criteria for picking the Claude Agent SDK over LangGraph, OpenAI Agents SDK, or CrewAI. Let’s replay that list against today’s lesson:
- “Tool-heavy” — the assistant ships with five tools.
- “Order depends on intermediate results” —
pkb_searchmight return enough context, or it might not; the LLM decides whether to escalate toweb_searchbased on what came back. - “Claude is the model” — yes, by construction in this course.
- “Don’t need a graph shape” — there is no fixed DAG. The same prompt for two different questions yields two different tool-call sequences, and that’s the point.
- “Don’t need multi-agent” — one agent, one loop, five tools.
What would push you off the SDK? If the tools had hard ordering constraints — e.g., “the writer step must always come after exactly one approval step, and approval can loop back to writer” — that’s a graph shape and LangGraph will model it more cleanly. If you wanted separate sub-agents with distinct personalities — a “search agent” that hands off to a “writer agent” — that’s a multi-agent shape and CrewAI is the better fit. None of that applies here. This lesson is the canonical Claude-Agent-SDK demo.
The toolset
In the lab you’ll build agent.py with five tools wired into an
Anthropic Agent. Each one is mocked in-process so the lab is self-contained,
reproducible, and free to run repeatedly. Real integrations come later in the course
(and in the companion MCP Servers course). Here’s the full surface:
-
pkb_search(query: str) -> str— searches an in-process dict (PKB) of pre-seeded knowledge-base entries and returns a one-line summary per hit. The mock for “look in our internal docs first” — the cheapest possible source. -
pkb_fetch(doc_id: str) -> str— returns the full text of a PKB entry. The LLM uses this afterpkb_searchwhen it finds a promising hit and needs the body, not just the headline. -
web_search(query: str) -> str— returns a JSON-ish string with three mocked{title, url, snippet}results. Stand-in for Tavily, Serper, Brave Search, or any other web-search API. -
web_fetch(url: str) -> str— returns the snippet of a previously-searched URL. Stand-in for a real fetcher (e.g.,httpx.getfollowed by readability-style cleanup). -
write_report(intro: str, bullets: list[str], sources: list[str]) -> str— the “final answer” tool. Returns the literal string"saved". The LLM’s next turn is then a short text confirmation that ends the loop. See the next section.
Real PKB-style integration belongs in the MCP Servers course, where the
pkb server actually runs over MCP and can be plugged into any client. Here
we mock it inline so you can read every byte of behaviour in one file.
The write_report “final-answer tool” pattern
This is the most novel idea in the lesson, so it’s worth dwelling on. Without
write_report, the agent finishes a task by producing a free-text final
answer — the LLM’s last assistant message is just prose. That’s fine
for a chat UI, but it’s unparseable for downstream code. You
can’t reliably grep an LLM’s prose for “the three bullet points”
or “the source list,” especially across many runs and across model upgrades.
With write_report, the agent’s final action is a tool call with three
explicit fields: intro, bullets, and sources. The
Pydantic-validated parameters mean three concrete benefits:
- Guaranteed structure. The schema requires all three fields. There is no “the model forgot to include sources” failure mode — the tool call won’t even validate without them.
-
Parseable output. Your caller can grab
tool_calls[-1].input["bullets"]and feed it to a templating engine, a CMS, a Slack post, a JSON API response — anything that expects structured data. - Composability. If you later want a second agent to review the report, the input shape is already JSON. No re-parsing prose, no fragility around Markdown headings.
This pattern generalises
Any agent with a structured final output should use a final-answer tool rather than
text parsing. This is also how LangGraph’s “structured-output node”
and OpenAI Agents SDK’s output_type parameter work under the hood
— they’re just dressing up the final-answer tool pattern with framework
sugar. Knowing the bare pattern means you can recreate it in any SDK.
The cost: one extra tool call per task (cheap — small input, tiny output), plus
one extra constraint in the system prompt (“when you have enough information, call
write_report — do not produce the report as free text”). Both
are good investments.
Parallel tool calls
When the LLM emits multiple tool_use blocks in a single turn, the
Claude Agent SDK runs them concurrently (under the hood, with asyncio) and
returns all results together in the next user message. Two practical wins:
-
Latency. Two
web_fetchcalls in parallel take ~max(t1, t2) wall-clock instead of t1 + t2. For network-bound tools (anything with HTTP latency), this can halve the per-turn time when the LLM has independent work to do. - Token cost. No saving here — the prompt size, response size, and per-token bill are identical whether you fire one tool at a time or three at once. But latency matters for UX more than people realise, and the SDK gives you parallelism for free.
When doesn’t the LLM emit parallel calls? When the second call’s arguments depend on the first call’s results. The model has to see the first result before it knows what to ask second. Parallelism therefore happens organically when the work is independent (e.g., “fetch these three URLs I found earlier”), not when it’s sequential (e.g., “search, then fetch the best hit”).
A concrete SDK win over hand-rolling
Hand-rolled L02 didn’t do this — tool calls ran serially in the order they
came out of the response. The SDK does it automatically: spot multiple
tool_use blocks, schedule them concurrently, gather, return. It’s
one of the most concrete wins for “use the SDK over hand-rolling” and it
happens with zero extra code in agent.py.
Stop-condition tuning for this agent
L03 enumerated the four stop conditions (natural exit, turn cap, cost cap, caller interrupt) and how they’re wired in the SDK. Picking good numbers for a specific agent is the harder skill. Here are realistic settings for the research assistant:
-
max_turns=15— long enough for a worked-example task: onepkb_search, one to twopkb_fetch, oneweb_search, one to threeweb_fetch, onewrite_report, plus one final text turn. Add headroom for one full retry cycle if a tool returns something unexpected. -
max_cost_usd=0.50— generous for Sonnet 4.7. In practice the agent settles around $0.02–$0.05 per question once you’ve tuned the system prompt. The cap is the runaway-cost safety net, not the typical bill. -
on_stephook — log the turn number, the tool calls issued that turn, and the cumulative cost. In production you’d ship those events to your observability backend (CloudWatch, Datadog, Honeycomb, etc.) and alert on anomalies.
Why these numbers? Because you don’t want the cap to normally fire — that means your typical task is on the cliff edge of failure. Caps should sit at roughly 5–10× the median task’s actual cost (and roughly 2× the worst observed). If the cap fires often, raise the cap and investigate why the agent is using more turns than expected. The usual culprits: a vague system prompt, or a tool returning unhelpful output that the LLM keeps re-querying.
What the lab gives you
By the end of the lab you’ll have run
python agent.py "What changed in Linux kernel 6.14’s networking stack?"
against the mocked tools and watched a multi-turn trace end in a structured report.
You’ll have deliberately constructed a prompt that triggers parallel tool calls,
and you’ll have used the on_step hook to log per-turn cost and
identify which tool was the most expensive in your run.
Most importantly: you’ll have one self-contained agent.py (~120 lines)
committed at agents/lesson04-tool-heavy-agent/agent.py in the course repo.
This is the same file Lesson 11’s cross-framework scorecard imports.
When you later see the LangGraph, OpenAI Agents SDK, and CrewAI versions of “the
same agent” in Modules 3–5, you’ll know exactly what each framework is
rewriting and what it’s preserving. The shape of the agent stays constant; what
changes is the framework around it.
References
- Anthropic, Claude Agent SDK docs —
Agentand@toolreferences atdocs.anthropic.com/en/docs/agents-and-tools/agent-sdk/overview. - Anthropic, Parallel tool use —
docs.anthropic.com/en/docs/agents-and-tools/tool-use/parallel-tool-use. - L02 —
agents/lesson02-agent-loop/theory.html, the hand-rolled baseline this lesson contrasts against. - L03 —
agents/lesson03-claude-agent-sdk-intro/theory.html, the SDK primer (primitives,@tool,on_step, stop conditions).