---
title: "Hooks"
description: "Typed extension points within the agent loop for observation and guardrails"
icon: "anchor"
---

Hooks let you run custom logic at defined points in the agent lifecycle -- before/after tool execution, LLM calls, or at turn boundaries. Use them for approval workflows, audit logging, content filtering, or typed denials.

<Note>
This page is the task-first guide. For the low-level type and payload inventory, see [Hooks reference](/reference/hooks-reference).
</Note>

## What this guide is for

Use this guide when you want to:

- add approval gates
- log or audit agent behavior
- block unsafe tool inputs or outputs
- choose between in-process, command, and HTTP hook runtimes

## Hook points

Eight extension points are available:

| HookPoint | Classification | When it fires |
|-----------|---------------|---------------|
| `RunStarted` | pre | Start of `Agent::run()`, before any LLM call |
| `PreLlmRequest` | pre | Before each LLM streaming call |
| `PreToolExecution` | pre | Before each individual tool call is dispatched |
| `TurnBoundary` | pre | Between turns, after all tool results are collected |
| `PostLlmResponse` | post | After the LLM response is received |
| `PostToolExecution` | post | After each tool execution completes |
| `RunCompleted` | post | After a successful run completes |
| `RunFailed` | post | After a run fails with an error |

Classification is determined by the `is_pre()` and `is_post()` methods on `HookPoint`. Foreground hooks block loop progression; background hooks run asynchronously for observation.

## Capabilities

| HookCapability | Purpose |
|----------------|---------|
| `Observe` | Read-only logging and metrics |
| `Guardrail` | Can issue `Deny` decisions to block execution |
| `Rewrite` | Retired compatibility label; semantic patch authority is removed |

## Execution modes

| HookExecutionMode | Behavior |
|-------------------|----------|
| `Foreground` | Blocks loop progression. `Allow` / `Deny` decisions are handled synchronously. |
| `Background` | Runs asynchronously for observation. |

<Warning>
**Pre-point background hooks must be observe-only.** A background hook on a `is_pre()` point with any capability other than `Observe` is rejected with `HookEngineError::InvalidConfiguration`.
</Warning>

Semantic patch publication is retired. Legacy non-empty patch payloads fail closed during runtime response deserialization.

## How to add a hook

<Steps>
  <Step title="Choose hook point and capability">
    Decide which `HookPoint` to fire at (e.g., `PreToolExecution` for tool guardrails) and whether the hook needs `Observe` or `Guardrail`.
  </Step>
  <Step title="Choose a runtime">
    Pick one of the three runtimes: in-process (Rust closure), command (subprocess), or HTTP (remote endpoint).
  </Step>
  <Step title="Add configuration">
    Add a `[[hooks.entries]]` block to the active realm `config.toml` (or register programmatically via `HooksConfig`).
  </Step>
  <Step title="Implement the handler">
    Write the handler that receives a `HookInvocation` and returns a `RuntimeHookResponse` with an optional decision.
  </Step>
  <Step title="Test with overrides">
    Use `HookRunOverrides` to test your hook in isolation through RPC, REST, MCP, or embedded builders before committing the config.
  </Step>
</Steps>

<Note>
The docs below show run-scoped hook overrides conceptually, but the current CLI does not expose run-scoped hook override flags in normal builds. For now, use RPC, REST, MCP, or the SDK/embedded surfaces when you want per-run hook overrides.
</Note>

## Runtimes

<Tabs>
  <Tab title="In-process">
    Calls a registered Rust closure. Config:

    ```json
    { "type": "in_process", "name": "my-handler" }
    ```

    The handler is registered at engine construction time via `DefaultHookEngine::with_in_process_handler()` or `register_in_process_handler()`. The handler type is:

    ```rust
    type InProcessHookHandler = Arc<dyn Fn(HookInvocation) -> HandlerFuture + Send + Sync>;
    ```
  </Tab>
  <Tab title="Command (subprocess)">
    Spawns a subprocess. The `HookInvocation` is serialized as JSON to stdin; the process must write a `RuntimeHookResponse` JSON object to stdout. Config:

    ```json
    {
      "type": "command",
      "command": "python3",
      "args": ["hooks/safety_check.py"],
      "env": { "LOG_LEVEL": "debug" }
    }
    ```

    The subprocess payload size is capped at `payload_max_bytes` (default 128 KiB). Non-zero exit codes produce `HookEngineError::ExecutionFailed`.
  </Tab>
  <Tab title="HTTP">
    Sends a POST request to a URL. The `HookInvocation` is the JSON body; the response must be a `RuntimeHookResponse` JSON object. Config:

    ```json
    {
      "type": "http",
      "url": "https://hooks.example.com/safety",
      "method": "POST",
      "headers": { "Authorization": "Bearer $TOKEN" }
    }
    ```

    The `method` defaults to `"POST"` if not specified. Response body size is capped at `payload_max_bytes`.
  </Tab>
</Tabs>

## Failure policies

`HookFailurePolicy` is retained as a compatibility field on hook configuration. Current hook runtime failures fail closed through typed `HookEngineError` values; they are not converted into warning-only success or hook-local `Deny` decisions by `FailOpen` / `FailClosed`.

Background hook failure policy is inert compatibility configuration. Pre-point background hooks are still required to be `Observe`, because they cannot safely block a pre-execution boundary after the loop has moved on.

## Decisions and patches

<Accordion title="HookDecision">
```rust
pub enum HookDecision {
    Allow,
    Deny {
        hook_id: HookId,
        reason_code: HookReasonCode,
        message: String,
        payload: Option<Value>,  // optional structured data
    },
}
```

**Reason codes:**

| HookReasonCode | Meaning |
|----------------|---------|
| `PolicyViolation` | Business rule or policy constraint |
| `SafetyViolation` | Content safety check |
| `SchemaViolation` | Schema or format validation failure |
| `Timeout` | Hook timed out (system-generated) |
| `RuntimeError` | Hook execution failed (system-generated) |
</Accordion>

<Accordion title="Retired patch payloads">
`HookPatch` no longer has valid variants. Older payloads such as `llm_request`, `assistant_text`, `tool_args`, `tool_result`, and `run_result` fail closed during deserialization instead of being ignored or applied.

Hooks can observe typed projections and return `Allow` or `Deny`. Canonical provider parameters, assistant text, tool arguments, tool results, and final run text are owned by the runtime and tool execution path.
</Accordion>

## Runtime hook response

All three runtimes return the same `RuntimeHookResponse` structure:

```json
{
  "decision": { "decision": "deny", "hook_id": "my-guard", "reason_code": "policy_violation", "message": "blocked" }
}
```

`decision` is optional. The compatibility `patches` field may be empty, but non-empty legacy semantic patch payloads are rejected.

## Invocation payload

Hooks receive a `HookInvocation` struct containing contextual data. Fields are populated based on the hook point:

| Field | Type | Populated at |
|-------|------|-------------|
| `point` | `HookPoint` | Always |
| `session_id` | `SessionId` | Always |
| `turn_number` | `Option<u32>` | Most points |
| `prompt` | `Option<String>` | `RunStarted` |
| `error` | `Option<String>` | `RunFailed` |
| `llm_request` | `Option<HookLlmRequest>` | `PreLlmRequest` |
| `llm_response` | `Option<HookLlmResponse>` | `PostLlmResponse` |
| `tool_call` | `Option<HookToolCall>` | `PreToolExecution` |
| `tool_result` | `Option<HookToolResult>` | `PostToolExecution` |

<Accordion title="Supporting types">
- **`HookLlmRequest`**: `max_tokens`, `temperature`, `provider_params`, `message_count`
- **`HookLlmResponse`**: `assistant_text`, `tool_call_names`, `stop_reason`, `usage`
- **`HookToolCall`**: `tool_use_id`, `name`, `args`
- **`HookToolResult`**: `tool_use_id`, `name`, `content`, `content_blocks`, `is_error`
</Accordion>

`HookToolResult.content_blocks` carries the typed tool-result blocks, including image blocks, in original order. `HookToolResult.content` remains as a legacy text projection for existing hooks. Both fields are observational projections and cannot be returned as a rewrite.

## Priority ordering and deny short-circuiting

Foreground hooks are sorted by `priority` (ascending), then by `registration_index` (ascending, for determinism when priorities are equal). Lower numeric priority values run first.

When a foreground hook returns `Deny`:

<Steps>
  <Step title="Record denial">
    The denial is recorded as the merged decision.
  </Step>
  <Step title="Skip remaining foreground hooks">
    All remaining foreground hooks are skipped (short-circuit).
  </Step>
  <Step title="Skip background hooks">
    All background hooks are skipped (they only fire when no foreground `Deny` occurred).
  </Step>
</Steps>

<Note>
A priority-1 guardrail that denies will prevent a priority-100 observer from running.
</Note>

## Configuration

<Accordion title="Config file example (realm config.toml)">
Hook configuration lives under the `[hooks]` table:

```toml
[hooks]
default_timeout_ms = 5000       # Default: 5000
payload_max_bytes = 131072      # Default: 128 * 1024 (128 KiB)
background_max_concurrency = 32 # Default: 32

[[hooks.entries]]
id = "safety-check"
enabled = true
point = "pre_tool_execution"
mode = "foreground"
capability = "guardrail"
priority = 10
# failure_policy = "fail_closed"  # Compatibility field; runtime failures remain typed engine errors
# timeout_ms = 10000              # Optional; overrides default_timeout_ms

[hooks.entries.runtime]
type = "command"
command = "python3"
args = ["hooks/safety_check.py"]
```
</Accordion>

<Accordion title="HookEntryConfig fields">
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `id` | `HookId` | `"hook"` | Unique identifier for the hook |
| `enabled` | `bool` | `true` | Whether the hook is active |
| `point` | `HookPoint` | `TurnBoundary` | Which hook point to fire at |
| `mode` | `HookExecutionMode` | `Foreground` | Foreground or background |
| `capability` | `HookCapability` | `Observe` | What the hook can do |
| `priority` | `i32` | `100` | Execution order (lower runs first) |
| `failure_policy` | `Option<HookFailurePolicy>` | `None` | Compatibility field; runtime failures remain typed engine errors |
| `timeout_ms` | `Option<u64>` | `None` | Override default timeout |
| `runtime` | `HookRuntimeConfig` | in_process/noop | Runtime configuration |
</Accordion>

<Accordion title="Layered config loading">
Hook loading is runtime-root aware. In workspace-derived realms, global and project hook entries are layered (`~/.rkat/config.toml` then `.rkat/config.toml`) and merged with active realm config hooks. In non-workspace realms, realm config is primary and no project config may be discovered.
</Accordion>

## Per-run overrides

The `HookRunOverrides` struct allows per-request hook customization:

```rust
pub struct HookRunOverrides {
    pub entries: Vec<HookEntryConfig>,  // Additional hooks for this run
    pub disable: Vec<HookId>,           // Hook IDs to disable for this run
}
```

Disabled hooks are removed from the effective entry list. Override entries are appended after the filtered base entries. All resulting entries are re-validated.

<Tabs>
  <Tab title="CLI">
    Run-scoped hook override flags are not currently exposed on the normal `rkat run` surface. Use JSON-RPC, REST, MCP, or SDK/embedded surfaces for per-run hook override testing.
  </Tab>
  <Tab title="RPC / REST / MCP">
    Hook overrides are passed via `HookParams`:

    ```json
    {
      "hooks_override": {
        "disable": ["safety-check"],
        "entries": []
      }
    }
    ```
  </Tab>
</Tabs>

## Agent events

The hook engine emits `AgentEvent` variants during execution:

| Event | When |
|-------|------|
| `HookStarted` | A hook begins execution |
| `HookCompleted` | A hook finishes successfully |
| `HookFailed` | A hook encounters an error |
| `HookDenied` | A hook returns a `Deny` decision |

## SDK usage

<Accordion title="Registering an in-process hook">
```rust
use meerkat_hooks::{DefaultHookEngine, InProcessHookHandler, RuntimeHookResponse};
use meerkat_core::{HooksConfig, HookEntryConfig, HookId, HookPoint,
                   HookCapability, HookRuntimeConfig, HookRuntimeKind};
use std::sync::Arc;

let mut config = HooksConfig::default();
config.entries.push(HookEntryConfig {
    id: HookId::new("my-guardrail"),
    point: HookPoint::PreToolExecution,
    capability: HookCapability::Guardrail,
    priority: 10,
    runtime: HookRuntimeConfig::new(
        HookRuntimeKind::InProcess,
        Some(serde_json::json!({"name": "my-handler"})),
    )?,
    ..Default::default()
});

let engine = DefaultHookEngine::new(config)
    .with_in_process_handler(
        "my-handler",
        Arc::new(|invocation| {
            Box::pin(async move {
                // Inspect invocation.tool_call, return decision
                Ok(RuntimeHookResponse::default())
            })
        }),
    );
```
</Accordion>

<Accordion title="Using hook overrides in AgentBuildConfig">
```rust
use meerkat_core::HookRunOverrides;

let overrides = HookRunOverrides {
    disable: vec![HookId::new("noisy-observer")],
    entries: vec![],
};

// Pass via AgentBuildConfig.hooks_override
```
</Accordion>

## See also

- [Examples: Hooks](/examples/hooks)
- [Concepts: Tools](/concepts/tools)
- [Structured output](/guides/structured-output)
