Tutorials · Claude Agent SDKPart 4 of 4 · Operate and architect

Part 4 of 4

Agentic design patterns in 2026: loops, graphs and harnesses, implemented with the Claude Agent SDK

The architecture layer: the five classic workflow patterns and the 2026 vocabulary of loop, harness, context and graph engineering, a 58-pattern catalog, and ten patterns implemented on the Agent SDK with real output.

Infographic: the 58 agent patterns as numbered dots grouped by family, with the 36 often-missed ones highlighted, beside eleven real runs from the ten pattern demos and how many patterns each part teaches.

Where you are. Parts 1–3 taught the primitives. This part is about architecture: the patterns people actually use to make agents reliable, from the five classic workflow patterns to the 2026 vocabulary of loop engineering, harness engineering, context engineering and graph engineering. Every pattern has a definition, a "when to use it", and runnable code on the Agent SDK with real output.

A caveat up front. Several of these terms were coined in mid-2026 and are contested. I cite primary sources, and where a term relabels something older I say so. Treat the taxonomy as a map, not a standard.


Contents#

  1. The vocabulary: five "engineerings"
  2. The building block: the augmented LLM in a loop
  3. The classic workflow patterns, with code and output
  4. Loop engineering
  5. Harness engineering
  6. Context engineering, with memory architectures, agentic RAG, prompt caching, test-time compute
  7. Graph engineering, with handoffs, swarms, blackboards, debate
  8. Multi-agent lessons from production systems
  9. Tool design as a pattern, with Bash as the universal tool, background and asynchronous agents, agentic coding practices
  10. Safety, governance and human-in-the-loop patterns
  11. Evaluation and observability patterns
  12. Protocols: MCP, A2A, skills, generative UI, marketplaces
  13. The full catalog, and which ones most teams miss
  14. Decision guide and anti-patterns
  15. Sources

1. The vocabulary: five "engineerings"#

Key points

  • Prompt → context → harness → loop → graph engineering is a stack: each layer controls a bigger unit (a request, a window, an agent, a cycle, a system).
  • "Loop engineering" was named in June 2026, "graph engineering" in July 2026. Both bundle older ideas under agent-centric names.
  • The Agent SDK has a concrete knob for every layer.
🧒 Plain-English version: five layers of "telling the intern what to do"
  1. Prompt engineering: wording one instruction well.
  2. Context engineering: deciding what's on the intern's desk at each moment, since a cluttered desk makes them worse.
  3. Harness engineering: everything around the intern that isn't the intern: their tools, the permission rules, their notebook, the alarms you set.
  4. Loop engineering: deciding what starts the intern working, how you check the work, and when they're allowed to stop.
  5. Graph engineering: when you have many interns, deciding who hands what to whom, in what order, and who checks whom.

A 2026 survey arranges the layers as a stack (Feng et al., Graph Engineering in the Era of LLM Agents): prompt and context engineering elicit model intelligence, harness and loop engineering produce individual agent intelligence, graph engineering produces system intelligence. (One reviewer notes the paper asserts, rather than demonstrates, that graphs are necessary for the last step.)

Layer What you control Representative artefacts Agent SDK surface
Prompt the instructions in one request system prompt, examples system_prompt, CLAUDE.md
Context which tokens are in the window at each step compaction, notes, retrieval, tool search, subagents setting_sources, tool search, /compact, subagents, skills
Harness the machinery around a fixed model the Claude Code binary ClaudeAgentOptions, hooks, permissions, sandbox
Loop how one agent is triggered, verified and stopped /goal, /loop, routines, Stop hooks, verifiers Stop hook, max_turns, max_budget_usd, schedulers
Graph how many loops connect: nodes, edges, shared state code-owned graphs, dynamic workflows, agent teams asyncio over query(), Workflow tool, subagents

Origins: Peter Steinberger's "stop prompting agents, design loops that prompt them", Boris Cherny's "my job is now to write loops", and Addy Osmani's essay that named the pattern, all June 2026 (Yahoo Tech, Lulla et al.); Steinberger asking in July whether the field had moved on to graphs (Turing Post).


2. The building block: the augmented LLM in a loop#

Key points

  • Workflows follow code paths you wrote; agents choose their own path. Both are built from one block: a model with tools, retrieval and memory, in a loop.
  • Start simple. Add agency only when it measurably helps.
  • All eleven coding-agent harnesses studied in 2026 implement the same ReAct loop.
🧒 Plain-English version

Anthropic's own guidance from late 2024 still holds: most tasks don't need an intern that improvises. A fixed checklist (a workflow) is cheaper, faster and more predictable. Use a free-roaming intern (an agent) only when you genuinely can't write the checklist in advance.

From Building Effective Agents: a workflow orchestrates LLM calls through predefined code paths; an agent lets the LLM direct its own process and tool use. Both compose the augmented LLM (model + retrieval + tools + memory). The Agent SDK is this block: query() runs the loop, tools are the augmentation, sessions and CLAUDE.md are memory. The eleven-system source-code study (arXiv 2609.00006) found every harness implements a ReAct variant (reason, act, observe), differing in concurrency and complexity.

Throughout this part I use one helper that turns a query() into a function call: a fresh-context "node" that returns typed JSON when given a schema, or text otherwise.

# shared by p2 and p3 (examples/python/)
async def run(prompt, schema=None, **opts):
    options = ClaudeAgentOptions(cwd=PROJECT, setting_sources=[], max_turns=opts.pop("max_turns", 5),
                                 output_format={"type": "json_schema", "schema": schema} if schema else None, **opts)
    async for m in query(prompt=prompt, options=options):
        if isinstance(m, ResultMessage):
            return (m.structured_output if schema else m.result), (m.total_cost_usd or 0)

3. The classic workflow patterns, with code and output#

Key points

  • Five patterns from Anthropic's guide: prompt chaining, routing, parallelization (sectioning, voting), orchestrator-workers, evaluator-optimizer.
  • Three more from later catalogs: reflection, planning, memory.
  • On the SDK: chaining and routing are plain Python control flow over run(); parallelization is asyncio.gather; orchestrator-workers is subagents; evaluator-optimizer is a verifier node or a Stop hook.
🧒 Plain-English version: the five checklists
  • Chaining: do step 1, check it, do step 2 with the result.
  • Routing: a cheap receptionist decides which specialist should handle the request.
  • Parallelization: split the work across several interns at once, or ask three interns the same question and take the majority.
  • Orchestrator-workers: a lead intern breaks the job down on the fly and hands pieces out.
  • Evaluator-optimizer: one intern drafts, a second critiques, repeat.

Code (p3_workflow_patterns.py), routing, chaining and voting in one file:

# ---------- 1. Routing: a cheap classifier picks the specialist -------------------------------
ROUTE_SCHEMA = {"type": "object", "properties": {"kind": {"type": "string", "enum": ["bug", "docs", "question"]},
                                                 "reason": {"type": "string"}}, "required": ["kind", "reason"]}
SPECIALISTS = {                                                                   # (1) one options bundle per route
    "bug":      dict(allowed_tools=["Read", "Grep", "Glob"], max_turns=8, system_prompt="You are a debugger. Locate the defect and describe the fix in two sentences."),
    "docs":     dict(allowed_tools=["Read", "Glob"],         system_prompt="You are a technical writer. Answer in two sentences."),
    "question": dict(tools=[],                               system_prompt="Answer from general knowledge in one sentence."),
}

async def routed(request):
    route, c1 = await run(f"Classify this request: {request!r}", ROUTE_SCHEMA,
                          tools=[], model="claude-haiku-5-5", effort="low")        # (2) cheap, tool-less, typed
    answer, c2 = await run(request, **SPECIALISTS[route["kind"]])                 # (3) dispatch
    print(f"[route] {request!r}\n   -> kind={route['kind']} ({route['reason']})\n   -> {answer}\n   cost=${c1 + c2:.4f}")

# ---------- 2. Prompt chaining with a programmatic gate -----------------------------------------
OUTLINE_SCHEMA = {"type": "object", "properties": {"sections": {"type": "array", "items": {"type": "string"}, "minItems": 3}},
                  "required": ["sections"]}

async def chained():
    outline, c1 = await run("Outline a README for this project in 3-5 section titles.", OUTLINE_SCHEMA, allowed_tools=["Read", "Glob"])
    assert 3 <= len(outline["sections"]) <= 5, "gate failed"                       # (4) a deterministic gate between steps
    doc, c2 = await run("Write one sentence for each of these README sections, as a markdown list:\n"
                        + json.dumps(outline["sections"]), tools=[])                 # (5) step 2 consumes step 1's output
    print(f"[chain] outline={outline['sections']}\n{doc}\n   cost=${c1 + c2:.4f}")

# ---------- 3. Parallelization: voting -----------------------------------------------------------
VOTE_SCHEMA = {"type": "object", "properties": {"safe": {"type": "boolean"}, "reason": {"type": "string"}},
               "required": ["safe", "reason"]}

async def voted():
    prompt = "Is src/app.py safe to deploy to production as-is? Answer safe=true/false with a one-sentence reason."
    results = await asyncio.gather(*(run(prompt, VOTE_SCHEMA, allowed_tools=["Read"], effort="low") for _ in range(3)))  # (6) 3 independent judges
    votes = [r[0]["safe"] for r in results]
    print(f"[vote] votes={votes} -> majority safe={sum(votes) > 1}")                 # (7) programmatic aggregation

Run it:

[route] 'calculate_average crashes on an empty list, where is the bug?'
   -> kind=bug (The user reports that calculate_average crashes on an empty list and asks where the bug is. ...)
   -> This one's already fixed — not a live bug. In `src/utils.py`, the original defect was that `calculate_average` divided
      `total / len(numbers)` unconditionally, causing a `ZeroDivisionError` on `[]`. The fix (already in place, per CHANGELOG.md)
      adds an `if not numbers: return 0.0` guard before the division ...
   cost=$0.0719
[route] 'What is the difference between a list and a tuple in Python?'
   -> kind=question (... It seeks an explanation, so it is classified as a question.)
   -> A list is mutable (can be changed after creation) and defined with `[]`, while a tuple is immutable ... and defined with `()`.
   cost=$0.0032
[chain] outline=["Overview – what the project is ...", 'Project Structure – ...', 'Usage – ...', 'Testing – ...', 'Known Issues / Notes – flag the intentional SQL injection bug ...']
- **Overview**: This project is a small Python demo built to showcase the Claude Agent SDK, containing a simple application (`src/app.py`) and a set of helper functions (`src/utils.py`).
- **Project Structure**: ...
- **Usage**: Run the app directly with `python src/app.py`, or import the utilities ...
- **Testing**: Run the full test suite from the project root using `pytest tests/`.
- **Known Issues / Notes**: `src/app.py` intentionally contains a SQL injection vulnerability as a deliberate, educational flaw ...
   cost=$0.0460
[vote] votes=[False, False, False] -> majority safe=False
   - safe=False: app.py has a SQL injection vulnerability (f-string interpolated query in find_user) and a hardcoded live API key, both unsafe for production.
   - safe=False: It hardcodes a live API key in source and builds SQL via unparameterized string interpolation ...
   - safe=False: It hardcodes a live API key in source and builds a SQL query via f-string interpolation ...
   cost=$0.0359

What just happened

  1. Routing. The Haiku classifier cost a fraction of a cent and chose correctly both times. The "bug" specialist had tools and found the file (and noticed the goal-loop run from section 4 had already fixed it, which is why the sample project state matters when you rerun). The "question" specialist had tools=[] and answered in one cheap turn.
  2. Chaining. The gate (assert 3 <= len(...) <= 5) is deterministic code between two model calls. In an earlier run the outline node produced bare titles and the writer node, having no project context, wrote generic filler; passing descriptive titles fixed it. Lesson: what crosses an edge is the whole context the next node has.
  3. Voting. Three independent judges, aggregated in code. Unanimous here; the pattern earns its cost when judges disagree and you want confidence rather than a coin flip.

Orchestrator-workers is subagents (part 2, section 4): the parent decides what to delegate, each worker has a fresh context, only summaries return. Use when subtasks can't be predicted in advance.

Evaluator-optimizer appears twice below: as a verifier node with a retry edge (section 7) and as a Stop hook (section 4).

Later additions (Agent Design Pattern Catalogue, SitePoint, Augment Code, Towards AI): reflection (self-critique; cheap but lenient, see 4.1), planning (plan-and-execute: permission_mode="plan" plus the built-in Plan subagent), memory (in-context, retrieval, episodic, procedural: sessions, SessionStore, CLAUDE.md, auto memory, .claude/rules, skills).


4. Loop engineering#

Key points

  • A loop = trigger + work + verification + stop rule + persistent state + budget + escalation.
  • The agent that does the work should not be the one that grades it.
  • On the SDK: a Stop hook is the verifier; max_turns/max_budget_usd/an iteration counter are the bounds; your scheduler is the trigger.
🧒 Plain-English version: don't prompt the intern, build the loop that prompts them

Instead of standing over the intern typing instructions, you design a routine: what starts the work (a ticket arrives, 9am every day, a build fails), how the work is checked (the tests pass, a reviewer signs off), when the intern may stop (only when the check passes, or after N tries), and who to call if stuck. The single most repeated lesson from 2026: never let the intern grade their own homework.

4.1 Definition and anatomy#

Lulla et al. define loops as systems that "start agent runs on a schedule or on repository events and stop them when a machine-checkable condition holds" (arXiv 2608.21884), and find six building blocks in practice: triggered runs, machine-checkable stop conditions, persistent state files, verifier sub-agents, token budgets, defined escalation points. Osmani's rule: the model that writes the code must not be the one that grades it; self-grading is too lenient.

4.2 The four loop types#

From the Claude Code team's Loop Engineering: Getting Started with Loops:

Loop Trigger Stops when You hand off Claude Code primitive
Turn-based a prompt Claude decides it is done the check verification skills
Goal-based a prompt an evaluator confirms the condition, or a cap the stop condition /goal
Time-based an interval you cancel, or the work finishes the trigger /loop, /schedule, routines
Proactive an event or schedule, no human present each task's goal is met the prompt all of the above plus dynamic workflows and auto mode

4.3 How /goal works#

/goal is a session-scoped prompt-based Stop hook: after each turn a small model judges the condition against the conversation and returns not yet met (continue, with the reason as guidance), met, or impossible (goal docs). Advice that applies to any verifier: one measurable end state, a stated check ("npm test exits 0"), constraints that must not change, and a turn or time clause. The SDK exposes the same mechanism as the Stop hook: return {"decision": "block", "reason": "..."} and the agent continues with your reason as its next instruction (cap: eight consecutive blocks, reset by any tool call, CLAUDE_CODE_STOP_HOOK_BLOCK_CAP raises it).

4.4 Implementation: a goal loop with a deterministic verifier#

Code (p1b_goal_loop_blocked.py, complete):

"""A goal-based loop. A Stop hook refuses to let the agent stop until a machine-checkable
condition holds (tests pass AND a CHANGELOG exists), with a hard iteration cap."""

import asyncio, os, subprocess
from claude_agent_sdk import AssistantMessage, ClaudeAgentOptions, HookMatcher, ResultMessage, ToolUseBlock, query
from demo_util import PROJECT

MAX_ITERATIONS = 4
CHANGELOG = os.path.join(PROJECT, "CHANGELOG.md")
state = {"iterations": 0, "history": []}


def run_tests() -> tuple[bool, str]:                                              # (1) the verifier is NOT a model
    proc = subprocess.run(["python3", "-m", "unittest", "-q", "tests.test_utils"],
                          cwd=PROJECT, capture_output=True, text=True)
    return proc.returncode == 0, (proc.stdout + proc.stderr)[-1500:]


async def enforce_goal(input_data, tool_use_id, context):                         # (2) the Stop hook
    state["iterations"] += 1
    passed, output = run_tests()
    has_changelog = os.path.exists(CHANGELOG)
    goal_met = passed and has_changelog
    state["history"].append((passed, has_changelog))
    print(f"  [Stop hook] iteration {state['iterations']}: tests {'PASS' if passed else 'FAIL'}, "
          f"CHANGELOG.md {'present' if has_changelog else 'missing'}, stop_hook_active={input_data.get('stop_hook_active')}")
    if goal_met:
        return {}                                                                 # (3) goal met: allow the stop
    if state["iterations"] >= MAX_ITERATIONS:
        print("  [Stop hook] iteration cap reached, allowing stop")
        return {}                                                                 # (4) bounded execution: never loop forever
    return {                                                                      # (5) not met: block, feed evidence back
        "decision": "block",
        "reason": "The goal is not met yet. Goal: all tests pass AND a CHANGELOG.md exists describing the fix. "
                  f"Tests passing: {passed}. CHANGELOG.md present: {has_changelog}. "
                  f"Latest test output:\n{output[-600:]}\nIteration {state['iterations']} of {MAX_ITERATIONS}.",
    }


async def main():
    options = ClaudeAgentOptions(
        cwd=PROJECT,
        permission_mode="acceptEdits",
        allowed_tools=["Read", "Edit", "Write", "Bash(python3 *)"],              # (6) scoped Bash rule: only python3 auto-runs
        hooks={"Stop": [HookMatcher(hooks=[enforce_goal])]},                      # (7) register the verifier
        max_turns=20,                                                             # (8) a second, independent bound
    )
    async for message in query(
        prompt="Make the unit tests in tests/ pass by fixing src/utils.py. "
               "Do NOT edit the tests. Run them with `python3 -m unittest -q tests.test_utils`.",   # (9) note: no mention of CHANGELOG
        options=options,
    ):
        if isinstance(message, AssistantMessage):
            for block in message.content:
                if isinstance(block, ToolUseBlock):
                    print(f"  tool: {block.name} {str(block.input.get('command') or block.input.get('file_path', ''))[:70]}")
        elif isinstance(message, ResultMessage):
            print(f"\n[result] {message.subtype} after {message.num_turns} turns, ${message.total_cost_usd:.4f}")
            print(f"[result] {message.result}")

    passed, _ = run_tests()                                                       # (10) verify OUTSIDE the agent too
    print(f"\nFinal verification outside the agent: tests pass = {passed}; Stop-hook verdicts = {state['history']}")


asyncio.run(main())

Run it:

  tool: Bash find . -type f -name "*.py" | head -50 && echo --- && cat src/utils.py
  tool: Edit .../src/utils.py
  tool: Bash python3 -m unittest -q tests.test_utils
  [Stop hook] iteration 1: tests PASS, CHANGELOG.md missing, stop_hook_active=False    <- blocked, reason fed back
  tool: Bash ls -la
  tool: Write .../CHANGELOG.md
  [Stop hook] iteration 2: tests PASS, CHANGELOG.md present, stop_hook_active=True    <- allowed

[result] success after 7 turns, $0.0575
[result] Added `CHANGELOG.md` documenting the fix. Tests still pass (5/5 OK).

Final verification outside the agent: tests pass = True; Stop-hook verdicts = [(True, False), (True, True)]

What just happened

  1. The agent fixed utils.py, ran the tests itself, and tried to stop.
  2. The hook ran the suite independently: tests passed, but the CHANGELOG criterion (which lived only in the verifier, as acceptance criteria often live only in CI) failed, so it blocked and sent the reason.
  3. The agent read the reason, wrote the CHANGELOG, tried to stop again; stop_hook_active=True tells the hook this is a continuation. Goal met, stop allowed.
  4. The script verified outside the agent at the end. Compare part 1's quickstart, where the agent reported a fix that still crashed on None; here the tests forced a real fix.

The simpler variant (p1_goal_loop.py, tests only) passed on the first check.

4.5 Named loop patterns and their SDK implementations#

The Ralph loop (attempt, get feedback from compiler/linter/tests, loop until clean) is what most people mean by loop engineering in coding; Lulla et al. call such explicit outer loops a workaround that /goal is absorbing. The operational patterns to layer on any loop (Data Science Dojo):

Pattern What it does SDK implementation
Tool-use loop (ReAct) base execution query()
Bounded execution hard ceiling on iterations or spend max_turns, max_budget_usd, MAX_ITERATIONS in the hook
Circuit breaker / stagnation detection stop when iterations stop making progress compare verifier output across iterations (code below)
Separate grader a verifier that is not the worker a Stop hook running tests; a second query() with fresh context
Verification skill encode manual checks so the agent can self-verify end to end a SKILL.md with the steps
Persistent state notes and progress between runs CLAUDE.md, a NOTES.md, task tools, sessions
Escalation hand to a human when stuck PermissionResultDeny, defer, AskUserQuestion, or end with a report
Checkpoint and resume survive crashes without restarting resume, SessionStore, file checkpointing

Circuit breaker, run for real: examples/python/p9_circuit_breaker.py. The verifier demands two things: tests pass and a staging health check passes. The health check is simulated as permanently down, so no amount of retrying can satisfy it. A loop without a breaker would hammer the model until max_turns; this one notices that the verifier's state stopped changing.

MAX_ITERATIONS, STALL_LIMIT = 6, 1      # trip as soon as one retry produced no change in the verifier's state
state = {"iterations": 0, "last_signature": None, "stalls": 0, "log": []}

def staging_healthy() -> bool:
    return False                          # simulated outage: nothing the agent does can change this

def verify() -> tuple[bool, str]:
    tests_ok = os.system(f"cd {WORK} && python3 -m unittest -q tests.test_utils >/dev/null 2>&1") == 0
    staging_ok = staging_healthy()
    sig = f"tests={'pass' if tests_ok else 'FAIL'} staging={'up' if staging_ok else 'DOWN'}"
    return tests_ok and staging_ok, sig

async def breaker(input_data, tool_use_id, context):
    state["iterations"] += 1
    ok, sig = verify()
    state["stalls"] = state["stalls"] + 1 if sig == state["last_signature"] else 0     # (1) same verifier state as last time?
    state["last_signature"] = sig
    state["log"].append((state["iterations"], sig, state["stalls"]))
    print(f"  [Stop hook] iteration {state['iterations']}: {sig} stalls={state['stalls']}")
    if ok:
        return {}
    if state["stalls"] >= STALL_LIMIT:                                                   # (2) trip the breaker: allow the stop, flag it
        print("  [Stop hook] CIRCUIT BREAKER: verifier state unchanged after a retry, stopping for human review")
        return {"systemMessage": "Circuit breaker tripped: goal unreachable from inside the loop."}
    if state["iterations"] >= MAX_ITERATIONS:
        return {}
    return {"decision": "block", "reason": f"Not done yet. Verifier state: {sig}. "                   # (3) otherwise, keep looping
                                           "Done means: tests pass AND the staging health check passes."}

async for m in query(prompt="Make the tests in tests/ pass by fixing src/utils.py.",
                     options=ClaudeAgentOptions(cwd=str(WORK), permission_mode="acceptEdits", setting_sources=[],
                                                allowed_tools=["Read", "Edit", "Write", "Bash(python3 *)"],
                                                hooks={"Stop": [HookMatcher(hooks=[breaker])]}, max_turns=20)):
    ...

Real output

  [Stop hook] iteration 1: tests=pass staging=DOWN stalls=0
  [Stop hook] iteration 2: tests=pass staging=DOWN stalls=1
  [Stop hook] CIRCUIT BREAKER: verifier state unchanged after a retry, stopping for human review

[result] success after 12 turns, $0.0881
[result] There's no staging infrastructure anywhere in this project — no Docker/Procfile/health-check script, no deploy config,
nothing beyond the two source files and the test file. The task as given ("make the tests in `tests/` pass by fixing
`src/utils.py`") is complete: all 5 tests pass.

The Stop-hook message claims a "staging" health check must also pass, but there's no staging server defined in this

verifier log (iteration, signature, stalls): [(1, 'tests=pass staging=DOWN', 0), (2, 'tests=pass staging=DOWN', 1)]

Walkthrough

  • Iteration 1: the agent fixed the code (tests pass), the verifier still said staging DOWN, the hook blocked the stop and sent the agent round again.
  • Iteration 2: the agent searched for a staging setup, found none, and stopped. The verifier signature was identical, so the stall counter hit the limit and the breaker let the stop through with a flag. Total: 12 turns and 9 cents, instead of 20 turns and a error_max_turns.
  • The agent's final text is exactly what you want a human to read: it explains why the goal is unreachable from inside the loop.

Two things I got wrong first.

My first version made the unreachable part a file the agent was forbidden to write; the agent spent 13 turns trying workarounds, hit max_turns, and the Stop hook never fired at all (it only runs on a natural stop). Put the real bound in the hook, keep max_turns as the backstop, and make sure the backstop is larger than the loop needs. Second, STALL_LIMIT=2 is the textbook value but it buys one extra wasted iteration; compare verifier state, not raw test output, and one stall is usually enough.

Time-based loop from the SDK (your scheduler is the trigger; sessions give continuity; illustrative, not executed here):

# runs from cron / a queue consumer / a webhook handler, e.g. every 15 minutes
async def check_pr(pr_number: int, store: SessionStore, session_id: str | None):
    opts = ClaudeAgentOptions(
        cwd=REPO, allowed_tools=["Bash(gh *)", "Bash(git *)", "Read", "Edit"], permission_mode="acceptEdits",
        max_turns=15, max_budget_usd=1.0,
        session_store=store, resume=session_id,                            # (1) continuity across hosts
        system_prompt={"type": "preset", "preset": "claude_code",
                       "append": "If CI is green and there are no new review comments, reply exactly: NOTHING_TO_DO."},
    )
    async for m in query(prompt=f"Check PR #{pr_number}: address new review comments, fix failing CI.", options=opts):
        if isinstance(m, ResultMessage):
            return m.session_id, m.result                                   # (2) persist session_id for the next tick

Inside Claude Code the same shapes are /loop 5m <prompt> (session-scoped, seven-day expiry), self-paced /loop (Claude picks 1–60 min between runs, or uses the Monitor tool to stream events instead of polling), and cloud routines for work that must run with your machine off (scheduled tasks docs).

4.6 Proactive loops and outcome loops#

The Claude Code team's proactive example composes everything: a schedule checks a feedback channel; for each bug, a workflow explores three fixes in parallel worktrees and a judge reviews them adversarially; /goal defines done; auto mode removes permission prompts. Managed Agents has the same iterate → grade → revise loop as a platform feature: send user.define_outcome with a rubric and a separate grader scores each iteration per criterion until satisfied, max_iterations_reached (default 3, max 20) or failed.

4.7 Managing cost and quality in loops#

Match the primitive and model to the job; define success and stop criteria precisely; pilot before large runs; use scripts for deterministic steps; poll no more often than the watched thing changes; and when a result falls short, fix the system (skill, CLAUDE.md, tool) rather than the output.


5. Harness engineering#

Key points

  • A harness is everything around a fixed model: loop, tools, permissions, context management, memory, hooks, state, evals.
  • The Agent SDK is a harness exposed as a library; every option in parts 1–3 is a harness knob.
  • 2026 research treats the harness, not the weights, as the unit of self-improvement, and warns that weak evaluators turn self-improvement into confident regression.
🧒 Plain-English version: the workbench, not the worker

If the model is the worker, the harness is the workbench: the tools laid out, the safety guards, the notebook, the checklist, the alarm clock. The 2026 insight is that most of the improvement in agents came from better workbenches, not smarter workers, and that you can even have an agent improve its own workbench, as long as you have a reliable way to measure whether it got better.

The eleven-system study describes harness engineering as "the deliberate shaping of environments, repo-local instructions, and feedback loops around a fixed model" (arXiv 2609.00006). Lilian Weng's July 2026 essay reframes self-improvement as harness improvement: a meta-agent drafts the harness, a task agent runs it, a feedback agent decides whether the next update targets the harness or the model; the biggest blocker is weak, fuzzy evaluators (Harness Engineering for Self-Improvement). The research line: Meta-Harness (outer-loop search over harness code), AutoHarness (LLMs synthesising harnesses from environment feedback), Self-Harness (propose → evaluate → accept), and RRSI, which shows recursive harness evolution overfits and proposes regularisation (RRSI, Hierarchical Self-Improvement, Self-Evolving Coding Agents); one paper redirects the same mechanisms to defence as an "agent-native immune system" (arXiv 2606.28270).

The practical version: treat CLAUDE.md, skills, tool descriptions, hooks and permission rules as code under test. When a run fails, the fix usually belongs in one of those, and an eval should catch regressions.

Harness-as-code, run for real: examples/python/p8_eval_gate.py. The question: does adding "use type hints" to CLAUDE.md actually change what the agent writes? An eval gate answers it with numbers instead of opinions.

CASES = [  # (prompt, end-state check: does the new function carry a return annotation?)
    ("Add a function clamp(x, lo, hi) that limits x to the range [lo, hi] to src/utils.py.",
     lambda src: "def clamp(" in src and "->" in src.split("def clamp(")[1].split("\n")[0]),
    ("Add a function is_even(n) to src/utils.py.",
     lambda src: "def is_even(" in src and "->" in src.split("def is_even(")[1].split("\n")[0]),
]

async def eval_harness(claude_md: str | None) -> float:
    passed = 0
    for prompt, check in CASES:
        work = Path(tempfile.mkdtemp(prefix="eval-")) / "project"                 # (1) fresh fixture per case
        shutil.copytree(PRISTINE, work)
        if claude_md is None: (work / "CLAUDE.md").unlink()
        else:                 (work / "CLAUDE.md").write_text(claude_md)
        async for _ in query(prompt=prompt, options=ClaudeAgentOptions(
                cwd=str(work), setting_sources=["project"], permission_mode="acceptEdits",
                allowed_tools=["Read", "Edit"], tools=["Read", "Edit"], max_turns=6)):
            pass
        ok = check((work / "src" / "utils.py").read_text())                        # (2) judge the END STATE, not the transcript
        passed += ok
        print(f"   {'PASS' if ok else 'FAIL'}  {prompt[:60]}")
    return passed / len(CASES)

a = await eval_harness(None)                                                       # variant A: no CLAUDE.md
b = await eval_harness("# Project conventions\n- Use type hints on all new or edited functions.\n")   # variant B
print(f"score without rule: {a:.0%}   score with rule: {b:.0%}   -> {'ship the rule' if b >= a else 'do not ship'}")

Real output

=== variant A: no CLAUDE.md ===
   FAIL  Add a function clamp(x, lo, hi) that limits x to the range [
   FAIL  Add a function is_even(n) to src/utils.py.
=== variant B: CLAUDE.md with the type-hint rule ===
   PASS  Add a function clamp(x, lo, hi) that limits x to the range [
   PASS  Add a function is_even(n) to src/utils.py.

score without rule: 0%   score with rule: 100%   -> ship the rule

Walkthrough

  • Without the rule the agent matched the existing untyped style of the file (0 of 2). With one line in CLAUDE.md it annotated both (2 of 2). The harness change is measurable, so it can be reviewed like code.
  • Two cases is a smoke test, not a benchmark. Real gates use 20 to 50 cases and run on every change to CLAUDE.md, skills or tool descriptions; temperature isn't a knob here, so run each case more than once when results are close.
  • Each case runs on a pristine copy of the project and is judged by reading the final file. The transcript is never inspected; that keeps the eval honest when the model's path changes.
Harness concern Agent SDK control
Loop bounds max_turns, max_budget_usd, task_budget, Stop-hook caps
Tool surface tools, allowed_tools, disallowed_tools, MCP servers, tool search
Permission policy modes, allow/deny/ask rules, can_use_tool, PreToolUse hooks, sandbox
Instructions system_prompt preset/append/custom, CLAUDE.md, rules, output styles
Memory sessions, auto memory, CLAUDE.md, SessionStore, subagent memory
Delegation agents, depth/concurrency caps, Workflow
Observability message stream, include_hook_events, OpenTelemetry, model_usage
Reasoning depth effort, thinking
Recovery resume, fork_session, file checkpointing, fallback_model

6. Context engineering#

Key points

  • Context is a budget with diminishing returns ("context rot"). Aim for the smallest set of high-signal tokens.
  • Patterns: just-in-time retrieval, progressive disclosure, compaction, note-taking, sub-agent isolation, token-efficient tools.
  • The SDK implements each; get_context_usage() shows what your configuration costs.
🧒 Plain-English version: a tidy desk

The intern gets worse as the desk fills up. So: don't dump every document on the desk up front, let them fetch what they need (retrieval); give them an index of recipe cards, not the cards (progressive disclosure); when the desk is full, summarise and clear it (compaction); keep a notebook for things to remember (notes); and send side projects to a colleague's desk (subagents).

From Effective Context Engineering for AI Agents:

Pattern Idea In the SDK
Right-altitude prompt between brittle rules and vague guidance preset + append; measure with get_context_usage()
Just-in-time retrieval keep identifiers, load with tools at runtime Glob/Grep/Read rather than pre-stuffing; CLAUDE.md up front as the hybrid
Progressive disclosure descriptions first, full content on demand skills, tool search, lazy child CLAUDE.md
Compaction summarise near the limit; keep decisions, drop stale output automatic compaction, /compact, PreCompact hook, summary instructions in CLAUDE.md
Structured note-taking durable notes outside the window task tools, a NOTES.md, auto memory, subagent memory
Sub-agent isolation thousands of tokens of exploration return as a 1–2k summary agents, the Agent tool
Token-efficient tools self-contained, non-overlapping, descriptive parameters custom tools with clear descriptions and readOnlyHint

A context-engineered configuration, with the numbers from part 3's introspection run as justification:

options = ClaudeAgentOptions(
    cwd=PROJECT,
    tools=["Read", "Edit", "Glob", "Grep", "Bash", "Skill", "Agent"],      # (1) drop built-ins you don't need: schemas were 11.4k tokens
    setting_sources=["project"],                                            # (2) CLAUDE.md in, personal settings out
    skills=["security-check", "code-review"],                               # (3) only the recipe cards this agent uses (5.6k for all)
    env={"ENABLE_TOOL_SEARCH": "auto:5"},                                   # (4) defer MCP schemas once they reach 5% of the window
    agents={"researcher": AgentDefinition(description="Reads many files and returns a short summary.",
                                          prompt="Return at most 10 bullets.", tools=["Read", "Grep", "Glob"], model="haiku")},  # (5) isolate exploration
    effort="medium",
)

And a CLAUDE.md section the compactor reads:

# Summary instructions
When summarizing this conversation, always preserve: the task objective and acceptance criteria,
file paths read or modified, test results and error messages, and decisions with their reasons.

Progressive disclosure is now studied in its own right (Is Progressive Disclosure All You Need?; the Agent Skills survey calls three-stage loading the defining innovation of SKILL.md).

6.1 Memory architectures#

Key points

  • Four memory types recur in every catalog: working (the context window), episodic (what happened in past runs), semantic (facts about the world/project), procedural (how to do things).
  • The SDK gives each a home; the design question is what gets written where, and when it gets read back.
  • Compaction is lossy by design. Anything that must survive it belongs in a file, not in the conversation.
🧒 Plain-English version

The intern has four kinds of memory: what's on the desk right now (working), the diary of past jobs (episodic), the facts they know about the project (semantic), and the recipe cards for how to do things (procedural). The desk is small and gets cleared when full, so anything important must be written into the diary, the handbook or a recipe card.

Memory type Holds SDK home Written by Read when
Working the current conversation, tool results the context window the loop every request; compacted near the limit
Episodic past runs, decisions, transcripts sessions on disk, SessionStore, subagent transcripts the SDK, automatically resume, continue_conversation, get_session_messages
Semantic (project) conventions, architecture, facts CLAUDE.md, .claude/rules/*.md, auto memory (~/.claude/projects/<p>/memory/) you (git) and the agent (Write/Edit on memory files) every request (re-injected, survives compaction)
Procedural how to perform a task skills (SKILL.md), plugins, output styles you description at startup, body on demand
Scratch / task the plan for this job task tools (TaskCreate…), a NOTES.md the agent maintains the agent when it chooses, and after compaction

Compaction-proof memory: tell the agent to externalise state, and tell the compactor what to keep.

# CLAUDE.md (project): semantic + procedural memory that survives compaction
# ----------------------------------------------------------------------------
# # Working memory discipline
# - Before any multi-step task, write the plan to NOTES.md and update it after each step.
# - Record every decision and its reason in NOTES.md under "## Decisions".
# # Summary instructions
# When summarizing this conversation, always preserve: the task objective, NOTES.md's current contents,
# file paths modified, test results, and open questions.

options = ClaudeAgentOptions(
    cwd=PROJECT, setting_sources=["project"],                 # (1) CLAUDE.md in: semantic + procedural memory
    allowed_tools=["Read", "Edit", "Write", "Bash(python3 *)"],
    hooks={"PreCompact": [HookMatcher(hooks=[archive_transcript])]},   # (2) episodic: snapshot the full transcript before it's summarised
    session_store=store,                                      # (3) episodic across hosts
)

async def archive_transcript(input_data, tool_use_id, context):
    shutil.copy(input_data["transcript_path"], f"archive/{input_data['session_id']}-{int(time.time())}.jsonl")
    print(f"[PreCompact] trigger={input_data['trigger']}: archived full transcript")
    return {}

For the Messages API side there's also Anthropic's file-based memory tool (memory_20250818), which gives a model a directory it reads and writes across conversations; in the Agent SDK the equivalent is auto memory plus the ordinary file tools, which is why Write/Edit must be enabled for the agent to save memories.

6.2 Agentic RAG#

Key points

  • Classic RAG retrieves before the model answers, once. Agentic RAG lets the agent decide what to retrieve, when, and whether to retrieve again.
  • In the SDK, retrieval is just a tool: Grep/Glob/Read for code, WebSearch/WebFetch for the web, a custom MCP tool for your vector store.
  • Hybrid works best: load the small, stable context up front (CLAUDE.md), fetch the rest just in time.
🧒 Plain-English version

Old-style RAG: before asking the intern anything, someone photocopies the ten "most relevant" pages and staples them to the question. Agentic RAG: the intern has a library card and goes to look things up, as many times as needed, refining the search as they learn. The SDK's file-search tools are that library card for code; for your own documents you hand over a custom search tool.

Retrieval as a tool, run for real: examples/python/p6_agentic_rag.py. The "knowledge base" is five support articles and a keyword scorer so the demo has no dependencies; swap score() for your embedding index and nothing else changes.

@tool("search_docs",
      "Keyword search over the support knowledge base. Returns the best-matching passages with their document IDs. "
      "Call again with different wording if results look off-topic.",                          # (1) the description teaches re-querying
      {"query": Annotated[str, "A short search query"], "k": Annotated[int, "How many results, 1-5"]},
      annotations=ToolAnnotations(readOnlyHint=True))                                           # (2) read-only: parallel searches allowed
async def search_docs(args):
    ranked = sorted(DOCS.items(), key=lambda kv: -score(args["query"], kv[1][0] + " " + kv[1][1]))[: max(1, min(args["k"], 5))]
    hits = [f"[{doc_id}] {title}: {text[:120]}..." for doc_id, (title, text) in ranked if score(args["query"], title + " " + text) > 0]
    return {"content": [{"type": "text", "text": "\n".join(hits) or "No passages matched. Try different wording."}]}

@tool("get_doc", "Fetch the full text of one knowledge-base document by ID", {"doc_id": str},
      annotations=ToolAnnotations(readOnlyHint=True))
async def get_doc(args):                                                                        # (3) just-in-time full load
    if args["doc_id"] not in DOCS:
        return {"content": [{"type": "text", "text": f"No document with id {args['doc_id']}. IDs look like kb-101."}], "is_error": True}
    title, text = DOCS[args["doc_id"]]
    return {"content": [{"type": "text", "text": f"[{args['doc_id']}] {title}\n{text}"}]}

ANSWER_SCHEMA = {"type": "object", "properties": {"answer": {"type": "string"},
                                                  "citations": {"type": "array", "items": {"type": "string"}},
                                                  "confidence": {"type": "string", "enum": ["low", "medium", "high"]}},
                 "required": ["answer", "citations", "confidence"]}
kb = create_sdk_mcp_server(name="kb", version="1.0.0", tools=[search_docs, get_doc])

options = ClaudeAgentOptions(
    mcp_servers={"kb": kb}, allowed_tools=["mcp__kb__*"], tools=[], setting_sources=[], max_turns=8,   # (4) only the KB tools
    system_prompt="Answer ONLY from the knowledge base tools. Cite document IDs. If the KB has nothing relevant, say so with confidence=low.",
    output_format={"type": "json_schema", "schema": ANSWER_SCHEMA})                            # (5) typed, citable answer

Real output (the retrieval calls line is reconstructed from ToolUseBlocks in the stream)

Q: A customer on a monthly plan was charged twice this month and wants their money back. What should I do?
  retrieval calls: ['search_docs(duplicate charge refund)', 'search_docs(charged twice billing error)', 'get_doc(kb-102)', 'get_doc(kb-101)', 'StructuredOutput(None)', 'StructuredOutput(None)']
  A: {'answer': 'Per KB policy:\n\n1. First confirm whether it's a real duplicate or just an authorization hold. Per kb-102, a second
charge in the same month is often just a hold that auto-drops in 3–7 days. [...]\n\nNote: kb-101's rule that "monthly plans are not
refundable after the billing date" applies to standard refund/cancellation requests, not to billing errors like duplicate charges —
those are explicitly handled via the BILL-DUP process in kb-102 [...]', 'citations': ['kb-102', 'kb-101'], 'confidence': 'high'}
  cost=$0.0300

Q: How long do we keep a cancelled account's data?
  retrieval calls: ['search_docs(cancelled account data retention)', 'get_doc(kb-305)', 'StructuredOutput(None)']
  A: {'answer': 'When an account is cancelled, its data is retained for 90 days after cancellation, after which it is permanently
deleted. Backups containing that data are then purged within an additional 30 days [...]', 'citations': ['kb-305'], 'confidence': 'high'}
  cost=$0.0105

Q: What is our policy on employee parking?
  retrieval calls: ['search_docs(employee parking policy)', 'search_docs(parking)', 'StructuredOutput(None)']
  A: {'answer': "I searched the knowledge base but found no documents related to employee parking policy. [...]", 'citations': [], 'confidence': 'low'}
  cost=$0.0107

Walkthrough

  • Question 1 is the agentic part: two searches with different wording, then two full-document fetches, then an answer that reconciles two articles (the monthly no-refund rule versus the duplicate-charge procedure). Classic single-shot RAG would have stapled the top-k passages to the question and hoped.
  • Question 2 needed one search and one fetch. The agent spends retrieval calls in proportion to the question.
  • Question 3 is the honesty check: two searches, nothing found, empty citations, confidence: low. That is what the system prompt asked for and the schema enforced.
  • StructuredOutput shows up as a tool call because that is how the CLI implements output_format; ignore it when counting retrievals.

For code, the built-in Grep/Glob/Read already implement agentic RAG over the repository, which is what part 1's first run showed (ls, then Read README.md, then ls src).

6.3 Prompt caching as a design constraint#

Key points

  • Caching is automatic and matches on an identical prefix: system prompt → tools → CLAUDE.md → conversation. Change anything early and everything after it misses.
  • The two biggest cache killers in agents: per-machine or per-request text in the system prompt, and switching models mid-session.
  • Default TTL is 5 minutes with an API key; opt into 1 hour for bursty workloads.
🧒 Plain-English version

Every request re-sends the whole desk: job description, tool list, handbook, conversation so far. The API remembers what it saw a few minutes ago and charges a tenth of the price for the unchanged part, as long as it's byte-for-byte identical from the start. So keep the unchanging parts first and never sprinkle timestamps or user names into them.

Part 3's cost run showed it: step 1 wrote 24 941 tokens to cache, step 2 read 24 941 back and paid for 2 new input tokens. Design rules:

Rule SDK knob
Keep the system prompt identical across users and machines system_prompt={"type": "preset", "preset": "claude_code", "append": SHARED, "exclude_dynamic_sections": True} (moves the per-machine memory path out of the prompt)
Put per-request context after the stable part TS: systemPrompt: [STATIC, SYSTEM_PROMPT_DYNAMIC_BOUNDARY, perRequest]; Python: put it in the first user message
Don't change the tool set between requests fixed tools/mcp_servers per agent type; tool search keeps deferred schemas out of the prefix
Avoid mid-session model switches unless needed each model has its own cache; set_model and fallback_model cost one uncached request
Lengthen the TTL for bursty or scheduled workloads env={"ENABLE_PROMPT_CACHING_1H": "1"} or CLAUDE_CODE_PROMPT_CACHE_TTL=1h; subagents and workflow agents use CLAUDE_CODE_SUBAGENT_PROMPT_CACHE_TTL
Let fan-outs share a prefix dynamic workflows hold sibling agents up to 5 s so they read the first agent's cache (CLAUDE_CODE_WORKFLOW_PREFIX_STAGGER_MS)
Verify cache_read_input_tokens on AssistantMessage.usage / ResultMessage.usage; zero on repeated requests means a silent invalidator

6.4 Test-time compute: spending more thinking per task#

Key points

  • Three levers to buy quality with compute at inference time: effort/thinking (deeper reasoning per response), best-of-N / voting (several independent attempts, pick or aggregate), iteration (evaluator-optimizer loops).
  • Measure per completed task, not per request: lower effort that needs three retries is not cheaper.
  • Start at medium/high; use xhigh/max only where measurement shows headroom.
# lever 1: depth per response
ClaudeAgentOptions(effort="xhigh", thinking={"type": "adaptive", "display": "summarized"})
# lever 2: breadth across attempts (voting, section 3; best-of-N with a judge, section 11)
candidates = await asyncio.gather(*(run(task, allowed_tools=["Read"]) for _ in range(3)))
best, _ = await run("Pick the best answer and say why:\n" + json.dumps([c[0] for c in candidates]), PICK_SCHEMA, tools=[])
# lever 3: iteration (goal loop, section 4; verifier node with retry edge, section 7)

effort trades latency and tokens for reasoning depth inside one response; it is independent of whether thinking text is shown. Per subagent, set effort on its AgentDefinition (low for lookups, high for review). The Claude Code team's cost guidance applies: model and effort are the largest cost levers you have.


7. Graph engineering#

Key points

  • A graph = nodes (agents or deterministic steps), edges (routing, conditional, cyclic), shared state.
  • Three coupled graphs: execution (what runs after what), context (what flows between nodes), control (what gates each edge).
  • The designed graph and the executed graph are different objects; observability compares them.
  • Three SDK implementations: a code-owned graph over query(), dynamic workflows, agent teams.
🧒 Plain-English version: an org chart for interns

Once you have several interns, loops aren't enough; you need an org chart. Who hands work to whom (execution)? What exactly do they pass (context)? Who's allowed to approve what (control)? And at the end of the day, did the work actually flow the way the chart says, or did someone skip a step (designed vs executed)? Writing that chart as code so it's repeatable and reviewable is graph engineering.

7.1 Definition#

Graph engineering designs the structure multiple loops operate in (Eigent, Analytics Vidhya); Feng et al. frame it as "explicit, dynamic, evolving graph structures representing tasks, agents, and system states". Practitioner guides decompose a production system into three coupled graphs (TrueFoundry): execution, context, control. And: "reachability is not authorization", a node being able to reach a tool does not mean it may call it. Is it new? Mostly a relabeling of workflow engines, state machines and LangGraph with the agent as node and the verifier as first-class edge (Turing Post).

7.2 When a graph beats a loop#

Reach for an explicit graph when the orchestration must be repeatable and reviewable, when there are more items than one context can hold, when stages have different trust levels, or when you want adversarial/cross-checked results as structure. Stay with one loop when the task is small, path-dependent, or human-steered.

7.3 Implementation 1: a code-owned graph over query()#

 plan ──► fan-out (one worker per file, in parallel) ──► synthesize ──► verify
              ▲                                                          │
              └──────────── conditional edge: rejected? retry once ◄─────┘

Code (p2_graph_pipeline.py, complete):

"""A code-owned graph. Your program is the orchestrator; each node is a query()."""

import asyncio, json
from dataclasses import dataclass, field
from claude_agent_sdk import ClaudeAgentOptions, ResultMessage, query
from demo_util import PROJECT


@dataclass
class State:                                   # (1) the shared state every node reads and writes
    files: list[str] = field(default_factory=list)
    findings: dict[str, list] = field(default_factory=dict)
    report: str = ""
    verdict: dict = field(default_factory=dict)
    cost: float = 0.0
    trace: list[str] = field(default_factory=list)        # (2) the EXECUTED graph, recorded as it happens


async def node(state: State, name: str, prompt: str, schema: dict | None = None, **opts):
    """Run one graph node as its own fresh-context agent and return its typed output."""
    options = ClaudeAgentOptions(
        cwd=PROJECT, setting_sources=[], max_turns=opts.pop("max_turns", 6),
        output_format={"type": "json_schema", "schema": schema} if schema else None, **opts,
    )
    out, text = None, None
    async for m in query(prompt=prompt, options=options):
        if isinstance(m, ResultMessage):
            out, text = m.structured_output, m.result
            state.cost += m.total_cost_usd or 0
            state.trace.append(f"{name}: {m.subtype}, {m.num_turns} turns, ${m.total_cost_usd:.4f}")
    return out if schema else text


FILES_SCHEMA = {"type": "object", "properties": {"files": {"type": "array", "items": {"type": "string"}}}, "required": ["files"]}
FINDINGS_SCHEMA = {"type": "object", "properties": {"findings": {"type": "array", "items": {
    "type": "object", "properties": {"line": {"type": "integer"}, "issue": {"type": "string"},
                                     "severity": {"type": "string", "enum": ["low", "medium", "high"]}},
    "required": ["line", "issue", "severity"]}}}, "required": ["findings"]}
VERDICT_SCHEMA = {"type": "object", "properties": {"approved": {"type": "boolean"}, "reason": {"type": "string"}},
                  "required": ["approved", "reason"]}


async def plan(state: State):                                                        # node: discover
    out = await node(state, "plan", "List every Python source file under src/ (relative paths).",
                     FILES_SCHEMA, allowed_tools=["Glob"])                           # (3) control graph: Glob only
    state.files = out["files"]


async def fan_out(state: State):                                                     # node: map, in parallel
    async def worker(path):
        out = await node(state, f"worker[{path}]",
                         f"Review {path} for bugs and security issues. Report findings with line numbers.",
                         FINDINGS_SCHEMA, allowed_tools=["Read"], effort="low")      # (4) workers: Read only, low effort
        return path, out["findings"]
    results = await asyncio.gather(*(worker(p) for p in state.files))                # (5) parallel edges
    state.findings = dict(results)


async def synthesize(state: State):                                                  # node: reduce
    state.report = await node(
        state, "synthesize",
        "Write a short prioritized review (max 8 bullets) from these findings. Do not read files.\n"
        + json.dumps(state.findings, indent=2), tools=[])                            # (6) NO tools: it only sees typed state


async def verify(state: State):                                                      # node: independent grader
    state.verdict = await node(
        state, "verify",
        "You are an independent reviewer with fresh context. Approve this report only if every finding "
        "cites a line number and the highest-severity items come first. Report:\n" + state.report,
        VERDICT_SCHEMA, tools=[])                                                    # (7) fresh context, no tools


async def main():
    state = State()
    await plan(state)
    print("plan ->", state.files)
    await fan_out(state)
    print("fan_out ->", {k: len(v) for k, v in state.findings.items()})
    for attempt in range(2):                                                         # (8) bounded retry edge
        await synthesize(state)
        await verify(state)
        print(f"verify (attempt {attempt + 1}) ->", state.verdict)
        if state.verdict["approved"]:
            break
    print("\nREPORT:\n" + state.report)
    print("\nTRACE:"); print("\n".join("  " + t for t in state.trace))
    print(f"TOTAL COST: ${state.cost:.4f}")


asyncio.run(main())

Run it:

plan -> ['src/app.py', 'src/utils.py']
fan_out -> {'src/app.py': 4, 'src/utils.py': 1}
verify (attempt 1) -> {'approved': True, 'reason': 'Every finding includes a specific file:line citation (app.py:5, app.py:11, app.py:9, utils.py:13, app.py:2), and items are ordered by descending severity ...'}

REPORT:
## Code Review — Prioritized
- 🔴 **[High] `src/app.py:5`** — Hardcoded live API key/secret in source. ...
- 🔴 **[High] `src/app.py:11`** — SQL injection via f-string interpolation of `name` into query. ...
- 🟡 **[Low] `src/app.py:9`** — DB connection isn't closed on exception ...
- 🟡 **[Low] `src/utils.py:13`** — `user.get("name", "").upper()` crashes with `AttributeError` if `name` is explicitly `None` ...
- 🟢 **[Low] `src/app.py:2`** — Unused import `get_user_name`; remove for cleanliness.

TRACE:
  plan: success, 3 turns, $0.0974
  worker[src/utils.py]: success, 3 turns, $0.0932
  worker[src/app.py]: success, 4 turns, $0.1011
  synthesize: success, 1 turns, $0.0061
  verify: success, 2 turns, $0.0095
TOTAL COST: $0.3072

What just happened

  1. Execution graph: plan → two workers in parallel → synthesize → verify, with a retry edge that wasn't needed.
  2. Context graph: every edge carried validated JSON (FILES_SCHEMA, FINDINGS_SCHEMA, VERDICT_SCHEMA), not prose.
  3. Control graph: the planner could only Glob, workers only Read, the synthesiser and verifier had no tools at all.
  4. Executed graph: the trace is the run as it actually happened, with per-node cost. That's what you'd export to OpenTelemetry in production.
  5. The worker caught the None bug that part 1's quickstart agent introduced.

7.4 Implementation 2: dynamic workflows (the script holds the plan)#

Claude Code's dynamic workflows move the plan into a JavaScript script a runtime executes in the background with agent(), parallel(), pipeline(), phase(), log() (docs). Claude writes the script for a task you describe; you can read, save and rerun it. Intermediate results live in script variables, not in anyone's context.

// .claude/workflows/audit-routes.js (what a saved workflow looks like)
export const meta = { name: 'audit-routes', description: 'Audit every route handler for missing auth checks',
                      phases: [{ title: 'Discover' }, { title: 'Audit' }, { title: 'Verify' }] }

phase('Discover')
const found = await agent('List every .ts file under src/routes/.', {
  schema: { type: 'object', required: ['files'], properties: { files: { type: 'array', items: { type: 'string' } } } },
})

phase('Audit')
const audits = await pipeline(found.files, file =>
  agent(`Audit ${file} for missing authentication checks. Return findings with line numbers.`, { label: file }))

phase('Verify')
const verified = await parallel(audits.filter(Boolean).map(a => () =>
  agent(`Adversarially verify these findings; drop any you cannot reproduce:\n${JSON.stringify(a)}`, { label: 'verify' })))

return verified.filter(Boolean)

From the TypeScript Agent SDK (v0.3.149+) the Workflow tool is a normal tool call through your permission flow:

for await (const message of query({
  prompt: "use a workflow to audit every route handler under src/routes/ for missing auth checks, and adversarially verify each finding",
  options: { allowedTools: ["Read", "Grep", "Glob", "Workflow"], cwd: REPO },
})) {
  if (message.type === "result") console.log(message.result);
}

The docs' comparison is the clearest statement of graph engineering I've seen:

Subagents Skills Agent teams Workflows
What it is a worker Claude spawns instructions Claude follows a lead supervising peers a script the runtime executes
Who decides what runs next Claude, turn by turn Claude the lead the script
Where intermediate results live Claude's context Claude's context a shared task list script variables
Scale a few per turn same a handful of peers dozens to hundreds per run
Interruption restarts the turn restarts the turn teammates keep running resumable in the same session

Guardrails are built in: 16 concurrent agents by default, 1 000 per run, 4 096 items per parallel()/pipeline(), stalled agents restart up to five times, Date.now()/Math.random() throw so relaunches replay deterministically. The bundled /deep-research workflow (fan out searches, cross-check, vote, filter) is the canonical example.

7.5 Implementation 3: agent teams#

Agent teams (experimental, interactive-only) are peers that message each other and claim tasks from a shared list under a lead (docs). The killer use case is competing hypotheses: five teammates debug with different theories and try to disprove each other, which defeats the anchoring of sequential investigation. Start with three to five.

7.6 Multi-agent topologies: handoffs, swarms, supervisors, blackboards, debate#

Key points

  • A handoff transfers control (and the conversation) to a different specialist. A swarm is a set of peers that hand off among themselves with no central boss. A supervisor keeps control and only delegates. A blackboard is shared state everyone reads and writes. Debate is peers trying to disprove each other.
  • On the Agent SDK: supervisor = subagents; handoff = a routing node that resumes or forks the session under a new agent definition; swarm = agent teams (CLI) or a SendMessage mesh; blackboard = the State object of a code-owned graph or the agent teams' shared task list.
  • Handoffs and swarms trade predictability for flexibility; most production systems keep a supervisor.
🧒 Plain-English version
  • Supervisor: a manager assigns tasks and collects results. Simple, predictable, the default.
  • Handoff: the receptionist passes your call to billing, who passes it to a technician. Whoever holds the call is in charge; the conversation goes with it.
  • Swarm: a group of specialists with no manager, each able to pass work to any other. Flexible, hard to predict and to debug.
  • Blackboard: a shared whiteboard everyone reads and writes to.
  • Debate: several specialists argue opposite theories until one survives.
Topology Who holds control Where the conversation lives Popularised by SDK implementation
Supervisor / hierarchical a lead; workers return results the lead's context; workers start fresh orchestrator-workers subagents (agents + Agent tool), nested up to CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH
Handoff whoever was handed the conversation travels with the handoff OpenAI Agents SDK "handoffs", Swarm a router node that resumes/forks the session as a different agent (code below); TS agent option to run a named agent definition as the main thread
Swarm no one; peers hand off among themselves distributed OpenAI Swarm, AutoGen group chat agent teams (CLI, experimental) with SendMessage; Managed Agents multiagent rosters; A2A across orgs
Blackboard the shared state in the blackboard classic AI the State dataclass in a code-owned graph; agent teams' shared task list; a NOTES.md
Debate / adversarial a judge each debater's own context multi-agent debate asyncio.gather of opposed query()s + a judge node; agent teams' "disprove each other" prompt

Handoffs as a state machine over sessions, run for real: examples/python/p4_handoff.py. The trick that makes a handoff different from routing is that the conversation moves with it. Each specialist has its own instructions and tool surface; the router in code decides who holds the conversation next and forks the session so the new specialist sees everything said so far.

SPECIALISTS = {
    "triage": dict(tools=[], prompt=(
        "You are front-desk triage for a software team. Acknowledge the user in one sentence, then hand off to the right "
        "specialist. If the message has BOTH a billing and a technical part, hand off to billing first. "
        "End your reply with exactly one line: HANDOFF:billing, HANDOFF:tech, or HANDOFF:done.")),
    "billing": dict(tools=[], prompt=(
        "You are the billing specialist. Resolve the billing part of the conversation so far in two sentences "
        "(you may promise a refund within 5 business days). If a technical issue remains unresolved in the conversation, "
        "end with HANDOFF:tech. Otherwise end with HANDOFF:done.")),
    "tech": dict(allowed_tools=["Read", "Grep", "Glob"], prompt=(
        "You are tech support with read access to the code in this directory. Diagnose the technical issue from the "
        "conversation so far by reading src/, name the file:line and a one-sentence fix. End with HANDOFF:done.")),
}

def next_agent(text: str) -> str | None:
    m = re.search(r"HANDOFF:(\w+)", text or "")
    return m.group(1) if m else None

async def handoff_loop(user_message: str, max_hops: int = 4):
    current, session_id, total = "triage", None, 0.0
    for hop in range(max_hops):                                                   # (1) bounded, like every loop
        spec = SPECIALISTS[current]
        opts = ClaudeAgentOptions(
            cwd=PROJECT, max_turns=6, setting_sources=[],
            tools=spec.get("tools"), allowed_tools=spec.get("allowed_tools", []),
            system_prompt=spec["prompt"],                                          # (2) the specialist's instructions
            resume=session_id, fork_session=session_id is not None,                # (3) the conversation travels with the handoff
        )
        prompt = user_message if hop == 0 else (
            f"[System: this conversation was handed off to the {current} specialist. "
            f"Your instructions: {spec['prompt']} Continue from the conversation so far.]")   # (4) re-inject: resumed sessions keep their recorded prompt
        async for m in query(prompt=prompt, options=opts):
            if isinstance(m, ResultMessage):
                session_id, text, total = m.session_id, m.result, total + (m.total_cost_usd or 0)
        print(f"\n[{current}] (session {session_id[:8]}...)\n{text}")
        target = next_agent(text)
        if target in (None, "done"):
            print(f"\n-- conversation closed by {current} after {hop + 1} hop(s), ${total:.4f}"); return
        current = target                                                           # (5) the handoff
    print("\n-- escalated to a human: too many handoffs")                          # (6) escalation

await handoff_loop("Hi, I was charged twice for my subscription last month. Also, looking up my account by name "
                   "on the user page crashes the app, I think the lookup code is broken.")

Real output

[triage] (session e14678c8...)
Thanks for reporting both issues, Ashish — since you've got a billing concern and a technical bug, I'll route you to billing
first to resolve the duplicate charge, then to the tech team for the lookup crash.

HANDOFF:billing

[billing] (session c1e3240d...)
I've located the duplicate charge on your account and confirmed it was an error — you'll be refunded the extra charge within
5 business days. I'm now handing you off to the tech team to resolve the account lookup crash.

HANDOFF:tech

[tech] (session 2dfa0d99...)
Found it: **src/app.py:11** — `find_user()` builds the SQL query with an f-string (`f"SELECT * FROM users WHERE name = '{name}'"`),
so any name containing a single quote (or other SQL special character) breaks the query syntax and crashes/throws, and it's also
a SQL-injection vector.

**Fix:** replace the f-string with a parameterized query: `conn.execute("SELECT * FROM users WHERE name = ?", (name,))`.

HANDOFF:done

-- conversation closed by tech after 3 hop(s), $0.0601

Walkthrough

  • Three session IDs, one conversation: each hop forks the previous session, so billing saw the user's original message without being re-sent it, and tech knew the "lookup crash" meant find_user without being told.
  • Each specialist has a different tool surface. Triage and billing had no tools at all; tech had read-only code access and used it to name a file and line.
  • The handoff signal is a plain-text convention (HANDOFF:name) parsed in code. That keeps the router deterministic and testable; the model never "calls" another agent.
  • Six cents for three hops. Every hop re-reads the transcript in fresh context, so chains cost more than a supervisor that passes summaries; keep max_hops small.

Two caveats from parts 1–2 apply: a resumed session keeps its recorded system prompt until compaction, which is why this forks and re-injects instructions as the first message rather than relying on system_prompt alone; and the fork means the original session is untouched, which is handy for audit.

A supervised swarm with a blackboard, run for real: examples/python/p5_debate.py. Three investigators hold competing theories about a failing test, work in parallel on a pristine copy of the project, write to a shared blackboard, and try to disprove each other across two rounds. A judge with no tools decides.

@dataclass
class Blackboard:                                   # shared state every peer reads and writes
    hypotheses: dict[str, str]
    evidence: dict[str, list[str]] = field(default_factory=dict)
    cost: float = 0.0

EVIDENCE_SCHEMA = {"type": "object",
                   "properties": {"for": {"type": "array", "items": {"type": "string"}},
                                  "against": {"type": "object", "additionalProperties": {"type": "array", "items": {"type": "string"}}}},
                   "required": ["for", "against"]}
VERDICT_SCHEMA = {"type": "object", "properties": {"winner": {"type": "string"}, "reason": {"type": "string"},
                                                   "confidence": {"type": "number"}}, "required": ["winner", "reason", "confidence"]}

async def investigate(bb: Blackboard, name: str, theory: str):
    others = {n: t for n, t in bb.hypotheses.items() if n != name}
    out, c = await run(
        f"You are investigator {name}. The failing test is tests/test_utils.py::test_user_name_none_returns_empty "
        f"(it calls get_user_name(None) and expects ''). Your theory: {theory}.\nOther open theories: {json.dumps(others)}\n"
        f"Evidence already on the blackboard:\n{json.dumps(bb.evidence, indent=2)}\n"
        "Read the code and the test. Return evidence FOR your theory and evidence AGAINST each other theory "
        "(cite file:line). Be honest: if your theory is wrong, say so in 'for' with an empty list.",
        EVIDENCE_SCHEMA, allowed_tools=["Read", "Grep", "Glob"], effort="low")
    bb.cost += c
    bb.evidence.setdefault(name, []).extend(out["for"])
    for other, reasons in out["against"].items():
        bb.evidence.setdefault(f"against_{other}", []).extend(reasons)

async def debate(bb: Blackboard, rounds: int = 2):
    for r in range(rounds):                                                                   # (1) bounded rounds
        await asyncio.gather(*(investigate(bb, n, t) for n, t in bb.hypotheses.items()))     # (2) peers in parallel, shared blackboard
        print(f"after round {r + 1}: evidence counts = { {k: len(v) for k, v in bb.evidence.items()} }")
    verdict, c = await run("Judge which hypothesis best explains the failing test given this evidence. "
                           "Prefer the theory with concrete file:line support and no rebuttals.\n" + json.dumps(bb.evidence, indent=2),
                           VERDICT_SCHEMA, tools=[])                                          # (3) independent judge, no tools
    bb.cost += c
    return verdict

bb = Blackboard(hypotheses={
    "A": "get_user_name crashes because it subscripts user['name'] without checking for None",
    "B": "the test fails because of an import error: src.utils cannot be imported",
    "C": "calculate_average's division by zero is what makes the whole test module fail",
})

Real output (blackboard entries truncated to one line)

after round 1: evidence counts = {'C': 0, 'against_A': 2, 'against_B': 2, 'B': 0, 'against_C': 2, 'A': 1}
after round 2: evidence counts = {'C': 0, 'against_A': 2, 'against_B': 4, 'B': 0, 'against_C': 4, 'A': 2}

BLACKBOARD:
  C:
  against_A:
    - src/utils.py:9 - get_user_name(None) executes user["name"] which raises TypeError: 'NoneType' object is not subscriptable, directly crashing
    - src/utils.py:8 get_user_name does `return user["name"].upper()` with no None check — calling get_user_name(None) raises TypeError
  against_B:
    - src/utils.py and tests/test_utils.py:2 - `from src.utils import calculate_average, get_user_name` succeeds; both names exist in src/utils.py
    - tests/test_utils.py:2 - `from src.utils import calculate_average, get_user_name` succeeds since both symbols are defined in src/utils.py
  B:
  against_C:
    - src/utils.py:1-5 calculate_average divides by len(numbers); with numbers=[] this is 0/0 which raises ZeroDivisionError. However tests/test_u
    - calculate_average's division by zero claim doesn't hold [...] under pytest/unittest each test runs independently
  A:
    - src/utils.py:8 - get_user_name(user) does `return user["name"].upper()` with no None/missing-key check. Calling get_user_name(None) raises T

VERDICT: {'winner': 'A', 'confidence': 0.95, 'reason': 'Hypothesis A has concrete, uncontested file:line support: src/utils.py:8 shows
get_user_name returns user[name].upper() with no None check, causing a TypeError when called with None, which directly explains the
failing test [...]. The against_A notes actually confirm rather than refute A. B is refuted since the import at tests/test_utils.py:2
succeeds cleanly. C is refuted since pytest/unittest run tests independently, so a ZeroDivisionError in a different test cannot cause
this specific test to fail.'}
TOTAL COST: $0.2320

Walkthrough

  • Investigators B and C were told to be honest and were: both returned an empty for list in both rounds ('B': 0, 'C': 0). The blackboard made that visible at a glance.
  • Round 2 added rebuttals, not new theories: against_B and against_C doubled while A gained one more citation. That is the "disprove each other" dynamic doing its job; a third round would have added cost and nothing else.
  • Look at against_A. Investigators B and C filed evidence under "against A" that actually supports A (the exact crash A predicts). The judge, reading with fresh context and no tools, caught the mislabelling: "The against_A notes actually confirm rather than refute A." This is why the judge must be a separate node.
  • Seven model calls (3 × 2 rounds + judge) for 23 cents. Debate is the most expensive topology here; use it when anchoring on one theory is the failure you fear.

In Claude Code itself the same shape is one prompt to an agent team ("spawn 5 teammates to investigate different hypotheses and try to disprove each other"), where teammates message each other directly and claim tasks from the shared list. Teams are interactive-only; from the SDK you build the mesh in code as above, or use Managed Agents' multiagent rosters ({"type": "self"} plus worker agents) when you want Anthropic to host it.

When to use which: supervisor by default; handoffs when a conversation genuinely changes owner (support tiers, sales → onboarding); swarms only when peers must challenge each other (debugging, research) and you can afford the tokens and the debugging; blackboards whenever more than two nodes share state; debate when anchoring is the failure you fear.

7.7 Observability for graphs#

Trace at least: node start/finish, the route selected, state fields changed, tool calls, model and prompt versions, tokens, latency, retries, validation results, human decisions. In the SDK: the trace list above plus OpenTelemetry spans (claude_code.interaction → tool → nested subagent spans), with session.id tying calls into one timeline.


8. Multi-agent lessons from production systems#

Key points

  • Multi-agent pays for parallelisable, high-value work: ~15× the tokens of chat, 90% better on a research eval.
  • Delegate explicitly, scale effort to complexity, parallelise, evaluate outcomes not paths, resume from checkpoints.
  • Let agents improve tools and prompts; humans still catch what judges miss.

From How we built our multi-agent research system: a lead plans and spawns subagents that search in parallel, each with its own context; a citation agent attaches sources. Token spend explained ~80% of performance variance. Lessons: give each subagent an objective, output format, tool guidance and boundaries; use explicit rules for how many agents a task warrants; start wide then narrow; a tool-testing agent that rewrote descriptions cut task time by 40%; judge outcomes with an LLM rubric (20 queries find large effects), but humans caught the SEO-content-farm failure; errors compound, so resume from checkpoints, tell the agent when a tool failed, trace everything, deploy with rainbow deployments; synchronous waiting is a bottleneck (the SDK's background subagents and workflow runtime are the asynchronous direction); pass filesystem artefacts and references rather than large payloads through context.

An explicit delegation prompt (the single most effective fix for duplicated subagent work):

DELEGATION = """Objective: {objective}
Scope: only files under {scope}. Do not look elsewhere.
Output format: a JSON list of {{"file","line","issue","severity"}} and nothing else.
Tools: use Grep to locate, Read to confirm. Do not edit.
Stop when you have checked every file in scope or after 10 tool calls."""

9. Tool design as a pattern#

Key points

  • Tools are the agent-computer interface; teams under-invest in them.
  • Consolidate workflows, namespace clearly, return meaningful context, make errors teach, write descriptions for a new teammate, poka-yoke the arguments.
  • Evaluate tools with realistic multi-call tasks and let a coding agent iterate the descriptions.

From Writing Effective Tools for Agents. 💻 Before and after:

# before: three thin wrappers around endpoints, opaque errors
@tool("list_slots", "List slots", {"user": str}) ...
@tool("create_event", "Create event", {"u": str, "t": str}) ...

# after: one workflow tool, unambiguous names, an error that teaches
@tool("schedule_meeting",
      "Find a free slot for every attendee in the next 7 days and book it. Returns the booked time and a calendar link. "
      "Use this instead of listing availability and creating events separately.",
      {"type": "object",
       "properties": {"attendee_user_ids": {"type": "array", "items": {"type": "string"}, "description": "User IDs, e.g. ['u_123']. Names are not accepted; call find_user first."},
                      "duration_minutes": {"type": "integer", "minimum": 15, "maximum": 240},
                      "earliest": {"type": "string", "description": "ISO 8601 date-time, e.g. 2026-10-17T09:00:00Z"}},
       "required": ["attendee_user_ids", "duration_minutes"]})
async def schedule_meeting(args):
    if not all(a.startswith("u_") for a in args["attendee_user_ids"]):
        return {"content": [{"type": "text", "text": "attendee_user_ids must be IDs like 'u_123'. You passed names; call find_user to resolve them first."}], "is_error": True}
    ...

2026 additions: tool search (keep thousands of tools out of context) and programmatic tool calling / code execution (the model writes code that calls tools in a sandbox, collapsing many round trips into one).

9.1 Bash as the universal tool#

Key points

  • Claude is unusually good at the shell, and a shell is the most general tool there is: anything with a CLI becomes an agent capability with zero integration work.
  • The harness makes it safe: read-only commands auto-run, mutating ones go through permissions, scoped rules (Bash(npm *), deny Bash(rm *)), AST-based command parsing, sandboxing, and background execution.
  • Prefer a dedicated tool over Bash when you need typed inputs, parallelism (readOnlyHint), or to hide a credential.
🧒 Plain-English version

The command line is the intern's Swiss army knife: with it they can run tests, use git, call any program your company already has, and chain things together. That's powerful, so the SDK wraps it in rules: harmless "look but don't touch" commands run freely; anything that changes things needs your OK unless you pre-approved that kind of command ("npm anything is fine, but never rm"); and the whole shell can run inside a padded room (the sandbox).

What the runs in this series showed Bash doing: part 1's first agent reached for ls before any file tool; the interrupt demo ran a for loop with sleep; the goal loop ran python3 -m unittest and find; the skill demo ran commands to enumerate src/. Claude Code parses each command into an AST before permission matching, so cd x && rm -rf y is judged on both parts, and constructs like eval always prompt.

options = ClaudeAgentOptions(
    allowed_tools=[
        "Bash(python3 *)", "Bash(pytest *)", "Bash(npm test*)",   # (1) pre-approve kinds of commands, not Bash wholesale
        "Bash(git status*)", "Bash(git diff*)", "Bash(gh pr view*)",
    ],
    disallowed_tools=["Bash(rm *)", "Bash(git push*)", "Bash(curl *)"],   # (2) denied in every mode, even bypass
    sandbox={"enabled": True, "autoAllowBashIfSandboxed": True,          # (3) or: sandbox everything and stop prompting
             "network": {"allowedDomains": ["pypi.org", "files.pythonhosted.org"]}},
)

Patterns:

  • Verification through Bash is the backbone of every loop in section 4: the Stop hook can call the same test command the agent does, so agent and verifier agree on the check.
  • Background commands: run_in_background: true on the Bash tool returns a task ID; the agent reads the output file later, and your stream sees TaskStartedMessage / TaskNotificationMessage. Pair with the Monitor tool to react to a log line or WebSocket frame instead of polling.
  • Deterministic steps as scripts: ship a script in a skill (scripts/fill_form.py) and have the agent run it, rather than reasoning through the steps each time. Cheaper and more reliable.
  • Poka-yoke: Claude Code's own lesson from SWE-bench was to require absolute paths after cd broke relative ones; cd inside a subagent worktree doesn't persist for the same reason.
  • When not Bash: wrap an operation as a custom tool when you need a typed schema, when calls should run in parallel (readOnlyHint), when the command needs a secret the agent must not see, or when the output needs shaping (pagination, truncation) before Claude reads it.

9.2 Background and asynchronous agents#

Key points

  • Three asynchronous shapes: background subagents (fire, continue, get notified), background commands/monitors, and scheduled/event-driven runs with no human present.
  • The stream tells you what's happening: TaskStartedMessage, TaskProgressMessage, TaskNotificationMessage; stop_task() cancels.
  • Budget and stall caps matter more here because nobody is watching.
🧒 Plain-English version

Instead of standing next to the intern, you hand off the job and get a message when it's done, or a scheduled routine runs it overnight. Because nobody is watching, the alarms (budget, time limits, "stop if stuck") do the supervising.

options = ClaudeAgentOptions(
    allowed_tools=["Read", "Grep", "Glob", "Agent", "Bash(python3 *)"],
    agents={"auditor": AgentDefinition(description="Audits one directory for issues.", prompt="...",
                                       tools=["Read", "Grep"], background=True, maxTurns=15)},   # (1) always non-blocking
    env={"CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS": "5", "CLAUDE_ASYNC_AGENT_STALL_TIMEOUT_MS": "120000"},   # (2) caps
    max_budget_usd=3.0,
)
async with ClaudeSDKClient(options) as client:
    await client.query("Audit src/, tests/ and scripts/ in parallel with the auditor agent, then summarize.")
    async for m in client.receive_response():
        if isinstance(m, TaskStartedMessage):   print(f"started  {m.task_id}: {m.description}")
        elif isinstance(m, TaskProgressMessage): print(f"progress {m.task_id}: {m.usage['tool_uses']} tool uses, last={m.last_tool_name}")
        elif isinstance(m, TaskNotificationMessage):
            print(f"finished {m.task_id}: {m.status}, summary={(m.summary or "")[:80]}")
            if m.status == "failed": await client.stop_task(m.task_id)             # (3) react

Subagents run in the background by default in current Claude Code; background: true forces it; the parent is notified and continues. In single-shot mode Claude Code waits for still-running background subagents before emitting the result (up to a cap). For work with no session at all, use a scheduler calling query() (section 4.5), Claude Code routines, or Managed Agents scheduled deployments. The multi-agent research team called synchronous waiting their main bottleneck; this is the fix.

9.3 Agentic coding practices#

Key points

  • The practices that make coding agents reliable are the same patterns in this part, applied to a repository: a handbook (CLAUDE.md), verification (tests as the Stop condition), isolation (worktrees), a separate reviewer, small reviewable units.
  • Give the agent a way to check its work, not just do it.
  • Fix the system when it fails: encode the lesson in CLAUDE.md, a skill, or a test.
🧒 Plain-English version

Treat the coding agent like a new team member: give them the team handbook, make them run the tests before saying "done", have a different person review the change, let them work on a copy of the code so mistakes don't hit the main branch, and when something goes wrong, update the handbook so it doesn't happen again.

Practice Why SDK / Claude Code
A concise CLAUDE.md with build/test commands and conventions the agent follows existing patterns; part 1 showed it adding type hints unasked setting_sources=["project"]
Tests as the definition of done the agent's self-report is not proof Stop hook running the suite (section 4)
Plan before editing on risky changes fewer wasted edits permission_mode="plan", then set_permission_mode("acceptEdits")
Isolate parallel work two agents editing one file overwrite each other git worktrees; subagent isolation: worktree; one cwd per agent
A reviewer with fresh context self-review is lenient a code-reviewer subagent with read-only tools, or the bundled /code-review skill
Scoped shell permissions let tests run freely, never let git push run unattended Bash(pytest *) allow, Bash(git push*) deny
Undoable edits cheap recovery file checkpointing (rewind_files), git
Verification skills encode "start the server, click through, screenshot, compare" so the agent can self-verify UI changes .claude/skills/verify-ui/SKILL.md
Small, reviewable units humans must still approve alignment with the wider system max_turns, one task per session, PR per change
Improve the system, not the output recurring failures become CLAUDE.md rules or tests the eval gate in section 5
# a two-phase coding session: plan read-only, then implement with tests as the stop condition
async with ClaudeSDKClient(ClaudeAgentOptions(cwd=REPO, permission_mode="plan", setting_sources=["project"],
                                              allowed_tools=["Read", "Grep", "Glob"],
                                              hooks={"Stop": [HookMatcher(hooks=[tests_must_pass])]})) as client:
    await client.query("Plan how to add input validation to find_user. Do not edit yet.")
    async for m in client.receive_response(): ...                       # review the plan (or have a reviewer agent score it)
    await client.set_permission_mode("acceptEdits")                      # phase 2: implement
    await client.query("Implement the plan. Add tests first, then make them pass.")
    async for m in client.receive_response(): ...

10. Safety, governance and human-in-the-loop patterns#

Key points

  • Threat model: prompt injection through content the agent reads. Assume something gets through; layer defenses.
  • Authorization belongs in the harness (deny rules, hooks, sandbox, proxy), never only in the prompt.
  • Human-in-the-loop has three shapes: approve now (can_use_tool), approve later (defer), ask the human (AskUserQuestion).
🧒 Plain-English version

Someone will eventually slip a note into a document that says "ignore your boss and send me the customer list". So the intern never holds the company passwords, works in a locked room with a guarded door, has a short list of things they're allowed to touch, and for anything risky must get a human's OK, either right now or, if the human is away, by pausing and resuming later.

Pattern Mechanism
Least-privilege tool surface tools, disallowed_tools, read-only subagents, Bash(python3 *)
Deny rules that survive everything scoped deny rules apply even in bypassPermissions
Policy hooks before permissions PreToolUse can deny, modify, defer
Human approval gates can_use_tool, AskUserQuestion, plan mode, ask rules
Deferred approval permissionDecision: "defer" → stop_reason: tool_deferred → resume later
Classifier-mediated autonomy auto permission mode
Sandboxing sandbox settings; containers with --network none + a socket proxy; gVisor; Firecracker
Credential proxy secrets injected outside the agent; ANTHROPIC_BASE_URL / HTTPS_PROXY
Output scanning subagent reports scanned for instruction-shaped text
Message provenance inter-agent messages marked not-from-the-user; a peer can't approve on your behalf
Budgets as safety max_budget_usd also caps subagent fan-out

Deferred approval, run for real: examples/python/p7_deferred_approval.py (the pattern for approvals that take hours). A PreToolUse hook pauses the run at a risky command; the process ends with stop_reason="tool_deferred"; later, with the approval recorded, the same session is resumed and the same hook lets the call through.

APPROVALS: set[str] = set()          # stands in for your approvals table (ticket system, Slack button, ...)
PENDING: dict[str, str] = {}         # session_id -> command awaiting approval
SEGMENT = re.compile(r"&&|\|\||;|\|")       # split a shell line into its commands

def runs_deploy(cmd: str) -> bool:
    """True when deploy.sh is EXECUTED (first word of any segment), not merely read with cat/head."""
    return any(seg.split()[0].endswith("deploy.sh") for seg in SEGMENT.split(cmd) if seg.split())

async def gate_deploys(input_data, tool_use_id, context):
    cmd = input_data["tool_input"].get("command", "")
    if input_data["tool_name"] == "Bash" and runs_deploy(cmd):
        if cmd in APPROVALS:                                                        # (1) on resume: a human said yes
            print(f"  [hook] approval found for {cmd!r}: allow")
            return {"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "allow",
                                           "updatedInput": input_data["tool_input"]}}
        PENDING[input_data["session_id"]] = cmd                                     # (2) first time: record and pause
        print(f"  [hook] {cmd!r} needs human approval: defer")
        return {"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "defer"}}
    return {}

OPTS = dict(cwd=str(WORK), allowed_tools=["Read", "Bash(./deploy.sh*)", "Bash(cat *)", "Bash(ls *)"], max_turns=6,
            setting_sources=[], hooks={"PreToolUse": [HookMatcher(matcher="Bash", hooks=[gate_deploys])]})

async def run_until_not_deferred(prompt, session_id=None):
    """Run; whenever the run pauses on a deferred tool, 'collect approval' and resume the same session."""
    for attempt in range(4):
        last = None
        async for m in query(prompt=prompt, options=ClaudeAgentOptions(resume=session_id, **OPTS)):
            if isinstance(m, ResultMessage):
                last, session_id = m, m.session_id
                print(f"  run {attempt + 1}: subtype={m.subtype} stop_reason={m.stop_reason!r}"
                      + (f" deferred={m.deferred_tool_use.name}({m.deferred_tool_use.input.get('command')!r})" if m.deferred_tool_use else ""))
                break                                                               # stop at the FIRST result: a deferred run is over
        if last is None or last.stop_reason != "tool_deferred":
            return last
        print("  ... the process could exit here; hours pass; a human approves in your UI ...")
        APPROVALS.add(PENDING.pop(session_id))                                      # (3) record the approval
        prompt = ""                                                                 # (4) resume with no new prompt
    return last

result = await run_until_not_deferred("Deploy the app by running ./deploy.sh, then report what it printed.")

Real output

  [hook] './deploy.sh' needs human approval: defer
  run 1: subtype=success stop_reason='tool_deferred' deferred=Bash('./deploy.sh')
  ... the process could exit here; hours pass; a human approves in your UI ...
  [hook] approval found for './deploy.sh': allow
  run 2: subtype=success stop_reason='end_turn'

final: success -> 'Deploy ran successfully. It printed:\n\n```\ndeploying version 1.2.3 to production\n```'

Walkthrough

  • Run 1 ends with a successful result whose stop_reason is tool_deferred and whose deferred_tool_use names the exact call. Persist session_id and that call; the process can exit.
  • Run 2 resumes the session with an empty prompt. The CLI replays the pending tool call, the hook fires again, finds the approval, allows it, and the deploy script runs. The model then finishes its original task and reports the output.
  • The approval is keyed by the exact command string. Approve what the human saw, nothing broader.

Two bugs I shipped first.

A substring match on deploy deferred ls -la && cat deploy.sh, which merely reads the script: the hook now splits the command line and checks the first word of each segment. And on resume the stream delivered a second ResultMessage after the deferred one; the loop now stops at the first result, which is the only one a deferred run should act on.


11. Evaluation and observability patterns#

Key points

  • Evaluate end state, not path. Use an LLM judge with a rubric, validated against humans; small sets find big effects.
  • Separate the grader from the worker. Always.
  • Account cost per completed task with model_usage; reconstruct the executed graph from traces.

LLM-as-judge with end-state evaluation (a complete eval node; illustrative, not executed here. The executed deterministic version is the eval gate in section 5):

JUDGE_SCHEMA = {"type": "object", "properties": {"score": {"type": "number", "minimum": 0, "maximum": 1},
                                                 "passed": {"type": "boolean"}, "notes": {"type": "string"}},
                "required": ["score", "passed", "notes"]}

async def judge(task: str, rubric: str) -> dict:
    final_state = open(f"{PROJECT}/src/utils.py").read()                             # (1) the END STATE, not the transcript
    tests_pass, test_output = run_tests()                                            # (2) deterministic signal first
    verdict, _ = await run(                                                          # (3) fresh-context judge, no tools
        f"Task: {task}\nRubric:\n{rubric}\n\nTests pass: {tests_pass}\n{test_output[-400:]}\n\nFinal file:\n{final_state}\n"
        "Score 0.0-1.0 against the rubric and decide pass/fail.", JUDGE_SCHEMA, tools=[], model="claude-opus-5-5")
    return verdict

Observability: OpenTelemetry spans with session.id, resource attributes for enduser.id/tenant.id, content logging only where approved (part 3, section 4).


12. Protocols: MCP, A2A, skills#

Key points

  • MCP is the agent-to-tool layer; A2A the agent-to-agent layer; skills the portable unit of know-how.
  • Keep tools, agents and know-how as separate, versioned artefacts.
  • A2A's 2026 version history is reported inconsistently; check the official spec.

Generative UI and agent-user protocols. A newer layer sits between the agent and the person: protocols such as AG-UI and the "generative UI" idea, where the agent emits structured events that a front end renders as components rather than as text. The Agent SDK already produces the raw material: the typed message stream (tool calls, tool results, progress, cost), StreamEvents for token-level rendering, TaskStartedMessage/TaskProgressMessage for progress bars, AskUserQuestion for forms (TypeScript can request HTML or Markdown option previews via toolConfig.askUserQuestion.previewFormat, which is generative UI in miniature), and structured outputs for anything your UI should render as data. A thin adapter that maps these to your front end's event schema is how teams implement agent-user protocols on this SDK.

// sketch: adapt the SDK stream to UI events
for await (const m of query({ prompt, options: { includePartialMessages: true, canUseTool, toolConfig: { askUserQuestion: { previewFormat: "html" } } } })) {
  if (m.type === "stream_event" && m.event.type === "content_block_delta" && m.event.delta.type === "text_delta") ui.appendText(m.event.delta.text);
  else if (m.type === "assistant") for (const b of m.message.content) if (b.type === "tool_use") ui.showToolCard(b.name, b.input);
  else if (m.type === "task_progress") ui.updateProgress(m.task_id, m.usage);
  else if (m.type === "result") ui.finish(m.subtype, m.total_cost_usd);
}
// canUseTool renders AskUserQuestion as a form, with option.preview as an HTML snippet

Marketplaces. Distribution is converging on registries: MCP server registries (Claude Code can search one with SearchMcpRegistry), Claude Code plugin marketplaces (/plugin install name@marketplace; the SDK loads the installed path from ~/.claude/plugins/), and skill catalogs. Treat anything installed from a marketplace as third-party code: scope its tools with allow/deny rules and read its hooks.

Out of scope for this SDK. Voice and on-device agents are front-end and runtime concerns: the Agent SDK runs a server-side claude subprocess and has no audio or mobile runtime. A voice product puts speech-to-text in front of query() and text-to-speech behind the text deltas; on-device inference is not something this SDK does.

The stack is settling into layers (State of Agentic AI Standards 2026, Six Agent Protocols). A governance paper notes what MCP, A2A and ACP cannot express: policy, consent, accountability (arXiv 2606.31498). The Agent SDK consumes MCP natively (part 2) and hosts in-process servers; A2A is a layer you'd put around orchestration nodes when crossing organisational boundaries; skills are SKILL.md files with progressive disclosure as the loading model (Agent Skills survey).


13. The full catalog, and which ones most teams miss#

The consolidated list. The last column marks patterns that most "top N" posts skip.

# Pattern Family Where in this series Often missed?
1 Augmented LLM / ReAct tool loop foundation P1 §2
2 Prompt chaining with gates workflow P4 §3
3 Routing (cheap classifier → specialist) workflow P4 §3
4 Parallelization: sectioning workflow P4 §7
5 Parallelization: voting / self-consistency workflow P4 §3 yes
6 Orchestrator-workers workflow P2 §4
7 Evaluator-optimizer workflow P4 §4, §7
8 Reflection / self-critique (and its lenience bias) reasoning P4 §3
9 Plan-and-execute (plan mode, Plan subagent) reasoning P2 §2
10 Goal-based loop with machine-checkable stop loop P4 §4 yes
11 Ralph loop (compile/lint/test feedback) loop P4 §4
12 Bounded execution (turns, budget, iterations) loop P1 §6, P4 §4
13 Circuit breaker / stagnation detection loop P4 §4.5 yes
14 Separate grader (verifier that is not the worker) loop P4 §4, §7, §11 yes
15 Verification skills (encode manual checks) loop P2 §7 yes
16 Time-based loops and routines loop P4 §4.5
17 Proactive / event-driven agents loop P4 §4.6 yes
18 Outcome loops with rubric graders (Managed Agents) loop P4 §4.6 yes
19 Checkpoint and resume (sessions, SessionStore, file checkpointing) harness P2 §5, P3 §1 yes
20 Fork to explore alternatives harness P2 §5 yes
21 Harness-as-code under eval harness P4 §5 yes
22 Self-improving harness (meta-agent, propose/evaluate/accept, RRSI) harness P4 §5 yes
23 Just-in-time retrieval over pre-loading context P4 §6
24 Progressive disclosure (skills, tool search, lazy CLAUDE.md) context P4 §6 yes
25 Compaction with preservation instructions context P1 §7, P4 §6
26 Structured note-taking / agentic memory context P4 §6
27 Sub-agent context isolation context P2 §4
28 Code-owned graph: typed state, conditional edges, retries graph P4 §7.3 yes
29 Dynamic workflows: the script holds the plan graph P4 §7.4 yes
30 Agent teams: peers, shared task list, competing hypotheses graph P4 §7.5 yes
31 Adversarial verification / cross-checking graph P4 §7.4 yes
32 Three graphs: execution, context, control; designed vs executed graph P4 §7 yes
33 Tool consolidation, namespacing, poka-yoke, eval-driven tool iteration tools P4 §9 yes
34 Programmatic tool calling / code execution as a tool tools P4 §9 yes
35 Least privilege, deny-rules-survive-bypass, sandbox, credential proxy safety P2 §2, P3 §6
36 Human-in-the-loop: approve now, defer, ask safety P2 §2–3, P4 §10
37 Classifier-mediated autonomy (auto mode) safety P2 §2 yes
38 Output scanning and message provenance between agents safety P2 §4 yes
39 End-state evaluation and LLM-as-judge rubrics evals P4 §11
40 Executed-graph observability (OTel, per-node cost) evals P3 §4, P4 §7 yes
41 Protocol layering: MCP, A2A, skills interop P4 §12 yes
42 Multi-tenant isolation deployment P3 §5 yes
43 Handoffs (control and conversation move to a specialist) graph P4 §7.6 yes
44 Swarms and supervised swarms (peer mesh, SendMessage, agent teams) graph P4 §7.6 yes
45 Blackboard shared state graph P4 §7.6 yes
46 Debate / competing hypotheses with a judge graph P4 §7.6 yes
47 Skills as portable procedural knowledge (SKILL.md, progressive disclosure, /name dispatch) interop P2 §7, P4 §6
48 Plugins as distribution units (skills + agents + hooks + MCP in one folder) interop P2 §7 yes
49 MCP: in-process servers for your functions, external servers for the world, tool search to scale tools P2 §1
50 Memory architectures: working / episodic / semantic / procedural, compaction-proof notes context P4 §6.1 yes
51 Agentic RAG: retrieval as a tool the agent calls repeatedly context P4 §6.2
52 Prompt caching as a design constraint (stable prefix, TTL, model switches) context P4 §6.3, P3 §2 yes
53 Test-time compute: effort, best-of-N, iteration reasoning P4 §6.4 yes
54 Bash as the universal tool, with scoped rules and sandboxing tools P4 §9.1 yes
55 Background and asynchronous agents (background subagents, Monitor, scheduled runs) loop P4 §9.2 yes
56 Agentic coding practices (handbook, tests as done, plan-then-edit, worktrees, reviewer) harness P4 §9.3
57 Generative UI and agent-user protocols (stream → UI events, AskUserQuestion previews) interop P4 §12 yes
58 Marketplaces and registries for MCP servers, plugins and skills interop P4 §12 yes

The three families most under-represented in catalogs are loop engineering proper (10, 13–18), graph engineering (28–32) and harness self-improvement (21–22). The most under-used pattern relative to its payoff is #14, the separate grader: nearly every reliability story in the sources is "we stopped letting the worker mark its own homework".


14. Decision guide and anti-patterns#

Is the task one well-specified step?                 ──► single call (+ retrieval, structured output)
Can the steps be fixed in advance?                   ──► workflow (chain / route / parallel) in code      (§3)
Steps unpredictable but the goal checkable?          ──► agent loop + Stop-hook verifier + bounds          (§4)
Outgrows one context?                                ──► subagents (orchestrator-workers)                 (P2 §4)
Orchestration must be reviewable and rerunnable?     ──► code-owned graph or dynamic workflow              (§7)
Work recurs on a schedule or event?                  ──► scheduler calling query() with resume + store     (§4.5)
Must Anthropic host it?                              ──► Managed Agents (outcomes for one-deliverable sessions)

Anti-patterns the sources agree on: the worker grades itself; vague stop criteria or no caps; reasoning through deterministic steps a script would do; loading every tool and document up front; unpiloted hundred-agent fan-outs; polling faster than the watched thing changes; fixing the output instead of the system; relying on the prompt for authorization; a graph for a task one turn would finish.


15. Sources#

Anthropic: Building Effective Agents · Effective Context Engineering for AI Agents · How we built our multi-agent research system · Writing Effective Tools for Agents · Loop Engineering: Getting Started with Loops · Claude Code docs: goals, scheduled tasks, dynamic workflows, agent teams, subagents, run agents in parallel, hooks, Agent SDK.

Loop, harness, graph engineering: Lulla et al., Loop Engineering: Building Blocks, Adoption, and Impact · Feng et al., Graph Engineering in the Era of LLM Agents · Harness Engineering: a source-code study of eleven systems · Lilian Weng, Harness Engineering for Self-Improvement · RRSI · Hierarchical Self-Improvement · Self-Evolving Coding Agents · Agent-Native Immune System · Turing Post, Is Graph Engineering Real? · TrueFoundry, Graph Engineering for Multi-Agent Systems · Eigent, Graph Engineering for AI Agents · Analytics Vidhya, Graph Engineering in LangGraph · Data Science Dojo, 10 loop engineering patterns · Yahoo Tech, Forget prompt engineering · Awesome Graph Engineering · awesome-harness-engineering · awesome-loop-engineering.

Catalogs, context, protocols: Liu et al., Agent Design Pattern Catalogue · SitePoint 2026 guide · Augment Code catalog · Towards AI: 7 patterns · vdf.ai: 12 production patterns · Is Progressive Disclosure All You Need? · Agent Skills for LLMs · Governance Gaps in Agent Interoperability Protocols · State of Agentic AI Standards 2026 · Six Agent Protocols.

Runnable examples for this part, every one executed with the output shown above: p1_goal_loop.py, p1b_goal_loop_blocked.py, p2_graph_pipeline.py, p3_workflow_patterns.py, p4_handoff.py, p5_debate.py, p6_agentic_rag.py, p7_deferred_approval.py, p8_eval_gate.py, p9_circuit_breaker.py; outputs in examples/outputs/. The remaining snippets (the scheduled PR loop, the LLM judge, the generative-UI adapter) are shapes, not runs, and are labelled as such.

Progress is saved in this browser only.