Hooks let you intercept and react to agent lifecycle events — running shell scripts or LLM-based checks at key moments without touching the core runtime.
Hooks are configured in JSON files loaded once at startup and merged in order (workspace entries appended after global):
| Scope | Path |
|---|---|
| Global | ~/.semaclaw/hooks.json |
| Per-workspace | <workingDir>/.semaclaw/hooks.json |
Edit the global config directly from the Web UI under Plugins → Hooks. Changes to hooks.json take effect after restarting SemaClaw.
| Event | When it fires | Blocking? |
|---|---|---|
UserPromptSubmit |
After user message enters processQuery, before first LLM call |
No (context injection only) |
PreToolUse |
Before each tool executes, before permission check | Yes |
PostToolUse |
After each tool completes | No (post-hoc) |
PermissionRequest |
When a permission approval request is raised | Yes |
Stop |
When agent finishes the current reply | No (post-hoc) |
SessionStart |
After a new session is initialized | No |
PreCompact |
Before context compaction runs | No (awaited) |
PostCompact |
After context compaction finishes | No (post-hoc) |
PermissionRequest note: hooks only fire when SemaClaw actually raises a permission prompt. If
skipBashExecPermission/skipFileEditPermission/ etc. are set, those tool categories are auto-approved and skip this event. UsePreToolUsefor unconditional interception.
Matched against the tool name for tool events, or the full query text for UserPromptSubmit.
| Pattern | Matches |
|---|---|
"Bash" |
exactly Bash |
"Bash,Write" |
Bash or Write |
"Bash*" |
anything starting with Bash |
"*" |
everything |
| (omitted) | everything (same as "*") |
Secondary filter on the tool input content. For Bash, it matches against tool_input.command; for all other tools, against the JSON-serialized tool_input.
"if": "rm -rf" // contains "rm -rf"
"if": "^git (commit|push)" // starts with git commit or git push
"if": "\\.(env|key|pem)$" // ends with .env, .key, or .pem| Config | Behavior | Typical use |
|---|---|---|
| default | Sync, failure blocks agent | Safety checks |
"blocking": false |
Sync, failure does not block | Low-latency logging |
"blocking": false, "async": true |
Fire-and-forget | Slow audit uploads |
Auto-correction rules applied at config load time:
prompttype forcesasync: false(must wait for LLM response)async: trueforcesblocking: false(async cannot block)- Post-hoc events (
Stop,PostToolUse,PostCompact, …) ignoreblockedresults — there is nothing left to block
Runs an external script in a subprocess. Cross-platform: sh -c on Mac/Linux, cmd /c on Windows.
Protocol:
- Input: event JSON sent to stdin
- Output: optional JSON printed to stdout (see below)
- Exit code: 0 = success; non-zero = failure (treated as blocked for blocking hooks)
Stdout JSON (all fields optional — omit entirely to take no action):
{
"decision": "approve",
"reason": "...",
"abort": false,
"updatedInput": { },
"additionalContext": "..."
}| Field | Values | Notes |
|---|---|---|
decision |
"approve" | "reject" | "skip" |
"skip" passes through without decision |
reason |
string | Returned to the agent when rejected |
abort |
boolean | true = hard-abort the entire session on reject (default false) |
updatedInput |
object | Rewrite the tool input in-place (PreToolUse only) |
additionalContext |
string | Inject a message into the conversation context |
reject behavior:
reject+abort: false(default) → returnsreasonas an error; agent continues and can retry or adaptreject+abort: true→ returns error and terminates the session; use for critical safety violations
Calls the quick LLM model with a single-shot prompt — no script required. The model receives only the event JSON as context, has no tools, and must return a decision JSON.
{
"type": "prompt",
"prompt": "Check if this bash command is safe. Reject only clearly destructive operations.",
"timeout": 30
}Counterintuitive point 1: a command hook can inject prompts.
The additionalContext field in the stdout JSON is injected into the agent's conversation context. So a shell script that runs an LLM call and returns {"additionalContext": "..."} is effectively a command hook that injects a prompt. Nothing in the protocol prevents this.
Counterintuitive point 2: a prompt hook is not an agent.
A prompt hook is an LLM-powered if statement. It receives the raw event JSON, produces a single approve/reject/skip decision, and exits. It has:
- No tools
- No conversation history
- No memory
- No multi-step reasoning
- One shot — no retry, no follow-up
If you need actual reasoning with tools (read a file, call an API, check a database), use a command hook whose script does that work — or use the Task tool to spawn a real virtual agent.
When to use which:
| Scenario | Choice |
|---|---|
| "Is this command safe?" — pure semantic judgment | prompt |
| "Log this to a file" — no LLM needed | command |
| "Check git blame before allowing this edit" — external tool required | command |
| "Approve only if the diff touches <100 lines" — local computation | command |
| Multi-step reasoning with tools | Task tool (virtual agent) |
command is strictly more powerful than prompt — a script can always call an LLM API. prompt exists because writing a script, managing dependencies, and parsing JSON responses is boilerplate that most "ask LLM to judge X" use cases don't need.
Available in command and prompt strings. Resolved at config load time, and also passed as environment variables to child processes.
| Variable | Value |
|---|---|
${SEMACLAW_ROOT} |
Global config dir (~/.semaclaw) |
${AGENT_WORKSPACE} |
Agent working directory |
Access from script: os.environ["AGENT_WORKSPACE"]
All events share these base fields:
{
"hook_event_name": "PreToolUse",
"session_id": "abc-123",
"agent_id": "main",
"timestamp": "2026-04-17T10:30:00.000Z",
"cwd": "/path/to/workspace"
}Event-specific fields:
PreToolUse / PostToolUse
{
"tool_name": "Bash",
"tool_input": { "command": "git status" },
"tool_response": "..."
}(tool_response is only present in PostToolUse)
PermissionRequest
{
"tool_name": "Bash",
"tool_input": { "command": "rm -rf /tmp/x" }
}PreCompact
{
"message_count": 42,
"context_history": [ ]
}Place scripts in ~/.semaclaw/hooks/ and reference them with ${SEMACLAW_ROOT}/hooks/<name>.py.
~/.semaclaw/hooks/block-dangerous.py
#!/usr/bin/env python3
import sys, json
DANGEROUS_PATTERNS = [
"rm -rf /",
"dd if=",
"mkfs.",
"> /dev/",
"chmod -R 777 /",
]
def main():
data = json.load(sys.stdin)
command = data.get("tool_input", {}).get("command", "")
for pattern in DANGEROUS_PATTERNS:
if pattern in command:
print(json.dumps({
"decision": "reject",
"reason": f"Blocked: command contains '{pattern}'",
"abort": False,
}))
return
print(json.dumps({"decision": "approve"}))
if __name__ == "__main__":
main()~/.semaclaw/hooks.json
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "python3 ${SEMACLAW_ROOT}/hooks/block-dangerous.py",
"timeout": 5,
"blocking": true
}
]
}
]
}
}Records every tool invocation to a per-workspace JSONL file. Non-blocking so it never delays the agent.
~/.semaclaw/hooks/tool-logger.py
#!/usr/bin/env python3
import sys, json, os
from datetime import datetime, timezone
from pathlib import Path
def main():
data = json.load(sys.stdin)
workspace = os.environ.get("AGENT_WORKSPACE", os.getcwd())
log_dir = Path(workspace) / ".semaclaw" / "hook-logs"
log_dir.mkdir(parents=True, exist_ok=True)
log_file = log_dir / "tool-calls.jsonl"
entry = {
"ts": datetime.now(timezone.utc).isoformat(),
"event": data.get("hook_event_name"),
"tool": data.get("tool_name"),
"input": data.get("tool_input"),
"session": data.get("session_id"),
"agent": data.get("agent_id"),
}
with open(log_file, "a", encoding="utf-8") as f:
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
if __name__ == "__main__":
main()Config:
{
"hooks": {
"PostToolUse": [
{
"hooks": [
{
"type": "command",
"command": "python3 ${SEMACLAW_ROOT}/hooks/tool-logger.py",
"timeout": 5,
"blocking": false
}
]
}
]
}
}Captures the full message list to a JSON file every time auto-compaction is about to run — useful for debugging context loss or auditing long sessions.
~/.semaclaw/hooks/compact-logger.py
#!/usr/bin/env python3
import sys, json, os
from datetime import datetime, timezone
from pathlib import Path
def main():
data = json.load(sys.stdin)
workspace = os.environ.get("AGENT_WORKSPACE", os.getcwd())
log_dir = Path(workspace) / ".semaclaw" / "hook-logs"
log_dir.mkdir(parents=True, exist_ok=True)
ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
log_file = log_dir / f"compact-{ts}.json"
payload = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"session_id": data.get("session_id"),
"agent_id": data.get("agent_id"),
"message_count": data.get("message_count"),
"context_history": data.get("context_history", []),
}
with open(log_file, "w", encoding="utf-8") as f:
json.dump(payload, f, ensure_ascii=False, indent=2)
# Keep only the 10 most recent compaction logs
logs = sorted(log_dir.glob("compact-*.json"))
for old in logs[:-10]:
old.unlink(missing_ok=True)
if __name__ == "__main__":
main()Config:
{
"hooks": {
"PreCompact": [
{
"hooks": [
{
"type": "command",
"command": "python3 ${SEMACLAW_ROOT}/hooks/compact-logger.py",
"timeout": 10,
"blocking": false
}
]
}
]
}
}Uses a prompt hook on PermissionRequest to automatically approve or escalate based on semantic understanding of the tool input — no script required.
{
"hooks": {
"PermissionRequest": [
{
"matcher": "Bash",
"hooks": [
{
"type": "prompt",
"prompt": "You are a security gate for an AI agent. Review the bash command in tool_input. Approve if it is a routine read, list, git, or build operation. Reject (with a short reason) only if it is clearly destructive or modifies system files outside the workspace. When in doubt, approve.",
"timeout": 30,
"blocking": true
}
]
}
]
}
}Adds a standing instruction to every agent turn without modifying the system prompt.
{
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{
"type": "prompt",
"prompt": "Read the user message in tool_input. If it mentions a file path, check whether that path looks relative or absolute and add a short note to additionalContext reminding the agent to confirm the working directory. Otherwise approve with no additionalContext.",
"timeout": 15,
"blocking": false
}
]
}
]
}
}Uses the if field to narrow the hook to only git push commands, avoiding unnecessary subprocess overhead on every Bash call.
~/.semaclaw/hooks/block-protected-push.py
#!/usr/bin/env python3
import sys, json, re
PROTECTED = re.compile(r"\b(main|master|production|release)\b")
def main():
data = json.load(sys.stdin)
command = data.get("tool_input", {}).get("command", "")
if PROTECTED.search(command):
print(json.dumps({
"decision": "reject",
"reason": f"Direct push to a protected branch is not allowed. Use a feature branch and open a PR.",
"abort": False,
}))
else:
print(json.dumps({"decision": "approve"}))
if __name__ == "__main__":
main()Config:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"if": "git push",
"hooks": [
{
"type": "command",
"command": "python3 ${SEMACLAW_ROOT}/hooks/block-protected-push.py",
"timeout": 5,
"blocking": true
}
]
}
]
}
}All hooks matching an event run in parallel (Promise.allSettled). Aggregation rules:
- Any blocking hook returning
reject→ the event is blocked - Non-blocking hook failures are logged but do not affect the agent
- Async hooks (
async: true) are not included in the aggregation result at all
This means you can safely combine a fast pattern-based command check (blocking) with a slow LLM-based prompt check (also blocking) and a fire-and-forget audit logger (async: true) on the same event — they all run concurrently.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "python3 ${SEMACLAW_ROOT}/hooks/block-dangerous.py",
"timeout": 5,
"blocking": true
},
{
"type": "prompt",
"prompt": "Check if this bash command is safe for a developer workstation. Reject only clearly destructive operations.",
"timeout": 20,
"blocking": true
},
{
"type": "command",
"command": "python3 ${SEMACLAW_ROOT}/hooks/tool-logger.py",
"timeout": 5,
"blocking": false,
"async": true
}
]
}
]
}
}
{ "hooks": { "<EventName>": [ { "matcher": "Bash", // glob on tool name (optional, default "*") "if": "^git (commit|push)", // regex on tool_input content (optional) "hooks": [ { "type": "command", // "command" or "prompt" "command": "python3 ${SEMACLAW_ROOT}/hooks/my-hook.py", "timeout": 10, // seconds (default 10) "blocking": true, // block agent on failure (default true) "async": false // fire-and-forget (default false) } ] } ] } }