# Hooks

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:

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

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

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

Response 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`:

```elixir
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:

```elixir
# 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`:

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

Skills can then reference the custom type in YAML:

```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:

```elixir
%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` |
