Skip to content

Core contracts

Bub keeps contracts beside the subsystem that owns their behavior. Import a contract from its defining module; the top-level bub package intentionally exposes only the common framework entry points.

from bub.envelope import Envelope, content_of, field_of, normalize_envelope, unpack_batch

type Envelope = Any

An Envelope may be a mapping, dataclass, Pydantic model, or any attribute-based object. Use field_of and content_of to read values without coupling code to one transport schema. normalize_envelope produces a mutable dict; unpack_batch normalizes one render_outbound result into a list.

from bub.turn import TurnResult, TurnState

type TurnState = dict[str, Any]

@dataclass(frozen=True)
class TurnResult:
    session_id: str
    prompt: str | list[dict[str, Any]]
    model_output: str
    outbounds: list[Envelope] = field(default_factory=list)
    state: TurnState = field(default_factory=dict)

TurnState is the mutable state shared by every stage of one turn. process_inbound returns the final TurnResult, including the state that produced its outbound envelopes.

from bub.model_selection import ModelChoice, ModelOptions

@dataclass(frozen=True)
class ModelChoice:
    id: str
    name: str | None = None
    description: str | None = None
    meta: dict[str, Any] | None = None

@dataclass(frozen=True)
class ModelOptions:
    models: list[ModelChoice] = field(default_factory=list)
    current_model: str | None = None

Channels and adapters may present these protocol-neutral choices to users. BubFramework.get_model_options merges values returned by provide_model_options; the caller owns selection UI and persistence.

from bub.streaming import AsyncStreamEvents, StreamEvent, StreamState

StreamEvent.kind is one of text, reasoning, tool_call, tool_result, usage, error, or final. AsyncStreamEvents wraps an async iterator and exposes terminal error and usage state.

from bub.errors import BubError, ErrorKind

BubError is the stable execution error object. Its kind is an ErrorKind, and as_dict() returns a transport-friendly payload.

Hook declarations and markers live in bub.hooks; agent-loop interception payloads live with their execution semantics:

from bub.hooks import BUB_HOOK_NAMESPACE, BubHookSpecs, hookimpl, hookspec
from bub.hooks.interception import (
    AgentHooks,
    LlmCallDecision,
    LlmCallRequest,
    LlmCallResult,
    ToolCall,
    ToolCallDecision,
    ToolCallResult,
)
from bub.hooks.runtime import HookRuntime

Plugin authors normally need only hookimpl plus the payload types used by their hooks. See the Hook reference for dispatch and fault-isolation semantics.

Handler and router — bub.channels.contracts

Section titled “Handler and router — bub.channels.contracts”
from bub.channels.contracts import ChannelRouter, MessageHandler

type MessageHandler = Callable[[Envelope], Coroutine[Any, Any, None]]

class ChannelRouter(Protocol):
    async def dispatch_output(self, message: Envelope) -> bool: ...
    def wrap_stream(
        self, message: Envelope, stream: AsyncIterable[StreamEvent]
    ) -> AsyncIterable[StreamEvent]: ...
    async def quit(self, session_id: str) -> None: ...
    async def admit_channel_message(
        self, session_id: str, message: Envelope, turn: TurnSnapshot
    ) -> AdmitDecision | None: ...

ChannelManager implements this protocol and binds it through BubFramework.bind_channel_router.

from bub.channels.admission import AdmitDecision, SteeringInbox, TurnSnapshot

@dataclass(frozen=True)
class AdmitDecision:
    action: Literal["process", "drop", "follow_up", "steer"]
    reason: str | None = None

@dataclass(frozen=True)
class TurnSnapshot:
    session_id: str
    is_running: bool
    running_count: int
    pending_count: int
    steering_count: int = 0

admit_message implementations use these types to select immediate processing, dropping, follow-up queuing, or steering. SteeringInbox is the protocol for session steering queues.

from bub.channels import Channel, Interface, Lifecycle

Channel requires start(stop_event) and stop(). Implement send, stream_events, or admit_message as needed. Interface marks user-facing I/O surfaces; Lifecycle marks background runtimes managed alongside channels.

The main public methods that connect these contracts are:

from bub import BubFramework

async def process_inbound(
    self, inbound: Envelope, stream_output: bool = False
) -> TurnResult: ...

async def get_model_options(
    self, *, session_id: str, workspace: str | Path | None = None
) -> ModelOptions: ...

async def admit_message(
    self, *, session_id: str, message: Envelope, turn: TurnSnapshot
) -> AdmitDecision | None: ...

def get_channels(self, message_handler: MessageHandler) -> dict[str, Channel]: ...
def bind_channel_router(self, router: ChannelRouter | None) -> None: ...

See Turn pipeline for the lifecycle of process_inbound and Channels for channel operation.

bub.__init__ deliberately stays small: BubFramework, Settings, config, ensure_config, home, hookimpl, and tool. Import subsystem contracts from the modules above so ownership remains explicit.