Agents & Workflows
The most expensive mistake in this domain is building an agent when a workflow would do. An agent lets the model decide the control flow; a workflow keeps the control flow in code you wrote. This lab teaches the decision first, then the mechanics: the manual agentic loop, the four distinct ways to build an agent (a heavily testable table), subagents for context isolation, and hooks for the actions that must not be probabilistic. Start at the simplest tier that works and climb only when the task forces you to.
- Agent Architecture (4.5%) — Principles, patterns, and tradeoffs of agent and workflow architecture, including the decision criteria for using a workflow versus an agent, the structure of manager/supervisor hierarchies, and the role of subagents in improving task execution.
- Agent Construction with Claude (5.3%) — Methods, tools, and platforms for constructing Claude agents, including the Claude Agent SDK, custom agent loops and harnesses, managed agent deployment models (self-hosted vs. Anthropic-hosted), and hooks for deterministic actions.
- Agent Patterns and Frameworks (4.9%) — Common agent design patterns (tool-use loops, sub-agents, memory, context-window management) and agentic abstraction frameworks (e.g., Strands, LangGraph, PydanticAI) for building agents and workflows for multi-step tasks.
1. Workflow vs. Agent: the Decision, Not the Vocabulary
The words are less important than the boundary they mark: who owns the control flow? In a workflow, you own it: you wrote the steps, the branches, the order. The model fills in the content of each step, but your code decides what happens next. In an agent, the model owns it. It chooses which tool to call, inspects the result, and keeps going until it decides the task is done. The loop count is unknown to you at design time.
Neither is better. They sit on a ladder, and the rule is to start at the simplest tier that solves the problem and climb only when forced:
| Tier | Control flow | Use when |
|---|---|---|
| Single call | None — one request, one response | Classification, extraction, summarization, Q&A. Most tasks live here. |
| Workflow | You write it — code-controlled multi-step | Fixed pipelines: retrieve → draft → critique → finalize. You know the steps in advance. |
| Agent | The model writes it — tool loop until done | Open-ended tasks you can't fully specify up front: "turn this design doc into a PR." |
Before you build an agent, pass all four gates. If any answer is "no," stay one tier down:
| Gate | The question |
|---|---|
| Complexity | Is the task genuinely multi-step and hard to fully specify in advance? (A workflow handles anything you can specify.) |
| Value | Does the outcome justify an agent's higher token cost and latency? |
| Viability | Is Claude actually capable at this task type today? |
| Cost of error | Can mistakes be caught and recovered (tests, review, rollback)? An agent acting irreversibly with no safety net is a liability. |
2. The Manual Agentic Loop
An agent is, mechanically, a while loop around POST /v1/messages. You keep calling until the model stops asking for tools. Three details break real integrations if you get them wrong.
import anthropic
client = anthropic.Anthropic()
tools = [...] # your tool definitions
messages = [{"role": "user", "content": user_input}]
while True:
response = client.messages.create(
model="claude-opus-4-8",
max_tokens=16000,
tools=tools,
messages=messages,
)
# (1) Server-side tool hit its iteration limit. Re-send to resume.
if response.stop_reason == "pause_turn":
messages.append({"role": "assistant", "content": response.content})
continue
# Done: Claude answered without asking for a tool.
if response.stop_reason != "tool_use":
break
# (2) Append the assistant's FULL content, not just the text.
# response.content holds the tool_use blocks the next request needs.
messages.append({"role": "assistant", "content": response.content})
# (3) Execute every tool_use block; collect ALL tool_results
# into a SINGLE user message.
tool_results = []
for block in response.content:
if block.type == "tool_use":
try:
result = execute_tool(block.name, block.input)
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id, # must match the tool_use
"content": result,
})
except Exception as e:
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": str(e),
"is_error": True, # report failures, don't drop them
})
messages.append({"role": "user", "content": tool_results})
final_text = next(b.text for b in response.content if b.type == "text")
response.content, not just the text. This is the single most common agentic-loop bug. If you append only response.content[0].text, you drop the tool_use blocks the model just emitted, and the very next request, which carries tool_result blocks referencing tool-use IDs that are no longer in the history, returns HTTP 400. Every tool_result must have a matching tool_use earlier in the conversation. Round-trip the whole block list.
Two more rules the exam tests. Parallel tool use is on by default: one assistant message can hold several tool_use blocks. Execute them, then return all their tool_result blocks in one user message. Splitting them across multiple messages silently trains Claude to stop calling tools in parallel. And a failed tool returns a tool_result with "is_error": True; you never simply drop it, or the model waits forever for a result that isn't coming. Handle pause_turn by re-sending the conversation with no extra "Continue." message; the trailing server-tool block tells the API to resume on its own.
3. The Four Ways to Build an Agent
This is the most testable table in the domain. Once you've decided you genuinely need an agent, there are four distinct ways to build one, separated by two independent questions:
- Who supplies the harness (the loop plus context management)?
- Who supplies the deployment (the infrastructure it runs on)?
| Approach | You write | Harness & deployment | Use when |
|---|---|---|---|
| Claude API — manual loop | The while stop_reason == "tool_use" loop yourself |
You build the harness; you host it. Neither is supplied | You want to own the entire loop: no beta dependency, or control flow the Tool Runner's hooks don't fit |
Claude API — Tool Runnerclient.beta.messages.tool_runner + @beta_tool |
Just the tool functions | SDK supplies the loop (harness only); you host | A custom-tool agent without hand-writing the loop (most cases) |
| Managed Agents (REST, beta) |
Agent config + your tool results | Anthropic supplies the harness and hosts a per-session sandbox (harness + deployment) | You want Anthropic to run the loop and host the workspace; persisted, versioned configs; long-running sessions |
Claude Agent SDKclaude-agent-sdk — a separate product |
A prompt + options | SDK supplies the Claude Code harness + built-in tools (harness only); you host | You want a batteries-included coding / filesystem agent on your own infra |
The harness/deployment split is the mental model. The manual loop supplies neither. The Tool Runner and the Claude Agent SDK both supply a harness only (you still deploy them yourself), which is exactly why they get confused. Only Managed Agents adds managed deployment: Anthropic runs the loop and hosts a per-session sandbox where bash, file ops, and code execution happen. Managed Agents also gives you persisted, versioned agent configs; a session pins to a specific version.
anthropic SDK (reached at client.beta.messages.tool_runner); it loops over tools you define, with no built-in tools and no sandbox (a thin helper over POST /v1/messages). The Claude Agent SDK (claude-agent-sdk / @anthropic-ai/claude-agent-sdk) is Claude Code packaged as a library: built-in Read/Write/Edit/Bash/Glob/Grep tools, subagents, hooks, and the full agent loop, driven by query(prompt, options). Both are harness-only; you host both. Neither is Managed Agents; only Managed Agents adds managed deployment.
Managed deployment itself has two flavors the exam names: Anthropic-hosted (the default: Anthropic provisions the per-session container) versus self-hosted sandboxes (the agent loop stays on Anthropic's side, but tool execution runs in a container you control via an outbound-polling worker, so filesystem contents and egress never leave your infrastructure).
4. Tool Runner with @beta_tool
For the common case (a custom-tool agent), the Tool Runner removes the hand-written loop entirely. Decorate your functions with @beta_tool; the SDK generates the schema from the signature and docstring, runs the loop, and feeds results back until Claude is done.
import anthropic
from anthropic import beta_tool
client = anthropic.Anthropic()
@beta_tool
def get_weather(location: str, unit: str = "celsius") -> str:
"""Get current weather for a location.
Args:
location: City and state, e.g., San Francisco, CA.
unit: Temperature unit, either "celsius" or "fahrenheit".
"""
return f"18°C and clear in {location}"
runner = client.beta.messages.tool_runner(
model="claude-opus-4-8",
max_tokens=16000,
tools=[get_weather], # just the function
messages=[{"role": "user", "content": "What's the weather in Paris?"}],
)
# Each iteration yields a message; the loop ends when Claude stops calling tools.
for message in runner:
print(message)
The TypeScript equivalent uses betaZodTool from @anthropic-ai/sdk/helpers/beta/zod with client.beta.messages.toolRunner(...).
tool_use block in the for message in runner: body before the tool runs. Per-turn hooks also give you logging, error interception, result modification (e.g. adding cache_control), and retries. Drop to a manual loop only for control the runner genuinely can't express. Note one sharp edge: the Tool Runner does not auto-resume pause_turn, so a paused server-tool turn silently ends the loop as the final message.
5. Subagents & Manager/Supervisor Hierarchies
The reason to use a subagent is context isolation, not parallelism for its own sake. A subagent gets a fresh context window, does a bounded task, and returns only a summary. The orchestrator never sees the 40k tokens of intermediate tool output the subagent waded through; it sees the conclusion. That keeps the manager's context lean and its reasoning sharp over long runs.
This is the manager/supervisor pattern: an orchestrator agent delegates bounded sub-tasks to worker subagents and integrates their summaries. Two rules govern when it's worth it:
- Fan out across independent items. Many files to read, many candidates to check, many tests to run: delegate one subagent per item and let them run concurrently.
- Don't delegate what you can do in one response. Spawning a subagent to
grepa single file adds a whole context window of overhead for nothing. Delegation earns its cost when the sub-task is large or independent.
Because each subagent's history is isolated, you must pass context explicitly. The subagent doesn't see the orchestrator's conversation; if it needs a file path, a constraint, or a prior decision, the delegated message must say so. "It's obvious from context" is false across the isolation boundary.
# Manager/supervisor: delegate a bounded task to a fresh context window.
# The 40k tokens of intermediate tool output stay inside the subagent.
def run_subagent(task: str, context: dict) -> str:
"""A subagent is just another conversation. Its history is isolated,
so every fact it needs must be stated in the delegated message."""
response = client.messages.create(
model="claude-opus-4-8",
max_tokens=16000,
thinking={"type": "adaptive"},
output_config={"effort": "low"}, # cheap worker; the manager reasons
system="You research one bounded question and return a concise summary.",
messages=[{"role": "user", "content": (
f"Task: {task}\n"
# Pass context EXPLICITLY. The subagent cannot see the manager's
# conversation -- "as discussed above" means nothing here.
f"Repository: {context['repo']}\n"
f"Constraint: {context['constraint']}\n"
"Return at most 300 words. State findings only, no preamble."
)}],
tools=RESEARCH_TOOLS,
)
return "".join(b.text for b in response.content if b.type == "text")
# Fan out across INDEPENDENT items. Do not delegate a single-file read.
summaries = [run_subagent(f"Audit {mod} for unsafe deserialization", ctx)
for mod in independent_modules]
# The orchestrator sees only the summaries -- never the raw tool output.
orchestrator_messages.append({"role": "user", "content":
"Subagent findings:\n\n" + "\n\n".join(summaries)})
6. Hooks for Deterministic Actions
The model is probabilistic. Some actions must not be. A rm -rf on production, a wire transfer, an irreversible delete: you cannot let a token sampler decide whether those happen. Hooks intercept tool calls and act deterministically: gate, log, validate, or block, in code, every time, regardless of what the model "intended."
# A PreToolUse gate: deterministic policy, evaluated in code before the tool runs.
DESTRUCTIVE = ("rm -rf", "DROP TABLE", "git push --force")
def pre_tool_use(tool_name: str, tool_input: dict) -> dict:
"""Runs before every tool call. Return a decision the harness enforces."""
if tool_name == "bash":
command = tool_input.get("command", "")
if any(bad in command for bad in DESTRUCTIVE):
return {"decision": "block",
"reason": "Destructive command refused by policy."}
return {"decision": "allow"}
The distinction the exam draws is sharp: a PreToolUse hook that refuses a destructive command is a control: it executes in your code and cannot be talked out of it. A sentence in the system prompt asking Claude not to run destructive commands is not a control: it's a suggestion the model may or may not follow, and prompt injection can override it. If the action must not happen, enforce it with a hook, not a request.
Hook (PreToolUse gate) | Prompt instruction | |
|---|---|---|
| Where it runs | Your code, in the harness | Inside the model's reasoning |
| Behavior | Deterministic — same input, same decision, every time | Probabilistic — usually followed, not guaranteed |
| Survives prompt injection? | Yes — injected text can't reach your code path | No — a crafted input can override the instruction |
| Is it a control? | Yes — enforceable | No — advisory only |
| Use for | Gating, blocking, validating, auditing must-not-fail actions | Style, preferences, soft guidance |
7. Context-Window Management in Agents (Preview)
Long-running agents accumulate history until they crowd or blow the context window. Four levers manage it (previewed here, taken deep in Lab 5):
- Tool-output pruning — drop or truncate stale tool results the agent no longer needs.
- Context editing — the beta
context-management-2025-06-27header withcontext_management={"edits": [{"type": "clear_tool_uses_20250919"}]}clears old tool results (there's alsoclear_thinking_20251015for thinking blocks). Clearing removes; it does not summarize. - Compaction — the beta
compact-2026-01-12header summarizes earlier context into a compaction block server-side. You must append the fullresponse.contentback each turn or the compaction state is lost. - Subagent isolation — covered above: the cheapest way to keep the orchestrator's context small is to never put the intermediate work there in the first place.
# Clearing vs. summarizing. These are different tools for different problems.
# (a) Context editing CLEARS stale tool results. Nothing is summarized.
response = client.beta.messages.create(
betas=["context-management-2025-06-27"],
model="claude-opus-4-8",
max_tokens=16000,
context_management={"edits": [{"type": "clear_tool_uses_20250919"}]},
tools=tools,
messages=messages,
)
# (b) Compaction SUMMARIZES earlier context server-side as the window fills.
response = client.beta.messages.create(
betas=["compact-2026-01-12"],
model="claude-opus-4-8",
max_tokens=16000,
context_management={"edits": [{"type": "compact_20260112"}]},
messages=messages,
)
# CRITICAL: append the FULL content list back, not the extracted text.
# The compaction blocks carry the state. Appending only text silently
# discards them and the next request re-sends the whole uncompacted history.
messages.append({"role": "assistant", "content": response.content}) # correct
# messages.append({"role": "assistant", "content": text}) # WRONG
8. Agentic Frameworks
The exam names three abstraction frameworks. You should know what they are, not evangelize any of them.
| Framework | What it is |
|---|---|
| Strands | An agent-building toolkit that wraps the tool-use loop with reusable agent and tool abstractions. |
| LangGraph | A graph/state-machine framework for agents: you model the workflow as nodes and edges with explicit shared state. |
| PydanticAI | A typed agent framework built on Pydantic: structured, validated inputs and outputs for agents and workflows. |
What they buy you: graph and state abstractions, built-in retries, and observability, so you're not rebuilding orchestration plumbing per project. What they cost: a layer of indirection between you and the API, plus version churn: the abstraction moves faster than your code. For a simple custom-tool agent, the Tool Runner is often less machinery than a framework. Frameworks pay off when the orchestration itself is complex enough to justify the abstraction. Know the names; pick deliberately.
Lab Exercise: Build Up an Agent, One Layer at a Time
Self-driven lab Lab3_Self_Driven_Lab.ipynbObjective: build the same weather-and-summary agent three ways, then harden it. Each step isolates one concept from this lab.
- Manual loop. Write a
whileloop that branches onstop_reason, executestool_useblocks, and appends the fullresponse.contentplus alltool_resultblocks in one user message. Deliberately append onlycontent[0].textonce and watch the next request return a 400; then fix it. This is the bug you must never write again. - Rebuild with the Tool Runner. Delete your loop. Decorate the same tool functions with
@beta_tool, hand them toclient.beta.messages.tool_runner(...), and iterate. Confirm the behavior is identical with a fraction of the code. - Add a subagent. Give the orchestrator a task that fans out: summarize weather across five cities. Delegate one subagent per city; each returns only a one-line summary. Verify the orchestrator's context never contains the raw per-city tool output. Then delegate a single-file
grepto a subagent and observe the wasted overhead; that's the anti-pattern. - Add a hook. Add a
PreToolUsegate that blocks anybashcommand containing a destructive token. Prove it refusesrm -rfdeterministically. Then remove the hook, put "please don't run destructive commands" in the system prompt instead, and craft an input that talks the model past it, demonstrating that the prompt instruction is not a control.
Step 4 is the one that shows up in an incident review. Do it.
Check Yourself
Exam-style items. Commit to an answer before expanding.
A team must process 50,000 invoices every night with a fixed, fully-specified sequence of steps: extract fields, validate against a schema, and write a row to a database. They are debating whether to build an agent. What is the best guidance?
Correct: B. An agent is warranted only when the task is hard to fully specify in advance so the model must decide the control flow. Here every step is known and fixed, which is the definition of a workflow, code-controlled multi-step. Building an agent would add token cost, latency, and nondeterminism to a problem that has none of the complexity that justifies them.
A inverts the decision: dynamic step ordering is exactly what you don't want when the sequence is already known. C reaches for subagents to solve a context-isolation problem that a single fixed pipeline doesn't have, and adds a full context window of overhead per invoice. D picks a tool by a feature (file tools) that the task doesn't need, and still assumes an agent is the right tier when a workflow is.
A developer's manual agentic loop appends only response.content[0].text to the message history after each turn. On the next request (the one that carries the tool results), the API returns HTTP 400. What is the root cause?
Correct: C. Every tool_result must have a matching tool_use earlier in the conversation. Appending content[0].text keeps the text but discards the tool_use blocks the model emitted; the follow-up request then contains tool_result blocks whose tool_use_id has no counterpart, and the API rejects it with a 400. The fix is to append the entire response.content list.
A truncation would surface as stop_reason == "max_tokens", not a 400 on the following request. B is unrelated: parallel tool use is on by default and returning results doesn't require disabling it. D would also be an error, but the developer did put results in a user message; the actual break is the missing tool_use blocks, which is a history problem, not a role problem.
An architect wants Anthropic to run the agent loop and host a per-session sandbox where bash and file operations execute, with persisted, versioned agent configurations. Which approach fits?
Correct: C. Managed Agents is the only one of the four approaches that supplies both the harness (the loop + context management) and managed deployment (a per-session sandbox Anthropic hosts). It also stores persisted, versioned agent configs, and sessions pin to a version, exactly the requirements stated.
A supplies neither the harness nor deployment: you write and host both. B and D both supply a harness only; you still host and deploy them yourself, and neither provides a managed per-session sandbox. The distractors all fail the "Anthropic hosts the sandbox" half of the requirement.
Which of the following are accurate statements about hooks versus prompt instructions for enforcing that an agent never runs a destructive command? (Choose two.)
Correct: A and C. A hook executes in your harness code, so its decision is deterministic and cannot be talked out of; that is what makes it a control. A prompt instruction lives inside the probabilistic model and is advisory only, which is why a crafted or injected input can override it. Together these are the reason must-not-fail actions are gated with hooks, not requests.
B is backwards: the model reading an instruction every turn does not make it enforceable; deterministic code beats a probabilistic reader. D is false: hooks can gate, block, and validate, not merely log. E is the exact confusion the lab warns against: for actions that must not happen, only the hook is a control; the prompt instruction is not a substitute.
Key Takeaways
- Workflow vs. agent is a control-flow question. You own a workflow's steps; the model owns an agent's. Start at the simplest tier (single call → workflow → agent) and clear all four gates (complexity, value, viability, cost of error) before building an agent.
- In the manual loop, append
response.content, not just the text: dropping thetool_useblocks 400s the next request. Return alltool_resultblocks in one user message; failed tools returnis_error: True; re-send onpause_turn. - Four ways to build an agent, split by who supplies the harness vs. the deployment: manual loop (neither), Tool Runner (harness only), Managed Agents (both, the only one with managed deployment), Claude Agent SDK (harness only, a separate product).
- Tool Runner ≠ Claude Agent SDK. The Tool Runner is part of the regular
anthropicSDK: loops over your tools, no built-in tools, no sandbox. The Claude Agent SDK is Claude Code as a library with built-in tools. Both are harness-only. - Subagents buy context isolation. Fresh window, bounded task, summary back. Fan out across independent items; don't delegate what fits in one response; pass context explicitly across the isolation boundary.
- Hooks are controls; prompt instructions are not. A
PreToolUsegate refuses deterministically in your code and survives prompt injection. A sentence asking the model to behave is advisory only. Gate must-not-fail actions with hooks. - Agentic frameworks (Strands, LangGraph, PydanticAI) buy graph/state abstractions, retries, and observability at the cost of indirection and version churn. Know the names; reach for them deliberately, not by default.