Bounded Autonomy and Guardrails for Claude Code

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.

  1. Hooks. A hook can deny outright. A hook that returns allow does not skip the deny and ask rules below. Those still run.
  2. Deny rules (disallowed_tools, settings.json). A scoped deny like Bash(rm *) blocks in every mode, including bypassPermissions. A bare Bash removes the tool from the window before this step.
  3. Ask rules. Matched calls fall through to can_use_tool, even in bypassPermissions.
  4. Active mode. bypassPermissions approves what reaches this step. acceptEdits approves file ops. plan never auto-approves writes. dontAsk has no prompt.
  5. Allow rules (allowed_tools). A match approves.
  6. can_use_tool. Only if nothing above resolved it. Skipped in dontAsk (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
2
3
4
5
6
Goal: a structured review of one PR, or a fail-closed halt.
Definition of done: lint, tests, and security notes in one handoff object,
plus merge_allowed true only when every spoke is ok and the hook agrees.
Spokes: lint (read-only), tests (bash for the test command), security-notes (read-only).
Stop and ask before: merging, publishing a release, writing a journal entry over 100000 cents.
Never: let the coordinator re-run a spoke's tools, or let a prompt override the merge hook.

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
# Sketch. Official fields only. Invented analog, not a course file.

from claude_agent_sdk import (
ClaudeAgentOptions,
AgentDefinition,
HookMatcher,
)

HANDOFF_SCHEMA = {
"type": "object",
"properties": {
"lint_ok": {"type": "boolean"},
"tests_ok": {"type": "boolean"},
"security_ok": {"type": "boolean"},
"files_changed": {"type": "integer"},
"merge_allowed": {"type": "boolean"},
"reason": {"type": "string"},
},
"required": [
"lint_ok",
"tests_ok",
"security_ok",
"files_changed",
"merge_allowed",
"reason",
],
"additionalProperties": False,
}

async def block_over_threshold(input_data, tool_use_id, context):
args = input_data.get("tool_input", {})
files_changed = args.get("files_changed", 0)
amount_cents = args.get("amount_cents", 0)

if files_changed > 20 or amount_cents > 100000:
return {
"hookSpecificOutput": {
"hookEventName": input_data["hook_event_name"],
"permissionDecision": "deny",
"permissionDecisionReason": (
"Over threshold. Human required. "
"Do not retry merge or post_journal_entry."
),
}
}
return {}

options = ClaudeAgentOptions(
allowed_tools=[
"Read",
"Grep",
"Glob",
"Bash",
"Agent",
"mcp__ledger__merge_pr",
"mcp__ledger__post_journal_entry",
],
disallowed_tools=["Write", "Edit"],
permission_mode="dontAsk",
max_turns=24,
max_budget_usd=5.0,
output_format={"type": "json_schema", "schema": HANDOFF_SCHEMA},
agents={
"lint": AgentDefinition(
description="Read-only lint pass. Use for style and import findings.",
prompt="Report lint_ok and lint_findings. Do not edit files.",
tools=["Read", "Grep", "Glob"],
maxTurns=8,
),
"tests": AgentDefinition(
description="Runs the targeted test command. Use when the PR touches src/.",
prompt="Run the test command you were given. Return tests_ok and failed_names.",
tools=["Bash", "Read", "Grep"],
maxTurns=10,
),
"security-notes": AgentDefinition(
description="Read-only security notes for src/ledger. Use on ledger diffs.",
prompt="List notes. Never approve a merge. Never edit.",
tools=["Read", "Grep", "Glob"],
model="sonnet",
),
},
hooks={
"PreToolUse": [
HookMatcher(
matcher="mcp__ledger__merge_pr|mcp__ledger__post_journal_entry",
hooks=[block_over_threshold],
)
]
},
env={
"CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH": "1",
"CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS": "3",
},
)

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
2
3
4
5
6
7
8
Grant (agents, tools per agent, permission_mode, max_turns, max_budget_usd):
What is cheaper or safer in isolation (and is therefore a spoke):
What each spoke returns (the handoff object):
What the hub is not allowed to redo:
Hook event + matcher + the arg you will read:
Threshold that denies even if the model wants another hop:
What fail-closed means (do not merge / post / publish X):
When a human is required:

Then inspect a real run:

  1. If you omit Agent from allowed_tools and set dontAsk, does the spawn die instead of the coordinator “just doing the lint”?
  2. If a spoke has no Write, can it still edit through the parent? (It should not. The tool is missing from that session.)
  3. If you keep the prompt “never merge over 20 files” and remove the hook, does a 47-file PR still get a merge_pr call?
  4. If the hook denies, does the audit line say denied and does merge_allowed stay false?
  5. If you put the parent in bypassPermissions, does the hook still deny? (Official docs: yes. allowed_tools will 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. matcher filters the event target (tool name for tool events). hooks is the callback list
  • permissionDecision: PreToolUse result: "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
  • Agent tool / agents / AgentDefinition: How the SDK names and invokes a spoke. Older traces used Task
  • maxTurns (spoke) / max_turns (query): Hop caps. The model does not get a courtesy extra turn
  • CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH / CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS: Official tree caps
  • Workflow tool: Scripted orchestration for jobs too large for turn-by-turn Agent calls
  • 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.

Evaluation and Observability for Claude Agents

Comments

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×