Tools
This guide shows how to define a Bub tool — a Python function the model can call during a turn — and how to make sure your plugin actually registers it.
Before you begin
Section titled “Before you begin”- A plugin package wired to the
bubentry-point group (see Plugins). - The marker
from bub import toolavailable in your module.
Define a tool
Section titled “Define a tool”Decorate any function with @tool. The decorator builds a Tool object, wraps it with timing logs, and inserts it into the central REGISTRY:
from bub import tool
@tool
def add(a: int, b: int) -> int:
"""Add two integers and return the sum."""
return a + b
The tool’s name defaults to the function name. Override it with @tool(name="math.add") or pass description= to control how it appears in the model prompt. Dotted registry names are preserved for runtime lookup and comma commands, but model-facing tool names replace dots with underscores (math.add becomes math_add). Async functions are supported — the wrapper awaits them.
Return structured results
Section titled “Return structured results”Tools should return structured data — a dict, TypedDict, list, or Pydantic model — rather than pre-formatted prose. The exceptions are tools marked preserve=True (see Code mode) and comma-command-only tools (agent_use=False): code never calls them, so they return plain text. Pass renderer= to control how that result is turned into the plain text the model reads:
from typing import TypedDict
from bub import tool
class Order(TypedDict):
id: str
status: str
@tool(name="orders.get", renderer=lambda order: f"order {order['id']}: {order['status']}")
def get_order(order_id: str) -> Order:
"""Look up an order by id."""
return {"id": order_id, "status": "shipped"}
Tool.render(result) applies the renderer; without one, strings pass through and other values are serialized as JSON. When the model calls a tool directly, the executor renders the result before after_tool_call hooks and tape recording, so hooks see the same text the model will. Rendering is a property of the ToolExecutor: the model-facing executor renders, while ToolExecutor(render=False) — used by run_code — returns structured results unchanged.
Code mode
Section titled “Code mode”Code mode lets the model call tools from Python instead of one tool call at a time. It is a per-session switch: send the comma command ,code_mode enable=true (or ,code_mode enable=false) to turn it on or off. The change applies from the next turn and persists across restarts. SDK callers can set state["code_mode"] = True instead. In a code-mode turn where run_code is among the allowed tools:
- The model sees only tools marked
preserve=True(bash,bash.output,bash.kill,fs.read,fs.write,fs.edit,spill.read) plusrun_code(code: str) -> str. Preserved tools are called directly only; they are not available undertools.*. - Every other allowed tool is an async function inside
run_code, named after its model-facing name (tape.infobecomesawait tools.tape_info()). Top-levelawaitis allowed andasyncio.gatherruns calls concurrently. Calls take keyword arguments, go through the usualbefore_tool_call/after_tool_callhooks, return structured results, and raise on failure. - Bub writes a Python stub with one function per non-preserved allowed tool — parameter and return types plus docstring — under
~/.bub/codemode/and puts its path in the system prompt. The path stays the same for a session as long as its tool set does not change. run_code(code, timeout_seconds=120)returns what the code writes to stdout. An uncaught exception becomes a tool error whose details carry the printed output and the traceback.
If run_code is not allowed (for example, a subagent restricted with allowed_tools), that turn falls back to direct tool calls. Declare preserve=True on your own tools to keep them directly callable. Nested calls run with ToolContext.code_mode set to True (hooks see it as call.context.code_mode); their results are not rendered or spilled.
run_code hands the code to the session environment’s run_code, together with a call_tool callback. Tool calls always run on the host through that callback, so hooks still see every call. timeout_seconds cancels the environment call. The builtin LocalEnvironment starts a fresh Python process for every call (sys.executable, working directory is the workspace) and forwards tool calls as JSON lines over its stdin and stdout, so arguments and results must be JSON-serializable. When the code finishes, fails or times out, it kills the process and everything it started. That process runs on the host with the same permissions as bash, so enable code mode only where bash would be acceptable. The return annotation of a tool function (or Tool.output_schema, for tools built by hand) determines the result type shown in the stub.
Per-request tool presentation
Section titled “Per-request tool presentation”Agent.tool_providers accepts async callbacks (tools, tape) -> (tools, tool_prompt). Bub applies the current tool scope and prepares code mode first, then runs these callbacks in registration order. They receive runtime names; model aliases are applied afterward. The returned tools and prompt fragment are used for that request.
Providers prepare already registered tools; they do not register tools or change connection lifecycles. A plugin binds its discovery helper alongside its tools and keeps discovery within the supplied scope. Full native definitions are sent through the tool schema instead of repeated in the system prompt.
Run tools in an environment
Section titled “Run tools in an environment”bash, bash.output, bash.kill and fs.* do not touch the host directly: they run on the session’s Environment (from bub.environment), which spawns processes, reads and writes text files, and runs code for run_code. Bub keeps the tool behavior — background shells, timeouts, rendering, hooks — on the host, so an environment only needs to implement a few operations:
spawn(command, *, cwd=None, env=None)returns aProcess: a string runs through the shell, a sequence runs as an argument vector. The process exposesstdout/stderrstreams,write_stdin,wait, andsignal(kill=...), which must reach the whole process tree.read_text(path)andwrite_text(path, content)access files inside the environment.workspaceis the working directory inside the environment;resolve_pathresolves relative paths against it.run_code(code, *, tools, call_tool, write)runs Python code in whichawait tools.<name>(**kwargs)callscall_tool(name, kwargs). It passes the code’s stdout towriteas it arrives, raisesCodeFailedwhen the code raises, and stops the code when cancelled. How it runs is up to the environment. An environment that has a Python interpreter can reusebub.builtin.codemode.code_runner.run_code_in_subprocess, which runs the code in a process started withspawn.close()releases the environment.
Provide one per session with the provide_environment(session_id, workspace) hook. Bub calls it on the session’s first turn, puts the result in state["_runtime_environment"], and closes it when framework.running() exits. Bub’s builtin hooks provide LocalEnvironment (in bub.builtin.environment), which runs everything on the host; a plugin’s implementation takes precedence over it. Your own tools can use the same environment through bub.builtin.environment.environment_from_state(context.state).
How tools become available
Section titled “How tools become available”The REGISTRY lives in bub.tools:
# from src/bub/tools.py
REGISTRY: dict[str, Tool] = {}
Every @tool call mutates this dict at import time. Bub’s builtin agent reads from REGISTRY when assembling the tool list for the model. There is no separate registration step.
Import the tools module from your plugin
Section titled “Import the tools module from your plugin”Because registration is an import-time side effect, the module that defines @tool functions must actually be imported before Bub asks for tools. Inside your plugin’s entry-point module, add:
# bub_myplugin/plugin.py
from bub import hookimpl
from . import tools # noqa: F401 — registers @tool decorators
If that import is missing, the tool module never runs, nothing lands in REGISTRY, and the model never sees the tool.
The builtin runtime follows the same pattern — see BuiltinImpl.__init__, which imports bub.builtin.tools for the same reason.
Tools versus comma commands
Section titled “Tools versus comma commands”Both are callable units inside Bub, but they are operated by different actors:
| Surface | Caller | Trigger |
|---|---|---|
| Tool | The model | Tool-call message during a turn |
| Comma command | The human | Inbound text starting with a comma |
A line like ,skill name=hello is a comma command — the operator typed it, and Bub’s builtin build_prompt marks the message as kind="command" so it bypasses the model. Tools, by contrast, are invoked by the model itself when it produces a tool-call event.
See Surfaces for the full split between operator and model surfaces.
Verify your tool is registered
Section titled “Verify your tool is registered”Tools do not appear in bub hooks, but you can confirm registration with a Python one-liner:
uv run python -c "import bub_myplugin.plugin; from bub.tools import REGISTRY; print(sorted(REGISTRY))"
The output should include your tool’s name. From there, run a turn that asks the model to call it.