Hooks let skills enforce policy at agent boundaries — moments where an agent is about to execute a tool, spawn a subagent, call the LLM, or persist a conversation. They are gate-only: a hook can allow, deny, or suspend the action, but never modify the data flowing through it.

Defining Hooks on Skills

Hooks are declared in a skill's YAML frontmatter under the hooks key. Each event name maps to a list of matcher entries, each with a list of handler configs:

---
name: ops:deploy
description: Deploy a service to staging.
hooks:
  PreToolUse:
    - matcher: "Shell"
      hooks:
        - type: command
          command: "./scripts/check-deploy-policy.sh"
  PostToolUse:
    - hooks:
        - type: http
          url: "https://audit.example.com/log"
---
Deploy $ARGUMENTS to staging.

Hooks are scoped to the skill's lifetime. When the skill is unregistered, its hooks stop firing.

Available Events

Each boundary has a pre-event (can deny) and a post-event (observational). Use PascalCase in YAML frontmatter:

Pre-eventPost-eventWhat it gates
PreToolUsePostToolUseOS command or module-skill tool execution
PreSubagentPostSubagentSpawning a subagent
PreSkillActivationPostSkillActivationActivating a skill
PreLlmRequestPostLlmRequestSending a request to the LLM provider
PreConversationSavePostConversationSavePersisting conversation history
PreConversationLoadPostConversationLoadLoading conversation history
PreTurnPostTurnProcessing a batch of messages (one agent loop)
PreAgentPostAgentAgent process init / terminate

Unknown event names are silently ignored.

Matchers

The optional matcher field is a regex that filters which boundary crossings trigger the hook. What it matches against depends on the boundary:

BoundaryMatched againstExample
Tool useLast segment of tool module name"Shell", "Sandbox"
SubagentSubagent name"researcher"
Skill activationSkill name"ops:deploy"
LLM requestModel string"claude-opus"
All othersAgent name"my-agent"

Omit matcher to match all crossings for that event.

Return Values

Pre-event hook handlers return one of:

ReturnEffect
:okAllow — proceed to the next hook or the action
{:deny, reason}Block — the action does not run
{:pending, state}Suspend — the action pauses for approval

Pre-hooks run in order. The first :deny or :pending short-circuits — remaining hooks and the action itself are skipped.

Post-event hooks always run after the action completes. Their return values are ignored.

Built-in Handler Types

Command

Shells out to a command. The hook context is passed as JSON in the HOOK_INPUT environment variable.

hooks:
  PreToolUse:
    - matcher: "Shell"
      hooks:
        - type: command
          command: "./scripts/validate.sh"

Exit code semantics (matching Claude Code):

Exit codeResult
0:ok
2{:deny, output}
Any other:ok (non-blocking error)

Http

POSTs the hook context as JSON to a URL.

hooks:
  PreSubagent:
    - hooks:
        - type: http
          url: "https://policy.example.com/approve"
          timeout: 10

Response interpretation:

ResponseResult
2xx with {"decision": "deny", "reason": "..."}{:deny, reason}
2xx with {"decision": "allow"} or no decision field:ok
Non-2xx{:deny, "HTTP hook returned status <code>"}
Connection error:ok (non-blocking)

Optional fields: headers (map), timeout (seconds, default 30).

Custom Handler Types

Implement SkillKit.Hooks.Handler:

defmodule MyApp.Hooks.Slack do
  @behaviour SkillKit.Hooks.Handler

  @impl true
  def execute(%{"channel" => channel} = config, context) do
    message = Map.get(config, "message", "Hook fired")
    MyApp.Slack.post(channel, "#{message}: #{inspect(context)}")
    :ok
  end
end

Register the type in application config:

# config/config.exs
config :skill_kit, :hook_handlers, %{
  "command" => SkillKit.Hooks.Command,
  "http" => SkillKit.Hooks.Http,
  "slack" => MyApp.Hooks.Slack
}

Or per-agent via start_agent:

SkillKit.start_agent("agents/my-agent",
  skills: ["skills"],
  hook_handlers: %{"slack" => MyApp.Hooks.Slack}
)

Skills can then reference the custom type in YAML:

hooks:
  PostToolUse:
    - hooks:
        - type: slack
          channel: "#deployments"
          message: "Tool executed"

The remaining YAML fields (everything except type) become the config map passed to your handler's execute/2.

Programmatic Hooks

Module-based kits and tests can define hooks directly on skill structs using function or MFA handlers:

%Skill{
  name: "policy:enforcer",
  hooks: [
    %Hook{
      event: :pre_tool_use,
      matcher: ~r/Shell/,
      handler: fn context -> check_policy(context) end
    }
  ]
}

Hook Context

Each boundary passes a context map to matching handlers. Common keys across all boundaries:

KeyTypePresent on
:agent_nameString.t()All boundaries
:scopeterm()Tool use, skill activation

Boundary-specific keys:

BoundaryAdditional keys
Tool use:tool (module), :input (map), :skill (Skill.t or nil)
Tool use (post)Same + :result
Subagent:name, :task, :depth
Subagent (post):name, :task, :result
Skill activation:skill (Skill.t), :skill_name, :arguments
LLM request:model, :message_count, :tool_count
Conversation save:message_count
Agent:definition (Agent.t)
Turn:message_count