LLM Hooks

LLM Hooks let you define policy enforcement rules as markdown files. Each hook contains a natural language prompt that an LLM evaluates at runtime to decide whether to approve, deny, or warn about an operation.

How it works

mermaid
flowchart LR
    A[Event fires] --> B{Condition match?}
    B -->|no| C[Skip]
    B -->|yes| D{Cached?}
    D -->|yes| E[Return cached result]
    D -->|no| F[Call LLM]
    F --> G{Decision}
    G -->|approve| H[Allow]
    G -->|deny| I[Block]
    G -->|warn| J[Warn + allow]

Hook files

Hooks are markdown files with YAML frontmatter, stored in two directories:

DirectoryScope
.mayros/hooks/Project-level hooks
~/.mayros/hooks/User-level hooks (global)

Format

markdown
---
name: "no-force-push"
events: "before_tool_call"
description: "Block force pushes to protected branches"
condition: 'params.command.includes("push") && params.command.includes("--force")'
model: "anthropic/claude-sonnet-4-20250514"
timeout: 20000
cache: "global"
priority: 150
enabled: "true"
---

# No Force Push Policy

Evaluate whether this git push command targets a protected branch.
If the command uses --force and targets main, master, or production branches,
deny the operation. Otherwise, approve it.

Context: {{context}}

Frontmatter fields

FieldRequiredDefaultDescription
nameyesUnique hook name
eventsyesComma-separated event names
descriptionnoHuman-readable description
conditionnoSafe expression (evaluated without eval)
modelnoconfig defaultLLM model override
timeoutno15000Evaluation timeout (ms)
cachenosessionCache scope: none, session, global
priorityno100Higher = runs earlier
enablednotrueEnable/disable hook

Safe condition language

Conditions use a safe expression parser — no eval is used. The grammar supports:

expr        → orExpr
orExpr      → andExpr ( "||" andExpr )*
andExpr     → notExpr ( "&&" notExpr )*
notExpr     → "!" notExpr | comparison
comparison  → primary ( ("==" | "!=") primary )?
primary     → "true" | "false" | string | propertyChain | "(" expr ")"

Supported methods: .includes(arg), .startsWith(arg), .endsWith(arg)

Examples:

toolName == "exec"
params.command.includes("rm")
!sessionKey
toolName == "exec" && params.command.startsWith("git")

If a condition fails to parse, it defaults to true (hook always runs).

Supported events

EventHook typeHandler output
before_tool_callModifying{ block, blockReason } on deny
after_tool_callVoid
message_sendingModifying{ cancel, cancelReason } or { modified }
before_prompt_buildModifying{ prependContext } on warn
before_agent_startModifying{ prependContext } on deny
session_startVoidClears caches
session_endVoidClears caches

When multiple hooks match an event, they run in priority order. The first deny result short-circuits — remaining hooks are skipped.

Caching

LLM evaluations are cached to avoid redundant calls:

ScopeBehaviorCleared
noneNever cached
sessionCached per sessionOn session_start / session_end
globalCached across sessionsTTL-based (default: 5 minutes)

Cache keys are derived from the hook body hash + context hash (SHA256).

LLM response format

The LLM must return a JSON object:

json
{
  "decision": "approve",
  "reason": "The command only reads from a safe branch"
}

Valid decisions: approve, deny, warn.

Configuration

json5
{
  llmHooks: {
    enabled: true,
    projectHooksDir: ".mayros/hooks",
    userHooksDir: "~/.mayros/hooks",
    defaultModel: "anthropic/claude-sonnet-4-20250514",
    defaultTimeoutMs: 15000,          // 1000–120000
    defaultCache: "session",          // "none" | "session" | "global"
    maxConcurrentEvals: 3,            // 1–10
    globalCacheTtlMs: 300000,         // >= 10000 (5 min default)
  }
}

CLI

bash
mayros llm-hooks list                    # Show all hooks (status, events, priority)
mayros llm-hooks test hook-file.md       # Dry-run hook (--tool, --params)
mayros llm-hooks cache                   # Cache statistics
mayros llm-hooks reload                  # Reload hooks from disk