OpenClaw Hooks Guide: Building Event-Driven Agent Workflows
OpenClaw hooks let you wire actions directly to system events — file saves, long-running task completions, cron triggers, tool calls. Instead of polling or waiting, your agent reacts. Here's how I use them in production and what I've learned building event-driven workflows on top of the hooks system.
Introduction to Hooks
A hook is a registered callback that fires when a specific event occurs in the OpenClaw runtime. The agent doesn't need to be in the middle of a conversation — hooks execute autonomously in response to the event, then optionally surface a result back into your session or a configured channel.
Think of hooks as the difference between a smoke detector and a person checking for fire every five minutes. One is reactive; the other wastes cycles. Hooks make your agent reactive.
Where skills teach the agent how to do something (see Building Custom Skills), hooks determine when something runs. They're complementary. A hook fires an event; a skill gives the agent the tools to handle it.
Hooks sit in openclaw.json under the hooks key. Each hook has a type, a trigger definition, and an action block. The runtime evaluates triggers continuously and dispatches actions when conditions are met.
Available Hook Types
OpenClaw ships with three categories of hooks. Understanding which category to reach for saves a lot of trial and error.
Pre-Execution Hooks
Pre-execution hooks fire before a tool call or agent action runs. Use them to validate inputs, inject context, log intent, or block unsafe operations.
// Pre-execution hook: fires before every file write
{
"type": "pre-execution",
"event": "tool:write",
"action": {
"type": "shell",
"command": "echo \"[$(date)] write to $HOOK_TARGET_PATH\" >> ~/agent-writes.log"
}
}The runtime injects context variables into the environment — HOOK_TARGET_PATH, HOOK_TOOL_NAME, HOOK_SESSION_ID — so your shell commands or scripts have full context without extra plumbing.
Pre-execution hooks can also return a non-zero exit code to abort the action. This is how you build guardrails: a hook that checks a path against a blocklist and exits 1 prevents the write entirely.
Post-Execution Hooks
Post-execution hooks fire after a tool call completes, with access to the result. Use them for notifications, backups, audit trails, or triggering follow-on work.
// Post-execution hook: fires after every shell exec
{
"type": "post-execution",
"event": "tool:exec",
"filter": {
"duration_ms": { "gte": 30000 }
},
"action": {
"type": "notify",
"channel": "telegram",
"message": "Long command finished: {{HOOK_COMMAND}} ({{HOOK_DURATION_S}}s)"
}
}The filter block narrows when the hook fires. Without a filter, every exec triggers a notification — noisy. With duration_ms ≥ 30000, only commands taking 30+ seconds notify you. This is the pattern I use most: coarse event + tight filter.
System Event Hooks
System event hooks fire on lifecycle events that aren't tied to individual tool calls: session start, session end, cron schedule, file system watch, and external webhooks.
// System hook: fires on cron schedule
{
"type": "system",
"event": "cron",
"schedule": "0 9 * * 1-5",
"action": {
"type": "agent",
"prompt": "Run the morning briefing: check calendar, email summary, top 3 priorities."
}
}// System hook: fires when a file changes
{
"type": "system",
"event": "fs:watch",
"path": "~/projects/my-app/src/**/*.ts",
"action": {
"type": "shell",
"command": "bash ~/scripts/backup-changed.sh $HOOK_CHANGED_FILE"
}
}System hooks are where the real automation lives. Cron-based hooks replace manual scheduled tasks. File watch hooks replace polling loops. Webhook hooks let external systems trigger agent actions — a Stripe payment fires a hook that provisions a user, for example.
Configuration Pattern
All hooks live in openclaw.json at the top level of your workspace or home directory. The full structure:
{
"hooks": [
{
"id": "unique-hook-id",
"name": "Human-readable name",
"enabled": true,
"type": "pre-execution | post-execution | system",
"event": "tool:write | tool:exec | tool:read | session:start | session:end | cron | fs:watch | webhook",
"filter": {
// optional — narrows when hook fires
},
"action": {
"type": "shell | notify | agent | http",
// type-specific fields
},
"on_error": "ignore | abort | notify"
}
]
}Action Types
Four action types cover most use cases:
- shell — Run a bash command or script. Gets full environment injection. Exit code controls success/failure.
- notify — Send a message to a configured channel (Telegram, Discord, Slack). Supports template variables.
- agent — Spawn a new agent session with a given prompt. Results can be piped back or sent to a channel.
- http — Make an HTTP request to an external endpoint. Useful for webhooks, third-party integrations, logging services.
Environment Variables Injected at Runtime
# Available in all hook actions
HOOK_ID # Hook's unique ID
HOOK_EVENT # Event that triggered this hook
HOOK_SESSION_ID # Current OpenClaw session ID
HOOK_TIMESTAMP # Unix timestamp of trigger
# Tool-specific (pre/post execution hooks)
HOOK_TOOL_NAME # Name of the tool being called
HOOK_TARGET_PATH # File path (for read/write hooks)
HOOK_COMMAND # Shell command (for exec hooks)
HOOK_DURATION_MS # Execution duration in ms (post only)
HOOK_EXIT_CODE # Tool exit code (post only)
HOOK_OUTPUT # Tool stdout, truncated to 4096 chars (post only)
# File watch hooks
HOOK_CHANGED_FILE # Absolute path of changed file
HOOK_CHANGE_TYPE # "created" | "modified" | "deleted"Reload After Editing
Hooks are read at gateway startup. After editing openclaw.json, reload the gateway:
openclaw gateway restartVerify hooks loaded cleanly — the gateway logs hook registration at startup. Check with:
openclaw gateway statusPractical Examples
Example 1: Auto-Backup on File Edit
I keep working copies of critical configs in a watched directory. Every time any .json or .md file changes, this hook creates a timestamped backup in ~/backups/.
// openclaw.json
{
"id": "auto-backup-on-edit",
"name": "Auto-backup config and doc edits",
"enabled": true,
"type": "system",
"event": "fs:watch",
"path": "~/.openclaw/workspace/**/*.{json,md}",
"filter": {
"change_type": ["created", "modified"]
},
"action": {
"type": "shell",
"command": "bash ~/scripts/backup-file.sh"
},
"on_error": "notify"
}The backup script (~/scripts/backup-file.sh):
#!/usr/bin/env bash
set -euo pipefail
FILE="$HOOK_CHANGED_FILE"
BACKUP_DIR="$HOME/backups/openclaw-workspace"
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
# Preserve directory structure under backup root
REL_PATH="FILE#$HOME/.openclaw/workspace/"
DEST="$BACKUP_DIR/$REL_PATH.$TIMESTAMP.bak"
mkdir -p "$(dirname "$DEST")"
cp "$FILE" "$DEST"
# Keep only last 10 backups per file
ls -t "$BACKUP_DIR/$REL_PATH."*.bak 2>/dev/null | tail -n +11 | xargs rm -f
echo "Backed up: $FILE → $DEST"The on_error: "notify" setting means if the backup fails (disk full, permission error), I get a Telegram message rather than silent failure. Critical for anything touching production data.
Example 2: Notification on Long-Running Task Completion
Build processes, data imports, and model fine-tunes can run 5-30 minutes. Instead of babysitting the terminal, this hook fires when any exec takes over 60 seconds and sends me a Telegram notification with the command, duration, and exit code.
{
"id": "long-run-notify",
"name": "Notify on long-running command finish",
"enabled": true,
"type": "post-execution",
"event": "tool:exec",
"filter": {
"duration_ms": { "gte": 60000 }
},
"action": {
"type": "notify",
"channel": "telegram",
"message": "Done"
},
"on_error": "ignore"
}The {{HOOK_DURATION_S}} template variable is computed automatically — it's HOOK_DURATION_MS / 1000, rounded. You don't need to do the math in your message template.
For failed commands specifically, add a filter on exit code and use a different message:
{
"id": "long-run-failure-notify",
"name": "Alert on long-running command failure",
"enabled": true,
"type": "post-execution",
"event": "tool:exec",
"filter": {
"duration_ms": { "gte": 60000 },
"exit_code": { "neq": 0 }
},
"action": {
"type": "notify",
"channel": "telegram",
"message": "Failed"
},
"on_error": "ignore"
}Example 3: MCP Server Health Check on Session Start
I run several MCP servers (see OpenClaw MCP Server Guide). A session-start hook pings each one and logs the result, so I know before starting work whether any server is down.
{
"id": "mcp-healthcheck-on-start",
"name": "Check MCP servers at session start",
"enabled": true,
"type": "system",
"event": "session:start",
"action": {
"type": "shell",
"command": "bash ~/scripts/mcp-healthcheck.sh"
},
"on_error": "notify"
}#!/usr/bin/env bash
# ~/scripts/mcp-healthcheck.sh
SERVERS=(
"http://localhost:3100/health"
"http://localhost:3101/health"
"http://localhost:3200/health"
)
FAILED=()
for URL in "${SERVERS[@]}"; do
STATUS=$(curl -s -o /dev/null -w "%{http_code}" --max-time 3 "$URL")
if [ "$STATUS" != "200" ]; then
FAILED+=("$URL ($STATUS)")
fi
done
if [ ${#FAILED[@]} -gt 0 ]; then
echo "MCP servers DOWN:"
printf ' %s
' "${FAILED[@]}"
exit 1
fi
echo "All MCP servers healthy"Custom Hook Development
Beyond the built-in action types, you can write fully custom hook handlers as standalone scripts. The pattern is: hook action calls a script; script does the work. This keepsopenclaw.json clean and makes hook logic testable independently.
Hook Script Conventions
- Store hook scripts in
~/scripts/hooks/— separate from general scripts for clarity. - Always
set -euo pipefailat the top — you want failures to surface, not silently continue. - Log to a dedicated file:
~/logs/hooks.log. Include timestamp, hook ID, and outcome. - Exit 0 on success, non-zero on failure. The runtime uses exit code to determine
on_errorbehavior.
Structured Hook Logger
A reusable logging helper for all your hook scripts:
#!/usr/bin/env bash
# ~/scripts/hooks/lib/log.sh — source this from hook scripts
LOG_FILE="${HOOK_LOG_FILE:-$HOME/logs/hooks.log}"
mkdir -p "$(dirname "$LOG_FILE")"
hook_log() {
local level="$1"; shift
echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] [$level] [hook:$HOOK_ID] $*" >> "$LOG_FILE"
}
hook_info() { hook_log INFO "$@"; }
hook_warn() { hook_log WARN "$@"; }
hook_error() { hook_log ERROR "$@"; >&2 echo "ERROR: $*"; }Using it in a hook script:
#!/usr/bin/env bash
set -euo pipefail
source ~/scripts/hooks/lib/log.sh
hook_info "Starting backup for $HOOK_CHANGED_FILE"
# ... your logic ...
hook_info "Backup complete"
exit 0TypeScript Hook Actions (via ts-node)
For hooks that need richer logic — API calls, structured data parsing, conditional branching — TypeScript via ts-node is cleaner than bash:
// ~/scripts/hooks/on-file-edit.ts
import fs from "fs";
import path from "path";
const filePath = process.env.HOOK_CHANGED_FILE!;
const ext = path.extname(filePath);
// Only backup TypeScript files
if (![".ts", ".tsx"].includes(ext)) {
process.exit(0);
}
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const dest = `${process.env.HOME}/backups/ts/${path.basename(filePath)}.${timestamp}.bak`;
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.copyFileSync(filePath, dest);
console.log(`Backed up ${filePath} → ${dest}`);Wire it in openclaw.json:
{
"action": {
"type": "shell",
"command": "ts-node ~/scripts/hooks/on-file-edit.ts"
}
}Chaining Hooks
Hooks can trigger follow-on agent sessions, which can trigger further hooks. This is how you build multi-step event-driven workflows:
- File changes →
fs:watchhook fires → backs up file + creates a review task - Agent session runs review → writes a summary file
- Summary file write → another
fs:watchhook → notifies via Telegram
Keep chains short (2-3 steps max). Deeper chains are hard to debug and can loop if you watch files your own hooks write.
Troubleshooting & Best Practices
Hook Not Firing
Work through this checklist in order:
- Gateway running? —
openclaw gateway status. If not running, hooks don't fire. - Hook enabled? — Check
"enabled": truein config. - JSON valid? — A syntax error anywhere in
openclaw.jsonprevents all hooks from loading. Runjq . openclaw.jsonto validate. - Event name correct? — Event strings are case-sensitive:
tool:writenottool:Write. - Filter too narrow? — Temporarily remove the filter to confirm the hook fires at all, then re-add the filter.
Hook Fires But Script Fails
# Check the gateway log for hook execution details
openclaw gateway logs --tail 100 | grep HOOK
# Run your hook script manually with fake env vars
HOOK_ID=test \
HOOK_EVENT=fs:watch \
HOOK_CHANGED_FILE=/tmp/test.md \
HOOK_CHANGE_TYPE=modified \
bash ~/scripts/hooks/my-hook.shAvoiding Hook Storms
File watch hooks on broad globs can fire hundreds of times during a build (every emitted file triggers the hook). Protect against this with debouncing:
{
"type": "system",
"event": "fs:watch",
"path": "~/projects/**/*.js",
"debounce_ms": 5000,
"action": { ... }
}With debounce_ms: 5000, the hook fires at most once per 5 seconds regardless of how many files change. The action sees HOOK_CHANGED_FILE set to the most-recently changed file in that window.
Performance Rules
- Keep hook scripts under 500ms for pre-execution hooks — they block the tool call. Post-execution and system hooks can run async.
- Use
"on_error": "ignore"for non-critical hooks (notifications, logging). Use"abort"only for guardrails that must block. - Don't write to watched paths inside a hook that watches the same path. Loop prevention requires explicit path exclusions or debouncing.
- Rotate hook logs. A
logrotateconfig or a weekly cron that trims~/logs/hooks.logprevents unbounded disk growth.
Testing Hooks Before Deploying
# 1. Validate JSON
jq . ~/.openclaw/openclaw.json && echo "JSON valid"
# 2. Restart gateway to pick up changes
openclaw gateway restart
# 3. Trigger the event manually
# For fs:watch — touch the watched file:
touch ~/.openclaw/workspace/test-trigger.md
# For tool:exec — run a long command:
sleep 65
# 4. Check hook fired
openclaw gateway logs --tail 50 | grep "hook:your-hook-id"
# 5. Check script output
tail -20 ~/logs/hooks.logFrequently Asked Questions
Can a pre-execution hook cancel a tool call?
Yes. Return a non-zero exit code from a shell action in a pre-execution hook and the tool call is aborted. The agent receives an error indicating the hook blocked execution. Use this for path blocklists, rate-limit guards, or requiring confirmation before destructive operations.
How many hooks can I register?
There's no hard cap in the runtime. Practically, I'd keep it under 50 active hooks — beyond that, startup time increases and debugging becomes difficult. If you're approaching that number, consolidate: one shell script that handles multiple cases beats ten individual hooks doing similar things.
Do hooks run in the same process as the agent?
No. Shell and script actions run in a subprocess. Agent actions spawn a new session. This isolation means a hook script crash doesn't take down your main session, but it also means hooks can't directly read or write the agent's in-memory state — they interact through files, environment variables, and the OpenClaw gateway API.
Can I use hooks with skills together?
Absolutely — this is the intended pattern. A hook fires, spawns an agent session with a prompt, and the agent session uses a skill to handle the work. For example: a session:start hook triggers a morning briefing prompt, and the agent uses the gog skill to pull calendar and email. See Building Custom Skills for how to build the skill side of this pairing.
How do I pass secrets to hook scripts?
Don't put secrets in openclaw.json. Instead, store them in environment variables in your shell profile (~/.zshrc or ~/.bashrc), or use the 1Password CLI to inject them at script runtime: op run --env-file=.env -- bash my-hook.sh. The OpenClaw gateway inherits the environment of the process that started it, so vars set before openclaw gateway start are available in all hook scripts.
The Bottom Line
Hooks are what separate an agent you talk to from an agent that works while you're not watching. The setup is a one-time cost — write the config, write the script, restart the gateway — and the payoff is automation that runs without intervention.
Start with two hooks: a long-run notification (post-execution, 60s filter) and a session-start health check. Those two alone change how you work. Once those are running, add file watch hooks and cron triggers as you identify manual tasks worth automating.
Hooks are infrastructure. Build them once, iterate quietly, and let them accumulate. A system with 20 well-tuned hooks running in the background is a fundamentally different thing than an agent waiting for you to type.
Continue Learning
Ready to build?
Get the OpenClaw Starter Kit — config templates, 5 production-ready skills, deployment checklist. Go from zero to running in under an hour.
$14 $6.99
Get the Starter Kit →Also in the OpenClaw store
Get the free OpenClaw quickstart guide
Step-by-step setup. Plain English. No jargon.