Skip to content

Change application behavior

Choose a hook at the point where you want to change the Bub app. Keep the rest of the application in place: a prompt hook changes the input, a tool decision controls one call, and a store provider changes where conversation history lives.

These examples belong in a plugin. Module-level functions can use a module entry point. For a class, register an instance or a factory that accepts the framework.

The hooks reference defines signatures, priority, and lifecycle rules.

Use build_prompt to replace the incoming content:

from bub import hookimpl
from bub.envelope import content_of


@hookimpl
def build_prompt(message, session_id, state):
    return f"Answer using the project's terminology.\n\n{content_of(message)}"

This text-only example reads the envelope through content_of. For media, return the user content types described in the reference.

To add a system instruction while keeping the incoming prompt, contribute a system_prompt fragment instead:

from bub import hookimpl


@hookimpl
def system_prompt(prompt, state):
    return "Explain which files support your answer."

Use the agent interception hooks when you want to retain the builtin agent and change an individual call. For example, cap a request’s output tokens:

from dataclasses import replace

from bub import hookimpl
from bub.hooks.interception import LLMCallRequest


@hookimpl
def before_llm_call(request: LLMCallRequest, state):
    return replace(request, max_tokens=min(request.max_tokens or 512, 512))

Return a decision to prevent a tool from running:

from bub import hookimpl
from bub.hooks.interception import ToolCall, ToolCallDecision


@hookimpl
def before_tool_call(call: ToolCall, state):
    if call.tool == "orders.cancel" and not state.get("can_cancel_orders", False):
        return ToolCallDecision.deny("This session cannot cancel orders.")
    return None

Your authorization logic sets can_cancel_orders; the hook uses that value to allow or deny the call.

An exception in these interception hooks is logged and skipped. Use deny, replace, or LLMCallDecision.finish for control. The interception contract explains when a decision stops the chain.

For call metrics, use the completion hooks:

from bub import hookimpl


@hookimpl
def after_llm_call(request, result, state):
    print(f"model={request.model} duration_ms={result.duration_ms} error={result.error!r}")


@hookimpl
def after_tool_call(call, result, state):
    print(f"tool={call.tool} duration_ms={result.duration_ms} error={result.error!r}")

These hooks observe completed calls, including ordinary failures. They do not report cancellation. For per-chunk output, use the stream interface in your host or channel and follow its closing rules.

To replace the entire model stage, implement either run_model or run_model_stream. Your first plugin shows a text replacement. A replacement owns its execution and recording behavior; the builtin agent’s interception hooks run only when that agent is used.

The framework builds a fresh state dictionary for each inbound turn. Use load_state and save_state to carry your application’s own values between turns:

from bub import hookimpl


class TurnCounter:
    def __init__(self) -> None:
        self.counts: dict[str, int] = {}

    @hookimpl
    def load_state(self, message, session_id):
        return {"completed_turns": self.counts.get(session_id, 0)}

    @hookimpl
    def save_state(self, session_id, state, message, model_output):
        self.counts[session_id] = state["completed_turns"] + 1


turn_counter = TurnCounter()

This in-memory example counts model-stage exits, including failures. It resets when the process exits and assumes one turn at a time per session. Use durable storage and an appropriate concurrency strategy for production state.

save_state runs in the model stage’s finally block, after session, state, and prompt preparation have succeeded. Errors during that preparation skip the save hook. Conversation history has a separate Tape lifecycle.

Return the store you want the application to use:

from pathlib import Path

from bub import hookimpl
from bub.core.store import FileTapeStore


@hookimpl
def provide_tape_store():
    return FileTapeStore(Path("./application-tapes"))

This example selects a plain JSONL file store in the current directory. Bub’s builtin application store adds application-specific search behavior; choose the store whose behavior you need. For a database-backed application store, see the SQLAlchemy integration.

The framework resolves the provider once when framework.running() opens. A provider can yield a store and release it in finally. The reference covers that lifecycle and the corresponding provide_lifespan hook.

A plugin can also contribute a named sibling tape:

from bub import hookimpl


class ArtifactSidecar:
    name = "artifacts"


@hookimpl
def provide_tape_sidecar():
    return ArtifactSidecar()

The shared Tape explanation describes sidecar naming and merge behavior.

register_cli_commands receives the root Typer app:

import typer

from bub import hookimpl


@hookimpl
def register_cli_commands(app: typer.Typer):
    @app.command()
    def hello(name: str = "world") -> None:
        typer.echo(f"hello, {name}")

After installing the plugin, run bub hello --name bub. This hook must be synchronous.

For other application tasks, use onboard_config to contribute configuration, provide_channels to connect a transport, or provide_environment to choose where builtin tools run. Their inputs and return values are in the hooks reference.