Build your own agent
Choose a model, add tools, and connect the agent to your program. bub.core.Agent runs the model and tool loop while you decide how to store conversations and manage the surrounding application.
You supply the agent’s components directly. The Bub application builds on the same core and adds configuration and plugin discovery. You can also reuse application plugins in your own service while keeping its existing entry points and calling code.
Install
Section titled “Install”Use Python 3.12+ and add Bub to your project:
uv init my-bub
cd my-bub
uv add bub
The base bub package installs the dependencies needed by the agent. Add bub[app] when you want to use the Bub application’s CLI, plugins, or channels. See Installation for other setup options.
Run your first agent
Section titled “Run your first agent”Save this as sdk_example.py in your project:
import asyncio
from pathlib import Path
import republic
from bub.core import Agent
from bub.core.store import FileTapeStore
from bub.core.tools import Tool
async def lookup_order(order_id: str) -> dict[str, str]:
"""Look up the status of an order."""
return {"order_id": order_id, "status": "shipped"}
async def main() -> None:
agent = Agent(
store=FileTapeStore(Path(__file__).resolve().parent / "sessions"),
tools=[Tool.from_callable(lookup_order)],
system_prompt="Use lookup_order to check order status before answering.",
)
async with republic.get_model("openai:gpt-5-mini") as model:
async with agent.run(
"Where is order A123?",
model=model,
options={"max_tokens": 4096},
tape="customer-42",
) as stream:
async for event in stream:
if event.kind == "text":
print(event.data.get("delta", ""), end="", flush=True)
elif event.kind == "error":
raise RuntimeError(str(event.data.get("message", "Agent failed")))
print()
if __name__ == "__main__":
asyncio.run(main())
Set REPUBLIC_OPENAI_API_KEY in your shell, then run:
uv run python sdk_example.py
The tool returns a demonstration order status. Replace its body with your own order service when you use it in an application. The agent gives the tool result back to the model, which writes the answer.
Run the program again with the same tape name to continue the conversation stored under sessions/. Change the name to start a separate conversation.
Manage the run and its stream
Section titled “Manage the run and its stream”agent.run() yields a stream. Iterating the stream drives the loop: call the model, run its requested tools, then call the model again until it answers without further tool calls. A final event ends one model step; exhaust the stream to finish the run.
Keep the stream inside async with. Leaving the block closes it after normal completion, an exception, or an early break. Closing also releases the open model stream and merges the run’s tape fork. Records already written are kept even when the run fails; see Forks and partial results.
If another component will consume the stream, await agent.start(...) returns it with the same arguments. That component must close it, including when it never starts iteration. Prefer run() when you own the consuming code.
Stream events include text, reasoning, tool_call, tool_result, usage, error, and final. Handle error events and exceptions from iteration. For internal lifecycle notifications and call interception, use agent events.
Choose a model
Section titled “Choose a model”Pass a Republic chat model to Agent(model=...) for a default, or to agent.run(model=...) for one run. A run that needs a model raises ValueError when neither is supplied. You own the model’s lifetime, so open it with async with around the runs that use it.
Republic accepts provider credentials and endpoints directly:
async with republic.get_model(
"openai:gpt-5-mini", api_key="...", api_base="https://example.test/v1"
) as model:
agent = Agent(model=model)
async with agent.run("Hello", tape="example", options={"max_tokens": 4096}) as stream:
async for event in stream:
print(event.kind, event.data)
options are Republic chat options sent with each model call of the run. The agent adds the tools for that step. Configure this client through Republic’s arguments or environment variables; BUB_* settings belong to the Bub application.
For your own fallback, credential, or timeout policy, implement bub.core.ModelClient:
| Member | Contract |
|---|---|
spec |
Default provider:model string. |
max_tokens |
Default output token limit, or None. |
open_stream(messages, *, spec, options) |
Async context manager yielding (provider_name, republic_stream). Messages are republic.Message objects; options include the step’s tools. |
A LLMCallStarted handler can change the requested model. With a Republic chat model, a change within the same provider reuses its client; a different provider uses a client configured from Republic’s environment. Supply a ModelClient when this policy needs to differ.
Set the instructions
Section titled “Set the instructions”system_prompt accepts text, a function, or an ordered list of either. Functions receive a PromptContext and may be synchronous or asynchronous. The agent renders the sections before each model step, omits empty results, and joins the rest with blank lines.
from bub.core import PromptContext
@agent.system_prompt.add
def customer(context: PromptContext) -> str | None:
if (name := context.state.get("user")) is not None:
return f"You are helping {name}."
return None
PromptContext carries the step’s prompt, model-facing tools, model spec, tape, turn state, and agent. available_tools lists the agent’s registered tools. Remove a section with agent.system_prompt.remove(section).
The state passed to a run is a mutable dictionary shared by tools, prompt sections, and event handlers. You decide which values to save and how to restore them for another run. See Turn state for what to keep between runs.
Configure the agent
Section titled “Configure the agent”| Parameter | Default and purpose |
|---|---|
model |
None. Default Republic chat model or ModelClient. |
store |
A new InMemoryTapeStore. Accepts synchronous or asynchronous tape stores. |
tools |
Empty. Accepts Tool objects and Republic provider tools such as republic.tools.WebSearch(). |
system_prompt |
Empty. Text or callable sections, rendered per step. |
context |
default_tape_context(). Selects tape entries for model messages. |
sidecars |
Empty. Extra storage capabilities mounted alongside the tape. |
max_steps |
sys.maxsize. Maximum model steps in a run. |
max_tool_concurrency |
None, so calls from one model response may all run concurrently. Set 1 to run them sequentially. Your application coordinates separate runs. |
name |
"agent". Name used in agent trace spans. |
command_prefix |
None. Set a prefix to invoke tools through text commands without a model. |
The agent keeps supplied Tool objects in agent.tools by name and provider tools in agent.builtin_tools. You select its tools explicitly when constructing or updating the agent.
Customize how a step is prepared
Section titled “Customize how a step is prepared”Subclass Agent and override prepare_tools(tape, prompt) when selecting tools needs application logic. By default it offers the supplied tools except those marked exposure="command". The tape’s context.state is the run’s state.
Handle two loop events to send messages that arrive during a run or to steer a continuing run:
| Event | When | Control |
|---|---|---|
MessagesPending |
Each step builds its model request, and a step ends without tool calls. | Append contents to event.messages; they are sent before the next prompt and recorded with it. Messages supplied when a step ends continue the run. |
StepFinished |
A step ends. With status == "continue", the run takes another step. |
Set next_prompt to give that step a user message; None continues the conversation as is. event.prompt is this step’s prompt and event.stream holds the run’s usage. |
from bub.core.events import MessagesPending, StepFinished
inbox: dict[str, list[str]] = {}
@agent.events.on(MessagesPending)
def send_queued(event: MessagesPending) -> None:
event.messages.extend([text] for text in inbox.pop(event.tape.name, []))
@agent.events.on(StepFinished)
def check_results(event: StepFinished) -> None:
if event.status == "continue":
event.next_prompt = ["Check the tool results before answering."]
For a tape whose lifecycle you manage, open agent.session(name, state=...) and use agent.run_on(tape, prompt, model=...). It returns a stream you must close. agent.run_once(prompt, model=..., tape=...) yields one model step when you need to drive the loop yourself.
Keep building
Section titled “Keep building”- Add tools and CodeMode to give your agent actions.
- Store conversation history to continue work across runs.
- Serve your agent with FastAPI when another program needs an HTTP endpoint.