Luxe

Hooks

Run your own commands at points in a turn: block, rewrite or audit tool calls, and add context.

View as Markdown

A hook runs a command of yours at a point in a turn, such as before each tool call. It receives the event as JSON on stdin and can answer with a decision as JSON on stdout. Any executable works: a shell script, Python, a compiled program.

Add a hook

Hooks go in ~/.config/luxe/hooks.json. This one asks before any recursive delete and refuses a wipe of / or your home folder:

~/.config/luxe/hooks.json
{
  "BeforeToolUse": [
    {
      "matcher": { "bash": "rm *" },
      "command": ["/home/you/.config/luxe/hooks/guard.sh"],
      "on_timeout": "deny"
    }
  ]
}
~/.config/luxe/hooks/guard.sh
#!/usr/bin/env bash
cmd=$(jq -r '.ToolUse.args.command // empty')
case "$cmd" in
  *"rm -rf /"*|*"rm -rf ~"*) echo '{"decision":"deny","reason":"refusing a root or home wipe"}' ;;
  *)                         echo '{"decision":"ask","reason":"recursive delete"}' ;;
esac

A project’s .luxe/hooks.json runs only when you start Luxe with --trust-project, since opening a repository should never run its commands. When both are loaded, a project’s hooks run after yours.

Hook settings

KeyDefaultDoes
commandThe program and its arguments, run without a shell. Use ["sh", "-c", "…"] for shell features.
matcherevery callWhich tool calls it runs for; see Matchers
timeout_ms5000One deadline for sending the event, reading output and waiting for exit. On timeout, it and everything it started are stopped.
on_timeoutallowWhat happens if the hook times out, fails to start, exits non-zero or prints something that is not a decision: allow, deny or ask

on_timeout: "deny" makes a guard fail closed. "ask" puts the question to you instead, at BeforeToolUse; at other points it acts as allow. A decision Luxe does not recognize, a typo or different casing included, gets the same treatment, with a warning in the log, and its args and context are dropped.

Matchers

A BeforeToolUse hook without a matcher runs on every tool call, and each run starts a process. A matcher skips the calls you do not care about, without starting anything:

"matcher": "bash"                                   a tool name, or a pattern like "web_*"
"matcher": { "bash": "rm *", "edit": "src/**" }     a tool and what the call is about

The second form matches if any entry does. The patterns are the same as in permissions.json: "rm *" finds the rm in cd /tmp && rm -rf x, and a pattern for read or edit matches the path, for web_fetch the address.

Only BeforeToolUse sees a call’s arguments. At AfterToolUse only the tool name is matched, and points without a tool call ignore the matcher.

Hook points

PointRunsstdinCan
SessionStartA session starts or resumes, including a switch to it from another session{"Other": null}
SessionEndA session ends, including a switch to another session; Luxe waits for the hook{"Other": null}
UserPromptSubmitYou send a message, including one sent while the agent works{"Other": {"content": "…"}}block it, add context
BeforeAgentStartA turn is about to start{"Other": null}
AgentStartLuxe is about to call the model{"Other": null}
BeforeToolUseBefore each tool call{"ToolUse": {"tool_name": "bash", "args": {…}}}block it, ask you, rewrite it
AfterToolUseAfter each tool call{"ToolResult": {"tool_name": "bash", "result": "…", "is_error": false}}add context to the result
FileChangedwrite, edit, an applied rename or code action, or an ast_grep rewrite changed a file{"FileChanged": {"path": "/abs/file", "operation": "edit"}}
BeforeCompact, AfterCompactAround compaction{"Other": {"tokens_before": 12345}}
AgentEndA turn finished{"Other": {"effective_context_percent": 12.5}}
ErrorA turn failed{"Other": {"message": "…"}}
PermissionRequested, PermissionResolvedAn approval prompt opens or is answered{"Other": {"tool": "bash", "phase": "requested", …}}
ReviewGate, LoopControlluxe loop only

A hook at a point with nothing under Can still runs, for logging or notifications, but its answer is ignored. Point names are matched loosely (before_tool_use works). An unknown name skips that hook alone, with a warning naming the nearest real point.

Answer with a decision

{ "decision": "deny", "reason": "rm -rf is blocked by policy" }
FieldWhereDoes
decisionRequired in any answerdeny blocks the call or message. ask raises a permission prompt, at BeforeToolUse. allow, or no output at all, means no opinion. An answer without decision counts as one Luxe cannot read.
reasonwith deny or askShown to the model when a call is blocked, to you when a message is blocked, and on the prompt
argsBeforeToolUseNew values for the call’s arguments. Keys you leave out keep their values.
contextAfterToolUse, UserPromptSubmitText added to the tool result, or saved with your message, for the model to see
{ "decision": "allow", "args": { "command": "bun test" } }

When several hooks answer the same point, their context and rewrites are combined in order. A deny ends it: hooks after it do not run.

What a hook cannot do

  • Approve a call. allow means no opinion; a hook can only make a call harder. To auto-approve, write a rule in permissions.json.
  • Rewrite its way past the rules. A rewritten call is checked by the permission rules as rewritten, and that is what runs. If a rewritten call matches an ask rule, you are asked about the new command.

When nobody is there to answer

In a headless or loop run, a hook’s ask is allowed, like every other ask. To change that:

  • --on-ask deny, or "agent": { "on_ask": "deny" } in config.json, refuses every ask in an unattended run, and tells the model why.
  • { "permission": { "hook_ask": "deny" } } in permissions.json turns every hook’s ask into a block everywhere, even when you are there. "allow" silences them instead.

A hook’s prompt offers Allow once and Reject, since a hook decides each call afresh.

What a hook sees

The event a hook receives is not redacted: it is a program you installed, with the same access you have, and the call’s arguments have already gone to your model provider. What a hook returns, its reason and context, is scrubbed of anything that looks like a secret before it is shown or saved. Read a hook script before installing it, as you would anything on your PATH.

Examples

Add lint output to every edit:

~/.config/luxe/hooks.json
{
  "AfterToolUse": [
    {
      "matcher": "edit",
      "command": ["sh", "-c", "jq -n --arg out \"$(npm run -s lint 2>&1 | tail -20)\" '{decision: \"allow\", context: $out}'"]
    }
  ]
}

Luxe already adds language server errors after every edit; a hook like this is for a different check.

Refuse messages that contain an internal token:

~/.config/luxe/hooks/prompt-policy.sh
#!/usr/bin/env bash
prompt=$(jq -r '.Other.content // empty')
if printf '%s' "$prompt" | grep -qE 'CORP-SECRET-[0-9]+'; then
  echo '{"decision":"deny","reason":"the message contained an internal token"}'
fi

Copy the sessions before compaction:

~/.config/luxe/hooks.json
{ "BeforeCompact": [{ "command": ["sh", "-c", "cp -r .luxe/sessions /tmp/luxe-precompact-$$"] }] }

Recipes has a hook that asks a classifier model whether a command is destructive.

Coming from Claude Code

Claude CodeLuxe
PreToolUse, PostToolUseBeforeToolUse, AfterToolUse
UserPromptSubmit with additionalContextUserPromptSubmit with context
SessionStart, SessionEndthe same
PreCompact, PostCompactBeforeCompact, AfterCompact
StopAgentEnd, which observes only
permissionDecision: "ask""decision": "ask"
updatedInputargs, checked again by the permission rules
matcher and ifmatcher, in either form
permissionDecision: "allow", which skips the promptNo equivalent: use permissions.json