Skip to content

Agent Hooks Reference

Reference for lionagi.hooks.HookPoint — the closed vocabulary of session lifecycle hook points — and the built-in handlers registered via lionagi.hooks.loader.DEFAULT_HOOKS.

These Session-level HookBus hooks are separate from the per-iModel service lifecycle. See HookRegistry service hooks for model invocation callbacks.

HookPoint catalog

Dispatched (active emit callsites)

Point Value Callsite
MESSAGE_ADD message.add branch.py _persist_via_bus — every inbound message
USER_PROMPT_SUBMIT prompt.submit operations/chat/chat.py and operations/run/run.py, immediately before provider invocation / streaming begins — fires only when the operation context carries a turn-origin token (blocking, via blocking_emit)
BRANCH_END branch.end cli/_runs.py teardown_persist — once per branch the teardown owns, only when the run reached a genuine terminal outcome (never for the "running" reconciliation-suppression case)
API_PRE_CALL api.pre_call operations/_api_hooks.py emit_api_pre_call, called from operations/chat/chat.py (before the request is compiled from the active view, so a handler that curates the view shapes this request) and operations/run/run.py (after the request is compiled, before the CLI stream starts) — only when the calling Branch is session-bound (branch._hooks is not None); a standalone iModel never reaches this callsite
API_POST_CALL api.post_call operations/_api_hooks.py emit_api_post_call, called from the same two sites once the call has settled — success, a provider-reported failure (api_call.status), or a raised exception (error=..., status="error")
API_STREAM_CHUNK api.stream_chunk operations/_api_hooks.py emit_api_stream_chunk, called from operations/run/run.py for every chunk of a session-bound stream; payload carries a redacted chunk_type only, not the raw chunk
TOOL_PRE tool.pre operations/act/act.py, before tool invocation (blocking via blocking_emit)
TOOL_POST tool.post operations/act/act.py, after successful tool invocation
TOOL_ERROR tool.error operations/act/act.py, on tool invocation failure

Registered in DEFAULT_HOOKS (handlers wired; emit callsite deferred to ADR-0023b)

Point Value Default handler
SESSION_START session.start persist_session_start
SESSION_END session.end persist_session_end
BRANCH_CREATE branch.create persist_branch_provenance

Dormant (vocabulary only; no handler and no emit callsite)

Point Value Status
ARTIFACT_CREATED artifact.created Deprecated compatibility vocabulary: no emit site or payload contract exists

ARTIFACT_CREATED is retained only for enum compatibility. Do not register new handlers against it until an artifact owner defines a typed payload and a production emit site. HookBus.on() emits a UserWarning when a handler is registered against any point in lionagi.hooks.DORMANT_POINTS (today, just ARTIFACT_CREATED) so that mistake surfaces at registration time instead of being discovered later as a handler that silently never ran.

Bus dispatch semantics

HookBus.emit fires handlers sequentially and logs exceptions without propagating them (isolation invariant). Exceptions: TOOL_PRE and USER_PROMPT_SUBMIT route through blocking_emit, which propagates exceptions so guards can raise PermissionError to abort the tool call or prompt submission. Before a blocking exception is re-raised, the bus records a denial HookSignal whose payload includes denied: true and an exception summary.

A store refusal is never suppressed by suppress_errors. A StateCompatibilityRefused raised by a tool call's own path, or by a TOOL_POST handler _act() fires under its default suppress_errors=True, is re-raised as itself: a refused write never becomes a swallowed tool error result, so a run cannot report a success the store refused to persist. A signal whose own persistence is refused is not raised by the emit: the session signal sink keeps the first such refusal, logs every later one, and counts none of them as dropped, and the run's teardown then fails the run with the kept refusal as its reason, also for a refusal recorded while the lifecycle emits run, since the terminal status is written after them. Every other failed signal write is still counted as dropped, as before. A lifecycle emit a run makes hands the run's own open handle to the default handlers, so each writes through that handle and meets whatever the store requires of the run (the SESSION_START and BRANCH_CREATE handlers, handed the agent setup's handle, write nothing: the rows the setup created already carry what they would write); an emit that carries no handle falls back to the store looked up by its URL, where a registered compatibility handle is returned ahead of a native one. A refusal a handler raises is raised by the emit as any handler's refusal is; a refusal the signal sink keeps (the store declined a signal write without raising) is not raised by the emit, and the run's teardown fails the run with it as its reason.

USER_PROMPT_SUBMIT fires at most once per genuine user-originated turn, regardless of how many internal calls that turn drives underneath it — see lionagi/operations/_turn_origin.py for the tri-state disposition (unset / forwarded / no-origin) that makes this exactly-once property hold across chat(), chat_and_record(), communicate(), operate(), run(), and ReAct().

StopHook may be raised by any handler to skip remaining handlers on the same point without propagating as an error.

Override semantics (build_session_bus)

build_session_bus(agent_hooks=...) is a low-level construction utility for callers that explicitly own a Session bus. It is not consumed automatically by Session or AgentSpec/profile construction. A point present in the mapping replaces its default handler list; an empty list disables a default. Points not mentioned keep their defaults.

bus = build_session_bus(
    agent_hooks={"api.post_call": ["log_api_metrics"]},
    observer=session.observer,
)

Assigning a separately built bus to Session internals is unsupported. Callers that need the standard Session-owned bus should use session.hooks and register runtime handlers with on().

Tool-event hooks at the invoke chokepoint

lionagi.protocols.action.tool_hooks defines a second, mutation-capable tool-event layer, separate from the HookBus points above. It attaches to ActionManager (manager.add_tool_pre_hook(hook) / manager.add_tool_post_hook(hook)) and runs inside ActionManager.invoke, outermost around every tool call the manager mediates — plain function tools, Tool objects, and MCP-discovered tools alike. Constructing a FunctionCalling directly (bypassing the manager) skips this layer entirely; that is a documented, tested limit, not an oversight.

Call order on a single tool invocation:

tool-pre hooks (registration order)
  -> Tool.preprocessor (spec-level security/user chain, security_pre last)
  -> [rewritten arguments revalidated against Tool.request_options]
  -> the tool callable
  -> Tool.postprocessor (spec-level chain)
  -> tool-post hooks (registration order)

A tool-pre hook receives (tool_name, arguments) and returns None (allow, unchanged), a dict (allow, replace the arguments), or a ToolPreDecision (decision ∈ allow | deny | ask, plus optional reason / updated_input). deny, ask (no interactive-approval surface exists in this runtime), and any unrecognized decision value all fail closed, raising ToolHookDeniedError (a PermissionError) directly out of ActionManager.invoke before the tool ever runs. security_pre always stays the last pre-stage validator: tool-pre hooks run entirely outside the spec-level chain, so any rewrite they make is visible to security_pre, never the other way around.

Whichever layer rewrites the arguments — tool-pre hooks or the spec-level chain — the final dict is revalidated against the tool's request_options (when declared) immediately before the callable executes; a validation failure is captured as a FAILED event (matching the existing spec-level-preprocessor-error convention) rather than raised out of invoke. A tool with no request_options never had schema enforcement, and this step does not invent one.

A tool-post hook receives (tool_name, arguments, result, error) — exactly one of result/error is set — after invocation completes, success or failure. Post hooks are advisory only: a raised exception from one is logged and skipped, and nothing a post hook returns can change the already- completed outcome.

Built-in handlers

Handler Registered for
persist_session_start SESSION_START
persist_session_end SESSION_END
persist_branch_provenance BRANCH_CREATE
persist_branch_end BRANCH_END
persist_message name-addressable; routed explicitly by hooks/persist.py, not in DEFAULT_HOOKS
log_api_metrics (name-addressable; not in DEFAULT_HOOKS)
log_tool_call (name-addressable; not in DEFAULT_HOOKS)
log_tool_use (name-addressable; not in DEFAULT_HOOKS; deprecated — use log_tool_call)

All handlers are name-addressable via the loader registry and can be referenced as strings in the explicit agent_hooks mapping passed to build_session_bus.

AgentSpec coding() guards

AgentSpec.coding(secure=True) (the default) wires two security guards via _wire_secure_guards:

  • guard_destructive as a pre-hook on bash — blocks destructive shell commands (rm -rf, git push --force, git reset --hard, git clean -fd, drop table, drop database, truncate table, mkfs, dd if=, > /dev/sd*).
  • guard_paths(allowed_paths=[workspace_root]) as a pre-hook on reader and editor — restricts file access to the workspace root (cwd if provided, else Path.cwd() at call time). Relative paths are resolved against the workspace root, not the process cwd.

Set secure=False to disable these defaults and manage guards manually.