Hooks are functions ex_athena calls at lifecycle events so hosts can observe, deny, halt, or augment the run without subclassing the loop or modes.
Shape
hooks = %{
PreToolUse: [%{matcher: "Write|Edit", hooks: [&deny_protected/2]}],
PostToolUse: [%{matcher: "Bash", hooks: [&capture_test/2]}],
ChatParams: [&inject_metadata/2],
Stop: [&log_stop/2]
}
ExAthena.run("...", tools: :all, hooks: hooks)Per-tool events take matcher groups: %{matcher: pattern, hooks: funs}. The matcher is a regex string, Regex struct, or nil. A
nil matcher fires for every tool. Lifecycle events take a flat list
of functions.
Callback contract
Each hook function receives (input, tool_use_id_or_session_id) and
returns one of:
| Return | Meaning |
|---|---|
:ok | Continue with no side effects. |
{:halt, reason} | Stop the loop. Sets finish_reason: :error_halted. |
{:deny, reason} | Only valid from PreToolUse / PermissionRequest. Denies the tool call; routed back to the model as an error tool-result. |
{:inject, msg_or_msgs} | Append a Message.t() (or list) to the conversation. |
{:transform, prompt} | Only valid from UserPromptSubmit. Rewrites the user's prompt before it enters the loop. |
{:augment, text} | Only valid from PostToolUse. Appends text to the tool-result content the model sees on the next turn. Multiple augments are joined with "\n". Halt takes priority. |
Catalog
ExAthena.Hooks.events/0 enumerates all 17 supported events.
Session lifecycle
| Event | Fires at | Payload |
|---|---|---|
:SessionStart | Just after Loop.run/2 builds initial state | %{session_id, parent_session_id} |
:SessionEnd | After Stop / StopFailure in to_result/2 | %{session_id, parent_session_id, finish_reason, result} |
Per-turn
| Event | Fires at | Payload |
|---|---|---|
:UserPromptSubmit | Before the first iteration | %{prompt, session_id, parent_session_id} |
:ChatParams | Before every provider call inside Modes.ReAct.iterate/1 | %{request, session_id, messages} |
:Stop | Run finished cleanly (finish_reason == :stop) | %{session_id, finish_reason: :stop, result} |
:StopFailure | Run finished with any error finish_reason | %{session_id, finish_reason, result} |
UserPromptSubmit honours {:transform, prompt} to rewrite the
incoming user message; ChatParams honours {:inject, msg} to add
context just before a provider call.
Per-tool
| Event | Fires at | Payload |
|---|---|---|
:PreToolUse | Before tool execution. Honours {:deny, reason}. | %{tool_name, tool_use_id, ...args} |
:PostToolUse | After successful execution. Supports {:augment, text} (deny is too late). | %{tool_name, result, arguments, cwd} |
:PostToolUseFailure | After tool returns {:error, reason} | %{tool_name, tool_use_id, reason} |
:PermissionRequest | Before can_use_tool callback (when :default mode prompts) | %{tool_name, tool_use_id, arguments} |
:PermissionDenied | Whenever the gate decides {:deny, _} | %{tool_name, tool_use_id, arguments, reason} |
PermissionDenied fires alongside the model getting the deny reason
as a tool-result — Claude Code's "deny as routing signal" pattern.
Hooks observe; the model adjusts.
Subagent
| Event | Fires at | Payload |
|---|---|---|
:SubagentStart | Before sub-loop starts in Tools.SpawnAgent | %{subagent_id, prompt, parent_session_id, agent, isolation} |
:SubagentStop | After sub-loop terminates (any outcome) | %{subagent_id, outcome, result, isolation} |
isolation carries the resolution decision ({:in_process, :requested},
{:in_process, :no_git}, {:in_process, :dirty_tree}, {:worktree, info}).
After completion it becomes :worktree_kept, :worktree_removed, or
:worktree_error.
Compaction
| Event | Fires at | Payload |
|---|---|---|
:PreCompact | Before maybe_compact/1 runs the pipeline | %{estimate} |
:PreCompactStage | Before each individual pipeline stage | %{stage, estimate} |
:PostCompact | After a successful compaction | %{metadata: %{before, after, dropped_count, stages_applied, reason}} |
Notification
| Event | Fires at | Payload |
|---|---|---|
:Notification | Manual host trigger via ExAthena.Hooks.run_lifecycle/3 | host-defined |
Worked examples
Deny writes to a protected path
deny_protected = fn %{tool_name: name, "path" => path}, _id ->
if name in ["write", "edit"] and String.contains?(path, "priv/secrets") do
{:deny, :protected_path}
else
:ok
end
end
ExAthena.run("ship it",
tools: :all,
hooks: %{PreToolUse: [%{matcher: "write|edit", hooks: [deny_protected]}]})Inject project metadata into every chat call
inject_metadata = fn _payload, _id ->
{:inject,
ExAthena.Messages.system("Current ticket: ENG-1234")
|> Map.put(:name, "ticket-context")}
end
ExAthena.run("...", tools: :all, hooks: %{ChatParams: [inject_metadata]})Rewrite a user prompt with project conventions
expand_macros = fn %{prompt: prompt}, _id ->
if String.starts_with?(prompt, "/deploy") do
{:transform, "Deploy the staging branch to production. Steps: ..."}
else
:ok
end
end
ExAthena.run("...", tools: :all, hooks: %{UserPromptSubmit: [expand_macros]})Capture every tool failure to telemetry
capture = fn %{tool_name: name, reason: reason}, tool_use_id ->
:telemetry.execute([:my_app, :tool_failure], %{}, %{
tool: name,
reason: inspect(reason),
tool_use_id: tool_use_id
})
:ok
end
ExAthena.run("...", tools: :all, hooks: %{PostToolUseFailure: [capture]})Persist results on every Stop
ExAthena.run("...",
tools: :all,
hooks: %{
Stop: [fn %{result: r}, sid -> MyApp.Sessions.persist(sid, r); :ok end],
StopFailure: [fn %{result: r}, sid -> MyApp.Sessions.alert(sid, r); :ok end]
})Programmatic dispatch
ExAthena.Hooks.run_lifecycle_with_outputs/3 returns the structured
outputs for callers that need them:
%{halt: nil, injects: [msg1, msg2], transform: nil} =
ExAthena.Hooks.run_lifecycle_with_outputs(hooks, :ChatParams, payload)run_lifecycle/3 returns :ok | {:halt, reason} for backward-compat;
use the _with_outputs variant when you need to read injects /
transform.
Implicit LSP diagnostics (PostToolUse)
When the model edits or writes a file, ex_athena automatically runs an LSP
diagnostic check and appends any errors or warnings to the tool-result the
model sees on the next turn. This is powered by
ExAthena.Lsp.ImplicitDiagnostics, which registers a built-in PostToolUse
hook matching Edit|Write.
No configuration is needed when elixir-ls (or another LSP server) is on
$PATH — the hook fires automatically on every Edit/Write call.
Seeing diagnostics in practice
The augmented tool-result looks like:
edited foo.ex (1 replacement)
[lsp diagnostics]
error: undefined function bar/0 at foo.ex:3:1The model reads both the edit confirmation and the compiler feedback in a
single turn, without needing to call the lsp tool explicitly.
Configuration
| Key | Default | Description |
|---|---|---|
:lsp_implicit_diagnostics_enabled | true | Set to false to disable the hook globally (e.g. in test.exs). |
:lsp_implicit_diagnostics_timeout_ms | 1500 | How long to poll for push-diagnostics before giving up. |
:lsp_implicit_diagnostics_severities | [:error, :warning] | Severity levels to include. Options: :error, :warning, :information, :hint. |
Example — disable in tests:
# config/test.exs
config :ex_athena, lsp_implicit_diagnostics_enabled: falseExample — extend to information-level messages:
config :ex_athena, lsp_implicit_diagnostics_severities: [:error, :warning, :information]Telemetry
The hook emits [:ex_athena, :lsp, :implicit_diagnostics, :start | :stop]
events. The :stop measurements include:
| Field | Type | Description |
|---|---|---|
duration_ms | integer | Wall-clock time in ms |
count | integer | Number of diagnostics in the augmented text |
had_errors | boolean | true when at least one error-severity diagnostic was found |
The :stop metadata includes tool_name (the triggering tool).
See also
ExAthena.Hooks- Permissions —
PermissionDeniedsemantics. - Compaction pipeline —
PreCompactStagepayload.