Source: AI Engineering with Claude (Udacity ND7426), plus current Claude docs I fetched: Claude Code hooks, hooks guide, Agent SDK hooks, permissions, subagents, custom tools, agent loop, Claude Code sub-agents, dynamic workflows, agent teams, and permission modes
Part 5 ended with a debt: autonomy I am willing to grant, and guardrails that hold even when the model would rather keep going.
This is Part 6 of 6 in my Agentic Coding with Claude Code series. The working model I am keeping is simple: eval tells me whether the loop did the job. Guardrails decide what the loop is allowed to do even when the model wants another hop. Bounded autonomy is the grant: which agents, which tools, how many turns, when a human is required.
This is the series closer. I will not rewrite Parts 1 through 5. I will bound the loop those posts already built.
Autonomy Is a Grant, Not a Vibe
Part 1 already said it: give the agent the smallest authority that still lets it finish. I used to treat that as a prompt habit. “Be careful with prod.” “Do not merge.” “Ask if you are unsure.” Those sentences are guidance. They live in the window. The model can still decide the next hop is worth it.
A grant is code. Part 3 already named the knobs. I am not re-teaching them. I am naming which ones are the grant:
| Knob | Official field | What I am actually granting |
|---|---|---|
| Who may act | agents on ClaudeAgentOptions, plus Agent in allowed_tools |
Which specialized loops I defined. Omit Agent from allowed_tools and a spawn falls through to can_use_tool, or is denied in dontAsk. The built-in general-purpose spoke needs no agents entry. |
| What each loop can see | AgentDefinition.tools / disallowedTools |
The spoke’s public API. A tool you omit is not in that session. |
| What may run without a human | permission_mode, allowed_tools, disallowed_tools |
The same permission order from Parts 3 and 4. |
| How many hops | max_turns on the query; maxTurns on an AgentDefinition |
When the loop must stop even if the model wants another tool. |
| How much spend | max_budget_usd / maxBudgetUsd |
The query ends with error_max_budget_usd. Subagent requests count. |
| How wide the tree gets | CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH, CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS |
Official env caps. Depth default is 3. Concurrency default is 20. |
| What must never run | A PreToolUse hook that returns permissionDecision: "deny" |
The grant that holds when the model would rather keep going. |
| When a human is required | plan, default plus can_use_tool, or a hook that returns "ask" |
The pause is a product decision, not a polite request in CLAUDE.md. |
CLAUDE.md is still guidance, the way Part 2 said. If a write must not happen, that is a permission rule or a hook. If a second agent must not exist, I do not define it. If the tree must stay one layer deep, I set CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH=1. I do not ask Opus to “please not nest.”
The takeaway I kept: autonomy is a budget of agents, tools, turns, and spend. I write the budget down. I do not vibe it.
Multi-Agent Only When Isolation Is Cheaper or Safer
Part 1 already said more agents do not automatically produce better work. Coordination cost is real. I start with one agent. I add a second loop only when a step is cheaper or safer in isolation: a noisy review that should not fill the parent window, a read-only lint pass that must never edit, a test runner whose bash I do not want on the coordinator.
Official subagents are that isolation. Each spoke is a fresh conversation. Intermediate tool calls stay inside it. The parent receives the final message, not every file the spoke read. That is the same fork trick Part 2 used on skills, applied to a named worker.
Hub-and-spoke is the shape I actually use. One coordinator. Scoped spokes. A structured handoff. The coordinator routes and integrates. It does not re-do the lint.
flowchart TB
Goal[Review request] --> Hub[Orchestrator]
Hub --> Lint[lint spoke]
Hub --> Tests[tests spoke]
Hub --> Sec[security notes]
Lint --> Handoff[Structured handoff]
Tests --> Handoff
Sec --> Handoff
Handoff --> Hub
Hub --> Gate{Merge hook}
Gate -->|under threshold| Notes[Publish review notes]
Gate -->|over threshold| Human[Human required]
classDef blueClass fill:#4A90E2,stroke:#333,stroke-width:2px,color:#fff
classDef orangeClass fill:#F39C12,stroke:#333,stroke-width:2px,color:#fff
classDef greenClass fill:#27AE60,stroke:#333,stroke-width:2px,color:#fff
classDef purpleClass fill:#9B59B6,stroke:#333,stroke-width:2px,color:#fff
classDef tealClass fill:#16A085,stroke:#333,stroke-width:2px,color:#fff
classDef redClass fill:#E74C3C,stroke:#333,stroke-width:2px,color:#fff
class Goal blueClass
class Hub orangeClass
class Lint,Tests,Sec purpleClass
class Handoff tealClass
class Gate redClass
class Notes greenClass
class Human redClass
The public Course 4 syllabus names sequential, parallel, and advanced orchestration. Those are not SDK type names. They are patterns I map onto official tools:
| Pattern | What I mean | Official surface I actually use | When I use it | When I do not |
|---|---|---|---|---|
| Single agent | One loop owns the task | query() with no agents map |
The work shares one context | A noisy subtask would tax the parent window |
| Sequential | Spoke B needs spoke A’s result | One Agent call, then another, after the first tool_result |
Handoff depends on prior evidence | The steps are independent. I am paying for a queue I do not need. |
| Parallel | Independent spokes in one turn | Several Agent calls; official docs say they can run concurrently |
Lint, tests, and security notes do not share changing state | Two spokes would edit the same files |
| Hub-and-spoke | One coordinator, scoped workers, structured handoff | agents={...} plus Agent in allowed_tools |
I need isolation and a single integrator | I cannot name what each spoke returns |
| Nested / wide tree | Spokes spawn spokes | Same Agent tool. Cap with CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH |
Rare. A spoke that must research further | Default. Depth 3 plus Opus 5 is how a review becomes a swarm. |
| Scripted orchestration | Dozens to hundreds of workers | The Workflow tool (TypeScript SDK v0.3.149+). Include Workflow in allowedTools. |
A repo-wide audit that does not fit one conversation | A three-spoke review. Subagents are enough. |
| Agent teams | Separate sessions that message each other | agent teams. Official page: experimental, off until CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1. SDK / -p sessions do not spawn teammates. |
Interactive research where workers must argue | A headless review desk. I stay on subagents. |
Claude invokes a spoke through the Agent tool (Task on older SDK traces). Include Agent in allowed_tools or, in dontAsk, the spawn is denied. A spoke’s description is how the coordinator decides to call it. Mention the name in the prompt when I need that spoke, not a guess.
What a spoke inherits is narrower than I first assumed. Official table: it gets its own prompt, the Agent tool’s prompt string, project CLAUDE.md if setting_sources loads it, and its tool list. It does not get the parent’s conversation or the parent’s system prompt. If the spoke needs a file path or a PR number, I put that in the delegation prompt. A missing handoff is a context bug, the same class Part 2 already named.
Subagents inherit the parent’s permission_mode. An AgentDefinition.permissionMode can override it, except when the parent is bypassPermissions, acceptEdits, or auto. Those three apply to every spoke and cannot be overridden. I do not put the coordinator in bypassPermissions and hope a spoke stays read-only.
Deterministic Hooks vs Prompts
A prompt is guidance. A hook is code that runs on an official event and can deny, rewrite, or halt.
The public Course 4 syllabus names a comparison harness: deterministic hooks block 100% of violations where a strong prompt cannot. I did not rerun their banking exercise. The takeaway I kept does not need their files. A sentence in CLAUDE.md occupies the window. A PreToolUse callback occupies the process. The model does not get a vote.
flowchart TB
subgraph Hook["Deterministic hook"]
direction LR
H1[Official event] --> H2[Callback reads args] --> H3["deny / allow / ask"]
end
subgraph Prompt["Prompt only"]
direction LR
P1["Please never merge"] --> P2[Model decides] --> P3[May still call]
end
classDef greenClass fill:#27AE60,stroke:#333,stroke-width:2px,color:#fff
classDef orangeClass fill:#F39C12,stroke:#333,stroke-width:2px,color:#fff
classDef redClass fill:#E74C3C,stroke:#333,stroke-width:2px,color:#fff
class H1,H2,H3 greenClass
class P1,P2 orangeClass
class P3 redClass
Hook on the right is the grant. Prompt on the left is the baseline I no longer trust for a write I cannot undo.
Official events I actually fetched, from the Agent SDK hooks table and the Claude Code hooks reference. I am not inventing names. I am also not dumping the full catalog (the Claude Code page lists session, worktree, elicitation, and file-watch events I am not using here). The ones that bound a loop:
| Event | Python SDK | What it is for | Guardrail I actually write |
|---|---|---|---|
PreToolUse |
Yes | Before a tool runs. Can block or rewrite. | Deny an over-threshold write. updatedInput to sandbox a path. |
PostToolUse |
Yes | After a tool succeeds | Normalize a messy result (updatedToolOutput). Append additionalContext. Audit. |
PostToolUseFailure |
Yes | After a tool fails | Log the failure class. Do not invent a success. |
PermissionRequest |
Yes | A tool call needs a permission decision | Notify. Or encode the ask in my own UI. |
UserPromptSubmit |
Yes | User prompt, before Claude sees it | Inject a facts block. Block a prompt I will not send. |
Stop |
Yes | Agent finished responding | Official decision: "block" prevents the stop and keeps the loop going (with a reason). I use that when the handoff object is missing. I do not treat Stop as a halt. |
SubagentStart / SubagentStop |
Yes | A spoke spawned or finished | Count spokes. Attach the handoff to the audit. |
PreCompact |
Yes | Before compaction | Archive the transcript I am about to squeeze. |
Notification |
Yes | Status messages | Side effects only. Official docs: Notification hooks do not modify agent behavior. In headless SDK sessions only elicitation_complete and elicitation_response fire. Permission waits go to can_use_tool, not this hook. |
TypeScript adds more (SessionStart, SessionEnd, PostToolBatch, TeammateIdle, TaskCreated, TaskCompleted, and others). The Python SDK page only calls out SessionStart and SessionEnd as missing from HookEvent: those two are shell hooks in .claude/settings.json, loaded with setting_sources=["project"]. I do not assume the rest of the TypeScript-only list is a Python callback.
How a hook is wired, official fields only:
| Field | Where | What I set |
|---|---|---|
| Event name | Key on options.hooks |
Case-sensitive. PreToolUse, not preToolUse. |
matcher |
HookMatcher(matcher=...) |
For tool events, the tool name. "Write|Edit", "Bash", or a regex like "^mcp__". Omit it and the callback runs for every event of that type. Matchers do not filter file paths. I check tool_input for that. |
| Callback | hooks=[...] |
Receives input_data, tool_use_id, context. Shared input fields: session_id, cwd, hook_event_name. Tool events also have tool_name and tool_input. |
permissionDecision |
Inside hookSpecificOutput on PreToolUse |
"allow", "deny", "ask", or "defer". "deny" blocks. "defer" ends the query so I can resume later (stop_reason: "tool_deferred"). |
permissionDecisionReason |
Same object | On "deny", shown to Claude. On "allow" / "ask", shown to the user, not the model. |
updatedInput |
Same object | Rewrite args. Pair with "allow" to auto-approve the rewrite, or omit the decision and let the normal permission flow run. Ignored with "defer". |
updatedToolOutput |
PostToolUse |
Replace what Claude sees. Official replacement for the older MCP-only updatedMCPToolOutput. |
continue_ / continue |
Top-level return | Whether the agent keeps running after this hook. |
async_ / async |
Top-level return | Fire-and-forget. Cannot deny. Logging only. |
When several hooks fire, official priority is deny over defer over ask over allow. One deny wins. Hooks run in parallel, so I do not write hook B as if hook A already ran.
A PreToolUse timeout does not run the tool. Official behavior: Claude gets a hook-timeout tool result and the turn continues. That is not a permissionDecision: "deny", and it is not a success. I treat a hung guardrail as fail-closed.
Hooks Sit First in the Permission Order
Part 3 and Part 4 already used this order. I re-fetched the permissions page. It still starts with hooks.
- Hooks. A hook can deny outright. A hook that returns
allowdoes not skip the deny and ask rules below. Those still run. - Deny rules (
disallowed_tools,settings.json). A scoped deny likeBash(rm *)blocks in every mode, includingbypassPermissions. A bareBashremoves the tool from the window before this step. - Ask rules. Matched calls fall through to
can_use_tool, even inbypassPermissions. - Active mode.
bypassPermissionsapproves what reaches this step.acceptEditsapproves file ops.plannever auto-approves writes.dontAskhas no prompt. - Allow rules (
allowed_tools). A match approves. can_use_tool. Only if nothing above resolved it. Skipped indontAsk(denied).
That is why a hook is the grant I reach for when a mode would lie. allowed_tools does not constrain bypassPermissions. can_use_tool never sees a call that an allow rule or acceptEdits already approved. Official wording: to gate every tool call, use a PreToolUse hook. Hooks still run in bypassPermissions. A hook deny still applies there.
auto is a newer mode than Part 3 listed: a model classifier approves or denies prompts. I am not using it as a guardrail. A classifier is still a model. A hook is still code.
Keel Ledger: a Review Desk
Course 4 trains this on an enterprise code-review orchestrator and a banking-hook layer. I am not reprinting those files. Invented analog: Keel Ledger, a fictional TypeScript ledger (same fictional shop as Part 5‘s Keel Freight). One PR lands. A hub routes three read-mostly spokes. A hook blocks merge when the change is over a threshold a prompt would have let through.
1 | Goal: a structured review of one PR, or a fail-closed halt. |
Three spokes, one coordinator. Intentionally small. Same isolation rule I used when warranty-desk escalated and shelfwatch skipped: the specialist does one job.
| Spoke | AgentDefinition grant |
Returns (handoff fields) | When not to spawn it |
|---|---|---|---|
lint |
tools=["Read", "Grep", "Glob"], maxTurns=8 |
lint_ok, lint_findings[] |
You already have a CI lint artifact |
tests |
tools=["Bash", "Read", "Grep"], maxTurns=10 |
tests_ok, failed_names[] |
The PR is docs-only |
security-notes |
tools=["Read", "Grep", "Glob"], model="sonnet" |
security_ok, notes[] |
The diff does not touch src/ledger/** |
The structured handoff the hub must produce. Same seam as Part 5: schema-valid is not truth-valid, but without a schema I cannot score or gate.
1 | # Sketch. Official fields only. Invented analog, not a course file. |
mcp__ledger__merge_pr and mcp__ledger__post_journal_entry are invented tool names on a fictional server, same naming rule Part 4 already used (mcp__server__tool). They sit on allowed_tools so dontAsk can approve an under-threshold call. Leave them off that list and every merge is denied by mode, which hides whether the hook did any work. The matcher is official syntax. The cents threshold is the same class of rule as a transfer gate: a prompt that says “never post over 100000 cents” will still try when the model wants to close the ticket. The hook reads amount_cents and denies. Fail closed. Do not retry the merge into an allow.
A prompt-only desk fails in a predictable way. The coordinator summarizes three green spokes, decides the PR “looks small,” and calls merge_pr with files_changed: 47. The hook is the only layer that still says no. Warranty-desk would have escalated. Shelfwatch would have skipped. Harbor Credit would have left rate_bps null. Same instinct, now enforced on the event, not in the window.
If a spoke returns security_ok: false, the hub sets merge_allowed: false. That is policy in the structured object. The hook is the second lock: even a hub that hallucinates merge_allowed: true cannot run merge_pr over the threshold.
The Six-Post Working Model
I am not restating the posts. I am naming the sentence each one left me with.
| Post | Working sentence |
|---|---|
| Part 1 | The model reasons inside a perceive / reason / act loop. Smallest authority. Coordination costs. |
| Part 2 | Context is assembled, not typed. Tokens are a budget. CLAUDE.md is guidance. |
| Part 3 | The harness decides. stop_reason chooses the next verb. Permissions and max_turns are the loop. |
| Part 4 | MCP is the surface. Host owns the window. Allow list, deny list, dontAsk. Fail closed. |
| Part 5 | Eval scores the loop. Structured output is the seam. Schema-valid is not truth-valid. |
| Part 6 | Guardrails bound the loop. Autonomy is a grant. Hooks hold when the model would rather keep going. |
One line: the model reasons, the harness decides, MCP is the surface, eval scores the loop, guardrails bound the loop. Context is the budget those five share.
Putting the Concepts into Practice
Pick one workflow you would not trust to a polite prompt (a merge, a journal write, a publish) and write this brief before you add a second agent:
1 | Grant (agents, tools per agent, permission_mode, max_turns, max_budget_usd): |
Then inspect a real run:
- If you omit
Agentfromallowed_toolsand setdontAsk, does the spawn die instead of the coordinator “just doing the lint”? - If a spoke has no
Write, can it still edit through the parent? (It should not. The tool is missing from that session.) - If you keep the prompt “never merge over 20 files” and remove the hook, does a 47-file PR still get a
merge_prcall? - If the hook denies, does the audit line say
deniedand doesmerge_allowedstay false? - If you put the parent in
bypassPermissions, does the hook still deny? (Official docs: yes.allowed_toolswill not save you. The hook will.)
If you cannot answer those, you have a multi-agent demo, not a grant.
Key Terms
- Bounded autonomy: The grant of agents, tools, turns, spend, and human pauses. Not a vibe in the prompt
- Guardrail: A check that holds when the model wants another hop. Permissions, caps, and hooks. Not
CLAUDE.md - Hook: Official callback or command on a lifecycle event (
PreToolUse,PostToolUse, and the rest I fetched) HookMatcher: SDK wrapper.matcherfilters the event target (tool name for tool events).hooksis the callback listpermissionDecision:PreToolUseresult:"allow","deny","ask","defer"updatedInput/updatedToolOutput: Rewrite the call or the result before Claude sees it- Permission evaluation order: Hooks, deny, ask, mode, allow,
can_use_tool. Still hooks first on the page I fetched - Hub-and-spoke: One coordinator, scoped spokes, structured handoff
Agenttool /agents/AgentDefinition: How the SDK names and invokes a spoke. Older traces usedTaskmaxTurns(spoke) /max_turns(query): Hop caps. The model does not get a courtesy extra turnCLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH/CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS: Official tree capsWorkflowtool: Scripted orchestration for jobs too large for turn-by-turnAgentcalls- Agent teams: Experimental multi-session teammates. Off by default. Not an SDK headless feature
- Fail closed: Deny, skip, or escalate when the grant is exceeded. Do not retry a deny into an allow
Final Thoughts
Eval from Part 5 tells me whether the loop did the job. It does not decide what the loop is allowed to do. That is this post. The model is still the reasoning engine from Part 1. The window is still a budget from Part 2. The harness is still the runtime from Part 3. MCP is still the surface from Part 4. Guardrails are the last lock: the grant I wrote down, and the hook that holds when the model would rather keep going.
I do not need more agents. I need a smaller authority, a structured handoff, and a PreToolUse deny that does not care how confident the last sentence sounded.
This is Part 6 of 6 in the Agentic Coding with Claude Code series. Earlier: Part 1, Part 2, Part 3, Part 4, Part 5.
Comments