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
| Event | When it fires |
|---|---|
pre-tool-use | Before any tool call. Can deny. |
tool-use | Immediately after a tool call. Informational. |
stop | When the agent exits a turn (waiting for user). |
start | When a profile launches an agent. |
notify | When the agent calls a notification API (e.g. osascript). |
cost-threshold | When a profile’s spend crosses a configured limit. |
daily-rollup | Once 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:
| Variable | Set on |
|---|---|
$RINGLET_PROFILE | All events |
$RINGLET_AGENT | All events |
$RINGLET_PROVIDER | All events |
$RINGLET_TOOL | pre-tool-use, tool-use |
$RINGLET_TOOL_INPUT | pre-tool-use, tool-use (the JSON-serialised tool input) |
$RINGLET_TOOL_RESULT | tool-use (after the call, success/error) |
$RINGLET_COST_USD | cost-threshold (the amount that triggered) |
$RINGLET_DURATION_MS | stop, daily-rollup |
$RINGLET_TOTAL_USD | daily-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.