Skip to content

Handbook

Create hooks

Use command and HTTP hooks for CLI events, understand payloads and verify actual blocking boundaries.

Reviewed CLI source version: 2.4.0 · Editorial update:

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.

EventContext fieldsActual effect
SessionStartsessionId, trigger: startupStartup observation; decision and extra context are not consumed as a startup gate.
SessionEndsessionId, trigger: exitShutdown observation, not a shutdown gate.
PreToolUsetoolName, toolInputdeny prevents the tool call; extra context is consumed.
PostToolUsetoolName, toolResult, isError: falseExtra context; cannot undo an action that already happened.
PostToolUseFailuretoolName, toolResult, isError: trueExtra context after failure; not every early abort emits this event.
UserPromptSubmitpromptdeny stops the prompt; extra context is consumed.
SubagentStartsubagentId, subagentTypeObserves subagent startup without blocking it.
SubagentStopsubagentId, subagentTypeObserves completion without rolling back results.
Stoptrigger: stopOne-shot extra context; no guaranteed subsequent agent run.
PreCompacttrigger: compact, messageCount, estimatedTokens, filesReadNot 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.

FieldType / default
<Event>Array of definitions; spell JSON event names exactly.
matcherOptional string such as ^Write$.
hooksArray of actions per definition.
type: commandcommand: string, optional timeout: number in ms.
type: httpurl: string, optional method: POST|PUT|PATCH, headers: Record<string,string>, timeout: number.
timeoutDefault 30,000 ms per action, not an overall run budget.
methodHTTP 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, optional reason/additionalContext.
  • Exit 2: deny; reason comes from stderr/stdout.
  • ask is 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.

A deliberate choice

Before your first prompt.

Interactive demo
Lurus Codeprivacy-demo · illustrative workspace

Demo session ready. Your model choice and data route are separate decisions.

Illustrative scenario. No real sign-in, security test or code transmission takes place.

Image preview