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

claude-codeagentskillhookmcp

There are four things you can configure in Claude Code: skills, agents, tools, hooks. Each lives in its own file, and with the right field one can start another. That gives sixteen combinations, and every one of them was run.

Environment:

  • CLI 2.1.252
  • Agent SDK 0.3.252

The four

Tool

A tool is a function the model can call. A name and a parameter schema, nothing else.

Names come in two shapes.

Built-ins are short: Read, Write, Edit, Bash, Grep, Glob, WebFetch, WebSearch.

MCP servers give you mcp__<server>__<tool>, like mcp__claude_ai_Jira__getJiraIssue.

The difference only shows up when writing a hook matcher. To catch Bash, write Bash. To catch a whole MCP server, write mcp__claude_ai_Jira__.*, because without the trailing part the matcher hits nothing.

Here is my own local tool frequency, roughly. 87 session transcripts, 12,017 calls.

  6388  Bash
  3384  Edit
  1129  Read
   585  Write
   181  WebSearch
   165  WebFetch
    43  Skill
    36  ToolSearch
    31  Agent
    27  AskUserQuestion
    25  Artifact
    12  mcp__claude-in-chrome__tabs_context_mcp

Bash alone is half of it. Grep and Glob are missing because I habitually use bash; one command chains find, grep, and head, where the built-in tools would be three separate calls.

Agent and Skill are on the same list. Starting a subagent is the model calling a tool named Agent. Running a skill is the model calling one named Skill. Same shape as calling Bash, just with a bigger effect.

Which gives you one useful place to intervene. A PreToolUse hook with matcher Agent runs before every subagent launch, and it can deny the launch.

Skill

A skill is a SKILL.md. Frontmatter on top, instructions for the model below.

---
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.

description decides when the model reaches for it. The body is what it follows once loaded. The skill itself does nothing. The model reading it does the work.

Agent

An agent definition gives you a subagent, a clean context that takes a task, finishes it, and hands a summary back. The steps in between never reach the main thread.

The CLI reads .claude/agents/<name>.md. The SDK takes the same fields through the agents option of query().

---
name: probe
description: Test agent for preloaded skills and tool limits.
skills: [secretskill]
tools: [Read, Bash]
model: haiku
---
 
You are a test agent. Answer exactly what is asked.

Leave tools out and the subagent inherits everything from its parent agent. List them and it gets only those, and that limit holds. A subagent defined with tools: ["Read"], told to run echo HELLO, replies that Bash is not in its tool set and there was nothing to call.

Hook

A hook is one rule in settings.json: which event, what the matcher matches, what runs.

{
  "hooks": {
    "PreToolUse": [{
      "matcher": "Write|Edit",
      "hooks": [{ "type": "command", "command": "bash ~/hooks/prose-guard.sh" }]
    }]
  }
}

The official reference defines 33 events:

PreToolUse         PostToolUse        PostToolUseFailure  PostToolBatch
Notification       UserPromptSubmit   UserPromptExpansion SessionStart
SessionEnd         Stop               StopFailure         SubagentStart
SubagentStop       PreCompact         PostCompact         PreModelSwitch
PostModelSwitch    PermissionRequest  PermissionDenied    Setup
TeammateIdle       TaskCreated        TaskCompleted       Elicitation
ElicitationResult  ConfigChange       WorktreeCreate      WorktreeRemove
InstructionsLoaded CwdChanged         FileChanged         DirectoryAdded
MessageDisplay

These five are the ones I reach for.

  • PreToolUse runs before a tool and can deny the call
  • PostToolUse runs after a tool succeeds
  • UserPromptSubmit runs after the prompt is submitted and before the model reads it
  • SessionStart runs once at the start
  • Stop runs when a turn ends

The matcher is a regex against the tool name. Empty or * matches everything on that event. A broken regex will not stop the session; it prints Invalid regex pattern in hook matcher, counts as no match, and otherwise stays quiet, so a broken one is easy to miss.

Five kinds of action can run:

typewhat runs
commanda shell command, hook input on stdin
prompta small model deciding whether a condition holds
agentan agent deciding, model selectable, Haiku if you leave it out
mcp_toola tool on an MCP server you already configured
httpa POST of the hook input to a URL

The last three only work on the tool events: PreToolUse, PostToolUse, PermissionRequest.

Direction

A tool is always the one being called. The callers are the model, a subagent, or the engine. Agent and Skill are no exception. The model calls them; they call nobody.

There is one case where running a tool starts something else, and it happens outside Claude Code. An MCP server you wrote yourself gets tools/call, and from there your code can do anything, including opening a fresh session. A delegate tool that shells out to claude -p returns delegated-agent said: DELEGATED-OK. Your node process is the caller, not any harness mechanism, so CLI and SDK make no difference here. Built-in tools have no such room. Bash finishes and that is the end of it.

"A tool calling a hook" points the wrong way too. Here is what actually runs.

settings.json holds a PreToolUse entry whose matcher is Bash. When the model wants to run Bash, the engine checks that table first, runs the matching script, waits to see whether it allows the call, and only then does Bash execute.

Nothing in the Bash tool's own definition names that script. Delete the entry from settings.json and Bash behaves exactly the same. The hook is the thing being attached, and the place it attaches to is the moment before Bash runs.

The sixteen combinations

Every combination ran in its own directory. The strings are what tool_result actually came back with. The two Tool rows are written together, for the reason above.

Caller and targetCLI 2.1.252SDK 0.3.252
SubAgent calls SubAgentworks, one level down returns NESTED-OKsame
SubAgent calls Hookfrontmatter hooks: has no effectno effect
SubAgent calls Toolworks, inherits everything by defaultworks, tools:["Read"] really does limit it
SubAgent calls Skillworks, returns MAGENTA-PELICAN-42same token
Hook calls SubAgentworks, Agent hook condition was not met: AGENT-HOOK-BLOCKEDsame string
Hook calls Hookno such mechanismnot tested
Hook calls Toolworks, server records note="echo MAIN"same
Hook calls Skillworks the long way round, ends at GREET-RANsame
Skill calls SubAgentworks, Skill "forktest" completed (forked execution).same string
Skill calls Hookworks, BLOCKED-BY-SKILL-HOOKsame string
Skill calls Toolworks, but allowed-tools is not a restrictionsame
Skill calls Skillworks, returns INNER-RANsame
Tool calls SubAgent / Skill / Toolno such path in the harness; your MCP server's own code can, returning delegated-agent said: DELEGATED-OKsame, has nothing to do with the runtime
Tool calls Hookbackwards: the engine consults the hook table around the callsame

Subagent nesting is capped, not forbidden. The function that supplies the cap holds var o=3, so three levels by default. CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH or a feature flag overrides it. Go past it and you get Subagent nesting limit reached (depth N).

A hook wanting to run a skill has no field for it, so it goes the long way. The hook returns additionalContext, that text lands in the model's context, and the model calls the Skill tool itself.

{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit",
 "additionalContext":"You must now invoke the skill named greet using the Skill tool before answering."}}

Type Say hello. and the next line of the log is TOOL_USE:Skill {"skill": "greet"}, then GREET-RAN. This route needs the model to play along, so it gives you nothing like the guarantee of an mcp_tool hook. That one the engine runs directly, without the model in the path.

The fields that wire a skill to the other three

To tools

---
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.

allowed-tools: Read looks like an allowlist, as though writing it leaves only Read available. It is not.

This skill lists only Read, its body asks for Bash, and both CLI and SDK run it anyway:

assistant | TOOL_USE:Bash {"command":"echo HELLO"}
user      | TOOL_RESULT:"HELLO"

The engine turns the field into a context layer of kind allowed_tools, which sits in the permission system and marks the listed tools as pre-approved. If you want to limit what is reachable, a subagent's tools is the only field that does it.

The SDK option of the same name means the same thing, per the TypeScript Agent SDK reference:

Tools to auto-approve without prompting. This does not restrict Claude to only these tools. […] Use disallowedTools to block tools.

To a subagent

---
name: forktest
description: Test skill. Use when the user says forktest.
context: fork
agent: Explore
---
 
Reply with exactly one word: FORKED

The skill body becomes the task prompt of a subagent of that type, and only the result comes back:

Skill "forktest" completed (forked execution).

Result:
FORKED

(forked execution) is how you tell them apart. An ordinary inline run returns Launching skill: <name>.

To a hook

---
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"}}'

Invoke that skill and its echo HELLO comes back as BLOCKED-BY-SKILL-HOOK, with the command never running.

The rule belongs to that skill alone. In the same session, echo HELLO outside the skill still prints HELLO.

Agent definitions take a hooks: field too. It loads without complaint and does nothing.

Copying that same deny rule into .claude/agents/probe.md and asking probe to run echo SUB returns SUB, the command running normally. Making probe the main agent with --agent probe gives the same result. Adding a line to deny.sh that writes a marker file leaves the marker empty afterwards, so the script never ran at all. CLI and SDK both behave this way.

How this was checked

One directory per case, so settings do not leak between them.

CLI:

claude -p "Invoke the hooktest skill now." \
       --output-format stream-json --verbose

One JSON event per line. Pull out the tool_use and tool_result blocks and you see what the model called and what it got back.

SDK:

import { query } from "@anthropic-ai/claude-agent-sdk";
const q = query({
  prompt: "Invoke the hooktest skill now.",
  options: { cwd: process.cwd(), settingSources: ["project"],
             skills: "all", permissionMode: "bypassPermissions" }
});
for await (const m of q) console.log(m.type, JSON.stringify(m.message?.content));

settingSources: ["project"] is not optional. It is what makes the SDK read .claude/settings.json from the working directory, and without it the project's hooks are never registered at all.

Whether a hook ran is invisible in the message stream, since hooks never appear there. For those cases, have the hook write a marker file as well:

#!/bin/bash
date +%s >> marker.txt

cat it afterwards. Empty means it never ran, which is how the agent-frontmatter case was settled.

The mcp_tool case needs a server. Fifty lines of stdio server covers it: implement initialize, tools/list, and tools/call, and have tools/call append its arguments to a file. The hook entry:

{"type":"mcp_tool","server":"marker","tool":"record",
 "input":{"note":"${tool_input.command}"}}

Run echo MAIN and the file holds CALLED note="echo MAIN", which also shows that ${tool_input.command} is replaced with the real command.

Does the SDK behave differently

No. Every combination above returns the same strings when the SDK drives it.

The packaging is why. On the npm registry entry, the latest @anthropic-ai/claude-agent-sdk is 0.3.252; unpack the tarball and there is no cli.js in it, only sdk.mjs, bridge.mjs, extractFromBunfs.js, plus the eight platform packages in optionalDependencies. The SDK drives the same native binary the CLI does, so frontmatter parsing, hook dispatch, and permission layering are one implementation. The versions move together: CLI 2.1.252, SDK 0.3.252.

Where the two differ is how configuration gets in. The CLI reads .claude/agents/*.md and layered settings.json files. The SDK takes agents, mcpServers, and hook callbacks as options, and does not touch filesystem settings unless settingSources says so; the boundaries are listed under what settingSources does not control.

Version

All of the above is 2.1.252 and 0.3.252. Some of these fields are new, mcp_tool hooks among them, and the nesting cap sits behind a feature flag, so another version can give another answer.