# 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](advanced.md#hookregistry)
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 `imodel.invoke()`) and `operations/run/run.py` (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.

`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.

```python
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:

```text
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.
