Skip to content

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.

  • A plugin package wired to the bub entry-point group (see Plugins).
  • The marker from bub import tool available in your module.

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.

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 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) plus run_code(code: str) -> str. Preserved tools are called directly only; they are not available under tools.*.
  • Every other allowed tool is an async function inside run_code, named after its model-facing name (tape.info becomes await tools.tape_info()). Top-level await is allowed and asyncio.gather runs calls concurrently. Calls take keyword arguments, go through the usual before_tool_call/after_tool_call hooks, 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.

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 a Process: a string runs through the shell, a sequence runs as an argument vector. The process exposes stdout/stderr streams, write_stdin, wait, and signal(kill=...), which must reach the whole process tree.
  • read_text(path) and write_text(path, content) access files inside the environment.
  • workspace is the working directory inside the environment; resolve_path resolves relative paths against it.
  • run_code(code, *, tools, call_tool, write) runs Python code in which await tools.<name>(**kwargs) calls call_tool(name, kwargs). It passes the code’s stdout to write as it arrives, raises CodeFailed when 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 reuse bub.builtin.codemode.code_runner.run_code_in_subprocess, which runs the code in a process started with spawn.
  • 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).

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.

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.

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.

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.

  • Hooks — combine tools with hooks to build a complete plugin
  • Skills — package model-facing instructions alongside tools
  • Surfaces — operator vs model surfaces