Profiles isolate state. Sandboxes isolate filesystems. The other lever you want is deciding what the agent is allowed to do, when, and what happens after. That’s what hooks are for.

A hook is a rule that fires shell commands, Rhai scripts, or webhooks on lifecycle events. There are seven event types out of the box.

The event surface

EventWhen it fires
pre-tool-useBefore any tool call. Can deny.
tool-useImmediately after a tool call. Informational.
stopWhen the agent exits a turn (waiting for user).
startWhen a profile launches an agent.
notifyWhen the agent calls a notification API (e.g. osascript).
cost-thresholdWhen a profile’s spend crosses a configured limit.
daily-rollupOnce per UTC day. Useful for summary webhooks.

Each event handler receives a small payload (profile name, agent, tool name + args for tool events, etc.) and decides what to do. The handler can be a shell command, a Rhai script, or an HTTP webhook.

Adding a hook

# Audit every tool call to a local log file
ringlet hooks add work --on tool-use \
  --shell 'echo "$(date -u) $RINGLET_PROFILE $RINGLET_TOOL" >> ~/audit.log'

# Page Slack when the work profile spends > $10 in a day
ringlet hooks add work --on cost-threshold \
  --period day --threshold 10 \
  --webhook 'https://hooks.slack.com/services/...'

# Block 'rm -rf' before the tool runs
ringlet hooks add work --on pre-tool-use \
  --shell 'echo "$RINGLET_TOOL_INPUT" | grep -qv "rm -rf"'
# Exit 0 → allow, non-zero → deny

Hooks live in ~/.ringlet/profiles/<name>/hooks/ (per-profile) or ~/.config/ringlet/hooks/ (global). They’re TOML files; the CLI just generates them for you.

Use case: audit log

The simplest valuable hook. Every tool call appended to a log:

ringlet hooks add work --on tool-use \
  --shell 'jq -n --arg t "$(date -u +%FT%TZ)" --arg p "$RINGLET_PROFILE" --arg tool "$RINGLET_TOOL" --arg in "$RINGLET_TOOL_INPUT" "{t:\$t, p:\$p, tool:\$tool, input:\$in}" >> ~/audit.jsonl'

You get one JSON line per tool call. jq queries are easy after the fact:

# What did the agent do today?
jq -r '.tool' ~/audit.jsonl | sort | uniq -c | sort -rn

# Anything destructive?
jq 'select(.input | contains("rm "))' ~/audit.jsonl

If you want a tamper-evident log (hash chain), that’s a planned feature for the team tier. Today the audit log is plain JSON — fine for after-the-fact review, not for cryptographic non-repudiation.

Use case: Slack alerts

ringlet hooks add work --on cost-threshold \
  --period day --threshold 10 \
  --webhook 'https://hooks.slack.com/services/T.../B.../...' \
  --webhook-template '{"text":"⚠️ work profile crossed $10 today: $RINGLET_TOTAL_USD"}'

The --webhook-template is a JSON template with $RINGLET_* variables substituted. The substitution is intentionally limited — no shell expansion, no arbitrary scripts — so the template is safe to share.

Use case: pre-flight checks

The pre-tool-use hook is the only one that can block a tool call. The handler runs synchronously; if it exits non-zero, Ringlet returns an error to the agent before the tool actually executes.

# Block destructive shell calls
ringlet hooks add prod --on pre-tool-use \
  --shell 'case "$RINGLET_TOOL_INPUT" in *"rm -rf"*|*"DROP TABLE"*|*"git push --force"*) exit 1 ;; *) exit 0 ;; esac'

For richer logic, use a Rhai script:

// ~/.ringlet/profiles/prod/hooks/pre-tool-use.rhai

fn on_pre_tool_use(ctx) {
  let input = ctx.tool_input;

  // Block dangerous shell commands.
  if ctx.tool == "bash" {
    let blocked = [
      "rm -rf /",
      "rm -rf ~",
      "git push --force",
      "DROP TABLE",
      ":(){ :|:& };:",
    ];
    for pattern in blocked {
      if input.contains(pattern) {
        return deny(\`Pattern '\${pattern}' is blocked on this profile.\`);
      }
    }
  }

  // Cap the size of file writes.
  if ctx.tool == "write_file" && ctx.tool_input.len() > 1_000_000 {
    return deny("Write > 1MB blocked by hook.");
  }

  return allow();
}

The Rhai sandbox is documented in the Rhai book. It’s effectively a JavaScript-like language with strict typing and no I/O escape hatches by default — you can read from a small allow-listed FS surface but you can’t shell out.

Use case: daily rollups

ringlet hooks add --all-profiles --on daily-rollup \
  --webhook 'https://hooks.slack.com/services/...' \
  --webhook-template '{"text":"📊 Yesterday: $RINGLET_TOTAL_USD across $RINGLET_PROFILE_COUNT profiles. Top: $RINGLET_TOP_PROFILE ($RINGLET_TOP_USD)"}'

Fires at 00:05 UTC. Useful for catching anomalies the day after they happen.

Variables available to hook handlers

Each event sets a slightly different set of $RINGLET_* environment variables:

VariableSet on
$RINGLET_PROFILEAll events
$RINGLET_AGENTAll events
$RINGLET_PROVIDERAll events
$RINGLET_TOOLpre-tool-use, tool-use
$RINGLET_TOOL_INPUTpre-tool-use, tool-use (the JSON-serialised tool input)
$RINGLET_TOOL_RESULTtool-use (after the call, success/error)
$RINGLET_COST_USDcost-threshold (the amount that triggered)
$RINGLET_DURATION_MSstop, daily-rollup
$RINGLET_TOTAL_USDdaily-rollup

Performance

Hook execution is non-blocking for events that don’t gate (tool-use, stop, notify, cost-threshold, daily-rollup). They run on a worker thread; the agent doesn’t wait.

pre-tool-use is gating — Ringlet waits for the handler to return before letting the tool call proceed. Keep these fast. Default timeout is 2 seconds; configurable per-hook.

Where the team tier picks up

Today’s hook system is single-developer. The team tier will add:

  • Centralised hook policies (one Slack webhook URL configured org-wide).
  • Signed audit logs (hash-chained).
  • Per-event webhooks delivered via a queue (no agent-side timeout pressure).
  • Role-based override (manager can bypass a pre-tool-use deny with an approval).

For now, hooks are the right shape for one developer or a small team that wants to roll their own setup. The TOML is portable; you can check it into a dotfiles repo and replay it on a new machine in seconds.