Skill、Agent、Tool、Hook:誰呼叫誰

claude-codeagentskillhookmcp

單純好奇嵌套是否可行,像俄羅斯娃娃那樣一層包一層,就實驗了一下。Claude Code 自己能設定的是 skill、agent、tool、hook 四種,兩兩配起來十六種,一種一種跑。

  • allowed-tools 只允許 Read,skill 裡面 trigger Bash,Bash 即便不在 allowed-tools 還是可以跑
  • 觸發帶 hooks: 的 skill 後,還在同一個 session 的話,hook 不會隨著 skill 結束消失
  • settings.json 裡 hook 的事件名多打一個 e 這種低級 typo 變成 PreToolUsee,不會報錯也不會觸發。加 --debug 或 --include-hook-events 想檢查也沒有錯誤訊息,只有本來該出現的 hook 事件不見了

環境:

  • CLI 2.1.257,每一次都直接叫 ~/.local/share/claude/versions/2.1.257,不走 claude symlink(它會自己更新)
  • Agent SDK 固定在 0.3.257
  • 每個實驗都跑在 -p(--print)底下:給一句 prompt,跑完把結果印出來就結束,不開互動介面,也不跳任何對話框

model 跟 engine

model 是 Opus、Fable 那種跑在 API 那頭的語言模型。要叫 tool 的時候,它回覆裡會多一塊 tool_use:

{"type": "tool_use", "name": "Bash", "input": {"command": "git status"}}

這段 JSON 是 model 寫出來的文字,git status 到這裡還沒跑。

engine 是你機器上那顆 claude binary,那支 process。它把工具清單交給 model,收到 tool_use 就去執行,執行前後還會去看 settings.json 裡有沒有 hook 要跑。

engine 每另外開一段 context 去做事,那段就叫 subagent。主線自己算一段,所以工具清單不只一份,一段 context 一份。

下面四種元件都是磁碟上的檔案。檔案本身不會做事,是 model 跟 engine 讀了以後才有行為。

四個元件

tool

tool 就是 model 能呼叫的函式,一個名字加一份參數 schema。

清單上有 Bash,沒有 git status。要 model 跑 git status,它發出來的是一次 Bash 呼叫,git status 是傳給 Bash 的參數。一個東西要成為 tool,得先登記進這個 session 的工具清單,沒登記 model 就看不到,也沒辦法呼叫。

清單上的 tool 本身不是 process。Bash 是一個名字加上參數格式,被呼叫的時候那行指令才交給 shell 去跑。

名字分兩種。內建的通常名稱偏短,像是 Read、Write、Edit、Bash。

MCP server 給的格式如下:

  • mcp__<server>__<tool>
  • mcp__claude_ai_Jira__getJiraIssue

大寫的 Agent 跟 Skill 也是 tool,跟 Bash、Read 排在同一份清單裡;小寫的 agent 跟 skill 是磁碟上的 .md 檔案,寫的是你是誰、做什麼。開一個 subagent 就是 model 呼叫 Agent,跑一份 skill 就是呼叫 Skill。SKILL.md 自己不會執行,model 呼叫 Skill 之後才載入。

Agent 也在清單上,所以 hook 的 matcher 可以填 Agent:model 每呼叫一次 Agent,hook 就觸發一次,寫法在下面的 hook 一節。

skill

skill 就是一份 SKILL.md,路徑是 .claude/skills/<名字>/SKILL.md。放在專案目錄底下,只有那個專案有;放在 ~/.claude/skills/ 底下,每個專案都有。

檔案開頭兩行 --- 中間夾的那塊叫 frontmatter,YAML 格式,設定都寫在那裡。第二行 --- 底下是寫給 model 看的步驟。

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

model 看 description 決定什麼時候用它,內文是載入之後照著跑的程序。SKILL.md 就是一份文字,不會自己執行;同一份檔案,換了一個新的 model 或 context,結果也會有所不同。

agent

agent 定義開出來的 subagent 是一段乾淨的 context,接任務、自己做完、回結論給主線,中間過程不會進主線。定義放在哪,兩邊不一樣:

  • CLI 讀 .claude/agents/<name>.md
  • SDK 沒有檔案這一層。跑一個 session 的入口是 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.

subagent 有哪些工具,看 tools 這一行:

  • 不寫,繼承 parent agent 的清單,parent 有什麼它就有什麼
  • 寫了,只能用宣告的那幾個
  • 想讓它自己再開下一層,清單裡要有 Agent(寫 Task 也通,engine 內部是同一個)

tools: 寫了有沒有真的限制,看 subagent 被叫去跑清單外的指令會怎樣。兩份定義只差 Bash 在不在清單上,都叫它跑 echo HELLO:

定義寫的跑出來
tools: [Read]echo 沒跑,tool_uses: 0。subagent 回 I don't have access to a Bash tool. The only available tool I have is the Read function for reading files from the filesystem. I cannot execute shell commands.(2.1.278 重跑的原話,字是 model 自己寫的,每次不一樣)
tools: [Read, Bash]呼叫 Bash,回 HELLO

skills 是另一個欄位,做的是另一件事:寫 skills: [inner],engine 只把 inner 的內文預先放進 subagent 的 context;subagent 的工具清單裡還是沒有 Skill 這個 tool,所以它沒辦法呼叫任何 skill。同一類的欄位還有 allowedTools、disallowedTools,四個的範圍各不一樣,下面單獨講。

tools、skills、allowedTools、disallowedTools

下面一律用大寫 Skill 指工具清單上的那個 tool,小寫 skill 指磁碟上的 SKILL.md。

agent 定義有三個欄位:

欄位這樣寫的影響
tools工具清單換成列出來的,MCP 的 tool 一起換掉;整行不寫就繼承 parent 的
skills把 skill 內文放進 context,工具清單不動
disallowedTools工具清單拿掉列出來的,MCP 的 tool 也一起拿掉

同樣的名字也能傳給 SDK 的 query()。disallowedTools 兩邊一樣,另外兩個不一樣,而且多一個 allowedTools:

SDK 的 query()這樣寫的影響
tools只換內建 tool,MCP 的留著
skills一份 allowlist,Skill 只受理列進去的
allowedTools工具清單不動,列出來的 tool 執行前不問你

陣列的引號也分兩邊:query() 收的是 JS 物件,一定要寫 ["Read","Bash"];agent 定義是 YAML,這篇一律寫 [Read, Bash]。

tools: [Read, Bash, Skill] 這樣寫,官方標了 deprecated(sdk.d.ts 的註解:passing 'Skill' here is deprecated — use the skills field instead)。但同一個 type 裡 skills 的定義是 Array of skill names to preload into the agent context,preload 進 context 不等於進工具清單,改寫成 skills: [inner],subagent 回的是 NO-SKILL-TOOL。要 subagent 跑 skill,tools: 整行不寫(繼承 parent 的清單)或列進 Skill,只有這兩條路。到 SDK 0.3.278(binary 2.1.278)還是一樣,那兩段 docstring 也一個字沒改。

hook

hook 是 settings.json 裡的一條規則,內容就是哪個事件、matcher 比對什麼、然後跑什麼。

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

hooks 在這份 JSON 裡出現兩次。外層是事件表,PreToolUse 這種事件名字掛在它下面。內層的陣列裝的是命中之後要執行的項目,下面都叫它 action。

官方文件定義了 33 種事件。常用的有:

  • PreToolUse 在 tool 執行之前跑,可以否決 tool 呼叫
  • PostToolUse 在 tool 成功之後跑
  • UserPromptSubmit 在訊息送出之後、model 讀到之前跑
  • SessionStart 開場跑一次
  • Stop 在一輪結束的時候跑

Matcher 是 regex,比對 tool 名字。留空或寫 * 就是全部命中。regex 寫壞,engine 不會停,只印一行 Invalid regex pattern in hook matcher 然後當作沒命中,所以寫壞了不太容易發現。事件名打錯更安靜,PreToolUse 多打一個 e 變 PreToolUsee,連 Invalid regex pattern 這種提示都沒有,hook 就是不觸發,silent fail 一節有。MCP 的名字要寫完整才命中,只寫 mcp__jira 什麼都不會命中,得寫成 mcp__jira__.*。

要在每次開 subagent 之前做點什麼,matcher 填 Agent:

{ "hooks": { "PreToolUse": [
  { "matcher": "Agent",
    "hooks": [{ "type": "command", "command": "bash ~/hooks/log-subagent.sh" }] }
] } }

內層 hooks 陣列每一項的 type 有五種可以填,就是五種 action。五種都是 engine 自己去執行的,主線的 model 不參與。prompt 跟 agent 會另外叫一次 model,那是 hook 自己開的呼叫,跟主線的 model 是兩回事。

type跑什麼誰去執行
command一段 shell 指令,hook 的輸入從 stdin 進去engine 開一個 process
prompt一個小 model 判斷條件成不成立engine 發一次 model 呼叫
agent一個 agent 做判斷,model 可以指定,不寫是 Haikuengine 開一個 subagent
mcp_tool呼叫某台已經設定好的 MCP server 上的 toolengine 直接對那台 server 發 tools/call
http把 hook 輸入 POST 到一個 URLengine 發一個 HTTP request

五種不是每種都測過:command、mcp_tool、prompt 三種跑過,agent 只在 tool 事件上跑過,http 沒跑。內建的 /goal 就是一條掛在 Stop 的 prompt,每一輪結束回一句 Stop hook feedback: [...] 進 context。

五種裡面只有 mcp_tool 填的是 tool,也就是 tool 一節講的、登記過的名字,像 "server": "marker", "tool": "record"(marker 是自己架的一台測試用 MCP server,只有一個叫 record 的 tool)。其他四種填的是 script 路徑、一段 prompt、一段 prompt 加 model、一個網址,engine 自己處理,log 裡不會出現 tool_use。matcher 欄位雖然也填 tool 名字,但那是條件,不是要執行的對象。

十六種組合

四個元件兩兩組合,16 種。中間那欄是誰去發呼叫,箭頭後面是決定成不成立的欄位。agent 那一欄寫的是 subagent,因為測的都是 .claude/agents/<name>.md 開出來的那一層,主線自己呼叫 tool 不在表格裡。engine 跟 model 各是什麼,上面 model 跟 engine 一節有。

標記什麼欄位為什麼是它發幾列
engineagent、mcp_tool、context: fork、hooks:欄位裡已經寫好叫誰、帶什麼參數5(hooks: 自己佔兩列)
modeltools:、allowed-tools只說可以用哪些,沒說叫哪一個4
間接沒有欄位只能把要求寫成文字送進 context,看 model 理不理3
🚫tool 沒有自己的設定檔skill、subagent、hook 各有一份 .md,tool 只是清單上的名字4
誰呼叫誰誰發的測到什麼
subagent 呼叫 subagentmodel ← tools: 含 Agent第二層的 tool_use 帶著第一層的 parent_tool_use_id
subagent 呼叫 hookengine ← frontmatter 的 hooks:回 BLOCKED-BY-AGENT-HOOK(資料夾要先信任過)
subagent 呼叫 toolmodel ← tools:不寫就跟 parent 一樣,寫了 tools: [Read] 就只剩 Read
subagent 呼叫 skillmodel ← tools: 含 Skill,或整行不寫tool_use 的 name 是 Skill,parent_tool_use_id 指向第一層
hook 呼叫 subagentengine ← agent actionAgent hook condition was not met: AGENT-HOOK-BLOCKED
hook 呼叫 hook間接 model ← 沒有欄位agent action 裡的 model 去呼叫 tool,才會再觸發一次
hook 呼叫 toolengine ← mcp_tool actionMCP server 收到 tools/call,log 裡沒有 tool_use
hook 呼叫 skill間接 model ← 沒有欄位靠 additionalContext 請 model 去叫,回 GREET-RAN
skill 呼叫 subagentengine ← context: forkSkill "forktest" completed (forked execution).
skill 呼叫 hookengine ← frontmatter 的 hooks:回 BLOCKED-BY-SKILL-HOOK
skill 呼叫 toolmodel ← allowed-tools 不是限制allowed-tools: Read 寫著,內文要 Bash 還是照發
skill 呼叫 skill間接 model ← 沒有欄位內文請 model 去叫,回 INNER-RAN
tool 呼叫 subagent🚫engine 沒有這條路
tool 呼叫 skill🚫engine 沒有這條路
tool 呼叫 tool🚫engine 沒有這條路
tool 呼叫 hook🚫hook 是 engine 在 tool 前後叫的,tool 沒參與

改用 SDK 測,行為都一樣,就不另外加 column 比對。

巢狀 subagent 有上限。 binary 裡的預設值是 3,但 engine 還會讀一個遠端 flag 蓋過它,所以 3 不保證是你這台現在的值;CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH 可以指定。深度只算 subagent:主線是 0,主線開的第一個 subagent 是 1,那個 subagent 再開一個就是 2。

設成 2 的時候,主線開 A(深度 1)、A 開 B(深度 2)都成立,輪到 B 要往下開第三層,它的工具清單裡就沒有 Agent。設成 1 的話,換成 A 那一層的清單裡沒有。

B 的定義自己寫了 tools: [Read, Bash, Agent],清單裡還是沒有,所以深度過濾優先於定義檔的宣告。

subagent 回報的那句話每次不一樣,因為那是 model 自己組的,不是 engine 的固定字串。同一組設定跑兩次,一次講 no Agent or Task tool available. The only tools in my toolset are Read and Bash,一次講 the Agent tool is not available to me。

tool 作為主動方的狀況

tool 呼叫 subagent、tool 呼叫 skill、tool 呼叫 tool,三列的答案是同一句:不可能,tool 是被動的,發動的是 model 或 engine。

Agent 被呼叫之後跑起來的 subagent 會去呼叫 tool、skill、下一層 subagent,但那是 subagent 在叫,不是 Agent 在叫。

有個繞過去的辦法:MCP server 是你自己寫的程式,tools/call 進來之後可以另外開一個 claude process,那邊的 engine 想做什麼都行。我那支測試 server 的 delegate 就這一行:

execFileSync("claude", ["-p", args.q, "--output-format", "text"])

回 delegated-agent said: DELEGATED-OK。但那是另一個 session,已經超出「誰呼叫誰」在問的範圍,只是看起來很像才放進來,四列還是 🚫。

tool 呼叫 hook 不成立的理由跟上面三列不一樣。hook 確實跑了,但跑在 Bash 開始之前,叫它的是 engine,Bash 從頭到尾沒參與:

model 寫出 tool_use,name=Bash,command=echo MAIN
→ engine 看看 settings.json 的 hook 設定
→ matcher 命中,engine 執行 action,等它回話
→ 回 deny  → 到此為止,shell 從頭到尾沒收到東西
→ 沒 deny  → engine 才把 echo MAIN 交給 shell

hook 跑的時候 Bash 一個字都還沒送進 shell。要說 tool 呼叫了 hook,得是 Bash 執行過程中自己去觸發了誰,而它連執行都還沒開始。PostToolUse 也一樣,Bash 跑完之後去發 hook 的還是 engine。

hook 呼叫 hook 或 skill

hook 呼叫 hook

action 只有五種,填進去的值分別是 script 路徑、一段 prompt、一段 prompt 加 model、一個 MCP tool、一個網址。沒有一種的值是另一條 hook,所以設定檔裡沒地方寫「然後去觸發那條 hook」。

透過 model 倒是走得通。同一條 PreToolUse 掛兩個 action,一個 command 把命中的指令記進 ran.txt,一個 agent 要求它開出來的 subagent 自己去跑 echo FROMHOOKAGENT。主線只呼叫一次 Bash,ran.txt 有兩行:

PreToolUse | echo MAIN
PreToolUse | echo FROMHOOKAGENT

第二行是 subagent 自己跑出來的。它沒有宣告任何 hook,但它呼叫 tool 的時候走同一份表,matcher: "Bash" 第二次命中。

只把 agent 的 prompt 換成「回一個 OK 就好,不用跑任何指令」,別的都不動,ran.txt 就剩一行。變數只有 model 有沒有去呼叫 tool,所以這條路成不成立是 model 決定的。

第二次命中只跑了 command,agent 沒有再開一次,所以停在兩行。engine 自己發的呼叫不算:mcp_tool action 由 engine 直接呼叫 MCP server,server 收得到,但 tools/call 不會回頭觸發 PreToolUse。

hook 呼叫 skill

上面 mcp_tool 是 engine 自己呼叫的:欄位裡寫死了哪台 server、哪個 tool、什麼參數,engine 讀完就能直接發。skill 沒有對應的欄位,五種 action 沒有一種的值可以填 skill 的名字。

剩下的路只有間接:hook script 回一段 additionalContext,那段字被塞進 model 的 context,model 讀到之後自己去叫 Skill tool。

#!/bin/bash
echo '{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"You must now invoke the skill named greet using the Skill tool before answering."}}'

掛在 UserPromptSubmit 上,然後輸入 Say hello.,log 的下一步就是一筆 tool_use,name 是 Skill、skill 是 greet,最後回 GREET-RAN。但這條路不一定會成功。hook 做的只是把一句話塞進 context,真的去叫 Skill 的是 model,它也可以不理。

skill 用哪些欄位串接

skill 要去叫 tool、subagent、hook,SKILL.md 的 frontmatter 各有欄位:allowed-tools 對 tool,context: fork 加 agent 對 subagent,hooks 對 hook。三個的作用不一樣。context: fork 跟 hooks 是 engine 照著做,寫了就會發生。allowed-tools 不是。它影響的是 skill 內文去叫 tool 的那一步:列進去的 tool,engine 不問你就跑;沒列的一樣能呼叫。

model 呼叫 Skill 的那一步,engine 另外判斷一次要不要核准。哪種 frontmatter 欄位會讓這一步變成要核准:假設五個不同的 skill,對應不同的 frontmatter 欄位,分別測試。每份只多一種欄位,都在 -p 底下各叫一次。不加 --dangerously-skip-permissions,加了 engine 每一次都判 allow,ask 不會出現,就測不出來。每一次 tool_use 都會有一筆 tool_result 回給 model,有跑沒跑都有,差在字串:

skillfrontmatter 多了什麼tool_result 回什麼
inner沒有,只有 name / descriptionLaunching skill: inner
forktestcontext: forkSkill "forktest" completed (forked execution).
tooltestallowed-tools: ReadExecute skill: tooltest
dtestdisallowed-tools: BashExecute skill: dtest
hooktesthooks: PreToolUse …Execute skill: hooktest

表上三個 Execute skill: <name> 讀起來像已經執行了,其實是拒絕。model 有送出 Skill 的 tool_use,engine 判斷要核准,-p 沒人可以按,就回拒絕,SKILL.md 的內文沒有載入。Execute skill: 是 engine 判成 ask 時附上的訊息,-p 沒人回答,這句就直接當 tool_result 回去。同一份 tooltest 不帶 -p 跑,manual mode 跳的對話框寫的是「Use skill "tooltest"?」,auto mode 直接放行;caveat 那張表是三種模式的對照。

engine 載入 skill 之後,拿 frontmatter 的 key 逐個比對一份寫死在 binary 裡的清單(44 個,strings 撈 new Set(["type","progressMessage"… 就找得到,沒有公開文件)。常用的 key 分兩邊:

frontmatter 的 key在清單上寫了會怎樣
name、description、model、context、agent、background在直接載入,不問
hooks、allowed-tools、disallowed-tools,以及任何清單上沒有的 key不在冒號後面有東西,engine 載入前先問人;後面空著、[]、{} 當作沒寫,照樣直接載入

tooltest 的 allowed-tools: Read 是第二列,回 Execute skill: tooltest;forktest 的 context: fork 是第一列,直接跑。

比對用的是載入後的物件屬性,allowed-tools 讀進去會變成 allowedTools,兩種寫法都不在清單上,結果一樣。

skill 叫另一份 skill 沒有欄位可以用。frontmatter 裡沒有任何 key 能填別份 skill 的名字,只能寫在內文。

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.

binary 裡的 zod schema 對 allowed-tools 的說明是 Tools available to the model while this file is active,讀起來像「只有這幾個能用」。跑起來不是。同一份內文、同一個問法,換 frontmatter 各跑一次:

frontmatter 寫了什麼Bash 跑不跑
沒寫這一行跑了,tool_result 回 HELLO
allowed-tools: Read跑了,tool_result 回 HELLO
disallowed-tools: BashPermission to use Bash has been denied.

allowed-tools 列進去的 tool,engine 不會跳出來問你,沒列的一樣能呼叫。disallowed-tools 不一樣,列進去的 tool 一呼叫就被拒絕,回 Permission to use Bash has been denied.。它的說明寫 Tools removed from the model while this file is active,加了 --dangerously-skip-permissions 結果也一樣。

disallowed-tools 的說明還有一句 Cleared when the user sends the next message。--resume 同一個 session 再送一則「跑 echo AFTER」,Bash 就回 AFTER,限制只在 skill 被叫起來的那一輪有效。

agent 定義那邊有 tools 跟 disallowedTools,SKILL.md 沒有 tools。tools 是整份清單的白名單,disallowedTools 反過來,列出來的拿掉、其他的照常。兩個的範圍在上面 agent 一節。

skill 串接 subagent

context: fork 有沒有真的開一個 subagent,看 tool_result 回的字串。同一份 skill,只差這一行:

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

寫了 context: fork,engine 開一個 subagent 去跑這份 skill。內文變成 subagent 的任務描述,過程不進主線,只有結果回來。沒寫 context: fork 的話,內文載入主線,model 自己照著跑。

兩份各叫一次,tool_result 回的字串不一樣。- 是沒寫的那份,+ 是加了 context: fork 的:

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

搭配的 agent 欄位指定用哪一種 agent 去跑,填 .claude/agents/<name>.md 的 name,或者內建的 agent type(像 Explore)。可選,只寫 context: fork 也 fork 得起來。填錯了不會報錯,下面 silent fail 一節有。

skill 串接 hook

故意寫一個拒絕的腳本,讓 skill 內文的 Bash 故意 fail,然後看 hook 的生命週期。hook 有沒有註冊進 session,看 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.

deny.sh 印一行 JSON 就夠。

#!/bin/bash
echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"BLOCKED-BY-SKILL-HOOK"}}'

permissionDecision 是 deny,engine 就不把 echo HELLO 交給 shell。permissionDecisionReason 的字串會變成 model 收到的 tool_result,所以 model 看到的是 BLOCKED-BY-SKILL-HOOK。

deny 什麼時候開始生效、什麼時候失效,同一個 session 裡連下三句。echo BEFORE 跟 echo AFTER 不在 hooktest 的 SKILL.md 裡,是我另外下的:

輸入回什麼當下的狀態
echo BEFOREBEFOREskill 還沒被叫起來
hooktestBLOCKED-BY-SKILL-HOOK被拒絕的是內文那句 echo HELLO
echo AFTERBLOCKED-BY-SKILL-HOOKskill 已經跑完了

skill 被叫起來之後,它 frontmatter 的 hooks: 就註冊在這個 session 上,之後每次呼叫 tool 都會經過,不管當時在不在跑 skill。session 關掉就沒了:重開一個 session,同一個目錄跑 echo AFTER,回的是 AFTER。

對照:agent 串接 hook

agent 定義的 frontmatter 也吃 hooks:,欄位跟 SKILL.md 一樣。把同一條 deny 規則搬到 .claude/agents/probe.md,同樣三句連下(這張是 2.1.278 上跑的):

輸入回什麼當下的狀態
echo BEFORE(主線)BEFOREprobe 還沒開
叫 probe 跑 echo SUBPreToolUse:Bash hook error: BLOCKED-BY-AGENT-HOOKprobe 的 hooks: 在 subagent 裡生效
echo AFTER(主線)AFTER主線沒受影響

跟 skill 那張差在第三列:skill 的 hooks: 跑過一次就留在 session 上,主線的 echo AFTER 也被拒;agent 的只在 subagent 裡,主線照跑。另一個差別在資料夾不受信任的時候:probe.md 的 hooks: engine 整份丟掉,echo SUB 回 SUB;SKILL.md 的照常註冊,echo HELLO 還是回 BLOCKED-BY-SKILL-HOOK。silent fail 一節有對照。

skill 串接 skill

這一種沒有欄位,要叫就寫在內文:

---
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 回一個字 INNER-RAN。輸入 outer,log 裡是兩次 Skill 呼叫:

TOOL_USE: Skill {"skill": "outer"}
TOOL_USE: Skill {"skill": "inner"}
RESULT: INNER-RAN

兩次都是 model 發的。outer 的 frontmatter 沒有一個欄位可以指向 inner,第二次呼叫是它內文那句話請 model 去叫的。

silent fail

上面三處提過設定寫錯不會報錯:hook 一節的事件名、skill 串接 subagent 的 agent 值、skill 串接 hook 的信任。三種跑起來跟寫對的時候一樣,也沒有錯誤訊息。能分辨的只有一個方法:先列出寫對的時候會出現什麼,再看它有沒有出現。

寫錯什麼正確的預期結果寫錯之後變成有沒有提示
settings.json 的事件名 PreToolUsee,多一個 e--include-hook-events 多出 hook_started 跟 hook_response 兩筆兩筆都沒有,Bash 照跑沒有。--debug(stderr 的除錯訊息)跟 --include-hook-events(stream-json 裡的 hook 事件)都沒有一行提到它
skill 的 agent: nosuchagentagent: mycustom 回 CUSTOM: FORKED回 FORKED,用預設 agent 照樣 fork沒有
資料夾不受信任deny.sh 有自己的 hook_started 跟 hook_response,echo SUB 回 BLOCKED-BY-AGENT-HOOK一筆事件都沒有,echo SUB 回 SUB。agent frontmatter 的 hooks: 整份被丟掉沒有。-p 不跳信任對話框,--debug 也不提

信任是每個資料夾問一次的:互動模式第一次在一個資料夾裡啟動會跳對話框問你信不信任,答應之後記在 ~/.claude.json 的 hasTrustDialogAccepted。-p 不跳這個對話框。

caveat

每一句都綁在 CLI 2.1.257 跟 Agent SDK 0.3.257。兩邊執行的是同一個 binary(shasum 一樣),SDK 差的只有 agents、mcpServers、hook callback 可以直接當參數傳。claude symlink 會自己更新,所以量測一律直接叫 versions/2.1.257;那個檔案現在已經不在這台機器上。巢狀上限 engine 會另外讀一個遠端 flag,連版本都不用換就可能變。

文章裡每一次量測都帶 -p。有兩個結果是 -p 造成的:tooltest 回 Execute skill: tooltest,因為 -p 沒人回答 ask;forktest 的結果直接回主線,因為 -p 不把 subagent 丟到背景。拿掉 -p,這兩個結果就變了。同樣兩份 skill 用另外兩種模式重跑:

模式allowed-tools 的 skillcontext: fork
--permission-mode manual跳對話框「Use skill "tooltest"?」subagent 在背景跑,主線不等它,FORKED 之後才送進來;frontmatter 加 background: false 主線才會等
預設 auto mode不問,log 寫 Allowed by auto mode classifier同上
-p沒人可以按,回 Execute skill: tooltest沒有背景,subagent 跑完 FORKED 直接回主線