Skills, Agents, Tools, Hooks: which one can call which
Can a skill call another skill? Can a hook open a subagent? Claude Code lets you configure four things: skills, agents, tools, hooks. I paired them up into sixteen combinations and ran every one.
The model (the language model behind the API) and the engine (the local claude binary) issue calls; a tool does not.
A "field" below means a setting in a definition file's header block (the frontmatter), or a setting in settings.json.
If a field names what to call, the engine always does it. For example, a skill that declares context: fork gets a subagent opened to run it, every time it is invoked.
If there is no field for it, as with a skill calling another skill, the only option is text in the body asking the model to make the call. The model can ignore it, so this kind of wiring is not guaranteed to happen.
allowed-toolsis not a whitelist. A skill declaresallowed-tools: Read; once it loads, the body asks for Bash and Bash still runs. The field only decides which tools run without asking the user first. If you want to forbid Bash, put it indisallowed-tools.- A skill's
hooks:stay after the skill finishes. As long as this run ofclaudehas not exited, every later tool call goes through them. So if you put a hook that denies Bash in a skill, Bash is still denied after the skill is done. - A misspelled hook event name raises no error. Type
PreToolUsewith oneetoo many,PreToolUsee, and the hook never runs; normal output gives no hint, and only--debugprints one error line.
Environment:
- CLI 2.1.302 (the
mcp_toolandprompthook actions were tested on 2.1.257) - Agent SDK 0.3.257
- every experiment runs with
claude -p: one prompt in, the result printed, then exit, no interactive session
model and engine
Type "show me the git status" into Claude Code and the terminal ends up printing the output of git status.
That sentence first goes to the model: Opus, Fable and the rest, running behind the API. The model cannot run a command itself. All it can do is write, in its reply, "I want to call Bash with the argument git status". That piece is called a tool_use, and it looks like this:
{"type": "tool_use", "name": "Bash", "input": {"command": "git status"}}At this point git status has not run. This is only text the model sent back.
Then it is the engine's turn: the claude binary on your machine. It receives that tool_use, actually hands git status to a shell, and sends the output back to the model wrapped as a tool_result. The full round trip:
user: show me the git status
→ the engine sends that message plus the tool list to the model
→ the model replies with a tool_use: name Bash, command git status
→ the engine runs git status in a shell and sends the output to the model as a tool_result
→ the model reads the output and writes a reply to the user
Before and after running a tool, the engine also checks settings.json for hooks to run.
Sometimes the engine opens a separate conversation with the model, without the main line's history, so it can focus on one task. That is a subagent. The main line and each subagent have their own tool list.
The four
Skills and agents are .md files on disk, hooks live in settings.json or frontmatter, and tools have no file at all.
tool
A tool is a function the model can call: a name plus a parameter schema. In the tool_use above, Bash is the tool's name and git status is the argument passed to it. So the tool list has Bash on it, and no git status.
To be callable, a thing has to be registered in this conversation's tool list. An unregistered tool is invisible to the model and cannot be called.
Built-in tool names are single words: Read, Write, Edit, Bash. An external MCP (Model Context Protocol) server can supply tools too, named mcp__<server>__<tool>, for example the GitHub MCP server's mcp__github__search_repositories.
The list also has a capitalised Agent and Skill. They are tools too, on the same list as Bash. To open a subagent, the model sends a tool_use like this one (from a real log, prompt shortened):
{"type": "tool_use", "name": "Agent", "input": {"subagent_type": "rb", "description": "Run echo HELLO via Bash", "prompt": "Run the shell command 'echo HELLO' with the Bash tool and report the output verbatim."}}To run a skill it calls Skill, with the skill's name as the argument:
{"type": "tool_use", "name": "Skill", "input": {"skill": "hooktest"}}The tools line in an agent definition takes exactly these names:
tools: [Read, Bash, Agent, Skill, mcp__github__search_repositories]In this article, capitalised Agent and Skill are the tools on the list, and lowercase agent and skill are the definition files on disk.
skill
A skill is a set of instructions written for the model. Say you want "when the user says hooktest, run echo HELLO with Bash". You write a SKILL.md:
---
name: hooktest
description: Test skill. Use when the user says hooktest.
---
Call the Bash tool with command: echo HELLO
Then report in one line exactly what happened.The file goes at .claude/skills/<name>/SKILL.md. Under a project directory, only that project has it; under ~/.claude/skills/, every project does.
The block between the two --- lines at the top is the frontmatter, YAML, and every setting goes there. The model reads description to decide when to use it. The body below is loaded only once the model calls Skill, and the model then follows it.
agent
An agent definition describes a kind of subagent: the main line hands it a task, it finishes the task in its own conversation, and only the conclusion comes back to the main line. The definition lives at .claude/agents/<name>.md:
---
name: probe
description: Test agent.
tools: [Read, Bash]
model: haiku
---
You are a test agent. Answer exactly what is asked.The tools line lists the tools the subagent can use:
tools: [Read, Bash] # Read and Bash only
tools: [Read, Bash, Agent] # can also open another level of subagent
# no tools line: whatever the level that opened it hasIn the test, tools: [Read] told to run echo HELLO called no tool at all and only answered I don't have access to a Bash tool.; with Bash added to the list it came back with HELLO.
MCP tools work the same way: if they are not listed in tools, the subagent does not get them. Tested with a GitHub MCP server attached:
tools says | MCP tools the subagent gets |
|---|---|
[Read] | none |
[Read, mcp__github__search_repositories] | only that one, and it can be called as usual |
no tools line | all 26 tools of the GitHub server |
Does skills let the subagent use skills?
No. Write skills: [inner] and the engine pastes inner's body into the subagent's conversation at the start, but no Skill appears on the tool list:
system prompt: You are a test agent. ...
the full SKILL.md of inner ← pasted in by the engine
tool list: Read ← no Skill
In other words, the subagent knows what kind of skill inner is and what it does, but has no Skill ability. In the test it reported having only Read, yet could say what inner asks for.
For a subagent to call skills, put Skill in tools, or leave the line out. Yet the SDK's type file sdk.d.ts marks tools: [..., Skill] as deprecated and tells you to write skills instead of declaring it in tools; read that literally, and the subagent can no longer call skills. I find this part pretty misleading.
The SDK's query()
The SDK has no definition file; the same fields go straight into query():
query({ prompt: "...", options: { tools: ["Read", "Bash"], skills: ["inner"] } })But tools and skills mean something different there:
| Field | agent definition | query() |
|---|---|---|
tools | replaces the whole list; MCP tools not declared there are left out | replaces built-in tools only, MCP tools stay |
skills | the subagent gets these skills' full text at the start, which has nothing to do with whether it can call Skill | pastes nothing, Skill can call only the listed ones |
disallowedTools | removes the listed tools | same |
allowedTools | no such field | listed tools run without asking the user |
With skills: ["inner"] in query(), asking the model to run outer and then inner gave (SDK 0.3.302):
Skill {"skill":"outer"} → Skill outer is not in this session's skills allowlist
Skill {"skill":"inner"} → Launching skill: inner
hook
A hook is a rule the engine runs automatically at a fixed moment, written in settings.json. For example, every time the model calls Write or Edit, the engine first runs a check script:
{ "hooks": { "PreToolUse": [
{ "matcher": "Write|Edit",
"hooks": [{ "type": "command", "command": "bash ~/hooks/prose-guard.sh" }] }
] } }PreToolUse is the event: it fires before a tool runs and can deny that tool call; the official reference lists 33 events in all. matcher is compared against the tool name. The inner hooks array holds what runs on a match, called the action from here on.
The engine runs the action; the main-line model takes no part. There are five types:
| type | how it is written | what the engine does |
|---|---|---|
command | {"type": "command", "command": "bash check.sh"} | starts a process, hook input on stdin |
prompt | {"type": "prompt", "prompt": "Is this safe? $ARGUMENTS"} | makes one extra call to a small model |
agent | {"type": "agent", "prompt": "... $ARGUMENTS"} | opens a subagent to decide, Haiku unless model is set |
mcp_tool | {"type": "mcp_tool", "server": "marker", "tool": "record"} | sends tools/call straight to that MCP server |
http | not tested | POSTs the hook input to a URL |
$ARGUMENTS is replaced with the hook input. Of the five, only mcp_tool actually calls a tool (marker is a test MCP server I set up); none of the other four leaves a tool_use in the log.
A matcher made only of letters, digits, _, -, spaces, , and | is an exact match; any other character makes it a regex:
| matcher | how it compares | which tools match |
|---|---|---|
Bash | exact | Bash only |
Write|Edit | exact | Write, Edit |
Write, Edit | exact | Write, Edit |
mcp__jira | exact | none |
mcp__jira__.* | regex | every tool on the jira server |
empty or * | everything | every tool |
If the regex is broken, say Bash( with no closing parenthesis, the engine does not stop; it only prints Invalid regex pattern in hook matcher and counts it as no match. Opening a subagent is a call to Agent, so to do something before every subagent launch, put Agent in the matcher.
The sixteen combinations
Each row below is one "who calls whom". The middle column says who actually issues the call, and after the arrow is the field that makes it work. The agent rows mean the subagent a .claude/agents/<name>.md opens.
The callers fall into four groups:
- model (4 rows): the call happens only when the model writes a
tool_use. If a subagent'stools:includesAgent, the model hasAgentavailable, but the model decides whether to call it. The "skill calls tool" row also counts as model, because the model sends Bash on the skill body's instruction;allowed-tools: Readdoes not stop it - engine (5 rows): the field names what to call, and the engine does it without the model. If a skill sets
context: fork, the engine opens a subagent to run it; if a hook has anmcp_toolaction, the engine calls that MCP tool itself.hooks:appears in two rows, "subagent calls hook" and "skill calls hook", so four fields map to five rows - indirect (3 rows): no field at all; the request goes into the conversation as text, and the model may or may not act on it
- not possible (4 rows): a tool has no config of its own, so there is nowhere to say what it should call
| Caller and target | Issued by | What came back |
|---|---|---|
| subagent calls subagent | model ← tools: including Agent | the second-level tool_use carries the first level's parent_tool_use_id |
| subagent calls hook | engine ← frontmatter hooks: | returns BLOCKED-BY-AGENT-HOOK (the folder must be trusted) |
| subagent calls tool | model ← tools: | leave it out and it matches the parent; tools: [Read] leaves it with Read only |
| subagent calls skill | model ← tools: including Skill, or no tools: line | a tool_use named Skill, whose parent_tool_use_id points at the first level |
| hook calls subagent | engine ← an agent action | Agent hook condition was not met: AGENT-HOOK-BLOCKED |
| hook calls hook | indirect model ← no field | the model inside an agent action calls a tool, which fires it again |
| hook calls tool | engine ← an mcp_tool action | the MCP server receives the tools/call, with no tool_use in the log |
| hook calls skill | indirect model ← no field | additionalContext asks the model to call it, ends at GREET-RAN |
| skill calls subagent | engine ← context: fork | Skill "forktest" completed (forked execution). |
| skill calls hook | engine ← frontmatter hooks: | returns BLOCKED-BY-SKILL-HOOK |
| skill calls tool | model ← allowed-tools does not restrict | allowed-tools: Read is declared and the body still gets Bash |
| skill calls skill | indirect model ← no field | the body asks the model to call it, returns INNER-RAN |
| tool calls subagent | not possible | no such path in the engine |
| tool calls skill | not possible | no such path in the engine |
| tool calls tool | not possible | no such path in the engine |
| tool calls hook | not possible | the engine calls the hook around the tool; the tool takes no part |
How deep can subagents nest?
A subagent can open another subagent, up to a ceiling. Levels count like this:
main line (level 0)
└─ subagent A (level 1)
└─ subagent B (level 2)
The default ceiling inside the binary is 3, but the engine also reads a remote flag that overrides it, so the actual ceiling is not necessarily 3. To set it yourself, pass an environment variable at startup:
CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH=2 claude -p "..."Set it to 2 and the main line opens A (depth 1) and A opens B (depth 2), both fine. When B goes to open a third level, its tool list has no Agent. Set it to 1 and it is A's list that is missing it.
B's own definition does list Agent:
---
name: nb
description: nesting test agent level B
tools: [Read, Bash, Agent]
---But B is already at level 2, the ceiling, so the tool list the engine hands to it has no Agent, and the Agent in the definition file has no effect.
Can a tool call anything itself?
tool calls subagent, tool calls skill, tool calls tool: none of these three holds. When a tool is called it does one thing with its arguments and calls nothing else.
Agent is no exception. The subagent it opens goes on to call tools, skills and further subagents, but those calls are all issued by the model inside the subagent; Agent only opens the subagent.
An MCP server you write yourself can, on receiving tools/call, start a separate claude process in its own code, and that process can open subagents and run skills. But that is a different session, so it does not count as a tool in this session calling anything.
tool calls hook does not hold either. The hook does run, but it runs before Bash starts, the engine calls it, and Bash takes no part from start to finish:
model writes a tool_use, name=Bash, command=echo MAIN
→ engine checks the hook settings in settings.json
→ the matcher hits, the engine runs that action and waits for it to finish
→ deny → it stops here, the shell never receives anything
→ no deny → only now does the engine hand echo MAIN to a shell
When the hook runs, Bash has not even started, so Bash cannot be what triggered it. PostToolUse also waits for Bash to finish, and then the engine runs the hook.
Can a hook fire another hook?
None of the five action types takes another hook as its value, so there is nowhere in the settings to write "and then fire that hook".
But a hook fires when something calls a tool. What if the subagent a hook opens calls a tool itself: does the hook fire again?
The setup is one PreToolUse rule, matcher Bash, with two actions:
command: writes the command about to run intoran.txt, to count how many times the rule firesagent: opens a subagent and tells it to runecho FROMHOOKAGENTitself
As settings:
{ "hooks": { "PreToolUse": [
{ "matcher": "Bash", "hooks": [
{ "type": "command", "command": "jq -r '\"PreToolUse | \" + .tool_input.command' >> ran.txt" },
{ "type": "agent", "prompt": "Use the Bash tool to run exactly: echo FROMHOOKAGENT . Then return ok=true. $ARGUMENTS" }
] }
] } }The command receives the hook input on stdin, pulls out tool_input.command (the command about to run) with jq, and appends it to ran.txt.
The main line calls Bash exactly once, running echo MAIN. Afterwards ran.txt has two lines:
PreToolUse | echo MAIN
PreToolUse | echo FROMHOOKAGENT
The first line is the main line's call. The second is from the subagent the hook opened: when it called Bash, the engine checked the same settings.json, and matcher: "Bash" matched a second time.
The control changes one thing: the agent prompt becomes "reply with the single word OK, no commands needed", and ran.txt has only one line.
The two runs differ only in whether the model calls a tool. So for a hook to fire another hook, the subagent the hook opens has to call a tool itself.
Why does it stop at two lines instead of firing forever?
Because on the second match, the engine ran only the command action and did not run the agent action again. In the --debug-file log, Processing agent hook appears only once. When the subagent the hook opened calls Bash, its agent_id is hook-agent-…; at that point the command hook fires as usual and the agent hook does not. Two runs of the same experiment gave the same result, and the official docs do not mention this.
Calls the engine makes itself do not fire hooks. An mcp_tool action has the engine call the MCP server directly; the server does receive it, but that tools/call never comes back round to PreToolUse.
Can a hook call a skill?
None of the five action types takes a skill name either. What is left is having the hook give the model a sentence asking it to make the call.
A hook script's output can include additionalContext, and the engine puts that text into the model's conversation. The script below is attached to UserPromptSubmit: every time the user sends a message, it tells the model to run the greet skill first:
#!/bin/bash
echo '{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"You must now invoke the skill named greet using the Skill tool before answering."}}'Registered in settings.json it looks like this. UserPromptSubmit does not compare tool names, so there is no matcher:
{ "hooks": { "UserPromptSubmit": [
{ "hooks": [{ "type": "command", "command": "bash greet-hook.sh" }] }
] } }The greet skill's body only tells the model to reply GREET-RAN. Type Say hello. and the next step in the log is a tool_use named Skill, skill greet, ending at GREET-RAN.
This route is not guaranteed. The hook only puts a sentence into the conversation; the model can follow it and call Skill, or ignore it.
Which fields a skill wires through
SKILL.md frontmatter has three fields that wire to something else:
---
name: example
description: Example skill.
allowed-tools: Read # tool
context: fork # subagent
agent: probe # goes with context: fork, optional
hooks: # hook
PreToolUse:
- matcher: Bash
hooks:
- type: command
command: bash deny.sh
---Write context: fork or hooks and the engine acts on it. allowed-tools, on the other hand, restricts no tool.
Why does Execute skill: come back when the skill never ran?
When the frontmatter has allowed-tools, disallowed-tools or hooks, the engine wants the user's approval before the Skill call goes through. Every experiment here runs under -p, where nobody can approve, so the engine refuses. Five skills, each with one extra field, invoked once each under -p:
| frontmatter adds | tool_result |
|---|---|
| (nothing) | Launching skill: inner |
context: fork | Skill "forktest" completed (forked execution). |
allowed-tools: Read | Execute skill: tooltest |
disallowed-tools: Bash | Execute skill: dtest |
hooks: PreToolUse … | Execute skill: hooktest |
Execute skill: <name> is a refusal; the body of SKILL.md never loaded.
Run the same tooltest in other modes and it is clear this step is waiting for approval:
- manual mode (asks the user every time): a dialog, "Use skill "tooltest"?"
- auto mode (a classifier decides): lets it through
-p: nobody can answer, so it is refused
That is why the three experiments below also pass --dangerously-skip-permissions (allow every permission); without it the body never loads.
So which fields require approval? The binary holds a hardcoded list of fields that need no prompt (57 entries as of 2.1.302; strings finds it as new Set(["type","progressMessage"…). hooks, allowed-tools and disallowed-tools are not on it:
| frontmatter | asks first? |
|---|---|
allowed-tools: Read | yes |
allowed-tools: [] | no, empty counts as not written |
context: fork | no, it is on the list |
foo: bar (a key the engine does not know) | no |
skill wires to a tool
allowed-tools: Read looks like "Read only". restricttest declares exactly that, and its body deliberately asks for Bash:
---
name: restricttest
description: Test skill. Use when the user says restricttest.
allowed-tools: Read
---
Call the Bash tool with command: echo HELLO
Then state in one line whether the Bash call succeeded or was refused.The binary describes the field as Tools available to the model while this file is active too. Typing restricttest each time and changing only the frontmatter:
| What the frontmatter declares | Does Bash run |
|---|---|
| no such line | it runs, tool_result comes back HELLO |
allowed-tools: Read | it runs, tool_result comes back HELLO |
disallowed-tools: Bash | Permission to use Bash has been denied. |
allowed-tools only decides which tools run without asking the user; unlisted ones can still be called. disallowed-tools is what refuses, though Bash stays on the tool list and the engine refuses the call when it arrives. The restriction also lasts only for the turn the skill was called in: reopen the same conversation with --resume, send "run echo AFTER", and Bash comes back with AFTER.
skill wires to a subagent
A skill's body runs in the main line by default. Add context: fork and the engine opens a subagent to run it, handing only the result back to the main line:
---
name: forktest
description: Test skill. Use when the user says forktest.
+ context: fork
---
Reply with exactly one word: FORKEDCalled once each, - without it, + with it:
- Launching skill: forktest
+ Skill "forktest" completed (forked execution).
+ Result:
+ FORKEDagent picks which agent type runs it: the name from a .claude/agents/<name>.md, or a built-in type such as Explore. A name that does not exist raises nothing; the engine forks with general-purpose.
skill wires to a hook
hooktest attaches a script that always denies Bash, and its body asks Bash to run echo HELLO:
---
name: hooktest
description: Test skill. Use when the user says hooktest.
hooks:
PreToolUse:
- matcher: Bash
hooks:
- type: command
command: bash .claude/skills/hooktest/deny.sh
---
Call the Bash tool with command: echo HELLO
Then report in one line exactly what happened.One line of JSON in deny.sh is enough:
#!/bin/bash
echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"BLOCKED-BY-SKILL-HOOK"}}'On deny the engine never hands echo HELLO to the shell, and the model receives PreToolUse:Bash hook error: BLOCKED-BY-SKILL-HOOK.
Does the deny still apply after the skill finishes? Three messages go to one process (claude -p --input-format stream-json, each sent after the previous result), the first and last being plain commands unrelated to the skill:
| Input | What came back | Where things stand |
|---|---|---|
echo BEFORE | BEFORE | the skill has not been invoked yet |
hooktest | PreToolUse:Bash hook error: BLOCKED-BY-SKILL-HOOK | what got blocked is the body's echo HELLO |
echo AFTER | PreToolUse:Bash hook error: BLOCKED-BY-SKILL-HOOK | the skill has already finished |
Once a skill has run, its hooks: stay on the process, and Bash after the skill is still refused. They go away only when the process exits; reopening the same session with --resume is already a new process, and echo AFTER comes back as AFTER.
Put the same rule in the hooks: of an agent definition at .claude/agents/probe.md and it applies only inside the subagent: probe running echo SUB gets BLOCKED-BY-AGENT-HOOK, and the main line's echo AFTER runs as usual. In an untrusted folder, probe.md's hooks: are dropped wholesale while SKILL.md's still register.
skill wires to another skill
No frontmatter field takes another skill's name, so the call has to go in the body:
---
name: outer
description: Outer test skill. Use when the user says outer.
---
Invoke the skill named inner using the Skill tool. Then reply with whatever it returned.inner's body only tells the model to reply INNER-RAN. After typing outer, this happens in order:
user: outer
→ the model calls Skill {"skill": "outer"}
→ the engine returns Launching skill: outer and loads outer's body
→ following outer's body, the model calls Skill {"skill": "inner"}
→ the engine returns Launching skill: inner and loads inner's body
→ following inner's body, the model replies INNER-RAN
Both calls come from the model. If the model does not follow outer's body, inner never runs.
Silent failures
With each of the three settings below, a mistake raises no error from the engine and no hint in normal output; only the result differs from a correct setup.
The first two are typos:
{ "hooks": { "PreToolUsee": [ { "matcher": "Bash", "hooks": [ ... ] } ] } }context: fork
agent: nosuchagentThe third has nothing misspelled: the folder holding .claude/agents/probe.md has simply never been trusted.
So to catch these, the only way is to list what a correct setup should produce, then check whether it appeared.
| What is wrong | What shows up when correct | What the wrong one shows | Any hint |
|---|---|---|---|
event name PreToolUsee in settings.json, one e too many | --include-hook-events adds hook_started and hook_response | neither appears, Bash runs anyway | nothing in --include-hook-events (hook events in stream-json). Only --debug (debug log on stderr) prints Unknown hook event "PreToolUsee" was ignored (settings file loaded through --settings) |
agent: nosuchagent on a skill | agent: mycustom returns CUSTOM: FORKED | returns FORKED, forking with general-purpose | none |
| folder not trusted | deny.sh gets its own hook_started and hook_response, echo SUB returns BLOCKED-BY-AGENT-HOOK | no events at all, echo SUB returns SUB; the hooks: in the agent's frontmatter are dropped wholesale | -p never shows the trust dialog. Only --debug prints Skipping frontmatter hooks for agent '…': the folder its definition file came from is not trusted |
Trust is asked once per folder: the first interactive session started there puts up a dialog asking whether to trust it, and the answer is stored as hasTrustDialogAccepted in ~/.claude.json. Under -p that dialog never appears.
caveat
The CLI and the SDK execute the same binary; shasum matches. The SDK can also take agents, mcpServers and hook callbacks straight as options; tools and skills behave differently in query(), see the query() table above.
Every measurement in this article ran with -p, and -p changes two results:
tooltestreturnsExecute skill: tooltest, because under-pnobody answers the approval promptforktest's result comes straight back to the main line, because-pdoes not push the subagent into the background
The same two skills, rerun in the other two modes. How each mode starts:
claude --permission-mode manual # manual mode
claude # default auto mode
claude -p "tooltest" # -pResults:
| Mode | A skill with allowed-tools | context: fork |
|---|---|---|
--permission-mode manual | a dialog, Use skill "tooltest"? | the subagent runs in the background, the main line does not wait for it, and FORKED arrives later; add background: false to the frontmatter and the main line waits |
| default auto mode | asks nothing, logs Allowed by auto mode classifier | same |
-p | nobody to ask, comes back as Execute skill: tooltest | no background: the subagent finishes and FORKED comes straight back to the main line |