Configure hooks

Run small scripts at lifecycle points without forking Cantrip.

What hooks are for

A hook is a shell command Cantrip runs at a specific event — every tool call, every compaction, every subagent start — with a JSON payload piped to the hook’s stdin. Use them for local policy (audit logs, Slack notifications, custom linters you want to trigger before packing) without changing Cantrip itself.

Hooks start in observer mode by default and can opt into veto mode with continue_on_error: false — see Vetoing operations below. A pre_tool_call hook can also rewrite the pending tool arguments before the tool runs — see Rewriting arguments.

Where to configure

Cantrip reads two YAML files, in order:

  1. User scope: ~/.config/cantrip/hooks.yaml (override via CANTRIP_HOOKS_USER_CONFIG) — the right place for personal audits, experiments, or shared tooling you want on every charm.
  2. Repo scope: cantrip.hooks.yaml next to your charm directory — for project-specific hooks that should be committed. Repo hooks with a matching name override user-scope hooks.

A malformed file is logged at WARNING and skipped rather than crashing the agent, so a typo in one place never takes the other down.

Schema

hooks:
  - name: log-git-pushes
    event: pre_tool_call
    if: tool == "git_push"
    run: logger -t cantrip "pushing $(jq -r .arguments.branch)"
    timeout: 5
    continue_on_error: true

Fields:

Conditional filters

The optional if: expression gates a hook on payload shape so you don’t need to write shell guards in every run:. Common patterns:

# Only audit git pushes, not every tool call.
- event: pre_tool_call
  if: tool == "git_push"
  run: ...

# Only react to builds the agent actually finished.
- event: post_subagent
  if: category == "BUILD" and exit_state == "completed"
  run: ...

# Don't notify for fast compactions.
- event: post_compact
  if: tokens_before > 100000
  run: ...

# Multiple values: set membership.
- event: pre_tool_call
  if: tool in ["git_push", "git_commit", "charmcraft_upload"]
  run: ...

# Nested fields walk through the payload.
- event: pre_tool_call
  if: tool == "juju_deploy" and arguments.channel == "edge"
  run: ...

What the expression language supports: comparisons (==, !=, <, <=, >, >=, in, not in), boolean combinators (and, or, not), nested field access (arguments.branch, task.category), subscripts (arguments["branch"]), and string / number / list literals.

What it doesn’t support: function calls, method calls (tool.startswith(…)), comprehensions, lambdas, imports — rejected at config-load time with a clear error. Substring checks work via "git" in tool.

Missing fields are safe. If a filter references a payload key the event doesn’t carry (e.g. task.category on a pre_compact event), the comparison evaluates to False and the hook is skipped. You can write one hook config that targets fields from several event shapes without worrying about KeyError crashes.

Events

The following events are fired today:

Event Fires when Payload keys (in addition to event and timestamp)
pre_tool_call Before every tool invocation (main agent or subagent). tool, arguments, source (main, main-stream, or subagent), task_category (subagent only)
post_tool_call After every tool invocation completes or errors. tool, arguments, success, error, source, task_category
pre_compact Just before context compaction runs. tokens_before, source
post_compact After compaction completes (even if it falls back to truncation). tokens_before, tokens_after, source
pre_subagent Before a subagent starts a task. task_id, title, category
post_subagent After the subagent returns (even on failure). task_id, title, category, exit_state

The following event names are reserved for future use: pre_pack, pre_push, pre_pr, on_task_complete, on_session_end. You can register hooks for them today — they will just never fire until the matching agent code lands.

Payload shape

Every hook receives a JSON object on stdin with at least three fields:

The remaining fields vary by event, as listed above. Parse with jq, or python -c 'import json, sys; d = json.load(sys.stdin)', or whatever your hook prefers.

Examples

Audit every tool call to a log file

hooks:
  - name: audit-tools
    event: post_tool_call
    run: |
      python3 -c 'import json, sys, datetime
      d = json.load(sys.stdin)
      with open("/tmp/cantrip-tools.log", "a") as f:
          f.write(f"{datetime.datetime.now().isoformat()} {d[\"tool\"]} ok={d[\"success\"]}\n")'
    timeout: 5

Slack notification when compaction fires

hooks:
  - name: slack-compact
    event: post_compact
    run: |
      jq -c '{text: "Cantrip compacted: \(.tokens_before) -> \(.tokens_after) tokens"}' \
        | curl -s -X POST -H 'Content-Type: application/json' \
          -d @- https://hooks.slack.com/services/XXX/YYY/ZZZ
    timeout: 10

Count how often each tool is called, per session

hooks:
  - name: tool-counter
    event: pre_tool_call
    run: |
      jq -r .tool | tee -a ~/.cache/cantrip/tool-counts

Vetoing operations

A hook with continue_on_error: false can refuse the pending operation by exiting non-zero (or by timing out). Use it to enforce local policy:

# Refuse git pushes when the working tree has uncommitted changes.
- name: require-clean-tree
  event: pre_tool_call
  if: tool == "git_push"
  continue_on_error: false
  run: |
    if ! git diff --quiet; then
      echo "working tree dirty; refusing to push" >&2
      exit 1
    fi

# Block compaction while a user-pinned session is in progress.
- name: pin-context
  event: pre_compact
  continue_on_error: false
  run: test ! -f ~/.cache/cantrip/pinned

What happens when a pre-hook vetoes:

Filter observability hooks on if: vetoed_by != None to get a feed of blocked-only events.

The default (continue_on_error: true) is unchanged from earlier releases — a failing lenient hook logs at DEBUG and doesn’t block anything. Only continue_on_error: false turns a hook into an enforcer.

Rewriting arguments

A pre_tool_call hook can rewrite the arguments a tool is about to receive by printing a JSON envelope to stdout:

{"mutate": {"arguments": {"branch": "main", "token": "[REDACTED]"}}}

The mutate.arguments object, when present, wholly replaces the tool's arguments before the tool runs. Typical uses: strip secrets from a run_shell command, canonicalise a filename, normalise a branch name before git_push.

# Redact anything that looks like a token before it hits run_shell.
- name: redact-tokens
  event: pre_tool_call
  if: tool == "run_shell"
  run: |
    payload=$(cat)
    echo "$payload" | jq '
      .arguments as $args
      | {"mutate": {"arguments": ($args | .command |= gsub(
            "ghp_[A-Za-z0-9]+"; "[REDACTED]"
        ))}}
    '

Rules:

post_tool_call and the session transcript record the effective arguments — the ones that actually ran — so the audit trail reflects the mutated call.

Inspecting hooks

Two debugging surfaces answer “is my hook wired right?” without having to trigger a real agent operation:

/hooks slash command

Available in the TUI, CLI REPL, and Web chat. Lists every configured hook grouped by event with its if: filter and (when continue_on_error: false) a veto-capable badge; for hooks that have fired this session, shows per-hook counts (invocations, successes, failures, vetoes, timeouts), average duration, and last-seen time:

/hooks
  Configured: 3 hook(s).

  pre_tool_call
    - `require-clean-tree`  if `tool == "git_push"`  veto-capable
        5 invocations (4 ok, 1 failed, 1 vetoed) · avg 23ms · last at 14:12:03
    - `audit-tools`
        147 invocations (147 ok, 0 failed) · avg 8ms · last at 14:12:04

  post_compact
    - `slack-compact`
        not invoked yet

  Transcript logging: on — hook_invocation events carry per-call detail
  in the session store.

cantrip hooks test <event>

Fires a synthetic event against the current hook config and prints results — great for authoring a new hook config before letting it touch a live session. Exit code is 0 on success, 2 on argument / JSON errors:

$ cantrip hooks test pre_tool_call --payload '{"tool": "git_push"}'
Firing `pre_tool_call` against 2 matching hook(s).
  ✓ audit-tools — exit 0 in 5ms
      stdout: saw git_push at 14:12:09
  ∅ require-clean-tree — exit 1 in 12ms (VETO)
      stderr: working tree dirty; refusing to push

--path DIR overrides the repo root for cantrip.hooks.yaml discovery (defaults to CWD). --payload is merged into the auto-added event + timestamp fields so your if: filter evaluates against realistic input.

Transcript events

Every executed hook records a hook_invocation event in the session store with hook_name, event, exit_code, duration_seconds, vetoed, timed_out, continue_on_error, and (when stderr isn’t empty) a 200-char stderr_excerpt. Skipped hooks (rejected by their if: filter) do not record events — the transcript is an audit log of real executions, not an attempt log.

Current limits