Writing extensions¶
An extension is an importable Python module with a module-level register(mb)
function. It can add LLM tools, observe MiniBot events, and register services.
Configure its import name in config.toml; a local module only needs to be on
PYTHONPATH.
Minimal useful extension¶
This extension gives the model a validated github_repository tool. It reads a
repository through GitHub’s API and returns only useful repository metadata.
[extensions]
modules = ["my_github_extension"]
[extensions.config.my_github_extension]
token_env = "GITHUB_TOKEN"
Set the token outside config.toml before starting MiniBot. A fine-grained token with
read-only access to the repositories you need is enough:
$ export GITHUB_TOKEN=github_pat_...
# my_github_extension.py
from __future__ import annotations
import json
import os
from typing import Any
from urllib.parse import quote
import aiosonic
from pydantic import BaseModel, Field
from minibot.app.extensions import ExtensionContext
from minibot.llm.tools.base import ToolContext
class GitHubRepositoryArgs(BaseModel):
owner: str = Field(min_length=1, description="GitHub organization or user name.")
repository: str = Field(min_length=1, description="Repository name.")
def register(mb: ExtensionContext) -> None:
token_env = str(mb.config.get("token_env", "GITHUB_TOKEN"))
token = os.environ.get(token_env, "").strip()
if not token:
raise ValueError(f"GitHub token is missing from ${token_env}")
# One async client per extension; every tool call reuses its connection pool.
client = aiosonic.HTTPClient()
@mb.tool
async def github_repository(args: GitHubRepositoryArgs, _: ToolContext) -> dict[str, Any]:
"""Read metadata for a GitHub repository. Use this to check a repository's
description, visibility, default branch, issue count, and last update time.
"""
owner = quote(args.owner, safe="")
repository = quote(args.repository, safe="")
response = await client.request(
f"https://api.github.com/repos/{owner}/{repository}",
method="GET",
headers={
"Accept": "application/vnd.github+json",
"Authorization": f"Bearer {token}",
"X-GitHub-Api-Version": "2022-11-28",
},
)
if response.status_code != 200:
raise RuntimeError(f"GitHub repository request failed: HTTP {response.status_code}")
repository_data = json.loads((await response.content()).decode("utf-8"))
return {
"ok": True,
"full_name": repository_data.get("full_name"),
"description": repository_data.get("description"),
"private": repository_data.get("private"),
"default_branch": repository_data.get("default_branch"),
"open_issues_count": repository_data.get("open_issues_count"),
"updated_at": repository_data.get("updated_at"),
"html_url": repository_data.get("html_url"),
}
Run it from the directory containing my_github_extension.py:
$ PYTHONPATH=. poetry run minibot console --once "What is the default branch of sonic182/minibot?"
@mb.tool uses the function name as the tool name, the docstring as the model-facing
description, and the first argument’s Pydantic model as the JSON schema. Invalid arguments
return invalid_tool_arguments to the model instead of reaching the handler.
Extension API¶
mb.configThe extension’s
[extensions.config.<module>]dictionary. Prefer it for extension settings;mb.settingsexposes the complete validated application configuration.mb.tool/mb.add_tool()Add a typed decorator-based tool or explicit
ToolBindinginstances. Useadd_toolfor a custom tool name or hand-written JSON schema.mb.add_tool_deriver(deriver)Contribute tools built from the tool list an agent ends up with.
add_toolregisters before any agent exists, so it cannot build a tool that acts on other tools.deriveris a plain function that takes the agent’s finalSequence[ToolBinding](after approval, event and output-spill wrapping,tools_allow/tools_denyand the removal of the delegation tools) and returns the extra bindings. It runs once per agent and again whenever the tool list is rebuilt, so keep it a pure function of its argument. The returned tools get approval and event wrapping and are then filtered by the same agent policy (the main agent’s allow/deny, a specialist’stools_allow/tools_deny); they are not part of the list the deriver received, and a name that already exists is skipped with a warning and the existing tool is kept. Runs in the daemon, the console and task workers; the bundledrun_tools_script(Agent Tools) is built this way.To take over tools instead of adding beside them, return
DerivedTool(binding=..., replaces=frozenset(names))(fromminibot.app.extensions) in place of a plain binding: while the derived tool survives the agent’s policy, the tools named inreplacesare no longer offered to the model, and the derived tool keeps calling them itself.run_tools_scriptreplaces every tool its scripts can call.mb.add_tool_wrapper(wrapper)Wrap the handler of every tool an agent can call, to check or change its calls and results.
wrapperis a plain function(bindings, approve) -> bindingsthat returns one binding per input binding, in the same order and with the same names (keepspill_after_chars, e.g. withdataclasses.replace); anything else fails the tool build.approve(tool_name, arguments, context, detail=...)asks the user in the chat the call came from with the[tools.approval]buttons and returnsTrueonly on an explicit approval; it denies on channels without buttons. Wrappers cover core, extension, MCP and derived tools in the daemon, the console and task workers, and run again whenever the tool list is rebuilt, so keep them pure. They sit innermost: they see a tool’s raw result before output spill, while[tools.approval]and tool-call events stay outside them. Calls nested in arun_tools_scriptscript go through them too, withcontext.parent_call_idset. The bundled[tools.safety_classifier](Agent Tools) is built this way.mb.on(EventType)/mb.on(EventType, handler)Observe internal events such as
TurnCompletedEvent. Handlers are async and lossy: slow handlers drop events rather than blocking MiniBot.mb.add_service(service)Register an object with async
start()andstop()methods for work that belongs to the daemon or console lifecycle.mb.add_route(path, handler)/mb.add_page(path, label, handler, icon="file")Serve a Starlette request handler on the built-in HTTP server (
[http] enabled).add_pagealso adds a navigation entry underlabel, drawn with the named Lucide icon. Requires thehttpextra.mb.add_prompt_fragment(text, tool_names=())Append a fragment to the main agent’s system prompt, optionally gated on the listed tools being attached. Use it for guidance no single tool description can carry.
Operational rules¶
Extension import and registration failures stop startup. Tool names must be unique.
Extensions are trusted in-process code. Treat adding a module to
config.tomllike installing a dependency.Define Pydantic argument models at module scope; with postponed annotations, models defined inside
register()cannot be resolved.Check
mb.entrypointbefore adding channel services. Only the daemon runs channels.Configured extension tools are available to task workers, but worker registries do not start extension services or event subscriptions. A worker loads the scheduler, safety and utility bundles, plus the ones listed in
[tasks] worker_extensions, and they contribute tools and tool wrappers only.
For the full type signatures behind mb, see Extension API Reference.
See minibot_ext_demo.py for a tool plus a TurnCompletedEvent subscriber.