Skip to content

Hooks

Hooks are an automatic trigger mechanism: you tell Mirri Code CLI in advance "whenever X happens, run this script." The script runs on your local machine, and you can put any logic inside it. Typical use cases:

  • Security interception: Before the Agent executes a shell command, check whether it contains dangerous operations (such as rm -rf) and block execution if so
  • Desktop notifications: When a background task completes, pop up a system notification to bring you back to review the results
  • Automatic checks: Each time the user submits a message, automatically append some background information to the context (such as the current Git branch)

How Hooks Work

Configuring a hook rule requires specifying three things: which event to trigger on, which targets to match, and which script to run.

When triggered, the CLI packages the event's details (trigger reason, tool name, command content, etc.) into JSON and passes it to your script via standard input (stdin). The script reads this information and decides how to respond.

The script's response is determined by two things:

  • Exit code: 0 means allow, 2 means block, other non-zero values default to allow
  • Standard output (stdout): can include explanatory text

Even if the script errors or times out, the CLI will not interrupt your work as a result — this "allow on failure" design is called fail-open, preventing hook errors from becoming blockers.

Note

Precisely because of fail-open, Hooks are suitable for alerts and lightweight interception, but should not be used as the sole security barrier. For truly high-risk operations, rely on permission approvals and manual confirmation.

Quick Start: A Minimal Hook

The following hook flashes a notification in the terminal title bar each time a background task completes (macOS requires terminal-notifier to be installed):

toml
# Written in ~/.mirri-code/config.toml
[[hooks]]
event = "Notification"           # Trigger: when a background task status changes
matcher = "task\\.completed"     # Only care about "completed" notifications
command = "terminal-notifier -title Mirri -message 'Task done'"

Save the config, start a new session, and a notification will appear the next time a background task completes.

Configuration

All hook rules are written in the [[hooks]] array in ~/.mirri-code/config.toml, where each entry is one rule:

FieldTypeRequiredDescription
eventstringYesTrigger event name; must be one of the entries in the "Event Reference" table below
matcherstringNoA regular expression to filter event targets; if omitted, matches all
commandstringYesThe shell command to run when triggered
timeoutintegerNoTimeout in seconds, range 1–600; defaults to 30 seconds
failClosedbooleanNoWhen set to true, hook crashes/timeouts/invalid JSON will block the operation instead of allowing it; defaults to false (fail-open). Only effective for blockable events (PreToolUse, Stop, UserPromptSubmit)

[[hooks]] only allows these five fields; extra fields will cause the config file to fail to load.

When multiple rules match the same event, all matching hooks run in parallel; multiple rules with identical command values run only once.

The working directory for hook commands is the current session's project directory. On non-Windows platforms, hook processes are placed in a separate process group; on timeout, a signal is sent first to give the process a chance to clean up, then it is forcibly terminated.

Event Data Format

Each time a hook triggers, the CLI passes the following base information to the script via stdin:

json
{
  "hook_event_name": "PreToolUse",
  "session_id": "session_abc",
  "cwd": "/path/to/project"
}

Specific events also include additional fields (such as tool name, command content, token usage, etc.). All field names use snake_case. The Event payload reference section below documents the complete stdin payload for each event.

Return Values

After the script exits, the CLI determines the hook's intent based on the exit code:

Exit codeMeaningCLI behavior
0Normal exit, allowContinue execution; if stdout is valid JSON, the structured output is parsed (see below)
2Intentional blockStop the current operation; stderr content is used as the block reason
Other non-zeroScript errorDefault allow (fail-open)
Timeout or crashScript exceptionDefault allow (fail-open)

Exit code 2 is the simplest way to block — just print the reason to stderr. But if you need finer control (injecting context, rewriting tool arguments, or returning a block reason via JSON), you can return a JSON object via stdout.

Hook Output Capabilities

Hooks return structured results via stdout JSON. Only blocking hooks consume output; observation-only events (fire-and-forget) ignore all output.

The fields inside hookSpecificOutput are not universal — each field is only effective for specific events. They map to three capabilities:

CapabilityFieldsApplicable events
Inject contextmessageUserPromptSubmit
Block operationpermissionDecision + permissionDecisionReasonUserPromptSubmit, PreToolUse, Stop
Rewrite tool argumentsupdatedInputRewriteToolInput

1. Inject Context

Only UserPromptSubmit supports this capability.

json
{
  "hookSpecificOutput": {
    "message": "Current Git branch: feature/hooks, 3 uncommitted changes"
  }
}

The message content is appended to the user's prompt and becomes visible to the LLM. Only effective when the hook allows (exit code 0 and no deny). The top-level message field is equivalent to hookSpecificOutput.message — the CLI falls back between them.

2. Block Operation

UserPromptSubmit, PreToolUse, and Stop support this capability.

json
{
  "hookSpecificOutput": {
    "permissionDecision": "deny",
    "permissionDecisionReason": "Please use rg instead of grep"
  }
}

permissionDecision only recognizes "deny"; any other value is a no-op. permissionDecisionReason is only extracted when denying. The behavior after deny differs per event:

EventBehavior after deny
UserPromptSubmitTurn stops; reason shown to the user as an assistant message
PreToolUseTool execution blocked; reason shown to the user
Stopreason injected as a user message; conversation continues (one continuation max per turn)

Exit code 2 vs JSON deny

Both methods block. The difference: exit code 2 can only pass stderr text as the reason; JSON deny can simultaneously set message (inject context) and permissionDecisionReason (block reason). For the Stop event, JSON gives precise control over the continuation text injected into the conversation.

3. Rewrite Tool Arguments

Only RewriteToolInput supports this capability (requires experimental flag hook-command-rewrite). The RewriteToolInput event is not available on the v2 engine — with v2, argument rewriting is carried by the updatedInput field on the PreToolUse event (also gated by hook-command-rewrite).

json
{
  "hookSpecificOutput": {
    "updatedInput": { "command": "rg pattern src/" }
  }
}

updatedInput replaces the original tool call arguments. Must be a complete argument object, not a patch. Exit code 0 without updatedInput uses the original arguments. See the command rewriting example.

Appendix: Complete Field Reference

FieldTypeApplicable eventsDescription
messagestring?UserPromptSubmitText injected into the user message; top-level message and hookSpecificOutput.message are equivalent
permissionDecision"deny"?UserPromptSubmit, PreToolUse, StopSet to "deny" to block the current operation; other values are no-ops
permissionDecisionReasonstring?Same as aboveBlock reason; only extracted when permissionDecision is "deny"
updatedInputobject?RewriteToolInputComplete rewritten tool argument object
additionalContextstring?Reserved field; parsed by the CLI but not currently wired into conversation injection — returning it won't error but has no effect

Which events support blocking?

Only blocking hooks (UserPromptSubmit, PreToolUse, Stop, RewriteToolInput) have return values that affect the main flow. Of these, RewriteToolInput cannot block — it can only rewrite arguments. All other events are observation-only — they fire and forget; the main flow is unaffected regardless of what the script returns.

Event Reference

EventMatcher matchesSupports blocking?Description
UserPromptSubmitThe text submitted by the userTriggered when the user sends a message; returned text is appended to context; if blocked, the model is not called for this turn
PreToolUseTool nameTriggered before a tool call (before permission checks); the tool will not execute if blocked
StopEmpty stringTriggered when the model is about to end the current turn; if blocked, a message can be appended to let the model continue
PostToolUseTool nameTriggered after a tool executes successfully (observation only)
PostToolUseFailureTool nameTriggered after a tool fails or is blocked (observation only)
PermissionRequestTool nameTriggered just before waiting for user approval (observation only)
PermissionResultTool nameTriggered after approval completes (observation only)
SessionStartstartup or resumeTriggered after a new session starts or a previous session resumes
SessionEndexitTriggered after a session closes
SubagentStartSub-agent nameTriggered before a sub-agent starts running
SubagentStopSub-agent nameTriggered after a sub-agent completes successfully (observation only)
StopFailureError typeTriggered after the current turn fails due to an error (observation only)
InterruptEmpty stringTriggered when the user interrupts the current turn (e.g. pressing Esc); not fired for timeouts or other programmatic aborts. Stop does not fire on interrupts, so this event fires instead. The payload includes a reason field (observation only)
PreCompactmanual or autoTriggered before context compaction begins; return values are completely ignored
PostCompactmanual or autoTriggered after context compaction completes (observation only)
NotificationNotification type (e.g. task.completed)Triggered when a background task status changes (observation only)
RewriteToolInputTool nameTriggered before tool execution (before permission checks); can modify tool arguments via updatedInput in the JSON response (requires experimental flag hook-command-rewrite). Only available on the v1 engine; the v2 engine does not support this event
PreLlmRequestEmpty stringTriggered before the LLM API call, after messages and tools are assembled (observation only)
PostLlmRequestEmpty stringTriggered after the LLM response returns, before tool execution begins (observation only)

Event payload reference

The base fields (hook_event_name, session_id, cwd) are included in every event. Below are the additional fields each event sends via stdin. Field values shown are illustrative examples.

UserPromptSubmit

json
{
  "hook_event_name": "UserPromptSubmit",
  "session_id": "session_abc",
  "cwd": "/path/to/project",
  "prompt": "Help me refactor this function"
}
FieldDescription
promptThe text the user submitted

PreToolUse

json
{
  "hook_event_name": "PreToolUse",
  "session_id": "session_abc",
  "cwd": "/path/to/project",
  "tool_name": "Bash",
  "tool_input": { "command": "git status" },
  "tool_call_id": "call_001"
}
FieldDescription
tool_nameThe tool about to be called
tool_inputThe arguments passed to the tool
tool_call_idUnique ID for this tool call

Stop

json
{
  "hook_event_name": "Stop",
  "session_id": "session_abc",
  "cwd": "/path/to/project",
  "stop_hook_active": false,
  "last_assistant_message": "I've finished refactoring the auth module."
}
FieldDescription
stop_hook_activeWhether the Stop hook continuation has already been used in this turn (true on the second and subsequent triggers)
last_assistant_messageThe text of the model's last assistant message (truncated to 2000 characters); lets the hook decide whether to block based on what the model just said

PostToolUse

json
{
  "hook_event_name": "PostToolUse",
  "session_id": "session_abc",
  "cwd": "/path/to/project",
  "tool_name": "Bash",
  "tool_input": { "command": "git status" },
  "tool_call_id": "call_001",
  "tool_output": "On branch main..."
}
FieldDescription
tool_nameThe tool that was called
tool_inputThe arguments passed to the tool
tool_call_idUnique ID for this tool call
tool_outputTool output text (truncated to 2000 characters)

PostToolUseFailure

json
{
  "hook_event_name": "PostToolUseFailure",
  "session_id": "session_abc",
  "cwd": "/path/to/project",
  "tool_name": "Bash",
  "tool_input": { "command": "git push" },
  "tool_call_id": "call_001",
  "error": { "message": "Permission denied" }
}
FieldDescription
tool_nameThe tool that was called
tool_inputThe arguments passed to the tool
tool_call_idUnique ID for this tool call
errorError payload describing why the tool failed

PermissionRequest

json
{
  "hook_event_name": "PermissionRequest",
  "session_id": "session_abc",
  "cwd": "/path/to/project",
  "turn_id": 5,
  "tool_call_id": "call_001",
  "tool_name": "Bash",
  "action": "allow",
  "tool_input": { "command": "rm temp.txt" },
  "display": "rm temp.txt"
}
FieldDescription
turn_idThe turn number this tool call belongs to
tool_call_idUnique ID for this tool call
tool_nameThe tool requesting permission
actionThe permission action being requested
tool_inputThe arguments passed to the tool
displayHuman-readable summary of the tool call

PermissionResult

json
{
  "hook_event_name": "PermissionResult",
  "session_id": "session_abc",
  "cwd": "/path/to/project",
  "turn_id": 5,
  "tool_call_id": "call_001",
  "tool_name": "Bash",
  "action": "allow",
  "decision": "allow",
  "scope": "session"
}

When an error occurs during permission resolution, decision is "error" and an error field replaces scope:

json
{
  "hook_event_name": "PermissionResult",
  "session_id": "session_abc",
  "cwd": "/path/to/project",
  "turn_id": 5,
  "tool_call_id": "call_001",
  "tool_name": "Bash",
  "action": "allow",
  "decision": "error",
  "error": "Approval timed out"
}
FieldDescription
turn_idThe turn number this tool call belongs to
tool_call_idUnique ID for this tool call
tool_nameThe tool that requested permission
actionThe permission action that was requested
decisionThe outcome: allow, deny, or error
scopeThe scope of the decision (e.g. session, project); omitted when decision is error
errorError message; present only when decision is error

SessionStart

json
{
  "hook_event_name": "SessionStart",
  "session_id": "session_abc",
  "cwd": "/path/to/project",
  "source": "startup"
}
FieldDescription
sourceHow the session started: startup (new session) or resume (restored session)

SessionEnd

json
{
  "hook_event_name": "SessionEnd",
  "session_id": "session_abc",
  "cwd": "/path/to/project",
  "reason": "exit"
}
FieldDescription
reasonWhy the session ended; currently always exit

SubagentStart

json
{
  "hook_event_name": "SubagentStart",
  "session_id": "session_abc",
  "cwd": "/path/to/project",
  "agent_name": "coder",
  "prompt": "Refactor the auth module",
  "model_alias": "sonnet"
}
FieldDescription
agent_nameThe sub-agent's profile name
promptThe prompt sent to the sub-agent (truncated for preview)
model_aliasThe model alias configured for the sub-agent

SubagentStop

json
{
  "hook_event_name": "SubagentStop",
  "session_id": "session_abc",
  "cwd": "/path/to/project",
  "agent_name": "coder",
  "response": "Refactoring complete. Changed 3 files...",
  "duration_ms": 15200
}
FieldDescription
agent_nameThe sub-agent's profile name
responseThe sub-agent's final response (truncated for preview)
duration_msSub-agent execution duration in milliseconds

StopFailure

json
{
  "hook_event_name": "StopFailure",
  "session_id": "session_abc",
  "cwd": "/path/to/project",
  "error_type": "APIError",
  "error_message": "Rate limit exceeded"
}
FieldDescription
error_typeThe error type name
error_messageThe error message

Interrupt

json
{
  "hook_event_name": "Interrupt",
  "session_id": "session_abc",
  "cwd": "/path/to/project",
  "turn_id": 5,
  "reason": "cancelled"
}
FieldDescription
turn_idThe turn number that was interrupted
reasonWhy the turn was interrupted; currently always cancelled

PreCompact

json
{
  "hook_event_name": "PreCompact",
  "session_id": "session_abc",
  "cwd": "/path/to/project",
  "trigger": "auto",
  "token_count": 45000,
  "context_window_size": 128000
}
FieldDescription
triggerWhat initiated compaction: manual or auto
token_countThe token count at the time compaction was triggered
context_window_sizeThe model's context window size in tokens; omitted when model capabilities are unknown

PostCompact

json
{
  "hook_event_name": "PostCompact",
  "session_id": "session_abc",
  "cwd": "/path/to/project",
  "trigger": "auto",
  "estimated_token_count": 12000
}
FieldDescription
triggerWhat initiated compaction: manual or auto
estimated_token_countThe estimated token count after compaction

Notification

json
{
  "hook_event_name": "Notification",
  "session_id": "session_abc",
  "cwd": "/path/to/project",
  "sink": "context",
  "notification_type": "task.completed",
  "title": "Background task completed",
  "body": "Linting finished with 0 errors",
  "severity": "info",
  "source_kind": "task"
}
FieldDescription
sinkWhere the notification is delivered (e.g. context)
notification_typeThe notification type (e.g. task.completed)
titleNotification title
bodyNotification body text
severityNotification severity level (e.g. info, warning, error)
source_kindWhat produced the notification (e.g. task)

RewriteToolInput

See the command rewriting example for the full payload and response format.

Note

RewriteToolInput is only available on the v1 engine; the v2 engine does not support this event. On v2, argument rewriting is carried by the updatedInput field of the PreToolUse event (see Hook output capabilities).

PreLlmRequest

Fires once per loop step, after messages and tools are assembled but before the LLM API call is made. Useful for observing which model is being called, how the request grows over time, and whether dynamic tool loading works as expected.

Media parts (images, audio, video) in messages are replaced by a size_bytes marker because their base64 payloads can be several MB. Text, thinking blocks, tool calls, and tool declarations are preserved.

json
{
  "hook_event_name": "PreLlmRequest",
  "session_id": "session_abc",
  "cwd": "/path/to/project",
  "turn_id": "3",
  "step": 1,
  "model": "claude-sonnet-4-20250514",
  "message_count": 12,
  "tool_count": 8,
  "messages": [
    {
      "role": "system",
      "content": [
        { "type": "text", "text": "You are a helpful coding assistant..." }
      ],
      "tools": [
        {
          "name": "dynamic_tool",
          "description": "A tool loaded mid-conversation",
          "parameters": { "type": "object", "properties": { "x": { "type": "number" } } }
        }
      ]
    },
    {
      "role": "user",
      "content": [
        { "type": "text", "text": "Look at this screenshot" },
        { "type": "image_url", "size_bytes": 1843200 }
      ]
    },
    {
      "role": "assistant",
      "content": [
        { "type": "think", "think": "The user sent a screenshot, I need to analyze it..." }
      ],
      "tool_calls": [
        { "id": "call_abc", "name": "Read", "arguments": "{\"path\":\"/src/main.ts\"}" }
      ]
    },
    {
      "role": "tool",
      "tool_call_id": "call_abc",
      "content": [
        { "type": "text", "text": "1\timport { createApp } from 'vue'\n2\t..." }
      ]
    }
  ]
}
FieldDescription
turn_idThe turn number this LLM call belongs to
stepThe step number within the turn (starts at 1)
modelThe model name being called
message_countNumber of messages in the request
tool_countNumber of tools available in the request
messagesSerialized conversation messages; media parts replaced by size_bytes

PostLlmRequest

Fires once per loop step, after the LLM response returns and usage is recorded, but before tool execution begins — earlier than step.end. Useful for tracking token usage, cost estimation, and response timing without waiting for tool calls to complete.

If the LLM call fails (after all retries), PreLlmRequest fires but PostLlmRequest does not — you can correlate the two to detect failures.

json
{
  "hook_event_name": "PostLlmRequest",
  "session_id": "session_abc",
  "cwd": "/path/to/project",
  "turn_id": "3",
  "step": 1,
  "model": "claude-sonnet-4-20250514",
  "finish_reason": "tool_use",
  "usage": {
    "input_other": 344,
    "output": 567,
    "input_cache_read": 890,
    "input_cache_creation": 0
  },
  "duration_ms": 3420,
  "ttft_ms": 850,
  "tool_call_count": 2
}
FieldDescription
turn_idThe turn number this LLM call belongs to
stepThe step number within the turn (starts at 1)
modelThe model name that was called
finish_reasonWhy the response ended: end_turn, tool_use, max_tokens, filtered, paused, or unknown
usageToken usage breakdown (see below)
duration_msEnd-to-end LLM call duration in milliseconds (including retries)
ttft_msTime to first token in milliseconds (may be omitted if the provider does not report it)
tool_call_countNumber of tool calls in the response

usage object fields:

FieldDescription
input_otherInput tokens that were neither cache-read nor cache-created
outputOutput (completion) tokens generated by the model
input_cache_readInput tokens served from the provider's prompt cache
input_cache_creationInput tokens written into the provider's prompt cache

Token tracking example

bash
#!/bin/bash
# ~/.mirri-code/hooks/token-tracker.sh
input=$(cat)
model=$(echo "$input" | jq -r '.model')
input_tokens=$(echo "$input" | jq -r '.usage.input_other + .usage.input_cache_read + .usage.input_cache_creation')
output_tokens=$(echo "$input" | jq -r '.usage.output')
echo "$(date -Iseconds),$model,$input_tokens,$output_tokens" >> ~/.mirri-code/token-usage.csv

Example: Blocking Dangerous Shell Commands

The following hook checks the command content before the Agent calls the Bash tool and blocks it if rm -rf is detected:

toml
[[hooks]]
event = "PreToolUse"
matcher = "Bash"
command = "node ~/.mirri-code/hooks/block-dangerous-bash.mjs"
timeout = 5
js
// block-dangerous-bash.mjs
// Read event data passed by the CLI from stdin
let input = '';
process.stdin.on('data', (chunk) => { input += chunk; });
process.stdin.on('end', () => {
  const payload = JSON.parse(input);         // Parse event data
  const command = payload.tool_input?.command ?? '';

  if (command.includes('rm -rf')) {
    // Explain the blocking reason via stderr; exit code 2 means block
    console.error('Dangerous command detected, blocked');
    process.exit(2);
  }
  // Normal exit (exit code 0) means allow
});

After blocking, Mirri Code CLI writes the blocking reason back into the context, and the model can use this to choose a safer alternative.

Note

This example only demonstrates the blocking mechanism — it is not a production-grade security parser. Real scenarios are better served by whitelists, or a dedicated shell parser to handle quoting, variable expansion, and multi-command sequences.

Example: Using failClosed for Security-Critical Hooks

For security-critical scenarios (such as checking whether a command is on a whitelist), you can use failClosed = true to ensure the operation is blocked rather than allowed when the hook errors:

toml
[[hooks]]
event = "PreToolUse"
matcher = "Bash"
command = "node ~/.mirri-code/hooks/whitelist-check.mjs"
failClosed = true
timeout = 10
js
// whitelist-check.mjs
// Check if a command is on the whitelist; block if not
let input = '';
process.stdin.on('data', (chunk) => { input += chunk; });
process.stdin.on('end', () => {
  const payload = JSON.parse(input);
  const command = payload.tool_input?.command ?? '';

  // Whitelist: only allow these command prefixes
  const whitelist = ['git status', 'git diff', 'npm test', 'ls '];
  const allowed = whitelist.some(prefix => command.startsWith(prefix));

  if (!allowed) {
    console.error('Command not on whitelist: ' + command);
    process.exit(2);  // Exit code 2 = block
  }
  // Exit code 0 = allow
});

This way, even if the script crashes, times out, or outputs invalid JSON, the operation will be blocked rather than allowed — better safe than sorry. Note that failClosed is only effective for blockable events (PreToolUse, Stop, UserPromptSubmit).

Example: Command Rewriting

The RewriteToolInput event allows transparently rewriting tool arguments before execution.

Note

This section demonstrates the v1 engine. The v2 engine does not support the RewriteToolInput event — use the updatedInput field of the PreToolUse event instead (see Hook output capabilities).

First, enable the experimental feature:

bash
# Via environment variable (for quick testing)
export MIRRICODE_EXPERIMENTAL_HOOK_COMMAND_REWRITE=true

# Or via config file (persistent)
# [experimental]
# hook-command-rewrite = true

Then configure a rewrite hook. The matcher field is a regex matched against the tool name — you can target Bash specifically or use .* to match all tools:

toml
[[hooks]]
event = "RewriteToolInput"
matcher = "Bash"
command = "node ~/.mirri-code/hooks/rewriter.mjs"
timeout = 5

How It Works

The RewriteToolInput hook runs in the tool call lifecycle after the preparation phase (dedup, etc.) but before permission checks. This means:

  1. Agent decides to call a tool (e.g. Bash with git status)
  2. Preparation hooks run (dedup, etc.)
  3. RewriteToolInput hook fires — your script can rewrite the arguments
  4. Rewritten arguments are validated
  5. Permission checks run (including PreToolUse hooks)
  6. Tool executes with the final arguments

Multiple Hooks

If multiple RewriteToolInput hooks match the same tool, the first hook that returns updatedInput wins. If a hook returns without updatedInput, it is skipped.

Writing Your Own Rewrite Hook

Any script that reads JSON from stdin and writes a JSON response to stdout can be a rewrite hook.

Here is a production-quality Node.js example — a command wrapper that logs every rewritten command to a file for audit purposes:

js
#!/usr/bin/env node
// ~/.mirri-code/hooks/command-logger.mjs
//
// Rewrites tool input and logs every rewrite to an audit file.
// Usage: set as a RewriteToolInput hook in config.toml.

import { appendFileSync, mkdirSync } from 'node:fs';
import { dirname, join } from 'node:path';

const LOG_DIR = join(process.env.HOME ?? '/tmp', '.mirri-code', 'logs');
const LOG_FILE = join(LOG_DIR, 'rewrite-audit.log');

function log(message) {
  try {
    mkdirSync(LOG_DIR, { recursive: true });
    appendFileSync(LOG_FILE, `${new Date().toISOString()} ${message}\n`);
  } catch {
    // Logging failure must not break the hook
  }
}

let input = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk) => { input += chunk; });
process.stdin.on('end', () => {
  let payload;
  try {
    payload = JSON.parse(input);
  } catch (err) {
    log(`PARSE_ERROR: ${err instanceof Error ? err.message : err}`);
    process.exit(1);
  }

  const toolName = payload.tool_name;
  const toolInput = payload.tool_input;
  const command = toolInput?.command;

  // Only rewrite Bash tool calls
  if (toolName !== 'Bash' || typeof command !== 'string' || command.length === 0) {
    process.exit(0);
  }

  // Example rewrite: prefix with a custom wrapper
  const rewritten = `my-tool ${command}`;

  log(`REWRITE tool=${toolName} original=${JSON.stringify(command)} rewritten=${JSON.stringify(rewritten)}`);

  process.stdout.write(JSON.stringify({
    hookSpecificOutput: {
      permissionDecision: 'allow',
      updatedInput: { command: rewritten },
    },
  }));
});

Configure it:

toml
[[hooks]]
event = "RewriteToolInput"
matcher = "Bash"
command = "node ~/.mirri-code/hooks/command-logger.mjs"
timeout = 5

The input your script receives via stdin:

json
{
  "hook_event_name": "RewriteToolInput",
  "session_id": "session_abc",
  "cwd": "/path/to/project",
  "tool_name": "Bash",
  "tool_input": { "command": "ls -la" },
  "tool_call_id": "call_001"
}

Your script must write a JSON response to stdout:

json
{
  "hookSpecificOutput": {
    "permissionDecision": "allow",
    "updatedInput": { "command": "my-tool ls -la" }
  }
}

Rules:

  • Exit 0 with updatedInput in the response → tool runs with rewritten arguments
  • Exit 0 without updatedInput → tool runs with original arguments
  • Exit non-zero → tool runs with original arguments (fail-open)
  • Crash or timeout → tool runs with original arguments (fail-open)

TIP

For production use, always validate tool_name and tool_input before rewriting. Not all tools have a command field — only rewrite what you understand.

Security Note

Allowing hooks to rewrite tool arguments introduces a security risk. A malicious hook could transparently redirect commands. Only enable this feature with trusted hooks from verified sources.

Use Cases

  • Command wrapping: Prepend a custom tool to every command (e.g. my-tool git status)
  • Token savings: Integrate rtk to compress CLI output
  • Audit logging: Log every command before execution
  • Environment injection: Add environment variables or flags to specific commands

Next steps