# Hooks reference (v0.5)
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
```elixir
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
```elixir
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
```elixir
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
```elixir
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
```elixir
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
```elixir
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:
```elixir
%{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:1
```
The 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:
```elixir
# config/test.exs
config :ex_athena, lsp_implicit_diagnostics_enabled: false
```
Example — extend to information-level messages:
```elixir
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`](https://hexdocs.pm/ex_athena/ExAthena.Hooks.html)
- [Permissions](permissions.md) — `PermissionDenied` semantics.
- [Compaction pipeline](compaction_pipeline.md) — `PreCompactStage`
payload.