Structure and prerequisites
Hooks respond to CLI events. They are real process or network actions, not prompt templates. Project sources require consent; global hooks are treated as a trusted user source. Review executable content, destinations and data flow before granting access.
.lurus/hooks.json contains the event map directly, without another outer hooks property. Use ~/.lurus/hooks.json globally. Settings also support hooks and scope-specific globalHooks or projectHooks; event arrays from the sources are appended, not generally deep-merged. Do not assume live reloading: start a new CLI session after editing.
Command hooks receive a JSON object on stdin:
{
"event": "PreToolUse",
"context": { "toolName": "Write", "toolInput": { "path": "example.txt" } },
"cwd": "/path/to/project"
}
toolInput is the actual tool input, not a universal input shape. The following examples require the correct project CWD and an available node executable. Do not experiment in your real global stores.
Block Write specifically
Create .lurus/hooks/block-write.cjs:
process.stdin.resume();
process.stdin.on('end', () => {
console.log(
JSON.stringify({
decision: 'deny',
reason: 'Write is blocked by this project hook.',
}),
);
});
And .lurus/hooks.json:
{
"PreToolUse": [
{
"matcher": "^Write$",
"hooks": [
{
"type": "command",
"command": "node .lurus/hooks/block-write.cjs",
"timeout": 30000
}
]
}
]
}
After restarting and deliberately granting project consent, deny prevents a Write call. It does not block Edit, MultiEdit, shell processes or all other mutations. This is not a universal write/security gate. The same event structure works globally; use a resolvable, reviewed script path for a global command rather than a project path missing in other projects.
Persist tool-event logs
Writing to stderr does not automatically create a user-visible log file. This complete .lurus/hooks/log-tool.cjs example deliberately records metadata only:
const { appendFileSync } = require('node:fs');
const { join } = require('node:path');
let input = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk) => {
input += chunk;
});
process.stdin.on('end', () => {
const payload = JSON.parse(input);
const record = {
event: payload.event,
toolName: payload.context.toolName,
isError: payload.context.isError,
};
appendFileSync(
join(payload.cwd, '.lurus/hooks/tool-events.log'),
JSON.stringify(record) + '\n',
);
console.log(JSON.stringify({ decision: 'allow' }));
});
Add the event to .lurus/hooks.json:
{
"PostToolUse": [
{
"matcher": "^(Write|Edit)$",
"hooks": [
{
"type": "command",
"command": "node .lurus/hooks/log-tool.cjs",
"timeout": 30000
}
]
}
]
}
These are separate complete event maps. To combine them, put both event keys in one JSON object. Expected result: lines in .lurus/hooks/tool-events.log after matching successful tools. Use the existing hooks directory; do not log tool inputs/results, tokens or secrets. Logging failures are not a dependable execution stop; see fail-open below.
All events and actual effects
Every hook receives {event, context, cwd}. Definitions and actions run sequentially; deny stops the remaining hooks. Only events designed for blocking also stop the CLI action, as shown in the table.
| Event | Context fields | Actual effect |
|---|---|---|
SessionStart | sessionId, trigger: startup | Startup observation; decision and extra context are not consumed as a startup gate. |
SessionEnd | sessionId, trigger: exit | Shutdown observation, not a shutdown gate. |
PreToolUse | toolName, toolInput | deny prevents the tool call; extra context is consumed. |
PostToolUse | toolName, toolResult, isError: false | Extra context; cannot undo an action that already happened. |
PostToolUseFailure | toolName, toolResult, isError: true | Extra context after failure; not every early abort emits this event. |
UserPromptSubmit | prompt | deny stops the prompt; extra context is consumed. |
SubagentStart | subagentId, subagentType | Observes subagent startup without blocking it. |
SubagentStop | subagentId, subagentType | Observes completion without rolling back results. |
Stop | trigger: stop | One-shot extra context; no guaranteed subsequent agent run. |
PreCompact | trigger: compact, messageCount, estimatedTokens, filesRead | Not a compaction-blocking gate. |
Matchers and fields
Matchers target context.toolName, otherwise context.trigger. If both are absent, every matcher matches. Empty and * always match. Without an asterisk, exact comparison is followed by regex matching; with an asterisk, an anchored regular expression is constructed and other regex metacharacters are not automatically escaped. This is not a standard glob matcher. Use ^(Write|Edit)$ for an explicit tool group.
| Field | Type / default |
|---|---|
<Event> | Array of definitions; spell JSON event names exactly. |
matcher | Optional string such as ^Write$. |
hooks | Array of actions per definition. |
type: command | command: string, optional timeout: number in ms. |
type: http | url: string, optional method: POST|PUT|PATCH, headers: Record<string,string>, timeout: number. |
timeout | Default 30,000 ms per action, not an overall run budget. |
method | HTTP default POST. |
Response contract and fail-open
Reserve stdout for the response contract; send diagnostics to stderr:
{
"decision": "deny",
"reason": "Explain why the action is blocked",
"additionalContext": "Optional guidance for the agent"
}
- Exit 0: JSON with
decision: allow|deny, optionalreason/additionalContext. - Exit 2: deny; reason comes from stderr/stdout.
askis not a parsed decision value.- Invalid JSON, other exit codes, execution errors and non-2xx HTTP: fail-open, not automatically blocked.
- HTTP responses with a 2xx status use the same JSON response contract.
- Shell timeout sends SIGTERM; hard termination of every child process is not guaranteed.
A missing hook, script error or timeout is therefore not a dependable security barrier. Protect secrets, paths and process access through the actual permissions too.
HTTP hook
For an existing, deliberately local development endpoint that responds with JSON:
{
"UserPromptSubmit": [
{
"hooks": [
{
"type": "http",
"url": "http://127.0.0.1:8765/hooks/prompt",
"method": "POST",
"headers": { "Content-Type": "application/json" },
"timeout": 30000
}
]
}
]
}
The request body is the event payload, including the prompt for this event. The existing endpoint must return, for example, {"decision":"allow"} with a 2xx HTTP status. Do not switch to somebody else’s public endpoint without reviewing data disclosure. Authorization, Cookie, Proxy-Authorization, X-API-Key and CRLF header values are blocked; do not commit authentication secrets as hook headers.
Generator, validation and environment
Warning: The generator writes project files and can overwrite scripts with the same name. Check names and existing files before running it.
/create-hook PreToolUse block-write
/create-hook PostToolUse log-tool --ts
/hooks validate
The generator creates project files and updates hooks.json when an event template exists. Event input is case-insensitive; JSON event names remain exact. An existing script with the same name can be overwritten. Shell templates are executable; TypeScript templates require npx tsx. Generated commands may use absolute paths.
/hooks validate checks only part of the project configuration, not complete matcher, HTTP, timeout or security semantics. There are no generic create or reload options. Child processes receive a fixed safe environment allowlist; LURUS_HOOK_PASS_ENV adds comma-separated variable names. Every forwarded value is fully visible to the hook process and is a deliberate trust grant.
Troubleshooting
An incorrect working directory, missing node or a missing script causes an execution failure with fail-open behavior. If no event is processed, check spelling and matcher. If deny does not block, check the event table; post-events cannot undo actions. If the log file is missing, check the target directory and write access. Start a new session after editing files.