Luxe

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:

FieldRequiredDefaultMeaning
commandyesProgram + args (["program", "arg1", …]). No shell is interposed; use ["sh","-c","…"] if you want shell features.
timeout_msno5000Hard timeout; on expiry the whole process group is SIGKILLed.
on_timeoutno"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): only BeforeToolUse (blocks the tool) and UserPromptSubmit (blocks the prompt).
  • Inject feedback/context (a context field): AfterToolUse (appended to the tool result the model sees — e.g. lint/typecheck output) and UserPromptSubmit (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 AfterToolUse hook to get LSP feedback after edits — Luxe ships this natively. After every edit/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 with LUXE_LSP_ON_EDIT=0. Reach for an AfterToolUse hook when you want a different check (project-specific lint, a test suite, a custom policy).

PointFiresstdin eventEffect
SessionStartSession created/resumed{"Other": null}observe
SessionEndSession closing{"Other": null}observe
UserPromptSubmitA user prompt is submitted (once per turn){"Other": {"content": "<prompt text>"}}block (deny) or inject context
BeforeAgentStartA turn is about to start{"Other": {}}observe
AgentStartAbout to call the provider{"Other": null}observe
BeforeToolUseBefore each tool call{"ToolUse": {"tool_name": "bash", "args": { … }}}block (deny)
AfterToolUseAfter each tool call{"ToolResult": {"tool_name": "bash", "result": "…", "is_error": false}}inject feedback to the model
FileChangedA tool edited/wrote a file{"FileChanged": {"path": "/abs/file", "operation": "edit"}}observe
BeforeCompactBefore conversation compaction{"Other": {"tokens_before": 12345}}observe
AfterCompactAfter compaction{"Other": {"tokens_before": 12345}}observe
AgentEndA 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 at AfterToolUse (so the model sees a linter/typecheck result and can react), or added to the turn’s context at UserPromptSubmit. 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 (default allow) — a broken hook fails open by default, or set "on_timeout": "deny" for a fail-closed guard.
  • A BeforeToolUse hook runs on every tool call, adding a subprocess spawn to each — keep such hooks fast, and prefer a tight timeout_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 CodeLuxeStatus
SessionStart / SessionEndSessionStart / SessionEnd
UserPromptSubmit (block)UserPromptSubmit (block)✅ blocking; context-injection not yet
PreToolUse (block)BeforeToolUse (block)
PostToolUseAfterToolUse
PreCompact / PostCompactBeforeCompact / AfterCompact
StopAgentEnd≈ observe (force-continue is handled by Luxe’s built-in loop self-continuation)
FileChangedFileChanged✅ (first-class in Luxe)
SubagentStopn/a — Luxe has no subagents
Notificationnot exposed as a hook (Luxe notifies via TUI bell / desktop)
PermissionRequestcovered 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.