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-event | Post-event | What it gates |
|---|---|---|
PreToolUse | PostToolUse | OS command or module-skill tool execution |
PreSubagent | PostSubagent | Spawning a subagent |
PreSkillActivation | PostSkillActivation | Activating a skill |
PreLlmRequest | PostLlmRequest | Sending a request to the LLM provider |
PreConversationSave | PostConversationSave | Persisting conversation history |
PreConversationLoad | PostConversationLoad | Loading conversation history |
PreTurn | PostTurn | Processing a batch of messages (one agent loop) |
PreAgent | PostAgent | Agent 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:
| Boundary | Matched against | Example |
|---|---|---|
| Tool use | Last segment of tool module name | "Shell", "Sandbox" |
| Subagent | Subagent name | "researcher" |
| Skill activation | Skill name | "ops:deploy" |
| LLM request | Model string | "claude-opus" |
| All others | Agent name | "my-agent" |
Omit matcher to match all crossings for that event.
Return Values
Pre-event hook handlers return one of:
| Return | Effect |
|---|---|
:ok | Allow — 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 code | Result |
|---|---|
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: 10Response interpretation:
| Response | Result |
|---|---|
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
endRegister 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:
| Key | Type | Present on |
|---|---|---|
:agent_name | String.t() | All boundaries |
:scope | term() | Tool use, skill activation |
Boundary-specific keys:
| Boundary | Additional 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 |