Skill、Agent、Tool、Hook:誰呼叫得動誰
Claude Code 可以自己設定的部分就四種,skill、agent、tool、hook。四種各自寫在不同檔案,欄位填對就能互相帶起來。兩兩配對十六種組合,下面每一種都跑過。
環境:
- CLI 2.1.252
- Agent SDK 0.3.252
四個元件
Tool
Tool 就是模型能呼叫的函式,一個名字加一份參數 schema,沒別的。
名字分兩種。
內建的短,Read、Write、Edit、Bash、Grep、Glob、WebFetch、WebSearch 都是。
MCP server 給的長,長成 mcp__<server>__<tool>,例如 mcp__claude_ai_Jira__getJiraIssue。
這個差別只有在寫 hook matcher 的時候會有感。要管 Bash 寫 Bash 就好,要涵蓋一整台 MCP server 得寫成 mcp__claude_ai_Jira__.*,少了後面那段等於沒設。
我拿我的本機工具頻率來看大致上是這樣,87 份 session 逐字檔、12017 次呼叫。
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 一支就佔一半。Grep 跟 Glob 沒在上面,是因為我習慣用 bash,一條指令就能把 find、grep、head 串起來,換成內建 tool 要分三次呼叫。
Agent 跟 Skill 也在名單上。開一個 subagent 就是模型呼叫 Agent 這個 tool,跑一份 skill 就是呼叫 Skill。跟呼叫 Bash 同一回事,只是效果大一點。
好處是 matcher 寫 Agent 的 PreToolUse hook,每次要開 subagent 之前都會先跑,想否決也可以。
Skill
Skill 就是一份 SKILL.md。上面 frontmatter,下面寫給模型看的步驟。
---
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 決定模型什麼時候會想用它,內文是載入之後照著跑的程序。Skill 自己不動,動的是讀它的模型。
Agent
Agent 定義開出來的 subagent 是一段乾淨的 context,接任務、自己做完、回結論給主線,中間過程不會進主線。
CLI 讀 .claude/agents/<name>.md,SDK 走 query() 的 agents 選項,欄位一樣。
---
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.tools 不寫就繼承 parent agent 的全部。寫了就只剩清單上那些,而且真的限制得住。定義成 tools: ["Read"] 的 subagent 叫它跑 echo HELLO,它回說 Bash 不在它的工具集裡,連選都選不到。
Hook
Hook 是 settings.json 裡的一條規則,內容就是哪個事件、matcher 比對什麼、然後跑什麼。
{
"hooks": {
"PreToolUse": [{
"matcher": "Write|Edit",
"hooks": [{ "type": "command", "command": "bash ~/hooks/prose-guard.sh" }]
}]
}
}官方文件定義了 33 種事件。
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
我常用到的是這五個。
PreToolUse在 tool 執行之前跑,可以否決那次呼叫PostToolUse在 tool 成功之後跑UserPromptSubmit在訊息送出之後、模型讀到之前跑SessionStart開場跑一次Stop一輪結束跑
Matcher 是正則,比對 tool 名字。留空或寫 * 就是全部命中。正則寫壞不會讓 session 停,只會 print 出一句 Invalid regex pattern in hook matcher 然後當作沒命中,所以寫壞了不太容易發現。
命中之後跑什麼分五種。
| type | 跑什麼 |
|---|---|
command | 一段 shell 指令,hook 的輸入從 stdin 進去 |
prompt | 一個小模型判斷條件成不成立 |
agent | 一個 agent 做判斷,model 可以指定,不寫是 Haiku |
mcp_tool | 呼叫某台已經設定好的 MCP server 上的 tool |
http | 把 hook 輸入 POST 到一個 URL |
後三種只在 tool 相關的事件上可用,也就是 PreToolUse、PostToolUse、PermissionRequest。
方向
Tool 永遠是被呼叫的那一端,發動的是模型、subagent、或 engine。Agent 跟 Skill 這兩個也一樣,模型叫它們,它們不叫別人。
唯一 tool 跑起來會帶起另一段執行的情況,發生在 Claude Code 外面。自己寫的 MCP server 收到 tools/call 之後,那段程式碼想做什麼都行,開一個新 session 也行。實測一支 delegate tool 在實作裡跑 claude -p,回傳 delegated-agent said: DELEGATED-OK。發動的是那支 node,不是 harness 的機制,所以 CLI 還 SDK 沒差。內建 tool 沒這種空間,Bash 跑完就結束。
「tool 帶 hook」這個說法方向也反了。實際跑起來是這樣。
settings.json 裡有一條 PreToolUse,matcher 寫 Bash。模型要跑 Bash 的時候,engine 先去翻這張表,翻到就執行那支 script,看它放不放行,才輪到 Bash 真的執行。
Bash 這個 tool 的定義裡沒有那支 script 的名字。把那條規則從 settings.json 刪掉,Bash 一模一樣照跑。所以掛上去的是 hook,被掛的位置是「Bash 執行前」這個時機。
十六種組合
下面每種組合都在自己的目錄跑過,字串是 tool_result 真的回傳的內容。Tool 那兩列合併寫,理由上一節講過了。
| 誰呼叫誰 | CLI 2.1.252 | SDK 0.3.252 |
|---|---|---|
| SubAgent 呼叫 SubAgent | 通,嵌一層回 NESTED-OK | 通,一樣 |
| SubAgent 呼叫 Hook | frontmatter 的 hooks: 沒有效果 | 沒有效果 |
| SubAgent 呼叫 Tool | 通,預設繼承全部 | 通,tools:["Read"] 真的限制住 |
| SubAgent 呼叫 Skill | 通,回 MAGENTA-PELICAN-42 | 通,同一個 token |
| Hook 呼叫 SubAgent | 通,Agent hook condition was not met: AGENT-HOOK-BLOCKED | 通,同一句 |
| Hook 呼叫 Hook | 沒有這種機制 | 未測 |
| Hook 呼叫 Tool | 通,server 記到 note="echo MAIN" | 通,一樣 |
| Hook 呼叫 Skill | 繞一圈才通,最後回 GREET-RAN | 一樣 |
| Skill 呼叫 SubAgent | 通,Skill "forktest" completed (forked execution). | 通,同一句 |
| Skill 呼叫 Hook | 通,BLOCKED-BY-SKILL-HOOK | 通,同一句 |
| Skill 呼叫 Tool | 通,但 allowed-tools 不是限制 | 一樣 |
| Skill 呼叫 Skill | 通,回 INNER-RAN | 通,一樣 |
| Tool 呼叫 SubAgent / Skill / Tool | harness 沒有這條路;MCP server 的實作碼可以,實測 delegated-agent said: DELEGATED-OK | 一樣,跟 runtime 無關 |
| Tool 呼叫 Hook | 方向相反,是 engine 在 tool 前後查 hook 表 | 一樣 |
Subagent 的巢狀不是不能,是有上限。給上限的那個函式裡寫著 var o=3,預設三層。CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH 或一個 feature flag 可以覆寫。超過會看到 Subagent nesting limit reached (depth N)。
Hook 想叫 skill 沒有欄位可以寫,只能繞。Hook 回一段 additionalContext 塞進模型的 context,模型讀到之後自己去叫 Skill tool。
{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit",
"additionalContext":"You must now invoke the skill named greet using the Skill tool before answering."}}輸入 Say hello.,log 下一步就是 TOOL_USE:Skill {"skill": "greet"},然後 GREET-RAN。這條路要模型配合,所以沒有 mcp_tool 那種保證。mcp_tool 是 engine 直接跑,不經過模型。
Skill 串接另外三種的欄位
串接 tool
---
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 看起來像白名單,寫了就只准用 Read。實際上不是。
這份 skill 的清單只有 Read,內文卻要求執行 Bash,CLI 跟 SDK 兩邊都照跑。
assistant | TOOL_USE:Bash {"command":"echo HELLO"}
user | TOOL_RESULT:"HELLO"
engine 把這個欄位轉成一層 kind 是 allowed_tools 的 context layer,位置在權限系統,效果是清單上那幾個免問就過。要真的限制可用範圍,只能靠 subagent 的 tools。
SDK 同名的選項語意一致,Agent SDK 的 TypeScript reference 這樣寫。
Tools to auto-approve without prompting. This does not restrict Claude to only these tools. […] Use
disallowedToolsto block tools.
串接 subagent
---
name: forktest
description: Test skill. Use when the user says forktest.
context: fork
agent: Explore
---
Reply with exactly one word: FORKEDSkill 內文直接變成那個 subagent 的任務描述,只有結果回主線。
Skill "forktest" completed (forked execution).
Result:
FORKED
(forked execution) 就是分辨的方法,一般 inline 跑完回的是 Launching skill: <name>。
串接 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.deny.sh 印一行 JSON 就夠。
#!/bin/bash
echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"BLOCKED-BY-SKILL-HOOK"}}'呼叫這份 skill 之後,它內部的 echo HELLO 回傳 BLOCKED-BY-SKILL-HOOK,指令沒跑到。
這條規則只在那份 skill 身上。同一個 session 裡,skill 外面跑 echo HELLO 照樣輸出 HELLO。
Agent 定義也吃 hooks: 這個欄位,寫進去不會報錯,但沒有作用。
我把上面那條 deny 規則原封不動搬到 .claude/agents/probe.md,叫 probe 跑 echo SUB,它回傳 SUB,指令正常執行。改用 --agent probe 讓 probe 當主線,結果一樣。deny.sh 裡我另外加了一行寫 marker 檔案,跑完 marker 是空的,代表那支 script 從頭到尾沒被執行過。CLI 跟 SDK 兩邊都這樣。
怎麼測的
每種組合開一個獨立目錄跑,免得設定互相汙染。
CLI:
claude -p "Invoke the hooktest skill now." \
--output-format stream-json --verbose輸出一行一個 JSON 事件,挑 tool_use 跟 tool_result 兩種 block 出來看就知道模型叫了什麼又拿回什麼。
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"] 不能少,SDK 要靠它才會讀工作目錄的 .claude/settings.json,不寫的話專案裡的 hook 根本沒被註冊。
Hook 有沒有真的執行,光看訊息串流看不出來,因為 hook 不會出現在裡面。這種就讓 hook 順便寫一個 marker 檔。
#!/bin/bash
date +%s >> marker.txt跑完 cat 一下,空的就是沒執行過。Agent frontmatter 的 hooks: 就是這樣判定沒生效的。
測 mcp_tool 要有一台 server。手寫一支五十行的 stdio server 就夠,實作 initialize、tools/list、tools/call 三個方法,tools/call 把參數附加到檔案。Hook 這樣設。
{"type":"mcp_tool","server":"marker","tool":"record",
"input":{"note":"${tool_input.command}"}}跑一次 echo MAIN,檔案裡出現 CALLED note="echo MAIN",順便證明 ${tool_input.command} 會被代換成真正的指令。
用 SDK 跑會不一樣嗎
不會。上面每一種組合,換成 SDK 驅動,回傳的字串一模一樣。
原因在打包方式。npm registry 上的 @anthropic-ai/claude-agent-sdk 最新版是 0.3.252,解開 tarball 裡面沒有 cli.js,只有 sdk.mjs、bridge.mjs 跟 extractFromBunfs.js,加上 optionalDependencies 那八個平台專屬套件。SDK 驅動的就是 CLI 那顆原生 binary,frontmatter 的解析、hook 的派送、權限層的疊加全是同一份 code。版本號也同步走,CLI 2.1.252 對 SDK 0.3.252。
兩邊真的有差的地方在設定怎麼進去。CLI 讀 .claude/agents/*.md 跟分層的 settings.json;SDK 是把 agents、mcpServers、hook callback 當參數傳,而且不指定 settingSources 的話它不讀檔案系統上的設定,細節在 what settingSources does not control。
版本
上面全部是 2.1.252 跟 0.3.252 跑出來的。有幾個欄位很新,mcp_tool hook 是一個,巢狀上限還受 feature flag 影響,換一個版本結果可能就不一樣。