Skill-Hook Subsystem
Status: Neo-unique extension point (no Claude Code equivalent) Audience: plugin and hook authors composing skills as event listeners Companion docs:
- architecture.md — plugin v1 architecture reference (skill-hook is one of five hook backends)
- README.md — user-facing plugin docs including the hooks surface
The skill-hook subsystem lets a hook event invoke a registered skill as a fire-and-forget sub-agent. It is one of five backends accepted by HookBackendSchema (src/hooks/types.ts:162-168) and the only one that bridges the hook system with the skill system.
This page describes the contract, semantics, and use cases. Everything cites file:line.
1. The contract
A skill hook is a hook backend entry with type: 'skill'. When the associated event fires, the runtime resolves the named skill and invokes it as a sub-agent. The hook always returns { continue: true } — skills are advisory listeners, never permission gates or blockers.
Schema
SkillHookSchema at src/hooks/types.ts:149-156:
export const SkillHookSchema = z.object({
type: z.literal('skill'),
skill: z.string(), // required — name of a registered skill
args: z.record(z.string(), z.unknown()).optional(), // forwarded as JSON to the skill's prompt
timeout: z.number().positive().optional(), // ms; default 60_000
statusMessage: z.string().optional(),
once: z.boolean().optional(),
});The schema participates in the top-level HookBackendSchema discriminated union at src/hooks/types.ts:162-168. Any surface that accepts a hook config (settings.json, plugin hooks.json, inline manifest hooks) accepts the skill backend the same way.
Configuration example
{
"SessionStart": [
{ "type": "skill", "skill": "morning-brief" }
],
"PostCompact": [
{ "type": "skill", "skill": "memory-curator", "args": { "depth": "shallow" } }
]
}When SessionStart fires, the runtime invokes the morning-brief skill with an empty args string. When PostCompact fires, it invokes memory-curator with {"depth":"shallow"} as the initial prompt.
2. Execution semantics
The runtime entry point is runSkillHook(hook, ctx, deps) at src/hooks/backends/skill-backend.ts:62-118.
Skill resolution
The backend looks up the named skill via deps.skillRegistry.get(skillName) (src/hooks/backends/skill-backend.ts:70). If the skill is not registered:
- Log a
decision: 'skill_hook_unknown_skill'warning with the full list of available skills (:71-83) - Return
{ continue: true }immediately
This is fail-open: a missing skill never blocks the event the hook is attached to. The warning is the only signal that something is wrong — there's no thrown exception, no caller-visible error.
Timeout
The hook accepts an optional timeout in milliseconds (default 60_000; src/hooks/backends/skill-backend.ts:38, 67). The runtime sets up an AbortController and a setTimeout that aborts the controller with a descriptive message (:85-88). If the timeout fires:
- The skill's
invokeSkillpromise rejects with anAbortError - The error path logs
decision: 'skill_hook_timeout'(:108-112) - The hook still returns
{ continue: true }
The timer is always cleared in finally (:113-115), even on success.
Args forwarding
The args record is JSON-stringified and passed as the skill's initial prompt (src/hooks/backends/skill-backend.ts:92). An empty or omitted args becomes the empty string. The skill itself decides how to parse — there is no schema enforcement on what the skill expects to receive.
Error handling
All errors in the invokeSkill call path are caught at src/hooks/backends/skill-backend.ts:102-112. The hook discriminates by err.name === 'AbortError' to log timeout vs. generic error, but the return value is identical: { continue: true }.
This is the entire point. Skill hooks must never affect their host event's outcome. A PreToolUse skill hook can observe the tool call but cannot deny it. A SessionStart skill hook can spin up a sub-agent doing arbitrary work but cannot delay the session start beyond its timeout.
Fire-and-forget vs awaited
The hook returns a Promise. Synchronous callers like runBlockingHooks and runHooksAndCollect (per the module docstring at src/hooks/backends/skill-backend.ts:18-19) await the returned Promise — but the hook's HookResult is always { continue: true }, so awaiting just bounds the latency, it doesn't gate anything.
For async (non-blocking) events the dispatcher may not await. The skill backend is designed to be safe in either case: it owns its own AbortController, releases its timer, and surfaces nothing to the caller that could change control flow.
3. Dependencies (SkillBackendDeps)
The backend is constructed with a narrow dependency interface (src/hooks/backends/skill-backend.ts:46-54):
interface SkillBackendDeps {
readonly skillRegistry: SkillRegistry;
readonly invokeSkill: (
skillName: string,
args: string,
signal: AbortSignal,
) => Promise<string>;
}skillRegistry— the session's populated registry; used for the existence check before any sub-agent setupinvokeSkill— implemented insrc/runtime/embedded.tsby routing through theSkillTool.executepath
Why a narrow interface? This module has no hard import-time dependency on agent-loop.ts. Importing the skill execution path directly would create a circular dependency via src/tools/skill/skill.ts. The deps shape lets embedded.ts wire concrete implementations at session construction time without dragging the agent loop into the hook backend's import graph.
4. Why it's Neo-unique
Claude Code's hook system has four backend types: command, prompt, http, agent (claude-code-src/src/schemas/hooks.ts:31-189). The CC agent backend runs a prompt as a sub-agent — semantically similar to Neo's agent backend, also at src/hooks/types.ts:123-130.
What CC does not have is a "fire a registered skill as a sub-agent" backend. Neo's skill backend is additive: it lets a plugin attach an advisory "watch this event and have a skill reason about it" listener without forcing the plugin author to inline a full prompt into the hook config.
The advantages over the agent backend for this use case:
- Reusability — the skill is a first-class entity in Neo (registered in
SkillRegistry, discoverable via skill picker, can have its own permissions and tool allowlists). The same skill that runs as a hook can also be invoked manually. - Configuration locality — the skill's prompt, model, allowed-tools, and its own sub-hooks live in
SKILL.md's frontmatter (src/skills/types.ts:53-66), not in the hook config. The hook config just names the skill. - Composability — a plugin that ships a skill can wire it as a hook in the same plugin's
hooks.json, declaratively.
The Wave 5 design doc names this a "Neo-unique" surface (plans/wave5-plugin-foundation.md §5.7) and the gap-map's audit row "Skill Hook Subsystem" describes it as "NEO-UNIQUE; no gaps."
5. Use cases
Session lifecycle
{
"SessionStart": [
{ "type": "skill", "skill": "morning-brief" }
]
}A skill that runs once at session start to summarise overnight memory updates, surface stale TODOs, or pre-warm an embedding cache. The user sees nothing in the foreground; the skill's output is captured in the session log but does not gate the user's first prompt.
Post-compaction memory curation
{
"PostCompact": [
{ "type": "skill", "skill": "memory-curator", "args": { "mode": "review" } }
]
}A skill that runs after context compaction to scan the new context window, validate that key facts survived the compaction summary, and write to memory if anything important was lost. Pairs naturally with the auto-memory system (src/memory/auto-memory/).
Tool-result observers
{
"PostToolUse": [
{ "type": "skill", "skill": "tool-observer" }
]
}A skill that watches every PostToolUse event to maintain a running ledger of file edits, command exit codes, or external API calls. Useful for plugins that want a passive audit trail without instrumenting every tool.
Plugin sub-task dispatcher
{
"TaskCreated": [
{ "type": "skill", "skill": "task-router" }
]
}When a TaskCreated event fires (a sub-agent is spawned via TaskCreate), a routing skill can inspect the task description and prep relevant context, fetch related memories, or annotate the task.
Workflow lifecycle
The workflow events WorkflowStepStart, WorkflowStepEnd, WorkflowApprovalRequested, WorkflowCompleted, WorkflowFailed, WorkflowCancelled (src/hooks/types.ts:83-89) all accept skill hooks. A plugin shipping a workflow can attach an observer skill that runs at each step boundary — useful for logging, telemetry, or generating step-by-step summaries.
6. Authoring a skill-hook-friendly skill
A skill invoked as a hook receives the JSON-stringified args as its initial prompt. To author a skill that handles this gracefully:
- Make the args optional — the same skill might also be invoked manually with no args. Parse defensively.
- Keep it short — every skill invocation costs LLM tokens. A hook firing on every
PostToolUseand running a 4k-token skill body will blow up the session cost. - Honour timeouts — the default is 60s. If the skill's natural work takes longer, bound it explicitly with a fast-path return.
- No side effects on the host session — the skill output is logged but not injected into the user-facing conversation. Treat skill hooks as observer-only.
- Don't depend on hook context fields — the
HookContext(src/hooks/types.ts:191-198) carriesevent,sessionId, plus event-specific fields liketoolName,toolInput,toolResult,userPrompt. These are not forwarded to the skill's prompt — only the explicitargsare. If the skill needs the tool name, the hook config must pass it inargs(which today requires the hook config to be aware of which event it's bound to; this is a known limitation).
7. What skill hooks cannot do
A non-exhaustive list of things the contract explicitly forbids:
| Forbidden | Why |
|---|---|
| Block tool execution | PreToolUse skill hooks always return { continue: true }. Use a command or agent backend if you need a gate. |
| Modify the tool's input | No updatedInput is ever returned. |
| Inject context into the user prompt | No additionalContext is ever returned. |
| Influence permission decisions | No permissionDecision is ever returned. |
| Communicate back to the host conversation | The skill's output is captured in the session log but not relayed into the agent's context window. |
| Crash the host session | All errors are caught and logged; the host event proceeds. |
If you need any of these behaviors, use one of the other four hook backends (command, prompt, http, agent) which have richer contracts. Skill hooks are deliberately the most limited of the five.
8. References
Neo source
- Backend implementation:
src/hooks/backends/skill-backend.ts:62-118(runSkillHook) - Backend deps interface:
src/hooks/backends/skill-backend.ts:46-54(SkillBackendDeps) - Backend default timeout:
src/hooks/backends/skill-backend.ts:38(DEFAULT_SKILL_HOOK_TIMEOUT_MS) - Schema:
src/hooks/types.ts:149-156(SkillHookSchema) - Backend type union:
src/hooks/types.ts:162-168(HookBackendSchema) - Hook context:
src/hooks/types.ts:191-198(HookContext) - Hook result:
src/hooks/types.ts:402-415(HookResult) - Skill frontmatter:
src/skills/types.ts:53-66(SkillFrontmatterSchema) - Skill registry:
src/skills/registry.ts:89-168(SkillRegistry) - Plugin hook dispatch (validates inline + file hook configs):
src/plugins/hook-validator.ts:50-113(dispatchPluginHooks)
Plans
- Wave 5 design doc §5.7 (Skill-Hook subsystem):
plans/wave5-plugin-foundation.md - Gap map row "Skill Hook Subsystem" (audit — Neo-unique):
plans/gap-map.md
Sibling docs
- architecture.md §5.4 (Hooks surface)
- README.md §8 (Hooks inside a plugin)
Claude Code reference
- Hook backend schemas:
claude-code-src/src/schemas/hooks.ts:31-189 - Hook events:
claude-code-src/src/types/hooks.ts:23 - (CC has no skill-hook equivalent — see §4 above)