Skip to content

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:

ts
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

json
{
  "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 invokeSkill promise rejects with an AbortError
  • 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):

ts
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 setup
  • invokeSkill — implemented in src/runtime/embedded.ts by routing through the SkillTool.execute path

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:

  1. 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.
  2. 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.
  3. 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

json
{
  "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

json
{
  "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

json
{
  "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

json
{
  "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:

  1. Make the args optional — the same skill might also be invoked manually with no args. Parse defensively.
  2. Keep it short — every skill invocation costs LLM tokens. A hook firing on every PostToolUse and running a 4k-token skill body will blow up the session cost.
  3. Honour timeouts — the default is 60s. If the skill's natural work takes longer, bound it explicitly with a fast-path return.
  4. 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.
  5. Don't depend on hook context fields — the HookContext (src/hooks/types.ts:191-198) carries event, sessionId, plus event-specific fields like toolName, toolInput, toolResult, userPrompt. These are not forwarded to the skill's prompt — only the explicit args are. If the skill needs the tool name, the hook config must pass it in args (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:

ForbiddenWhy
Block tool executionPreToolUse skill hooks always return { continue: true }. Use a command or agent backend if you need a gate.
Modify the tool's inputNo updatedInput is ever returned.
Inject context into the user promptNo additionalContext is ever returned.
Influence permission decisionsNo permissionDecision is ever returned.
Communicate back to the host conversationThe skill's output is captured in the session log but not relayed into the agent's context window.
Crash the host sessionAll 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

Sibling docs

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)