Skills, Agents, Tools, Hooks: which one can call which

claude-codeagentskillhookmcp

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-tools is not a whitelist. A skill declares allowed-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 in disallowed-tools.
  • A skill's hooks: stay after the skill finishes. As long as this run of claude has 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 PreToolUse with one e too many, PreToolUsee, and the hook never runs; normal output gives no hint, and only --debug prints one error line.

Environment:

  • CLI 2.1.302 (the mcp_tool and prompt hook 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 has

In 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 saysMCP tools the subagent gets
[Read]none
[Read, mcp__github__search_repositories]only that one, and it can be called as usual
no tools lineall 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:

Fieldagent definitionquery()
toolsreplaces the whole list; MCP tools not declared there are left outreplaces built-in tools only, MCP tools stay
skillsthe subagent gets these skills' full text at the start, which has nothing to do with whether it can call Skillpastes nothing, Skill can call only the listed ones
disallowedToolsremoves the listed toolssame
allowedToolsno such fieldlisted 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:

typehow it is writtenwhat 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
httpnot testedPOSTs 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:

matcherhow it compareswhich tools match
BashexactBash only
Write|EditexactWrite, Edit
Write, EditexactWrite, Edit
mcp__jiraexactnone
mcp__jira__.*regexevery tool on the jira server
empty or *everythingevery 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's tools: includes Agent, the model has Agent available, 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: Read does 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 an mcp_tool action, 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 targetIssued byWhat came back
subagent calls subagentmodel ← tools: including Agentthe second-level tool_use carries the first level's parent_tool_use_id
subagent calls hookengine ← frontmatter hooks:returns BLOCKED-BY-AGENT-HOOK (the folder must be trusted)
subagent calls toolmodel ← tools:leave it out and it matches the parent; tools: [Read] leaves it with Read only
subagent calls skillmodel ← tools: including Skill, or no tools: linea tool_use named Skill, whose parent_tool_use_id points at the first level
hook calls subagentengine ← an agent actionAgent hook condition was not met: AGENT-HOOK-BLOCKED
hook calls hookindirect model ← no fieldthe model inside an agent action calls a tool, which fires it again
hook calls toolengine ← an mcp_tool actionthe MCP server receives the tools/call, with no tool_use in the log
hook calls skillindirect model ← no fieldadditionalContext asks the model to call it, ends at GREET-RAN
skill calls subagentengine ← context: forkSkill "forktest" completed (forked execution).
skill calls hookengine ← frontmatter hooks:returns BLOCKED-BY-SKILL-HOOK
skill calls toolmodel ← allowed-tools does not restrictallowed-tools: Read is declared and the body still gets Bash
skill calls skillindirect model ← no fieldthe body asks the model to call it, returns INNER-RAN
tool calls subagentnot possibleno such path in the engine
tool calls skillnot possibleno such path in the engine
tool calls toolnot possibleno such path in the engine
tool calls hooknot possiblethe 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 into ran.txt, to count how many times the rule fires
  • agent: opens a subagent and tells it to run echo FROMHOOKAGENT itself

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 addstool_result
(nothing)Launching skill: inner
context: forkSkill "forktest" completed (forked execution).
allowed-tools: ReadExecute skill: tooltest
disallowed-tools: BashExecute 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:

frontmatterasks first?
allowed-tools: Readyes
allowed-tools: []no, empty counts as not written
context: forkno, 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 declaresDoes Bash run
no such lineit runs, tool_result comes back HELLO
allowed-tools: Readit runs, tool_result comes back HELLO
disallowed-tools: BashPermission 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: FORKED

Called once each, - without it, + with it:

- Launching skill: forktest
+ Skill "forktest" completed (forked execution).
+ Result:
+ FORKED

agent 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:

InputWhat came backWhere things stand
echo BEFOREBEFOREthe skill has not been invoked yet
hooktestPreToolUse:Bash hook error: BLOCKED-BY-SKILL-HOOKwhat got blocked is the body's echo HELLO
echo AFTERPreToolUse:Bash hook error: BLOCKED-BY-SKILL-HOOKthe 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: nosuchagent

The 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 wrongWhat shows up when correctWhat the wrong one showsAny hint
event name PreToolUsee in settings.json, one e too many--include-hook-events adds hook_started and hook_responseneither appears, Bash runs anywaynothing 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 skillagent: mycustom returns CUSTOM: FORKEDreturns FORKED, forking with general-purposenone
folder not trusteddeny.sh gets its own hook_started and hook_response, echo SUB returns BLOCKED-BY-AGENT-HOOKno 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:

  • tooltest returns Execute skill: tooltest, because under -p nobody answers the approval prompt
  • forktest's result comes straight back to the main line, because -p does 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"              # -p

Results:

ModeA skill with allowed-toolscontext: fork
--permission-mode manuala 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 modeasks nothing, logs Allowed by auto mode classifiersame
-pnobody to ask, comes back as Execute skill: tooltestno background: the subagent finishes and FORKED comes straight back to the main line