---
title: "Bounded Autonomy and Guardrails for Claude Code"
url: "https://vanducng.dev/2026/08/15/bounded-autonomy-and-guardrails-for-claude-code/"
date: "2026-08-15"
series: "agentic-coding-with-claude-code"
categories: ["AI"]
tags: ["agentic-coding","claude-code","agent-sdk","hooks","orchestration","guardrails"]
---

# Bounded Autonomy and Guardrails for Claude Code

[Part 5](https://vanducng.dev/2026/08/15/evaluation-and-observability-for-claude-agents/) 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](https://vanducng.dev/2026/08/09/core-concepts-of-agentic-coding-with-claude-code/) through 5. I will bound the loop those posts already built.

## Autonomy Is a Grant, Not a Vibe

[Part 1](https://vanducng.dev/2026/08/09/core-concepts-of-agentic-coding-with-claude-code/) 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](https://vanducng.dev/2026/08/15/the-harness-models-agent-sdk-and-stop-reason-loops/) 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`](https://code.claude.com/docs/en/agent-sdk/subagents), 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](https://code.claude.com/docs/en/agent-sdk/permissions#how-permissions-are-evaluated) 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`](https://code.claude.com/docs/en/agent-sdk/hooks) 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](https://vanducng.dev/2026/08/15/context-engineering-for-claude-code/) 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](https://vanducng.dev/2026/08/09/core-concepts-of-agentic-coding-with-claude-code/) 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](https://code.claude.com/docs/en/agent-sdk/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](https://vanducng.dev/2026/08/15/context-engineering-for-claude-code/) 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.

```mermaid
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`](https://code.claude.com/docs/en/workflows) 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`](https://code.claude.com/docs/en/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](https://vanducng.dev/2026/08/15/context-engineering-for-claude-code/) 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`](https://code.claude.com/docs/en/agent-sdk/hooks) callback occupies the process. The model does not get a vote.

```mermaid
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](https://code.claude.com/docs/en/agent-sdk/hooks#available-hooks) table and the [Claude Code hooks](https://code.claude.com/docs/en/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](https://vanducng.dev/2026/08/15/the-harness-models-agent-sdk-and-stop-reason-loops/) and [Part 4](https://vanducng.dev/2026/08/15/mcp-and-tool-governance/) already used this order. I re-fetched the [permissions](https://code.claude.com/docs/en/agent-sdk/permissions#how-permissions-are-evaluated) 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](https://vanducng.dev/2026/08/15/evaluation-and-observability-for-claude-agents/)'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.

```text
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](https://vanducng.dev/2026/08/15/evaluation-and-observability-for-claude-agents/): schema-valid is not truth-valid, but without a schema I cannot score or gate.

```python
# 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](https://vanducng.dev/2026/08/15/mcp-and-tool-governance/) 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](https://vanducng.dev/2026/08/09/core-concepts-of-agentic-coding-with-claude-code/) | The model reasons inside a perceive / reason / act loop. Smallest authority. Coordination costs. |
| [Part 2](https://vanducng.dev/2026/08/15/context-engineering-for-claude-code/) | Context is assembled, not typed. Tokens are a budget. `CLAUDE.md` is guidance. |
| [Part 3](https://vanducng.dev/2026/08/15/the-harness-models-agent-sdk-and-stop-reason-loops/) | The harness decides. `stop_reason` chooses the next verb. Permissions and `max_turns` are the loop. |
| [Part 4](https://vanducng.dev/2026/08/15/mcp-and-tool-governance/) | MCP is the surface. Host owns the window. Allow list, deny list, `dontAsk`. Fail closed. |
| [Part 5](https://vanducng.dev/2026/08/15/evaluation-and-observability-for-claude-agents/) | 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:

```text
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](https://vanducng.dev/2026/08/15/evaluation-and-observability-for-claude-agents/) 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](https://vanducng.dev/2026/08/09/core-concepts-of-agentic-coding-with-claude-code/). The window is still a budget from [Part 2](https://vanducng.dev/2026/08/15/context-engineering-for-claude-code/). The harness is still the runtime from [Part 3](https://vanducng.dev/2026/08/15/the-harness-models-agent-sdk-and-stop-reason-loops/). MCP is still the surface from [Part 4](https://vanducng.dev/2026/08/15/mcp-and-tool-governance/). 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.

## References

- AI Engineering with Claude
- [Claude Code hooks](https://code.claude.com/docs/en/hooks)
- [Hooks guide](https://code.claude.com/docs/en/hooks-guide)
- [Agent SDK hooks](https://code.claude.com/docs/en/agent-sdk/hooks)
- [Permissions](https://code.claude.com/docs/en/agent-sdk/permissions)
- [Subagents](https://code.claude.com/docs/en/agent-sdk/subagents)
- [Custom tools](https://code.claude.com/docs/en/agent-sdk/custom-tools)
- [Agent loop](https://code.claude.com/docs/en/agent-sdk/agent-loop)
- [Claude Code sub-agents](https://code.claude.com/docs/en/sub-agents)
- [Dynamic workflows](https://code.claude.com/docs/en/workflows)
- [Agent teams](https://code.claude.com/docs/en/agent-teams)
- [Permission modes](https://code.claude.com/docs/en/permission-modes)

---

*This is Part 6 of 6 in the Agentic Coding with Claude Code series. Earlier: [Part 1](https://vanducng.dev/2026/08/09/core-concepts-of-agentic-coding-with-claude-code/), [Part 2](https://vanducng.dev/2026/08/15/context-engineering-for-claude-code/), [Part 3](https://vanducng.dev/2026/08/15/the-harness-models-agent-sdk-and-stop-reason-loops/), [Part 4](https://vanducng.dev/2026/08/15/mcp-and-tool-governance/), [Part 5](https://vanducng.dev/2026/08/15/evaluation-and-observability-for-claude-agents/).*
