MemberPress AI Academy memberpress.ai

Courses

Claude Code Agents — Sub-Agents, Tools, and MCP

Sub-agents, the tool-use primitive, MCP servers, and a sub-agent that ships PRs end-to-end.

advanced4h 11m10 lessonsTaught by Seth Shoultes

Sub-AgentsWorking With Sub-Agents

1. What a sub-agent is and isn't 22 min

Lesson notes

You keep hitting a context-length ceiling. The parent agent has accumulated forty thousand tokens of file reads, intermediate reasoning, and tool results, and now the task it is holding is simply too large to finish cleanly. The instinct is to prompt more carefully, or to ask for a longer context window. The correct move is to dispatch a sub-agent.

But first: understand what you are dispatching.

What a sub-agent actually is

A sub-agent is a separate Claude instance with its own context, its own tool permissions, and a strict one-way data channel: you send it a brief, it runs, it returns a result. When it exits, everything it held — file contents, intermediate reasoning, the entire conversation — is gone. Nothing leaks back to the parent.

The isolation is total and intentional. It is not a bug you work around. It is the feature you design for.

Here is what the dispatch looks like in Claude Code's Agent tool:

Task: "Read /src/auth/session.ts. Find every place a token is written to localStorage or sessionStorage. Return a markdown list: path, line number, variable name, storage key."
Tools allowed: Read, Bash
Output format: markdown list only, no preamble

That brief is self-contained. The agent can execute it without asking a clarifying question. The output format is specified so the parent can use the result without parsing. The tools are constrained so the agent cannot, say, delete files while it is reading them.

What a sub-agent is not

It is not a tool call. A tool call is synchronous — the model emits a structured block, your runtime executes a function, control returns in the same turn with the result. A sub-agent is asynchronous and isolated. The parent does not hold the file open while the child reads it.

It is not a long conversation. Appending messages to a conversation expands context. Dispatching a sub-agent does not touch the parent's context at all. You do not get a "longer session" — you get a separate session.

It is not a function. Functions have shared scope. Sub-agents do not. A sub-agent cannot read a variable the parent set, cannot call back into the parent's scope, and cannot modify shared state unless you explicitly design a handoff through a file or structured return value.

The boundary rule

If a task requires shared mutable state between steps — a file locked for writing, a database transaction open across calls, a variable one step sets and the next reads — it cannot be fully delegated to a sub-agent. The sub-agent's isolation is the feature, but isolation requires each step to be independently completable.

The test: can you write a brief that contains everything the agent needs to execute from a cold start? If you find yourself writing "and you will need to know what the previous agent returned," you have violated the boundary. Either sequence the tasks explicitly — parent reads agent 1's output, embeds it in agent 2's brief, then dispatches agent 2 — or combine them into one agent.

Notice that sequencing is a valid pattern. It is not a failure to use sub-agents. It is the correct design when steps genuinely depend on each other.

If you get stuck

The most common mistake at this stage: dispatching an agent with an underspecified output format, then discovering the parent cannot parse what came back. The agent did exactly what you asked. You asked for prose. Fix the brief, not the agent. Add an explicit output format to every brief before you dispatch.

Second: confusing "the agent has write access" with "the agent will be careful." Tool permissions are a whitelist, not a safety net. If you allow the Bash tool, the agent can run rm -rf. Constrain the tool list to Read and Bash with a specific path scope when the task is read-only. The brief's tools field is the place to enforce this — not the task description.

Third: assuming the sub-agent knows your project layout. It does not. Its context starts with your brief and nothing else. If it needs to know that /src/auth/ is the authentication module, say so in the brief. If it needs to know that the project uses TypeScript, say so. Every piece of context the agent needs that is not in the brief is a gap that will produce a question or a wrong assumption.

Cross-reference

l2 shows parallel dispatch — multiple agents running simultaneous briefs, which is where the "no shared state" constraint becomes structurally important. l3 shows how to isolate file writes when agents need to modify code, not just read it. If you are unsure whether a task belongs in an agent or a tool call, l4 draws that line precisely.

Deliverable

Write a sub-agent brief for one task you have open right now. It must contain: (1) the exact task statement, (2) the tools allowed, (3) the output format the parent will consume, and (4) the one question the agent is answering. Read it back and ask: could the agent execute this from a cold start without a follow-up message? If the answer is no, find the missing dependency and embed it in the brief before you continue to l2.

Further reading

Try these prompts

  • Draft a sub-agent brief to audit all files in /src/auth/ for localStorage token writes. Include: task statement, tools allowed, output format, and the one question the agent is answering.
  • Explain in two sentences why a sub-agent cannot read a variable the parent set, and how the parent must pass that value instead.
  • Write the output format field for a sub-agent that finds hardcoded API keys. The parent will parse the result programmatically.

Back to contents

2. Dispatching for parallel research 25 min

Lesson notes

Sequential context-loading is the bottleneck you stop tolerating after you understand fan-out. Three agents reading three modules simultaneously collapse fifteen minutes of sequential file reads into five — not because agents are faster readers, but because they work at the same time and their findings land in the parent as structured summaries rather than raw file contents.

The fan-out pattern is the fastest thing you can do with sub-agents. Here is a working example.

A real parallel dispatch

This is a security audit across three subsystems. These three briefs run concurrently:

Agent 1 brief:

Read every file in /src/auth/. Summarize the session lifecycle in 3-5 sentences.
List every place a token is written to disk or browser storage: file path, line
number, storage key, token type. Return as JSON:
{ "summary": string, "writes": [{path, line, key, type}] }

Agent 2 brief:

Read every file in /src/api/. List every route that accepts a Bearer token.
For each route: method, path, middleware chain (in order), handler file.
Return as JSON: { "routes": [{method, path, middleware, handler}] }

Agent 3 brief:

Read every file in /src/db/. List every table touched by operations related to
authentication, sessions, or tokens. For each: table name, operation
(read/write/delete), calling file and function.
Return as JSON: { "tables": [{name, operation, caller}] }

Parent integration step (runs after all three return):

You have three JSON objects from a security audit of the same codebase.
Cross-reference them: find any route in agent 2's output where the token type
written by agent 1 does not match the token type the route expects. Also find
any auth table from agent 3 that is written by a route from agent 2 without
middleware validation appearing before the handler in the middleware chain.
Return a markdown audit report with findings only. No findings = empty list,
not "no issues found."

Each brief specifies JSON output with a fixed envelope shape. The parent receives structured objects and reasons about them — it does not parse prose or extract values from narrative text. This discipline is what makes fan-out composable.

Why output format discipline is not optional

If agents 1, 2, and 3 return prose in different structures, the parent integration step becomes a parsing problem, not a reasoning problem. The parent will spend its context budget trying to interpret three different formats instead of reasoning about their contents. Specify the output format in every brief. Make the envelope shape consistent across all agents in a fan-out. The field names do not have to match — but the shape ({ key: value } vs. [{...}, {...}]) should be decided before dispatch.

When fan-out is the wrong pattern

Fan-out fails when briefs have hidden dependencies. If agent 2 needs to see agent 1's token type list to know which routes to flag, you cannot run them in parallel. You have a pipeline, not a fan-out. Run agent 1 first, embed the result in agent 2's brief, then run agents 2 and 3 in parallel if they are now independent of each other.

The test: can each brief be executed knowing only its own contents? If yes, fan-out is correct. If one agent's brief implicitly assumes something another agent would discover, sequence them.

If you get stuck

You dispatched three agents and one returned partial results. The temptation is to re-run all three. Do not. Identify which brief was underspecified, fix that brief, re-run that agent only, and re-integrate. The other two results are still valid — they are sitting in the parent's context as structured objects. Re-running them wastes time and context budget.

Context bleed is the second failure mode. You embedded a file's full contents in each brief rather than giving the path and letting the agent read it. Now each agent starts with 8,000 tokens already consumed and the parent's context is also heavy. Give agents paths, not file contents, unless the file is too large for the agent to read in its own context or the file changes between dispatch and execution.

The third failure mode: agents returning at different times, and the parent reasoning about partial results before all three have finished. If you are using Claude Code's parallel dispatch, wait for all agents to return before running the integration step. Do not short-circuit by integrating as agents trickle in — you will produce an incomplete audit.

Cross-reference

l1 defines the sub-agent primitive and the boundary rule that governs when agents can run in parallel versus when they must be sequenced. l3 handles the case where your fan-out agents need to write code, not just read — which requires worktrees to prevent conflicts. l7 extends fan-out to a full PR pipeline.

Deliverable

Write a parallel dispatch plan for one investigation you have open. Three agents, each with a self-contained JSON-output brief. Then write the parent integration step that synthesizes their outputs into a single artifact. Before you dispatch anything, review each brief for hidden dependencies: does any brief's task require knowing what another agent will find? If yes, redesign the sequence. The planning step is the lesson — run it after you have reviewed all three briefs.

Try these prompts

  • Write a parallel dispatch plan for auditing three subsystems — auth, API routes, and database — for a token-handling security review. Include one brief per agent with a JSON output envelope.
  • Explain in two sentences why output format discipline across parallel agents is not optional, with a concrete example of what breaks when formats differ.
  • Identify the hidden dependency in these two parallel briefs and explain whether they can run concurrently or must be sequenced.

Back to contents

3. Worktree isolation 22 min

Lesson notes

Reading is non-destructive. When a sub-agent reads files, isolation is free — two agents can read the same file at the same time without interfering. When a sub-agent writes code, isolation has to be constructed. Two agents writing to the same checkout at the same time will produce conflicts. Sometimes git catches them. Sometimes it silently merges in ways that break the build at 11 PM and leave you with no clean state to roll back to.

Git worktrees are the construction. A worktree is a second (or third, or tenth) checkout of the same repository, sharing the same git object store but with its own working directory and HEAD. You can have ten agents each writing to their own worktree, and they cannot interfere with each other because they are physically writing to different directories.

Setting up a worktree

# Create a worktree on a new branch
git worktree add /Users/seth/projects/myrepo-agent-1 -b agent/fix-auth-tokens

# Verify both are registered
git worktree list
# → /Users/seth/projects/myrepo          abc1234 [main]
# → /Users/seth/projects/myrepo-agent-1  abc1234 [agent/fix-auth-tokens]

The agent's brief must include the worktree path explicitly. Do not rely on the agent to discover the right directory:

Working directory: /Users/seth/projects/myrepo-agent-1
Branch: agent/fix-auth-tokens
Task: In /src/auth/session.ts, change every localStorage.setItem call to use
httpOnly cookie via the setCookie helper at /src/utils/cookies.ts. Run
`npm test -- --testPathPattern=auth` after each change. Commit when all tests
pass. Do not touch files outside /src/auth/.

The agent never touches the main checkout. When it finishes, the parent reviews the diff and merges.

The cleanup pattern

Worktrees accumulate. After ten sessions, you have ten stale worktrees and branches cluttering git worktree list. Build cleanup into every brief — otherwise the list becomes archaeology:

When finished: commit your changes, then run:
  git worktree remove /Users/seth/projects/myrepo-agent-1
  git push origin agent/fix-auth-tokens
Return: the branch name and the last commit hash.

The parent then runs git merge --no-ff agent/fix-auth-tokens or opens a PR. The merge is explicit and reviewed — the agent does not merge into main.

Parallel worktrees

When you have multiple writing agents from a fan-out dispatch (l2), each agent gets its own worktree and branch:

git worktree add /Users/seth/projects/myrepo-agent-auth  -b agent/auth-refactor
git worktree add /Users/seth/projects/myrepo-agent-api   -b agent/api-cleanup
git worktree add /Users/seth/projects/myrepo-agent-tests -b agent/test-coverage

Each brief specifies a different worktree path and a different branch name. The agents write simultaneously. The parent integrates by reviewing three separate diffs, then merging or rebasing in sequence.

If you get stuck

git worktree add fails with "already exists." A previous session created the worktree and the agent crashed before removing it. Clean up manually, then re-create:

git worktree remove /Users/seth/projects/myrepo-agent-1 --force
git branch -d agent/fix-auth-tokens
git worktree add /Users/seth/projects/myrepo-agent-1 -b agent/fix-auth-tokens

The --force flag removes the worktree directory without deleting the branch — the branch still exists in the object store until you delete it explicitly with git branch -d.

The agent commits to the wrong branch because it did not verify git branch --show-current before working. Add a verification step to every brief: "First, confirm you are on branch agent/fix-auth-tokens. If git branch --show-current returns anything else, stop and return an error — do not proceed." This check costs one tool call and prevents a merge to main.

Worktree paths with spaces break some tool invocations inside the agent's context. Keep worktree directories adjacent to the main repo, no spaces in the path, named with the ticket or branch identifier.

Cross-reference

l1 establishes when to use sub-agents versus tool calls. l2 shows parallel dispatch where worktrees become essential — you cannot have multiple writing agents without multiple worktrees. l7 puts this pattern into a full PR pipeline where the worktree creation, the agent brief, and the PR open are all coordinated by the parent.

Deliverable

Create a worktree for one current branch in a real project. Write an agent brief that specifies: the worktree path, the branch name, a concrete coding task with a verifiable completion signal (a test command that exits 0, a lint check that clears), and a cleanup sequence. Run git worktree list and confirm the worktree is registered. You do not have to run the agent for this lesson — you do have to run the worktree creation command and verify the output.

Try these prompts

  • Write the git commands to create a worktree at /Users/seth/projects/myrepo-agent-1 on a new branch named agent/fix-auth-tokens, then verify it is registered.
  • Write the agent brief section that verifies the working directory and branch before any code is written. Include a hard stop condition if the branch is wrong.
  • Write the cleanup sequence an agent should run after committing its changes to a worktree branch — removing the worktree and pushing the branch.

Back to contents

Tools and MCPTools and the Model Context Protocol

4. The tool-use primitive 25 min

Lesson notes

The model does not call your function. Read that sentence again before writing your first tool definition, because the architecture matters for every mistake you will make later.

What actually happens: you send a list of tool definitions alongside the user message. The model reads the definitions. If it decides a tool is appropriate, it emits a structured JSON block — a tool_use content block — that names the tool and provides the arguments. Your code receives that block, calls the actual function, and sends the result back as a tool_result message. The model reads the result and continues.

The model is making a decision, not an API call. It can only reason about what you return. If you return a fabricated or cached result, the model reasons about the fabrication. This is why tool implementations need to be honest even when they are simple.

The schema is a decision rule, not documentation

The description field in a tool definition is not for humans reading your code. It is the signal the model uses to decide when to call this tool versus any other tool available in the session. Write it as a decision rule: what should the model observe in the conversation that makes this the right tool to call?

{
  "name": "get_deploy_status",
  "description": "Return the current deploy status for a given app ID. Call this when the user asks about whether a deploy succeeded, failed, is in progress, or when you need to verify a deploy before proceeding.",
  "input_schema": {
    "type": "object",
    "properties": {
      "app_id": {
        "type": "string",
        "description": "The application identifier, e.g. 'api-prod' or 'dashboard-staging'"
      },
      "include_logs": {
        "type": "boolean",
        "description": "If true, include the last 50 lines of deploy output. Default false."
      }
    },
    "required": ["app_id"]
  }
}

Notice: the description tells the model both what the tool does and when to reach for it. "Return the current deploy status" is the what. "Call this when the user asks about whether a deploy succeeded" is the when. Both sentences are load-bearing. Drop either one and the model will under-use or over-use the tool.

Schema correctness matters more than you expect

The model will attempt to call the tool with the schema you provided. If the schema says app_id is required and the model cannot infer it from context, it will either ask the user or make a guess. If the schema says include_logs is optional with a default, the model can omit it safely. Incorrect schemas produce incorrect arguments — and incorrect arguments produce failures your handler has to handle gracefully.

Do not copy-paste a schema from a different tool and edit the name without reading every field. The description carries tool-selection logic. The field descriptions carry argument-generation logic. Every field description in the schema is a prompt to the model.

What the handler must return

Return exactly what the description promises, in a structure the model can interpret without ambiguity. For a status check, return a status object with fields that clearly map to states the model knows how to reason about. Do not return a raw API response if the raw response has 40 fields the model does not need — the noise degrades reasoning quality. Strip it to the signal.

On failure, return a structured error, not an exception. The model needs to read the failure and decide what to do next. An unhandled exception that crashes your handler returns nothing — the model receives a malformed response and either hallucinates a result or loops.

If you get stuck

The model keeps calling the wrong tool. Check your tool descriptions for semantic overlap — two tools with similar descriptions will cause the model to guess between them. Make each description specific enough that there is no ambiguous case. Add a negative example if needed: "Do not call this for questions about deploy logs — use get_deploy_logs for that."

The model calls the tool with valid JSON but your handler throws a type error. The schema said string but you expected a number, or the schema marked a field optional but your code assumed it was always present. Fix the schema to match the implementation, not the other way around — the schema is the interface, and the model is generating arguments against it.

Tool call loops: the model calls a tool, gets a result, calls the same tool again with slightly different arguments. The result format is ambiguous — the model got a result it cannot interpret and is retrying with a variation. Fix the return format. Return exactly what the description promises. If the tool is supposed to return a deploy status, return { "status": "succeeded", "deployed_at": "..." } — not a blob of text that mentions the status somewhere in the middle.

Cross-reference

l5 wraps tool definitions into an MCP server so Claude Code can discover them automatically — the schema patterns here map directly to the Zod schemas used there. l6 answers the question this lesson probably raised: when should this be a tool versus a shell script that the model just runs with Bash?

Deliverable

Write a tool definition for one real operation in your stack: name, description written as a decision rule ("Call this when..."), and complete JSON Schema for the parameters including field descriptions. Then write the two-sentence implementation contract: what the handler must return when it succeeds, and what it must return when it fails. Keep the schema and the contract in the same file — they are the interface, and they need to stay in sync.

Try these prompts

  • Write a tool definition for a get_deploy_status tool. Include the name, a description written as a decision rule ("Call this when..."), and JSON Schema for two parameters: app_id (required) and include_logs (optional boolean).
  • Write the two-sentence implementation contract for a tool that checks feature flag status — what it returns on success and what it returns on failure.
  • Explain why the description field in a tool definition is a decision rule, not documentation. Give one example of a description that causes under-use and one that causes correct use.

Back to contents

5. Writing an MCP server for your stack 30 min

Lesson notes

A tool you define inline in a single API call is available only to that call. The next session, you redefine it. The next project, you copy-paste it and it drifts. An MCP server makes your tools available to every MCP-aware client — Claude Code, Claude Desktop, or any other client that speaks the protocol — without re-defining them per call. You write the tool once. You register it once. Every session picks it up automatically.

Here is a minimal working server. It wraps one internal API endpoint and exposes it as a single tool:

// servers/internal-api/index.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";

const server = new McpServer({ name: "internal-api", version: "1.0.0" });

server.tool(
  "get_deploy_status",
  "Return the current deploy status for a given app ID. Call this when the user asks whether a deploy succeeded, failed, or is in progress.",
  { app_id: z.string().describe("The app identifier, e.g. 'api-prod'") },
  async ({ app_id }) => {
    const resp = await fetch(`https://internal.example.com/deploys/${app_id}/status`);
    if (!resp.ok) {
      return {
        content: [{ type: "text", text: `Error: ${resp.status} ${resp.statusText}` }],
        isError: true
      };
    }
    const data = await resp.json();
    return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
  }
);

await server.connect(new StdioServerTransport());

Build it, then register it in .mcp.json at your project root:

{
  "mcpServers": {
    "internal-api": {
      "command": "node",
      "args": ["servers/internal-api/dist/index.js"],
      "env": {
        "INTERNAL_API_TOKEN": "${INTERNAL_API_TOKEN}"
      }
    }
  }
}

Restart Claude Code. The tool appears automatically in the session. The INTERNAL_API_TOKEN environment variable is read from your shell at startup — it is never hardcoded in .mcp.json. The ${INTERNAL_API_TOKEN} syntax in the env block is a pass-through: Claude Code reads the value from the environment and injects it into the server process. If you source your secrets in your shell profile, they are available without listing them explicitly in the env block.

Resources and prompts

MCP servers expose three kinds of things. Tools (functions the model calls) are the most common. Resources (files or structured data the model can read) are useful when you have reference data that changes infrequently — an OpenAPI schema, a list of valid environment identifiers, a product catalog. Prompts (reusable message templates) are rarely needed for internal API servers but become useful for standardizing how teams invoke complex workflows.

For most internal wrappers, ship tools only and add resources if you find yourself embedding the same reference data in every tool call. The rule: if the model needs it every time, put it in a resource. If the model needs it sometimes, put it in the tool's description.

Authenticating correctly

The env block in .mcp.json is for variables that must be explicitly passed to the server process. Variables already in your shell environment are inherited automatically — you do not need to list PATH or variables you have in your profile. List only variables that are specific to this server or that need to be set to a specific value. Never hardcode a token value in .mcp.json — the file is committed to the repo.

If you get stuck

Claude Code does not show the new tool. Check three things in order: (1) .mcp.json is at the project root, not in a subdirectory. (2) The server binary exists at the path specified in args — run the node command from that path manually and confirm the server starts without errors. (3) You restarted Claude Code after editing .mcp.json — MCP servers are loaded at session start, not dynamically. Editing .mcp.json while a session is open has no effect until restart.

The server starts but tools are not listed. The name in McpServer({ name: ... }) must be unique across all servers registered in .mcp.json. If two servers share a name, one will be silently dropped with no error message. Choose a name that is specific to your stack and will not collide.

The tool call returns a 401 or 403. The environment variable is not being passed. In .mcp.json, verify the variable name in the env block matches the exact name your code reads with process.env. Case matters. If the variable is in your shell but not in the env block, add it to the block — inheritance is not guaranteed across all session launch paths.

Cross-reference

l4 covers tool definition schemas in depth — the z.string().describe(...) pattern in this lesson maps directly to the JSON Schema covered there. l6 answers when a tool in an MCP server is the right choice versus a shell script the model invokes with Bash. The HeyGen MCP server in .mcp.json at this project root is a working example of exactly this pattern at production scale.

Deliverable

Build a single-tool MCP server that wraps one real endpoint in your stack. It must: authenticate via an environment variable read from process.env (never hardcoded), return isError: true with a human-readable message on failure, and be registered in .mcp.json so Claude Code discovers it automatically. Verify by opening a new Claude Code session and confirming the tool name appears in the session tool list before you write any prompts that use it.

Further reading

Try these prompts

  • Write the minimal TypeScript MCP server skeleton that registers one tool called get_deploy_status using the McpServer SDK and StdioServerTransport. Include the Zod schema for app_id.
  • Write the .mcp.json entry to register an MCP server called internal-api that runs node servers/internal-api/dist/index.js and passes INTERNAL_API_TOKEN from the environment.
  • List the three things to check in order when Claude Code does not show a newly registered MCP tool after editing .mcp.json.

Back to contents

6. When to add a tool vs. when to write a script 18 min

Lesson notes

Every tool you build carries a cost the model pays at runtime: the schema must be accurate, the description must be precise, and the model spends a decision cycle on "should I call this now?" For operations that always run at the same point in a fixed sequence, that cost buys nothing. The operation belongs in a script.

Here is the decision in one sentence: if the model needs to choose whether to call the operation — because the choice depends on something it discovers mid-task — make it a tool. If the model just needs to execute it at a known point in the sequence, write a script and call it with the Bash tool.

Three real examples

A build-and-test script runs after every code change in a fixed pipeline. The model does not decide whether to run it — it always runs at step 3. Script:

# scripts/build-and-test.sh
npm run build && npm test -- --passWithNoTests

A database migration applies once, in a specific order, triggered by a deploy event. The sequence is fixed and the model does not decide when to run it. Script:

# scripts/migrate.sh
npx prisma migrate deploy

A feature flag check is different. The model does not know ahead of time whether a flag is enabled — it needs to check, read the result, and branch based on what it finds. The call depends on a value that only exists during execution. Tool:

{
  "name": "get_feature_flag",
  "description": "Check if a feature flag is enabled for a given environment. Call this before attempting any operation that may be gated by a flag, or when the user asks whether a feature is live.",
  "input_schema": {
    "type": "object",
    "properties": {
      "flag_key": { "type": "string" },
      "environment": { "type": "string", "enum": ["production", "staging", "development"] }
    },
    "required": ["flag_key", "environment"]
  }
}

The quick test

Ask: could a programmer reading the task description predict exactly when this operation gets called, without running the task? If yes — if the call is predictable from the task spec — it is a script. If no — if the call depends on a value, a state, or a finding that only exists during execution — it is a tool.

A second test: does the model need to do something different based on what the operation returns? If the model acts the same way regardless of the result, it does not need to make a decision — it just needs to run something. That is a script.

Where this goes wrong

The most common mistake is building a tool for something that always gets called the same way. The tool adds a description to maintain, a schema to keep accurate, and cognitive weight in the model's decision loop. Three months later, the description is stale, the schema has drifted from the implementation, and the model occasionally decides not to call the tool because the description no longer matches the task. The operation that should always run is sometimes skipped.

The inverse mistake: writing a script for something that requires a decision. The script gets called unconditionally, fails in cases it was not written for, and the model has no way to handle the failure gracefully because the decision point was removed from the model's control. The model cannot branch — it can only run what comes next.

If you get stuck

You built a tool and the model calls it too aggressively — every turn, even when the task does not require it. The description is too broad. Narrow it with a negative case: "Call this when X. Do not call this when Y." The negative case is as important as the positive one for tools that are adjacent to other tools in the session.

You have a script, and it fails silently on some inputs. The Bash tool returns the exit code — but only if you check it. Wrap the call: if ! ./scripts/migrate.sh; then echo "Migration failed, stopping"; exit 1; fi. A silent failure that lets the pipeline continue is harder to debug than a loud one that stops it. Scripts should fail loudly or not at all.

You are not sure whether something is a tool or a script. Default to script. Build the tool only when you have observed the model needing to make a decision based on the operation's result. Do not speculate about future decision-making needs. Tools added speculatively are maintained indefinitely.

Cross-reference

l4 and l5 cover tool schemas and MCP servers — the mechanics of building a tool. This lesson is the complement: it tells you when not to build one. l7 applies both choices to a full PR pipeline where some operations are scripted (build, test, commit) and some require tool calls (branch existence check, PR status).

Deliverable

Audit three operations in your current project. For each: state whether it is currently a tool or a script, whether that classification is correct given the decision test above, and one sentence of justification. If one is wrong, fix it. The deliverable is the audit plus one actual change — a tool moved to a script, or a script promoted to a tool — with the reasoning committed to the repo as a comment in the file.

Try these prompts

  • Apply the decision test to these three operations and classify each as tool or script: (1) run npm test after every code change, (2) check whether a feature flag is enabled before rendering a component, (3) apply a database migration during deploy.
  • Write a one-sentence negative case to add to a tool description that is being called too aggressively on every turn.
  • Write the bash wrapper for a migration script that fails loudly and stops the pipeline if the script exits non-zero.

Back to contents

WorkflowsReal Agentic Workflows

7. A sub-agent that ships PRs 50 min

Lesson notes

A sub-agent that ships PRs is not a toy. It is a junior engineer that reads a ticket, writes code, runs tests, and opens a PR — without asking clarifying questions. Getting there requires the brief to be written at the same quality as a good engineering spec. The brief is the lesson. The merged PR is the proof.

The gap between "dispatching an agent" and "having an agent ship production-quality PRs" is almost entirely in the brief. Weak briefs produce agents that ask questions, loop on errors, commit to the wrong branch, or open PRs with no relation to the ticket. Every failure mode below can be traced back to a gap in the brief.

A working brief template

The gaps in angle brackets are what you fill in:

Repository: /Users/seth/projects/<repo>
Working directory: /Users/seth/projects/<repo>-agent-<ticket-id>
Branch: agent/<ticket-id>-<short-description>
Ticket: <ticket text embedded in full — do not make the agent fetch it>

Phase 1 — Understand
Read the files mentioned in the ticket plus any files they import that
are relevant to the change. When complete: write a one-paragraph summary
of what you understand the change to be. If the change requires touching
files not mentioned in the ticket, list them.

Phase 2 — Branch
Confirm your working directory: run `git branch --show-current`.
It must return `agent/<ticket-id>-<short-description>`.
If it returns anything else, stop and return: "Wrong branch: <actual>."

Phase 3 — Implement
Make the changes described in the ticket.
Completion signal: `<specific test command>` exits 0.
Do not consider Phase 3 complete until the test exits 0.
If the test does not exit 0 after three attempts, stop and return
the full test output. Do not attempt a fourth fix.

Phase 4 — Commit
Stage only the files you changed: `git add <specific files>`.
Do not use `git add .` or `git add -A`.
Commit message format: `<type>(<scope>): <description>`

Phase 5 — PR
`gh pr create --title "<title>" --body "Closes <ticket-id>. <one sentence of what changed and why.>"`
Return: the PR URL.

Constraints:
- Do not modify files outside <list of allowed paths>.
- Do not install new dependencies without stating why.
- If a dependency is needed, state the package name and reason before installing.

Every line addresses a specific failure mode. Phase 2's branch verification prevents commits to main. Phase 3's three-attempt limit prevents the "almost done" loop (described below). Phase 4's explicit staging prevents accidental inclusion of debug files, .env changes, or generated artifacts. The embedded ticket prevents the agent from fetching a URL that resolves to a login page.

The "almost done" loop

Long-running agents develop a characteristic failure mode: they get close to a working state, encounter one remaining error, attempt a fix, introduce a new error, fix that one, and cycle. From the outside it looks like progress. From the inside the agent is stuck. The loop can run for thirty turns, burning context, before the session times out or you intervene.

The fix is a hard stop condition in the brief — not "keep trying until it works" but "stop after N failed attempts and return the error." Three attempts is usually the right limit. An agent that cannot solve a problem in three attempts is encountering something outside its scope: a missing test fixture, a misconfigured environment, a schema conflict. The error output will tell you. You fix the external issue and re-dispatch.

Running the brief

Start with a real ticket. Not a toy. A real ticket with real acceptance criteria and a verifiable completion signal. Create the worktree (l3). Dispatch the agent. Do not interrupt it while it works.

When it returns, read the diff before merging. The agent's code is a first draft, not a signed review. Your job after dispatch is the code review, not the implementation.

The first run will almost certainly surface something wrong with the brief — a file the agent needed to read that was not mentioned, an ambiguous completion signal, a constraint that was too narrow. Fix the brief, run it again. After two or three iterations, the brief template will produce mergeable PRs consistently.

If you get stuck

The agent returns "Wrong branch" in Phase 2. The worktree was not created before dispatch, or it was created on the wrong branch. Run git worktree list to see what exists. Create the correct worktree, confirm the branch, then re-dispatch.

The agent loops in Phase 3 past your attempt limit. Your completion signal was not specific enough. "All tests pass" is not a signal — it is a hope. Specify the exact command: npm test -- --testPathPattern=auth/session. An ambiguous signal lets the agent decide what "done" means. It will choose wrong.

The PR opens but references the wrong ticket. The ticket text was not embedded in the brief — you gave the agent a URL and it fetched a redirect, a login page, or a stale cache. Embed the full ticket text. The brief is a self-contained document.

Cross-reference

l3 covers worktree creation — Phase 2 assumes a worktree exists and is correctly branched. l8 covers what to do when the agent crashes mid-run on a long ticket. If the PR command syntax is unclear, the form above uses the minimum viable flags for gh pr create.

Deliverable

Fill in the template above for one open ticket in your tracker. Create the worktree. Dispatch the agent. When it returns, review the diff and either merge or close the PR with a comment explaining why it did not merge. Return the PR URL. Open, merged, and closed are all valid outcomes for this lesson. The invalid outcome is not running the agent.

Further reading

  • "Building in the Age of AI" — a four-day build where the PR-shipping pattern in this lesson is the unit of work, scaled across an entire site.

Try these prompts

  • Fill in the Phase 2 branch verification block from the PR-shipping brief template for a ticket named auth-session-fix on branch agent/auth-session-fix. Include the hard stop condition.
  • Write the Phase 3 completion signal for a ticket that requires passing a specific Jest test file at src/auth/session.test.ts. Include the three-attempt limit and the stop instruction.
  • Write a one-paragraph PR description for a fix that changed localStorage token storage to httpOnly cookies to prevent XSS exposure.

Back to contents

8. Long-running agents and recovery 30 min

Lesson notes

A long-running agent fails at the worst moment. Mid-migration. Halfway through a 200-file refactor. At step 47 of 50. The question is not whether this will happen — it will — but whether the failure leaves the system in a resumable state or forces a restart from zero.

The answer is checkpointing. Not as an afterthought, and not as a feature you add after the first crash. As a design constraint you enforce before the first run.

The checkpoint schema

{
  "task_id": "refactor-imports-2026-05-05",
  "schema_version": 1,
  "current_step": 23,
  "total_steps": 200,
  "next_input": {
    "file": "/src/components/Button.tsx",
    "target_import": "@/utils/cn",
    "replacement": "~/lib/cn"
  },
  "completed": [
    "/src/components/Alert.tsx",
    "/src/components/Badge.tsx"
  ],
  "errors": []
}

The checkpoint is written before the next step begins, not after. If the agent completes a step and crashes before writing the checkpoint, the step is lost but the file is already changed — you have a partial state with no record of where you are. If the agent writes the checkpoint and then crashes before the next step, you know exactly where to resume, and the incomplete step was not started.

This is the only rule about checkpointing that matters: write before the destructive operation, not after.

The recovery loop

# scripts/resume-refactor.py
import json, pathlib

CHECKPOINT = pathlib.Path("checkpoint.json")
SCHEMA_VERSION = 1

def load_checkpoint():
    if CHECKPOINT.exists():
        state = json.loads(CHECKPOINT.read_text())
        if state.get("schema_version") != SCHEMA_VERSION:
            print("Checkpoint schema mismatch — starting from zero")
            return {"current_step": 0, "completed": [], "errors": []}
        return state
    return {"current_step": 0, "completed": [], "errors": []}

def save_checkpoint(state):
    state["schema_version"] = SCHEMA_VERSION
    CHECKPOINT.write_text(json.dumps(state, indent=2))

state = load_checkpoint()
files = get_file_list()  # full list, same order, every run

for i, file in enumerate(files):
    if file in state["completed"]:
        continue  # already done, skip
    state["current_step"] = i
    state["next_input"] = {"file": file}
    save_checkpoint(state)        # write BEFORE the operation
    try:
        process_file(file)
        state["completed"].append(file)
        save_checkpoint(state)    # write after success
    except Exception as e:
        state["errors"].append({"file": file, "error": str(e)})
        save_checkpoint(state)
        break                     # stop on first error, inspect, resume

The loop is intentionally simple. The complexity is in the discipline: checkpoint before the destructive operation, every time, without exception.

Idempotent operations

Every operation in a long-running agent should be safe to repeat. If an API call creates a record, add a lookup-or-create pattern. If a file write applies a patch, check whether the patch is already applied before writing. The test: if you ran the same step twice in sequence, would the second run produce a different result or an error?

# Not idempotent — creates a duplicate on second run
resp = api.create_record(data)

# Idempotent — returns existing record if already created
existing = api.find_record(data["external_id"])
resp = existing if existing else api.create_record(data)

Audit every operation for idempotency before the first run. The time to discover a non-idempotent operation is in code review, not at 2 AM after a crash at step 140 that left 40 duplicate records in the database.

If you get stuck

The agent resumed but re-processed files already in completed. The write order is wrong — the completed entry was added to the in-memory state after a crash that prevented the checkpoint write. Fix the write order: add to completed, write the checkpoint, then advance to the next file. The entry must be on disk before you move on.

The checkpoint file is valid JSON but the schema_version does not match. You changed the checkpoint format — added a field, renamed one — and there is a stale checkpoint from a previous run. The version check in load_checkpoint above handles this: mismatched version restarts from zero. This is intentional. A checkpoint from a different schema cannot be safely resumed — resuming it silently is worse than restarting cleanly.

The recovery loop runs but operations are not idempotent, so resuming creates duplicates or overwrites changes. There is no shortcut here. You must audit every operation before the first run. If you discover a non-idempotent operation after a crash, fix it, clean up the partial state manually, and restart from the last clean checkpoint. Document what you cleaned up — the next crash will happen, and you will want the record.

Cross-reference

l7 covers the "almost done" loop in PR-shipping agents — the hard stop condition in the brief is the brief-level fix, and this lesson's checkpointing is the infrastructure-level fix. l3's worktree pattern becomes essential for long-running code agents: a crash leaves the worktree in a partial state, so the checkpoint must record the worktree path and branch so recovery can verify the state before resuming.

Deliverable

Write a checkpoint schema for one long-running task you run or plan to run. The schema must include: a schema_version field, a step index, a completed list, an errors list, and the next input. Then write the idempotency check for the most dangerous operation in the pipeline — the one that creates data or modifies an external resource. These two artifacts together are the pre-flight for any autonomous agent you ship.

Further reading

  • "The Office That Worked While He Was Out" — what an autonomous Claude Code daemon actually produced over weeks of unattended runs, and the three architectural layers that kept it resumable.
  • "The Office Held a Vote" — a worked example of a long-running pipeline crossing dream → debate → plan → build → QA → ship without a human in the loop, and what failed mid-run.

Try these prompts

  • Write a checkpoint schema in JSON for a 200-file import refactor. Include schema_version, current_step, total_steps, completed list, errors list, and next_input with the file path.
  • Write the Python idempotency check for an API call that creates a membership record — show the lookup-or-create pattern that prevents duplicates on resume.
  • Explain in two sentences why the checkpoint must be written before the destructive operation, not after, and what partial state results if the order is reversed.

Back to contents

9. Verify the docs before the agent writes a line 16 min

Lesson notes

Hook

You dispatched an agent against a real ticket. It came back with three thousand lines of code, a clean diff, no syntax errors, and tests that imported nothing fictional. One hundred and twenty-one calls to throw new Response(). Fourteen references to rc.user. process.env in a runtime that does not expose it. The reviewer reading the diff would not have caught it. The framework caught it — at runtime, when the sandbox refused to load. This is not a broken agent. It is a known failure mode with a known fix.

Model memory is not a query

When the agent writes code against a framework, it is not looking up that framework's API. It is generating from its training-data impression of what frameworks like this look like. The output is plausible because plausibility is what the model is optimized for. The output is wrong because the model has never seen this framework — only frameworks like it.

Think of a sketch artist who has drawn ten thousand faces and is now drawing yours from a description. The result will look like a face. It will not look like your face. The pencil knows faces. The pencil does not know yours.

The agent reaches for throw new Response() because that surface exists in Cloudflare Workers. It reaches for process.env because that surface exists in Node. It reaches for rc.user because some auth library in its training data named the request context rc. None of those exist in Emdash. Authentication does not catch it. Code review does not catch it. Only the framework, at runtime, knows.

The fix is structural, not careful prompting

Four mechanics, each from the same principle: replace model memory with ground truth, and put the verification in the pipeline. The mechanics below are the worked example for one framework. The principle is what travels.

Mechanic 1 — the verified guide. Write docs/<framework>-GUIDE.md by reading the framework's source code, not its public docs. Public docs lag the source and omit exactly the surfaces the agent reaches for. Extract the response API, the request API, env access, the user/session API. One file. Real exports only.

Mechanic 2 — doc-reading as pipeline input. Every brief that touches the framework starts with one line:

Read `docs/EMDASH-GUIDE.md` before writing any code. Use only the
surfaces it documents. If you need a surface not in the guide, stop
and ask.

"See also docs/EMDASH-GUIDE.md" produces zero behavior change. The mandatory form above produces near-total compliance. The doc is now a tool, not a suggestion.

Mechanic 3 — the banned-patterns list. BANNED-PATTERNS.md enumerates the wrong surfaces the model reaches for in this framework. Three parts per entry: the wrong surface, the right surface, and one sentence on why the wrong one is appealing.

# BANNED-PATTERNS.md

## throw new Response()
Wrong: throw new Response("not found", { status: 404 })
Right: ctx.json({ error: "not found" }, 404)
Why appealing: works in Cloudflare Workers; Emdash uses ctx.json.

Grow the file from observed failures, not from imagination. When an agent ships a hallucination, add the pattern before the next dispatch.

Mechanic 4 — the QA grep. A pre-review script scans the staged diff for every banned pattern:

# scripts/qa-grep.sh
PATTERNS=("throw new Response" "rc\.user" "process\.env")
for pattern in "${PATTERNS[@]}"; do
  if git diff --cached | grep -qE "$pattern"; then
    echo "QA: banned pattern '$pattern' in staged diff"
    exit 1
  fi
done

This is not a linter. A linter checks for general code quality. This is a narrow pattern-match for one class of failure — hallucinated API surfaces — and it runs before any human reads the diff. Do not fold it into ESLint. The two tools answer different questions.

If you get stuck

Wrote the guide from public docs. Public docs omit the surfaces the model needs. Read the source. Check exports, not documentation pages.

Doc-reading mentioned but not mandatory. "See also" produces no behavior change. The brief must say Read this before writing any code. That phrasing moves compliance from near-zero to near-total.

BANNED-PATTERNS.md curated by speculation. Grow it from agent failures, not from imagination. Agent hallucinates, you add the entry, you re-dispatch. The file gets stronger every cycle.

Cross-reference

l7 establishes the brief as the unit of work; this lesson extends the same discipline to framework verification — the brief is where doc-reading becomes mandatory. l5 is the same problem at the tool-definition layer: descriptions that lie produce tools that fabricate.

Deliverable

Pick one framework or internal API you use with agents — yours, not Emdash. Write docs/<framework>-GUIDE.md by reading the source. Add the one-line doc-reading directive to your next brief. Run the agent. If the diff is clean, the discipline worked. If the diff contains a hallucinated surface, add it to BANNED-PATTERNS.md and re-dispatch. The file gets stronger with each failure. That is the loop.

Further reading

  • "Code Review Is Not QA" — why catching hallucinated API surfaces is a structural problem, not a reviewer's vigilance problem.

Try these prompts

  • Write the doc-reading directive that goes at the top of every agent brief, naming the verified guide and what the agent must do if it needs a surface not in the guide.
  • Write a BANNED-PATTERNS.md entry for `throw new Response()` in a framework whose actual response API is `ctx.json()` — include the wrong surface, the right surface, and one sentence on why the wrong one is appealing.
  • Write the bash grep gate (with exit-code wrapping) that scans a staged diff for three banned patterns and fails the pipeline before code review if any match.

Back to contents

10. Forcing functions for autonomous pipelines 13 min

Lesson notes

Hook

Walk into a stalled autonomous-agent repo and look at the top level. Seventeen analysis documents. RISK-CHECKLIST-blog-infrastructure.md, PULSE_RISK_SCANNER_REPORT.md, PULSE_RISK_SUMMARY_EXECUTIVE.md, CODEBASE_SCOUT_REPORT.md, three WARDROBE-* files, five REQUIREMENTS* variants. Inside .planning/, thirty-three more — phase-1-plan.md, phase-1-plan-old.md, phase-1-plan-old-eventdash.md, phase-1-plan-spark.md.backup. The STATUS.md and TASKS.md files have drifted weeks behind git. The pipeline is technically running. Crons are firing. Agents are returning. No code has shipped in eleven days.

This is not a broken pipeline. It is a pipeline doing exactly what it was built to do — produce analysis. There was just nothing in it that ever turned analysis into a decision.

The pattern

Call this the analysis-as-progress fallacy: when an autonomous-agent pipeline lacks a forcing function, agents will produce analysis instead of progress. A PRD enters. Debates run. Planning starts. Scout reports get written. Risk checklists get written. Requirements analyses get written. And then — nothing forces the question given these analyses, are we shipping or killing? So the analyses pile up at the repo root and the project drifts off the daemon's radar.

The pattern shows up wherever (a) agents can produce documents, (b) agents have no explicit "decide" authority, and (c) "more analysis" is a valid move from any state. Every autonomous pipeline you build will have all three properties unless you design against them. Generating an analysis doc feels like progress to the agent, the operator watching the log, and the diff viewer reading commits. It is not progress. It is a phase that has no exit.

The graveyard is diagnostic. Three analysis docs at the repo root is a smell. Five is a stalled project. Seventeen is a project that has been stalled long enough that the daemon stopped trying to dispatch it and the human stopped checking. The names of the docs tell you what the project tried to plan and never shipped. Read them before deleting — that history is the lesson the next pipeline pays for if you lose it.

Why "more agents" is the wrong fix

The instinct, looking at this graveyard, is "we need a product manager." The instinct is directionally right. The proposed fix — add a great-pm-plugin with new PM personas — is the wrong shape. The PM personas, like every other persona in the pipeline, will produce documents. They will produce PM documents instead of risk documents. The great-pm-plugin ships an AGILE-FRAMEWORK-RECOMMENDATION.md and the project is now stalled at eighteen analysis docs instead of seventeen.

You do not have a missing-persona problem. You have a missing-decision problem. Adding more analysis-producing agents to a pipeline that already over-produces analysis amplifies the failure. The gap is structural, not staffing.

The forcing function

A forcing function is the thing in the pipeline that, at hour N or doc-count M, forces the question build, kill, or page a human? It is not an agent that produces a document. It is a structural mechanism that dispatches a decision-register persona — someone whose literal job is to decide, not to analyze.

Marty Cagan's four-risks framing is the worked example. For any project, ask: of the four product risks — value (will users want it?), usability (can they use it?), feasibility (can we build it?), viability (does it make business sense?) — which did the existing analyses test, and was the answer no? If the analyses tested feasibility seventeen times and never tested value, the project is stuck on the wrong axis and should be killed or restarted. If feasibility-no came back clear three docs ago, the project should have shipped a week ago. The four-risks lens is a finite ruler against an infinite analysis surface.

The mechanism that uses the lens is a stall-detector cron. Every four hours, scan projects/, classify each project GREEN/YELLOW/RED/BLACK by analysis-doc count, time-in-phase, and hours-since-last-commit. For YELLOW and RED, write a Cagan dispatch entry to .daemon-queue.json. For BLACK, escalate to the human. Cagan does not write another analysis. He reads the existing ones, applies the four-risks lens, and returns SHIP, KILL, or HUMAN-PAGE. The register is "decide, don't analyze," and the prompt is structured to refuse a fourth option.

Throttle the forcing function

A forcing function with no cap loops. Cagan dispatched at 4h, returns "more discovery needed" (he should not, but he sometimes will), the stall persists, the cron fires at 8h, dispatches Cagan again, repeat. You now have a faster version of the same problem. Cap it: max_per_project_per_window: 2. After the cap, the next firing is not Cagan — it is an escalation entry pinged at the human. The human is the ultimate forcing function. The cron's job is to triage what reaches them, not replace them.

Deliverable

Walk into your own autonomous-agent project and count the analysis docs at the root. If the count is over three, you do not have a missing-PM problem — you have a missing forcing function. Build the stall-detector with the four-risks lens and the dispatch cap before you add another persona to the roster. The pipeline that ships is the one that has somewhere "decide" can come from.

Cross-reference

l8 covers what to do when an individual agent crashes mid-task — that is recovery at the agent level. This lesson is recovery at the pipeline level: the agents are not crashing, they are completing successfully and producing the wrong kind of output. l7 establishes the brief as the unit of agent work; the forcing function is the brief equivalent at the pipeline scale, naming what counts as done so the system can refuse to keep generating.

Further reading

Try these prompts

  • Write a Node.js cron script that scans projects/<slug>/ directories every 4 hours, counts analysis docs (files matching *REPORT*.md, *RISK*.md, *ANALYSIS*.md at the top level), and writes a YELLOW/RED/BLACK classification to .daemon-queue.json based on count thresholds 3/5/8.
  • Take this stalled project's 14 analysis docs (file list below) and apply Cagan's four-risks framing — for each, label whether it tested value, usability, feasibility, or viability risk. Then rank what should be killed vs. shipped.
  • I'm running an autonomous pipeline with 8 great-minds personas and no PM. Write me a marty-cagan-decide-or-kill skill that takes a project slug, reads the project's analysis docs, applies the four-risks lens, and produces ONE of three outputs: SHIP (with the riskiest assumption named), KILL (with the reason), or HUMAN-PAGE (with the open question).

Back to contents