NAME
Langertha::Raider::Hooks - Internal runner of Claude Code hooks from settings and plugins
VERSION
version 0.504
SYNOPSIS
# Internal to Langertha-Raider -- no API promise.
my $hooks = Langertha::Raider::Hooks->from_context( $app->context, loop => $loop )
or return; # not in the Claude layout
my $r = await $hooks->run_f( PreToolUse => {
session_id => $sid,
transcript_path => $journal_file,
permission_mode => 'default',
tool_name => 'edit_file', # raider name and arguments;
tool_input => { path => 'lib/A.pm', ... }, # the hooks see Edit and file_path
tool_use_id => $call_id,
});
# $r is a Langertha::Raider::Hooks::Result
my @late = $hooks->take_async_results; # async hooks done since last time
$hooks->shutdown; # end of session: stop async hooks
DESCRIPTION
Internal module. Its interface may change without notice.
Runs the hooks of a Claude Code project the way Claude Code does (its hooks reference, https://code.claude.com/docs/en/hooks): the hooks of the settings files ("hooks" in Langertha::Raider::Context::Settings) and of every installed and enabled plugin (hooks/hooks.json and the manifest's hooks, "hooks" in Langertha::Raider::Context::Plugins), unless the settings say disableAllHooks. This module runs them and merges what they say into a Langertha::Raider::Hooks::Result; acting on it (denying a tool call, adding context, ...) is the caller's business -- Langertha::Raider::Application (hooks, tool_hook, run_prompt) and Langertha::Raider::Plugin::ClaudeHooks in the Claude layout, as ADR 0022 records.
Selection
Per event the matcher groups in configuration order -- settings files (user, project, local), then plugins in "plugins" in Langertha::Raider::Context::Plugins order. A group's matcher is tested against one field of the event's input: the tool name for PreToolUse, PostToolUse, PostToolUseFailure, PermissionRequest and PermissionDenied, the source for SessionStart (startup, resume, clear, compact), the agent_type for SubagentStart/SubagentStop, the reason for SessionEnd, the trigger for PreCompact/PostCompact, the notification_type for Notification. Other events (UserPromptSubmit, Stop, ...) ignore the matcher. Like Claude Code: *, empty or missing matches all; a matcher of only letters, digits, _, -, spaces, , and | is a list of exact names split at | or ,; anything else is a regular expression, unanchored (Claude Code uses JavaScript's; the common syntax is the same in Perl). An invalid expression skips the group and is reported in "errors" in Langertha::Raider::Hooks::Result, never fatal.
Tool names are matched in Claude Code's spelling: the raider tool is turned into its Claude name with "claude_name" in Langertha::Raider::ToolNames (edit_file is Edit), an mcp__* name stays, a raider-only tool (perl_eval) keeps its raider name.
Only type: command runs; a http, mcp_tool, prompt or agent hook is skipped and reported. A hook with an if rule runs on tool events only (as in Claude Code); the rule is checked by "if_matcher" when one is set, otherwise the hook runs (the rule is a filter, the hook still decides). The same command (with the same args) from several settings files runs once; a plugin's copy runs separately.
Running a hook
Each hook is a process of its own session (setsid, no terminal) in "root", all hooks of an event in parallel, on "loop" (one raid, one loop: ADR 0016). ${CLAUDE_PROJECT_DIR}, and for plugin hooks ${CLAUDE_PLUGIN_ROOT} and ${CLAUDE_PLUGIN_DATA}, are replaced in command and every element of args, and set in the environment (CLAUDE_PLUGIN_* removed for settings hooks). With args the command is started directly (exec form, looked up on PATH, no shell); without, it runs with sh -c (shell form; shell: bash uses bash, powershell is not supported). The data directory is created before a plugin hook runs. ${user_config.*} is not supported and stays as written.
The event's input is written to stdin as JSON: what the caller passes, plus hook_event_name and cwd ("root" unless given -- the application gives its working root, a subagent its own, ADR 0032). For tool events tool_name is turned into the Claude name and tool_input into the Claude form ("claude_input" in Langertha::Raider::ToolNames); the file_path of Read, Write, Edit and MultiEdit is made absolute against that cwd, as Claude Code hands it.
timeout is in seconds; the default is 600, 30 on UserPromptSubmit. A hook over its time gets TERM (its process group), KILL two seconds later; its output is dropped and it is a non-blocking error. async: true starts the hook and does not wait: no timeout, no decision; when it ends, its additionalContext and systemMessage are in "take_async_results". asyncRewake runs like async (raider has no idle session to wake).
onFailure: "block" makes a failed hook -- one that cannot start, exits other than 0 or 2 (even with a JSON answer), prints invalid JSON or times out -- do what exit 2 does on its event; not on Stop, SubagentStop, TaskCompleted, TeammateIdle and async hooks, as in Claude Code.
Reading the answer
As Claude Code reads it:
stdout that is one JSON object (
{...}, whitespace around) is the hook's answer; stdout that is not is plain text;{...}that does not parse is invalid. An async hook may also print its object on a line of its own.Exit 0: the JSON fields apply; plain text is context on
UserPromptSubmitandSessionStart; invalid JSON is a non-blocking error.Exit 2: blocks --
PreToolUseis denied, a blocking event (UserPromptSubmit,Stop,SubagentStop,PostToolUse,PostToolUseFailure,PreCompact, ...) is blocked; stderr is the reason unless the JSON gives one. The JSON is still read but cannot undo the block. On events that cannot block (SessionStart,SubagentStart, ...) exit 2 is a non-blocking error. OnStop,SubagentStopand a plugin'sUserPromptSubmit, an exit 2 with empty stdout whose stderr saysNo such file or directoryis a non-blocking error (a missing script).Any other exit: with a valid JSON object the fields decide (Claude Code ignores the exit code then); otherwise a non-blocking error with the first line of stderr.
hookSpecificOutputmust name the event inhookEventName, else the answer is a non-blocking error.
Merging
continue: falsewins over everything: "stopped" in Langertha::Raider::Hooks::Result.PreToolUse:deny>ask>allow; the reasons of the winning decision in configuration order.defer(Claude Code's-pmode only) is not supported and counts as no decision. The deprecated top-leveldecisionapprove/blockcounts asallow/deny.updatedInputreplaces the whole input (Claude Code's rule). The hooks of one event run in parallel on the same input, so two of them cannot build on each other: the last one in configuration order wins, and when several hooks return different inputs that is reported in "errors" in Langertha::Raider::Hooks::Result. It is translated back with "raider_input" in Langertha::Raider::ToolNames; afile_paththat is still the absolute form of the original relativepathgets the original back. It never applies to a denied call.additionalContext,systemMessageand plain-text context are collected, never merged away;decision: "block"on a blocking event sets "blocked" in Langertha::Raider::Hooks::Result with itsreason.
Task events and the words of a denial
TaskCreated and TaskCompleted are fired by the application when the task tools (task_create, task_update) create a task or complete one, with task_id, task_subject and task_description in the input and no matcher. Exit 2 (or decision: "block") vetoes the change; the model gets TaskCreated hook error: STDERR as the tool's error result. They are blocking events here, so "blocked" in Langertha::Raider::Hooks::Result tells (ADR 0022, addendum).
What the model is told when a PreToolUse hook denies is "deny_messages" in Langertha::Raider::Hooks::Result: PreToolUse:Bash hook error: STDERR for exit 2, the permissionDecisionReason as it stands for a JSON denial.
Briefing loaded natively
The briefing plugin's briefing-preload rewrites an Agent call's prompt on PreToolUse (and answers SubagentStart for Codex) to put the skills an agent file declares under briefing.skills in front of the task. When raider loads those skills itself ("native_briefing"), running the hook as well would put every skill body into the prompt twice -- or deny a spawn raider would brief correctly, as the hook resolves skills through its own copy of the lookup. Raider's own loading wins: with "native_briefing" the hooks whose command is briefing-preload are skipped (reported as skipped) on PreToolUse for Agent and on SubagentStart. Its SessionStart check still runs.
loop
The IO::Async::Loop the hooks run on -- the raid's loop. Required.
root
The working directory: the hook processes run there, and it is the cwd of an input that names none, the base of its relative tool paths. Required.
project_dir
CLAUDE_PROJECT_DIR: the project root. Defaults to "root".
settings
The Langertha::Raider::Context::Settings whose hooks run, or undef.
plugins
The Langertha::Raider::Context::Plugins whose hooks run, or undef.
native_briefing
True when raider loads briefing.skills of agent files itself; then the briefing-preload hooks are skipped where they would load them a second time (see "Briefing loaded natively"). Default false.
if_matcher
Optional code ref that decides a hook's if rule (Claude Code's permission rule syntax, Bash(git *)) for a tool call:
if_matcher => sub { my ( $rule, $call ) = @_; ... } # $call: { name, source, arguments }, raider form
True runs the hook, false skips it, undef (the rule cannot be decided) runs it marked if_unchecked, as Claude Code runs a hook whose rule it cannot evaluate. Without one, a hook with an if rule runs on every tool call its group matches, marked the same way. The application passes "rule_matches" in Langertha::Raider::Permissions.
groups
The matcher groups per event, { Event => [ { matcher, hooks, source, plugin }, ... ] }, plugin being { name, key, root, data_dir } for a plugin's group and undef otherwise. Built from "settings" and "plugins"; empty when the settings say disableAllHooks.
config_errors
Problems found while collecting "groups" (a broken plugin hooks file), one line each.
from_context
my $hooks = Langertha::Raider::Hooks->from_context( $context, loop => $loop, %more );
A runner for a Langertha::Raider::Context: its root, project directory, settings and plugins. undef outside the Claude layout (no settings). %more goes to new ("native_briefing", "if_matcher").
has_hooks
return unless $hooks->has_hooks('PostToolUse');
True when any group is configured for the event (whether its matcher matches is not checked). Cheap; lets the caller skip building the input.
run_f
my $result = await $hooks->run_f( $event => \%input, tool_source => 'engine:1' );
tool_source (optional) is where a tool call runs, as the gate sees it; it only reaches "if_matcher", never the hooks. Runs the hooks of $event that match \%input (see "DESCRIPTION") and resolves to a Langertha::Raider::Hooks::Result once every hook that is not async has finished. \%input holds the event's fields in raider form: session_id, transcript_path, permission_mode, ... and per event tool_name/tool_input/tool_use_id/tool_response, prompt, source, stop_hook_active, agent_id/agent_type, last_assistant_message. JSON booleans for booleans. The future never fails: every problem ends up in the result's errors. With nothing to run it is done at once with an empty result.
run
my $result = $hooks->run( $event => \%input );
"run_f", waited for on "loop".
take_async_results
for my $r ($hooks->take_async_results) { ... $r->context, $r->system_messages ... }
A Langertha::Raider::Hooks::Result for every async hook that ended since the last call, in the order they ended; each carries only that hook's additionalContext and systemMessage (an async hook decides nothing), its run report and errors. Claude Code delivers these on the next turn.
running_async
The number of async hooks still running.
shutdown
await $hooks->shutdown;
Stops every async hook still running (TERM to its process group, KILL after two seconds, as claude -p does at the end); they end as cancelled and show up in "take_async_results". Returns a future that is done when they have ended.
SEE ALSO
Claude Code's hooks reference, https://code.claude.com/docs/en/hooks
SUPPORT
Issues
Please report bugs and feature requests at https://github.com/Getty/langertha-raider/issues.
IRC
Join #langertha on irc.perl.org or message Getty directly.
CONTRIBUTING
Contributions are welcome! Please fork the repository and submit a pull request.
AUTHOR
Torsten Raudssus <torsten@raudssus.de> https://raudssus.de/
COPYRIGHT AND LICENSE
This software is copyright (c) 2026 by Torsten Raudssus.
This is free software; you can redistribute it and/or modify it under the same terms as the Perl 5 programming language system itself.