Hooks reference (v0.4)

Copy Markdown View Source

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:

ReturnMeaning
:okContinue 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.

Catalog

ExAthena.Hooks.events/0 enumerates all 17 supported events.

Session lifecycle

EventFires atPayload
:SessionStartJust after Loop.run/2 builds initial state%{session_id, parent_session_id}
:SessionEndAfter Stop / StopFailure in to_result/2%{session_id, parent_session_id, finish_reason, result}

Per-turn

EventFires atPayload
:UserPromptSubmitBefore the first iteration%{prompt, session_id, parent_session_id}
:ChatParamsBefore every provider call inside Modes.ReAct.iterate/1%{request, session_id, messages}
:StopRun finished cleanly (finish_reason == :stop)%{session_id, finish_reason: :stop, result}
:StopFailureRun 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

EventFires atPayload
:PreToolUseBefore tool execution. Honours {:deny, reason}.%{tool_name, tool_use_id, ...args}
:PostToolUseAfter successful execution. Halt-only (deny is too late).%{tool_name, tool_use_id, result}
:PostToolUseFailureAfter tool returns {:error, reason}%{tool_name, tool_use_id, reason}
:PermissionRequestBefore can_use_tool callback (when :default mode prompts)%{tool_name, tool_use_id, arguments}
:PermissionDeniedWhenever 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

EventFires atPayload
:SubagentStartBefore sub-loop starts in Tools.SpawnAgent%{subagent_id, prompt, parent_session_id, agent, isolation}
:SubagentStopAfter 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

EventFires atPayload
:PreCompactBefore maybe_compact/1 runs the pipeline%{estimate}
:PreCompactStageBefore each individual pipeline stage%{stage, estimate}
:PostCompactAfter a successful compaction%{metadata: %{before, after, dropped_count, stages_applied, reason}}

Notification

EventFires atPayload
:NotificationManual host trigger via ExAthena.Hooks.run_lifecycle/3host-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.

See also