What you’ll learn
- Map the five Claude Code extension points — hooks, skills, subagents, MCP servers, and CLAUDE.md — to what each can actually enforce
- Decide where a behavior lives using a five-question procedure ordered by constraint strength
- Control skill invocation with
disable-model-invocationanduser-invocable - Migrate a rule between layers when you picked wrong, with three worked examples
- Compare the context cost of each mechanism before you commit to it
Prerequisites
- Claude Code v2.1.3 or later (the version where custom commands merged into skills)
- A project you already run Claude Code in daily, with a
CLAUDE.mdand at least one skill or hook - Familiarity with
.claude/settings.jsonandSKILL.mdfrontmatter
The question that decides most cases
You want Claude Code to never push to main. Five mechanisms could plausibly hold that rule: a CLAUDE.md line, a skill, a slash command, a hook, or a permission rule.
Most comparisons of Claude Code extension points sort them by capability. That is the wrong axis.
The axis we use instead: who executes it — the harness or the model?
Hooks and permission rules run deterministically in the harness. They fire on every matching event, in every session, regardless of what is in the context window.
Everything else — CLAUDE.md, rules files, skills, subagent instructions — is text the model interprets. Compliance is probabilistic.
The official docs state this directly: an instruction like “never edit .env” in CLAUDE.md or a skill is “a request, not a guarantee.” A PreToolUse hook that blocks the edit is enforcement.
Instructions are requests. Hooks are enforcement. If a rule must hold every single time, it cannot live only in a prompt layer — no matter how emphatically you phrase it.
So the first sorting question is enforcement. The tiebreaker is cost: choose by the cost of the rule being violated once.
A style preference violated once costs a review comment. A push to main violated once costs an incident. Price them differently.
CLAUDE.md remains the baseline layer for facts Claude needs every session — conventions, stack, structure. Keep it lean and factual; treat CLAUDE.md as a contract, not a wiki, and promote anything that must be guaranteed out of it.
The Claude Code extension points matrix
Here is the full matrix. Bookmark this table; the rest of the article explains and defends it.
| Mechanism | Executed by | Can it enforce? | Context cost |
|---|---|---|---|
| Hook | Harness | Yes — blocks tools, prompts, stops | Zero unless it returns output |
| Permission rule | Harness | Yes — allows/denies at permission layer | Zero |
| Skill (incl. slash command) | Model | No — interpreted | Description every request; body on invoke |
| Subagent | Model | No — isolates, doesn’t enforce | Own window; main thread pays summary only |
| MCP server | Model chooses tools | No — adds capability | Tool names at start; schemas on demand |
| CLAUDE.md / rules | Model | No — interpreted | Full content, every request |
The decision procedure, ordered by constraint strength:
- Must this hold every time, deterministically? Hook (plus a permission deny rule for file tools).
- Does the state live in someone else’s running system? MCP server.
- Does Claude need this every session?
CLAUDE.md, or a path-scoped rules file. - Is it on-demand knowledge or a repeatable procedure? Skill.
- Is it a noisy side task whose intermediate output you’ll never reread? Subagent, or a forked skill.
Shared across repos? That is not a sixth mechanism — package whatever you chose as a plugin.
What each layer can and cannot enforce
The matrix rows deserve a defense. Here is what each extension point actually guarantees — and where it quietly falls back to trusting the model.
Hooks block deterministically
Hooks fire on lifecycle events, and the blockable events are the ones that matter here. PreToolUse blocks the tool call, UserPromptSubmit rejects the prompt, PermissionRequest can deny permission through its JSON decision, and Stop prevents Claude from stopping. Events like PostToolUse and SessionStart cannot block — they are for side effects and feedback.
The contract is exit codes. Exit 0 means success, and Claude Code parses stdout for JSON decisions. Exit 2 is a blocking error: Claude Code cancels the pending action and feeds stderr back to the model as the reason.
JSON output goes further — permissionDecision: "deny", updatedInput to rewrite the tool input, or continue: false to halt Claude entirely.
One caveat: hooks come in five types (command, HTTP, mcp_tool, prompt, agent). A prompt or agent hook reintroduces model judgment at the handler. The trigger is still guaranteed — the hook always fires — but what a prompt hook decides is interpreted, not deterministic.
The mechanics of exit codes, matchers, and JSON decisions get full treatment in hooks as guardrails, not suggestions.
Skills are interpreted
A skill is content the model loads and follows. Claude matches your request against skill descriptions and decides whether to invoke — the docs’ own comparison says the outcome “can vary.” Two failure modes follow: Claude skips a skill whose description doesn’t match how you phrased the request, and Claude invokes a side-effect skill you didn’t want run.
Skills also have context mechanics worth knowing. Descriptions sit in a skill listing whose budget scales at 1% of the context window. Each skill’s description text is cut at 1,536 characters, and when the listing overflows its budget, some skills lose their descriptions entirely; /doctor estimates the listing’s context cost. After compaction, each invoked skill keeps its first 5,000 tokens within a 25,000-token combined budget — older skills can silently drop, which is one more reason to plan for surviving auto-compact.
Subagents isolate, they don’t enforce
A subagent is an isolated worker with its own context window. The main thread pays only for the returned summary. That makes isolation a context guarantee, not a behavior guarantee — the subagent is still a model interpreting instructions, and it can go off-script inside its sandbox.
Reach for one when the side task floods your conversation, not when you need control. The full pattern is covered in subagents as context firewalls.
MCP servers add capability, not control
MCP connects Claude Code to external systems. It enforces nothing: the model chooses when to call the tools, and a server cannot make Claude do anything. Its context cost is lower than its reputation now that tool search is on by default — idle servers load tool names only, with full schemas deferred until use.
The official framing is composition: MCP connects to your database; a skill documents your schema and query patterns.
Slash commands are skills now
Custom commands merged into skills in v2.1.3. A file at .claude/commands/deploy.md and a skill at .claude/skills/deploy/SKILL.md both create /deploy and work the same way. So the old “command vs skill” column collapses, and the surviving decision is invocation control, set by two frontmatter fields:
- Default: you can invoke
/name, and Claude can auto-invoke. The description sits in context on every request. disable-model-invocation: true: only you can invoke it. The description stays out of context — zero cost until used.user-invocable: false: only Claude can invoke it. For background knowledge that isn’t a meaningful user action.
Here is the skill sweet spot in one file — a repeatable procedure that needs model judgment and should load only when used:
---
description: Summarizes uncommitted changes and flags anything
risky. Use when the user asks what changed, wants a commit
message, or asks to review their diff.
---
## Current changes
!`git diff HEAD`
## Instructions
Summarize the changes above in two or three bullet points, then
list any risks you notice such as missing error handling,
hardcoded values, or tests that need updating. If the diff is
empty, say there are no uncommitted changes.
The !`git diff HEAD` line is dynamic context injection: Claude Code runs the command and inlines the output before the model sees the skill. Ask “what did I change?” and Claude auto-invokes it, or type /summarize-changes yourself.
Set disable-model-invocation: true on any skill with side effects — deploys, migrations, releases. You don’t want Claude deciding to deploy because your code looks ready. As a bonus, it zeroes the skill’s per-request description cost.
Three migrations for wrong picks
Picking the wrong extension point is normal. What matters is recognizing the symptom and relocating the behavior. Here are the three boundaries people cross most.
From CLAUDE.md rule to PreToolUse hook
The symptom: a NEVER push directly to main. line in CLAUDE.md that works — until one long session where it doesn’t. Probabilistic compliance fails occasionally by definition. Promote the rule to the harness layer:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"if": "Bash(git push *)",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/block-main-push.sh",
"timeout": 10
}
]
}
]
}
}
#!/bin/bash
input=$(cat)
cmd=$(echo "$input" | jq -r '.tool_input.command // ""')
branch=$(git branch --show-current)
if [[ "$cmd" == *"git push"* && "$branch" == "main" ]]; then
echo "Blocked: direct pushes to main are not allowed." \
"Push a feature branch and open a PR." >&2
exit 2
fi
exit 0
Exit 2 cancels the pending git push and feeds the stderr message back to Claude as the reason. The hook fires on every matching Bash call, in every session, regardless of conversation state.
So if you ask Claude to push a fix while on main, the push never runs. What Claude receives instead is the script’s own stderr line, “Blocked: direct pushes to main are not allowed. Push a feature branch and open a PR.”, and it can follow that instruction in the same turn.
Keep a one-line explanation in CLAUDE.md so Claude understands why pushes get blocked. The prompt layer explains; the hook enforces. Pair the hook with a permission deny rule so file tools are covered at the permission layer too.
From MCP server to CLI plus skill
The symptom: a server that wraps a handful of API calls you could make with curl, costing setup and schema overhead for no real state access.
The working rule we use: build CLI plus skill first. Reach for MCP only when the useful state genuinely lives inside someone else’s running system.
With a CLI script, Claude can open the script, edit it, and rerun it. An MCP fix must flow through tool contracts, schemas, and client compatibility. The overhead side of this trade is covered in context budgeting.
- 01
Identify what the server actually does
List the tools Claude actually called in the last month. If it is two or three read/write operations against an API, the server is wrapping a script’s worth of work.
- 02
Wrap it as a CLI script
Write a small script in
scripts/that takes arguments and prints results. Claude can read it, run it, and fix it when the API changes. - 03
Write the SKILL.md
Document when to use the script, its arguments, and output format in a skill. The description handles discovery; the body handles usage.
- 04
Remove the server and compare
Delete the server entry from
.mcp.json, restart, and run/contextbefore and after. Confirm the workflow still works end to end.
From inline skill to forked skill
The symptom: a /dependency-audit skill reads dozens of manifests and lockfiles inline, flooding your main context with output you will never reference again. The mechanism was right; the placement was wrong. Two frontmatter lines relocate it:
--- name: dependency-audit description: Audit dependencies for outdated or vulnerable packages ---
--- name: dependency-audit description: Audit dependencies for outdated or vulnerable packages context: fork agent: Explore ---
With context: fork, the skill body becomes a subagent’s task prompt. agent: Explore gives it read-only exploration tools and skips CLAUDE.md and git status for a lean start. Only the findings return to your main thread.
Mind the inverse trap: forking a reference-only skill (“use these API conventions”) hands a subagent guidelines with no actionable task, and it returns nothing useful. Fork procedures with deliverables; keep reference material inline.
Composition patterns worth stealing
Extension points combine, and the strongest setups pair an enforcing layer with an interpreting one:
- MCP + skill: the server provides the connection; the skill provides the knowledge of how to use it well.
- Skill + subagent, both directions:
context: forkpushes a skill out to isolation; a subagent’sskills:field preloads full skill content at startup. - Hook + skill: the hook enforces, the skill remediates. A
PostToolUsehook surfaces lint failures; a/fix-lintskill cleans them up. This pairing is the backbone of verification loops. - Skill-registered hooks: a
hooks:field in skill frontmatter registers hooks only once the skill is invoked, so a session that never uses the skill never runs them. Once registered, they stay active for the rest of the session.
Layering rules differ per mechanism, and it matters when you package things. CLAUDE.md files are additive across levels, skills and MCP servers override by name, and hooks merge — every registered hook fires regardless of source. A plugin bundles all of the above for distribution; it is packaging, not a sixth decision.
The one-minute decision procedure
Run the five questions in order — deterministic guarantee, external state, every-session fact, on-demand procedure, isolation — and stop at the first yes. Break ties by the cost of one violation. If the answer changed since you built it, move the behavior; the migrations above are cheap.
The official “build your setup over time” triggers work just as well as move triggers:
- Claude got a convention wrong twice → add it to
CLAUDE.md. - You keep typing the same prompt → skill.
- A side task floods the conversation → subagent.
- It must happen every time without asking → hook.
- A second repository needs the same setup → plugin.
To audit an existing setup, run this prompt in your project:
❯ Read CLAUDE.md and classify every line as FACT (keep it here), SCOPED (move to .claude/rules/ with a paths field), PROCEDURE (move to a skill), GUARANTEE (move to a hook), or STALE (delete). Output a table with one row per line and the target file for anything that moves.
Everything the model reads costs tokens on a budget — that is the second axis of the matrix, and context budgeting covers how to spend it deliberately.
The matrix matters most when a whole team adopts it. Without a shared rule, the same guarantee ends up as a CLAUDE.md line in one repo, a personal hook in another and an MCP server in a third, and nobody can say which rules actually hold. Agreeing the decision procedure once and applying it across repos is a large part of what we do in Enable.
Next steps
- The escalation ladder for agentic tasks — the matrix decides where a behavior lives; the ladder decides how much autonomy it gets.
- Headless Claude Code in CI and cron — hooks and
disable-model-invocationskills behave differently in-pmode. - CLAUDE.md as a contract, not a wiki — apply the FACT/SCOPED/PROCEDURE/GUARANTEE/STALE refactor to your baseline layer.
- External: Extend Claude Code (official docs) and the hooks reference.