Command hooks
External commands at loop points — gate a tool call, observe a turn.
Command hooks run a user-configured external command at points in the agent loop. The command receives the event as JSON on stdin and may return a decision as JSON on stdout. They’re a language-agnostic extension point — any executable (shell, Python, a compiled binary) — for gating, validation, auditing, notifications, and automation. This is Luxe’s analogue of Claude Code’s hooks; see parity below.
For the security model in brief, see security.md;
this page is the full reference.
Where hooks live (and the trust model)
- Global:
~/.config/luxe/hooks.json— always loaded. - Project:
<repo>/.luxe/hooks.json— loaded only when you pass--trust-project.
A hook runs an arbitrary command, so honoring a project’s hooks by default would let cloning and
opening a repo execute code on the next tool call (the same footgun as malicious git hooks). By
default only your global hooks run; a project’s hooks are opt-in per invocation via
--trust-project (the same trust switch as project permission config). When both are present and
trusted, project hooks are appended to global ones for each point.
Config schema
A JSON object mapping a hook point to a list of hook definitions:
{
"BeforeToolUse": [
{ "command": ["/abs/path/guard.sh"], "timeout_ms": 5000, "on_timeout": "deny" }
],
"AfterToolUse": [
{ "command": ["python3", "/abs/path/audit.py"] }
],
"UserPromptSubmit": [
{ "command": ["/abs/path/prompt-policy.sh"], "on_timeout": "deny" }
]
}
Each definition:
| Field | Required | Default | Meaning |
|---|---|---|---|
command | yes | — | Program + args (["program", "arg1", …]). No shell is interposed; use ["sh","-c","…"] if you want shell features. |
timeout_ms | no | 5000 | Hard timeout; on expiry the whole process group is SIGKILLed. |
on_timeout | no | "allow" | Fail-safe when the command times out, can’t spawn, exits non-zero, or returns unparseable output. "allow" | "deny" | "ask" (ask behaves as allow). |
Point names are the exact values in the table below (PascalCase).
Hook points
At each point the event is written to the hook’s stdin as a single JSON object. A hook can affect the run in two ways, at specific points:
- Block (
deny): onlyBeforeToolUse(blocks the tool) andUserPromptSubmit(blocks the prompt). - Inject feedback/context (a
contextfield):AfterToolUse(appended to the tool result the model sees — e.g. lint/typecheck output) andUserPromptSubmit(added to the turn’s context).
Every other point is observe-only: the hook still runs (logging, notifying, backups, snapshots), but its decision is ignored.
Built-in: diagnostics on edit. You do not need an
AfterToolUsehook to get LSP feedback after edits — Luxe ships this natively. After everyedit/write, Luxe runs the file’s language server (warm, no per-call spawn), waits for it to re-analyze and settle (so the diagnostics aren’t stale), and appends the errors/warnings — errors first, capped — to the tool result the model sees, closing the edit→verify→fix loop automatically. Toggle withLUXE_LSP_ON_EDIT=0. Reach for anAfterToolUsehook when you want a different check (project-specific lint, a test suite, a custom policy).
| Point | Fires | stdin event | Effect |
|---|---|---|---|
SessionStart | Session created/resumed | {"Other": null} | observe |
SessionEnd | Session closing | {"Other": null} | observe |
UserPromptSubmit | A user prompt is submitted (once per turn) | {"Other": {"content": "<prompt text>"}} | block (deny) or inject context |
BeforeAgentStart | A turn is about to start | {"Other": {}} | observe |
AgentStart | About to call the provider | {"Other": null} | observe |
BeforeToolUse | Before each tool call | {"ToolUse": {"tool_name": "bash", "args": { … }}} | block (deny) |
AfterToolUse | After each tool call | {"ToolResult": {"tool_name": "bash", "result": "…", "is_error": false}} | inject feedback to the model |
FileChanged | A tool edited/wrote a file | {"FileChanged": {"path": "/abs/file", "operation": "edit"}} | observe |
BeforeCompact | Before conversation compaction | {"Other": {"tokens_before": 12345}} | observe |
AfterCompact | After compaction | {"Other": {"tokens_before": 12345}} | observe |
AgentEnd | A turn finished | {"Other": null} | observe |
The JSON is an externally-tagged enum: the single top-level key (
ToolUse,ToolResult,FileChanged,Other) tells you the shape. Parse defensively.
Decision contract (stdout)
A hook writes a JSON object to stdout:
{ "decision": "deny", "reason": "rm -rf is blocked by policy" }
{ "decision": "allow", "context": "cargo check failed: unused import at src/x.rs:3" }
decision:"deny"(or"block") rejects at a blockable point;"allow"/"continue"/any other value (or no output) continues.reason: shown to the model (a denied tool) or surfaced to the user (a denied prompt).context: when non-empty and the decision isn’t a deny, it’s injected as feedback — appended to the tool result atAfterToolUse(so the model sees a linter/typecheck result and can react), or added to the turn’s context atUserPromptSubmit. Ignored at observe-only points.- Observe-only points ignore output entirely — a hook there needs none.
Note: input/output rewriting (Claude Code’s
updatedInput/updatedToolOutput) is not implemented — a hook can deny or add context, not mutate the tool’s arguments or replace its output. (If you need mutation, do it inside the tool via a wrapper.)
Fail-safe, timeout & process hygiene
- The command is spawned as its own process group with kill-on-drop; on timeout the whole group is SIGKILLed, so a hung hook can’t orphan a subprocess tree.
- Any failure (spawn error, stdin-write error, non-zero exit, invalid JSON, or timeout) resolves to
the definition’s
on_timeout(defaultallow) — a broken hook fails open by default, or set"on_timeout": "deny"for a fail-closed guard. - A
BeforeToolUsehook runs on every tool call, adding a subprocess spawn to each — keep such hooks fast, and prefer a tighttimeout_ms.
Examples
Block rm -rf at the tool boundary (on_timeout: deny makes it fail-closed):
#!/usr/bin/env bash
# ~/.config/luxe/hooks/guard.sh — reads the BeforeToolUse event, denies risky bash.
event=$(cat)
cmd=$(printf '%s' "$event" | jq -r '.ToolUse.args.command // empty')
case "$cmd" in
*"rm -rf"*|*"mkfs"*) echo '{"decision":"deny","reason":"blocked by guard.sh"}' ;;
*) echo '{"decision":"allow"}' ;;
esac
Audit every tool result (observe-only — no output needed):
#!/usr/bin/env python3
# audit.py — append each tool result to a log.
import json, sys, datetime
ev = json.load(sys.stdin).get("ToolResult", {})
with open("/tmp/luxe-audit.log", "a") as f:
f.write(f'{datetime.datetime.now().isoformat()} {ev.get("tool_name")} error={ev.get("is_error")}\n')
Reject prompts that leak an internal token (blockable):
#!/usr/bin/env bash
event=$(cat)
prompt=$(printf '%s' "$event" | jq -r '.Other.content // empty')
if printf '%s' "$prompt" | grep -qE 'CORP-SECRET-[0-9]+'; then
echo '{"decision":"deny","reason":"prompt contained an internal token"}'
else
echo '{"decision":"allow"}'
fi
Snapshot before compaction (observe-only):
{ "BeforeCompact": [ { "command": ["sh", "-c", "cp -r .luxe/sessions /tmp/luxe-precompact-$$"] } ] }
Parity
vs Claude Code
Luxe covers Claude Code’s core hook lifecycle:
| Claude Code | Luxe | Status |
|---|---|---|
SessionStart / SessionEnd | SessionStart / SessionEnd | ✅ |
UserPromptSubmit (block) | UserPromptSubmit (block) | ✅ blocking; context-injection not yet |
PreToolUse (block) | BeforeToolUse (block) | ✅ |
PostToolUse | AfterToolUse | ✅ |
PreCompact / PostCompact | BeforeCompact / AfterCompact | ✅ |
Stop | AgentEnd | ≈ observe (force-continue is handled by Luxe’s built-in loop self-continuation) |
FileChanged | FileChanged | ✅ (first-class in Luxe) |
SubagentStop | — | n/a — Luxe has no subagents |
Notification | — | not exposed as a hook (Luxe notifies via TUI bell / desktop) |
PermissionRequest | — | covered natively by the permission engine + TUI approval prompt |
Known non-goals for now: input mutation (Claude Code’s updatedInput) and UserPromptSubmit
context injection — a hook can allow/deny but not rewrite. HTTP hooks aren’t supported (command
hooks only).
vs OpenCode
OpenCode’s hooks are in-process TypeScript plugins (tool.execute.before/after, event,
auth, custom tools) — richer in-process modification, but they require writing TS and run inside
the agent. Luxe’s command hooks are language-agnostic external processes (any executable),
which trade in-process mutation for isolation and zero-dependency authoring.