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.

Builtin tool_call events use Republic fields: id, name, arguments, and metadata. Usage dictionaries contain input_tokens, output_tokens, reasoning_tokens, cached_tokens, cache_write_tokens, and total_tokens.

Builtin multipart prompts contain text blocks (type: text, text) or media blocks (type: image|audio|video, media_type, url). The URL can be remote or a base64 data URL. Bub converts these persisted blocks into native Republic parts; Republic owns provider encoding. Hooks supply these text and media blocks.

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
from bub.sidecars import TapeSidecar, sidecar_tape_name

Plugin authors normally need only hookimpl plus the payload types used by their hooks. TapeSidecar is the minimal named contract returned by provide_tape_sidecar; sidecar_tape_name resolves its sibling tape name. 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.