Skip to content

Hooks

This page lists every hook declared in bub/hooks/specs.py (BubHookSpecs). Hook kind uses three labels:

  • firstresult — pluggy firstresult=True; HookRuntime.call_first returns the first non-None value in priority order.
  • broadcast — every implementation runs; results are collected into a list (call_many).
  • sync-only consumer — invoked through call_first_sync or call_many_sync; awaitable returns are skipped with a warning.

For the why and how of each stage see Turn pipeline and Build › Hooks.

Hook Kind Signature Returns Invoked from Notes
resolve_session firstresult (message: Envelope) -> str session id BubFramework.process_inbound Falls back to "{channel}:{chat_id}" when no impl returns a value.
load_state broadcast (message: Envelope, session_id: str) -> TurnState dict to merge BubFramework.process_inbound Framework reverses results, then merges in order so high-priority keys overwrite low-priority.
build_prompt firstresult (message: Envelope, session_id: str, state: TurnState) -> str | list[dict] prompt or content parts BubFramework.process_inbound Falls back to content_of(message) when no impl returns a non-None value, or when the selected firstresult is falsy. A falsy selected value does not cause lower-priority impls to run.
run_model firstresult (prompt, session_id, state) -> str model text HookRuntime.run_model Legacy text path. Implement either run_model or run_model_stream, not both.
run_model_stream firstresult (prompt, session_id, state) -> AsyncStreamEvents async stream HookRuntime.run_model_stream Preferred. Falls back to wrapping a run_model result in a one-chunk stream when no streaming impl exists.
continue_prompt firstresult (prompt: str | list[dict] | None, tape: Tape, state: StreamState) -> str | None optional next-step user message Agent._stream_events_with_auto_handoff Runs before each continued agent-loop step. prompt is the prompt used by the completed step, or None if none was added. Implementations may be sync or async. If no hook returns a string, the next model call uses the existing conversation without adding a user message.
save_state broadcast (session_id, state, message, model_output) -> None none process_inbound model-stage finally block Runs after prompt resolution for model-stage success or failure; failures before prompt/model execution skip it.
render_outbound broadcast (message, session_id, state, model_output) -> list[Envelope] outbound batch BubFramework._collect_outbounds All batches are concatenated via unpack_batch. Empty results trigger a default echo envelope.
dispatch_outbound broadcast (message: Envelope) -> bool sent flag process_inbound per outbound Each outbound is fanned out to every impl.
register_cli_commands sync-only consumer (app: typer.Typer) -> None none BubFramework.create_cli_app (call_many_sync) Bootstrap only; async impls log a warning and are skipped.
onboard_config sync-only consumer (custom merge) (current_config: dict) -> dict | None config fragment BubFramework.collect_onboard_config Iterated by priority; each fragment is merged via configure.merge. Non-dict returns raise TypeError.
provide_model_options broadcast (session_id: str, workspace: Path | None) -> ModelOptions | None model choices BubFramework.get_model_options Model choices are appended in hook priority order. Selection state is owned by the caller or adapter.
provide_environment firstresult (session_id: str, workspace: Path) -> Environment | None tool execution environment BubFramework.get_environment Called on a session’s first turn; the result is cached in state["_runtime_environment"] and closed with close() when running() exits. Builtin hooks provide LocalEnvironment on the host; plugins take precedence. With no provider at all, the state has no environment.
on_error observer (stage: str, error: Exception, message: Envelope | None) -> None none HookRuntime.notify_error / notify_error_sync Failures inside an on_error impl are caught and logged so other observers still run.
system_prompt broadcast (joined) (prompt, state) -> str prompt fragment BubFramework.get_system_prompt (call_many_sync) Results are reversed and joined with \n\n; truthy fragments only.
provide_tape_store firstresult () -> TapeStore | AsyncTapeStore tape store BubFramework.running() Resolved once when the runtime scope opens; sync/async iterators are entered as context managers.
provide_lifespan sync-only consumer () -> Iterator[None] | AsyncIterator[None] runtime resources BubFramework.running() Each iterator is entered once; cleanup runs in reverse order on normal exit, exceptions, or cancellation.
provide_tape_sidecar sync-only consumer (deduped) () -> TapeSidecar mounted sidecars BubFramework.get_tape_sidecars Each implementation contributes one sidecar. Sidecars are deduplicated by name; the first value in hook priority order wins.
provide_channels sync-only consumer (deduped) (message_handler: MessageHandler) -> list[Channel] channels BubFramework.get_channels (call_many_sync) Channels are deduplicated by Channel.name; the first channel seen in hook priority order wins.
build_tape_context firstresult () -> TapeContext tape context BubFramework.build_tape_context (call_first_sync) Sync-only; awaitable returns are skipped.
admit_message firstresult (session_id, message, turn) -> AdmitDecision | None turn admission decision ChannelManager Runs before channel scheduling. None keeps default concurrent scheduling; decision types are listed in Core contracts.
before_llm_call chained (request: LlmCallRequest, state: TurnState) -> LlmCallRequest | LlmCallDecision | None modified request or finish decision ModelRunner.run via AgentHooks Impls chain in LIFO order; each sees the previous impl’s request. LlmCallDecision.finish(text) skips the provider call. Raising impls are logged and skipped.
after_llm_call observer (request: LlmCallRequest, result: LlmCallResult, state: TurnState) -> None none ModelRunner.run via AgentHooks Fires exactly once per completed call: success or Exception failure. Cancellation / consumer aclose() is not observed. result.error is the original exception.
before_tool_call chained (call: ToolCall, state: TurnState) -> ToolCallDecision | None decision ToolExecutor via AgentHooks Per tool invocation. proceed(arguments=…) folds argument changes; replace(result) / deny(message) short-circuit. Veto only via the decision object — exceptions are logged and skipped.
after_tool_call terminal handler (call: ToolCall, result: ToolCallResult, state: TurnState) -> None none ToolExecutor via AgentHooks Fires for success, failure and deny/replace. Cancellation is not observed. Assigning result.result replaces the model-facing value; result.error remains the original BubError for failures.

HookRuntime (in src/bub/hooks/runtime.py) wraps the pluggy.PluginManager with the following semantics.

def _iter_hookimpls(self, hook_name: str) -> list[Any]:
    hook = getattr(self._plugin_manager.hook, hook_name, None)
    if hook is None or not hasattr(hook, "get_hookimpls"):
        return []
    return list(reversed(hook.get_hookimpls()))

pluggy returns implementations in registration order. HookRuntime reverses that list, so the most recently registered plugin runs first. Builtin is registered before entry-point plugins, so user plugins always win on firstresult hooks.

bub hooks is a discovery report. In the current implementation it prints the raw hook implementation order from pluggy, so use this section rather than the printed order alone when reasoning about runtime precedence.

Method Behavior
call_first(name, **kwargs) Walk impls in reversed order, return the first non-None value.
call_many(name, **kwargs) Walk all impls, collect each non-skipped value into a list.
call_first_sync / call_many_sync Same iteration; awaitable returns are dropped with a warning.

Async calls (call_first, call_many) await any awaitable result. Sync calls (*_sync) check inspect.isawaitable(value) and emit hook.async_not_supported hook=<name> adapter=<plugin>, then skip the value.

Bootstrap hooks must be synchronous: register_cli_commands, onboard_config, provide_channels, provide_lifespan, provide_tape_store, provide_tape_sidecar, build_tape_context, plus system_prompt.

@staticmethod
def _kwargs_for_impl(impl: Any, kwargs: dict[str, Any]) -> dict[str, Any]:
    return {name: kwargs[name] for name in impl.argnames if name in kwargs}

Each impl receives only the kwargs it declares. You can omit unused parameters from your function signature without breaking dispatch.

provide_lifespan implementations return a sync or async generator that yields once. Bub enters each generator before acquiring the tape store and steering inbox, and closes it after those resources. An exception while entering a resource still closes previously entered lifespans.

The builtin implementation owns shell processes for the scope of framework.running(). Commands moved to the background by a timeout remain available across turns in that scope. Normal exit, exceptions, and cancellation terminate its shells; another runtime’s shells remain independent, even when session IDs match. Cleanup finishes before cancellation propagates. SDK callers using builtin shell tools should enter framework.running() around their application’s lifetime. On POSIX, cleanup targets the process group, including redirected children left after the shell exits. Processes that deliberately leave that group, or a Bub process killed with SIGKILL, require OS-level supervision. Windows cleanup currently targets the direct shell process only.

provide_tape_store is called once per BubFramework.running() scope, not per turn:

  • bub run opens the scope around a single inbound turn, then closes it.
  • bub chat and bub gateway keep the scope open until the listener exits.
  • Returning a sync or async iterator turns the impl into a contextmanager/asynccontextmanager; the yielded value is the active store until the scope exits.

BubFramework.get_tape_store() returns None outside the scope.

Each provide_tape_sidecar implementation contributes one named capability backed by a sibling tape in the active TapeStore. Bub mounts the combined set when it constructs the agent’s root Tape; a scoped session then maps sidecar spill to <session-tape>__sidecar__spill. Fork, merge, archive, and reset operate on every mounted sidecar without requiring a new storage interface. Removing a provider stops mounting its sidecar but does not delete stored data.

notify_error and notify_error_sync wrap each impl in a try/except; observer failures are logged (hook.on_error_failed stage=… adapter=…) but never propagate. This guarantees one broken observer does not block the others, and prevents an on_error from masking the original exception.

The four *_llm_call / *_tool_call hooks are dispatched through the AgentHooks facade (src/bub/hooks/interception.py), not call_first/call_many, with three deliberate differences:

  • Chaining — before_llm_call and before_tool_call run every implementation in LIFO order and fold modifications: each impl receives the request/call as modified by the impls before it. The first short-circuiting decision (LlmCallDecision.finish, ToolCallDecision.replace/deny) stops the chain.
  • Fault isolation — a raising implementation is logged (hook.agent_hook_failed) and skipped, never fatal to the turn. Blocking is only expressible through decision objects, so a broken policy plugin cannot veto by crashing.
  • Exactly-once terminal handling — after_llm_call and after_tool_call fire exactly once per call for real completions: success or Exception failure. Cancellation and consumer close (BaseException) intentionally bypass after hooks. result.error carries the original exception object (a BubError for tool failures with kind/details intact). after_tool_call implementations share one outcome, so assigning result.result changes the model-facing value seen by later implementations while leaving any failure in result.error intact.

Payload dataclasses (LlmCallRequest, LlmCallResult, ToolCall, ToolCallDecision, ToolCallResult, LlmCallDecision) live beside those semantics in src/bub/hooks/interception.py. Every payload carries run_id, matching the run_id meta on tape entries, so observers can correlate hook events with the tape. Rewritten model/max_tokens from before_llm_call are honored end-to-end: the provider receives them and the tape records the effective model.

A continuation-style wrap_tool_call (retry / human-in-the-loop / caching) was prototyped in the original PR and removed by review direction; if those use cases materialize it will return as a separate proposal.

For the contract behind every signature, read src/bub/hooks/specs.py. To verify what is registered in your environment, run bub hooks (see CLI › hooks).