Skip to main content
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 input rewriting.

Hook points

Eight extension points are available:
HookPointClassificationWhen it fires
RunStartedpreStart of Agent::run(), before any LLM call
PreLlmRequestpreBefore each LLM streaming call
PreToolExecutionpreBefore each individual tool call is dispatched
TurnBoundarypreBetween turns, after all tool results are collected
PostLlmResponsepostAfter the LLM response is received
PostToolExecutionpostAfter each tool execution completes
RunCompletedpostAfter a successful run completes
RunFailedpostAfter a run fails with an error
Classification is determined by the is_pre() and is_post() methods on HookPoint. Pre-points block loop progression in foreground mode; post-points publish results asynchronously in background mode.

Capabilities

HookCapabilityPurposeDefault failure policy
ObserveRead-only logging and metricsFailOpen
GuardrailCan issue Deny decisions to block executionFailClosed
RewriteCan return HookPatch values to mutate dataFailClosed
Default failure policies can be overridden per-hook via the failure_policy field.

Execution modes

HookExecutionModeBehavior
ForegroundBlocks loop progression. Decision and patches are applied synchronously.
BackgroundRuns asynchronously. Patches are published via HookPatchEnvelope.
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.
Background hooks must use FailOpen policy. A background hook with an effective policy of FailClosed is rejected with HookEngineError::InvalidConfiguration.
Background hooks on post-points publish patches via HookPatchEnvelope (containing revision, hook_id, point, patch, published_at). These envelopes are drained and included in the HookExecutionReport on subsequent execute() calls.

How to add a hook

1

Choose hook point and capability

Decide which HookPoint to fire at (e.g., PreToolExecution for tool guardrails) and which HookCapability you need (Observe, Guardrail, or Rewrite).
2

Choose a runtime

Pick one of the three runtimes: in-process (Rust closure), command (subprocess), or HTTP (remote endpoint).
3

Add configuration

Add a [[hooks.entries]] block to .rkat/config.toml or register programmatically via HooksConfig.
4

Implement the handler

Write the handler that receives a HookInvocation and returns a RuntimeHookResponse with an optional decision and patches.
5

Test with overrides

Use HookRunOverrides to test your hook in isolation before committing the config.

Runtimes

Calls a registered Rust closure. Config:
{ "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:
type InProcessHookHandler = Arc<dyn Fn(HookInvocation) -> HandlerFuture + Send + Sync>;

Failure policies

HookFailurePolicyOn timeoutOn runtime error
FailOpenLogged as error, execution continuesLogged as error, execution continues
FailClosedEmits Deny with HookReasonCode::TimeoutEmits Deny with HookReasonCode::RuntimeError
The effective policy is resolved by HookEntryConfig::effective_failure_policy():
  • If failure_policy is explicitly set on the entry, that value is used.
  • Otherwise, default_failure_policy(capability) is applied.

Decisions and patches

pub enum HookDecision {
    Allow,
    Deny {
        hook_id: HookId,
        reason_code: HookReasonCode,
        message: String,
        payload: Option<Value>,  // optional structured data
    },
}
Reason codes:
HookReasonCodeMeaning
PolicyViolationBusiness rule or policy constraint
SafetyViolationContent safety check
SchemaViolationSchema or format validation failure
TimeoutHook timed out (system-generated)
RuntimeErrorHook execution failed (system-generated)
Each variant is valid only at certain hook points:
HookPatch variantFieldsValid at
LlmRequestmax_tokens: Option<u32>, temperature: Option<f32>, provider_params: Option<Value>PreLlmRequest
AssistantTexttext: StringPostLlmResponse
ToolArgsargs: ValuePreToolExecution
ToolResultcontent: String, is_error: Option<bool>PostToolExecution
RunResulttext: StringRunCompleted

Runtime hook response

All three runtimes return the same RuntimeHookResponse structure:
{
  "decision": { "decision": "deny", "hook_id": "my-guard", "reason_code": "policy_violation", "message": "blocked" },
  "patches": [
    { "patch_type": "tool_args", "args": { "sanitized": true } }
  ]
}
Both decision and patches are optional. An empty response {} is equivalent to “no opinion”.

Invocation payload

Hooks receive a HookInvocation struct containing contextual data. Fields are populated based on the hook point:
FieldTypePopulated at
pointHookPointAlways
session_idSessionIdAlways
turn_numberOption<u32>Most points
promptOption<String>RunStarted
errorOption<String>RunFailed
llm_requestOption<HookLlmRequest>PreLlmRequest
llm_responseOption<HookLlmResponse>PostLlmResponse
tool_callOption<HookToolCall>PreToolExecution
tool_resultOption<HookToolResult>PostToolExecution
  • 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, is_error

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

Record denial

The denial is recorded as the merged decision.
2

Skip remaining foreground hooks

All remaining foreground hooks are skipped (short-circuit).
3

Skip background hooks

All background hooks are skipped (they only fire when no foreground Deny occurred).
A priority-1 guardrail that denies will prevent a priority-100 observer from running.

Configuration

Hook configuration lives under the [hooks] table:
[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"  # Optional; defaults based on capability
# timeout_ms = 10000              # Optional; overrides default_timeout_ms

[hooks.entries.runtime]
type = "command"
command = "python3"
args = ["hooks/safety_check.py"]
FieldTypeDefaultDescription
idHookId"hook"Unique identifier for the hook
enabledbooltrueWhether the hook is active
pointHookPointTurnBoundaryWhich hook point to fire at
modeHookExecutionModeForegroundForeground or background
capabilityHookCapabilityObserveWhat the hook can do
priorityi32100Execution order (lower runs first)
failure_policyOption<HookFailurePolicy>NoneOverride default failure policy
timeout_msOption<u64>NoneOverride default timeout
runtimeHookRuntimeConfigin_process/noopRuntime configuration
Hook entries from global config (~/.rkat/config.toml) and project config (.rkat/config.toml) are combined additively via HooksConfig::append_entries_from(). Global entries load first, then project entries are appended after them.

Per-run overrides

The HookRunOverrides struct allows per-request hook customization:
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.
# Inline JSON
rkat run "prompt" --hooks-override-json '{"disable":["safety-check"]}'

# From file
rkat run "prompt" --hooks-override-file overrides.json

Agent events

The hook engine emits AgentEvent variants during execution:
EventWhen
HookStartedA hook begins execution
HookCompletedA hook finishes successfully
HookFailedA hook encounters an error
HookDeniedA hook returns a Deny decision
HookPatchPublishedA background hook publishes a patch envelope

SDK usage

use meerkat_hooks::{DefaultHookEngine, InProcessHookHandler, RuntimeHookResponse};
use meerkat_core::{HooksConfig, HookEntryConfig, HookId, HookPoint,
                   HookCapability, HookRuntimeConfig};
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(
        "in_process",
        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())
            })
        }),
    );
use meerkat_core::HookRunOverrides;

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

// Pass via AgentBuildConfig.hooks_override