Add a Tool
Corresponding source
| Extension step | Source |
|---|---|
| Tool base contract | nanobot/agent/tools/base.py::Tool |
| Discovery, scopes, and entry points | nanobot/agent/tools/loader.py::ToolLoader.discover, load |
| Registration and schema | nanobot/agent/tools/registry.py::ToolRegistry |
| Configuration and context integration | nanobot/config/schema.py, nanobot/agent/tools/context.py |
| Runner execution boundary | nanobot/agent/runner.py::_run_tool, _execute_tools |
| Example and tests | example/nanobot/learning-EN/examples/text-stats-tool-plugin/, tests/agent/test_tool_loader_entrypoints.py, tests/tools/ |
Learning objectives
After this lesson, you should be able to:
- implement the minimum Tool contract;
- explain built-in scanning and external entry-point discovery;
- choose Tool scope, configuration, and concurrency properties;
- design tests for argument validation, side effects, and security boundaries.
Prerequisites
- Understand Tool registration, validation, and execution.
- Write a Python class and asynchronous function.
- If the Tool accesses a file or URL, first complete Security boundaries.
1. Minimum Tool contract
nanobot/agent/tools/base.py::Tool requires:
name;description;parameters;async execute(**kwargs).
A minimal pure-text word counter looks like:
from typing import Any
from nanobot.agent.tools.base import Tool, tool_parameters
from nanobot.agent.tools.schema import StringSchema, tool_parameters_schema
@tool_parameters(
tool_parameters_schema(
required=["text"],
text=StringSchema("Text whose words should be counted"),
)
)
class WordCountTool(Tool):
@property
def name(self) -> str:
return "word_count"
@property
def description(self) -> str:
return "Count whitespace-separated words in text."
@property
def read_only(self) -> bool:
return True
async def execute(self, text: str, **_: Any) -> str:
return str(len(text.split()))This demonstrates the contract. It does not need to be added to nanobot source.
2. Why description is runtime logic
The Provider sees the function schema generated by Tool.to_schema(). The model mainly uses:
- name to identify the broad capability;
- description to decide when to use it;
- parameter descriptions to understand each field.
An ambiguous schema produces bad arguments even when the Python implementation is perfect. Describe purpose and boundaries rather than internal implementation.
3. Argument conversion and validation order
ToolRegistry.execute():
- locates the Tool by name;
- converts safely coercible strings to integer, number, or boolean;
- calls
validate_params(); - returns an explicit error after validation failure;
- calls
execute()only after validation succeeds.
Tool’s JSON Schema validator supports common types, required fields, enums, min/max, string lengths, arrays, and nested objects. A Tool can still override validate_params() for business rules.
Schema validation is not business authorization
Proving that
"path"is a string says nothing about whether the caller may access that path. Filesystem access still requires workspace policy.
4. Built-in Tool discovery
ToolLoader.discover() scans nanobot.agent.tools with pkgutil.iter_modules():
- skips infrastructure modules such as
base,schema,registry,loader, andmcp; - imports other public modules;
- finds concrete, nonabstract, nonprivate
Toolsubclasses; - skips
_plugin_discoverable=False; - sorts by class name.
A normal built-in Tool can therefore live in a scanned module without a manual import/register call in AgentLoop. A special Tool referenced directly by AgentLoop can disable discovery like MyTool and register manually, but that is not the default extension path.
5. External Tool plugin
An independent Python package can expose an entry point without modifying nanobot:
[project.entry-points."nanobot.tools"]
word-count = "my_nanobot_tools:WordCountTool"ToolLoader._discover_plugins() loads classes from the nanobot.tools group.
Name-conflict rules:
- built-in Tools load first;
- a plugin whose
tool.nameconflicts with a built-in Tool is skipped; - other collisions log a warning, and a later registration may replace an earlier one.
Do not use replacement behavior as monkey-patching. Choose a unique, stable Tool name.
6. ToolContext and lifecycle
A Tool can integrate through class methods:
@classmethod
def enabled(cls, ctx: ToolContext) -> bool:
...
@classmethod
def create(cls, ctx: ToolContext) -> Tool:
...ToolContext can provide:
- Tools configuration;
- workspace;
- MessageBus;
- Subagent manager;
- Cron service;
- file-state store;
- Provider snapshot loader;
- image-generation Provider configuration;
- timezone;
- RuntimeEventBus and other extension context.
Read only the fields the Tool needs. A pure-function Tool should not depend on the entire runtime.
7. Scope
_scopes determines which registries contain the Tool:
coresubagentmemory
The base default is only {"core"}. A Tool available to the parent Agent may be inappropriate for a Dream/Memory runner or Subagent.
Before adding Subagent scope, check:
- whether it changes the parent Session;
- whether it needs the parent MessageBus;
- whether it depends on state available only in the main loop;
- whether workspace permissions remain correct.
Implementation and test:
nanobot/agent/tools/loader.py::ToolLoader.loadtests/agent/test_tool_loader_scopes.py
8. Configuration integration
Built-in Tools commonly use:
config_key;config_cls();enabled(ctx);create(ctx).
For example, ExecTool uses config_key="exec", defines its configuration type in shell.py, and lets ToolsConfig resolve the type through a delayed import.
Entry-point discovery does not complete configuration integration
ToolLoader discovers and instantiates; it does not design a WebUI form, configuration migration, or documentation. An external plugin with custom configuration must verify the actual shape of
ToolsConfigextra fields and parse them explicitly inenabled/create. A built-in Tool may also need a matching field underToolsConfiginnanobot/config/schema.py.
9. Concurrency properties
Base values:
read_only=Falseexclusive=Falseconcurrency_safe = read_only and not exclusive
Set read_only=True or override concurrency only when concurrent execution cannot compete for shared state. A read-only network request may still share a rate limit, non-thread-safe client, or global cursor.
10. Security and result contract
At minimum, consider:
- paths: reuse
resolve_workspace_path(); - URLs: reuse
validate_url_target(); - secrets: never put keys in schemas, results, or logs;
- timeout: bound external I/O;
- cancellation: release files, clients, and processes;
- output: bound size so one result cannot fill context;
- errors: give the model actionable information without suggesting policy bypass;
- side effects: describe behavior clearly and require explicit confirmation when appropriate.
11. Minimum test set
- Correct schema.
- Expected result for valid arguments.
- Missing, wrong-type, and boundary values.
enabled/createwith a real configuration object.- ToolLoader discovery and correct scope.
- Filesystem/network security boundaries.
- Cancellation, timeout, and resource cleanup.
- Oversized result or external failure.
Test references:
tests/tools/test_tool_loader.pytests/agent/test_tool_loader_entrypoints.pytests/agent/test_tool_loader_scopes.pytests/tools/test_tool_registry.py- matching
tests/tools/test_*.pyfor a similar Tool
12. Minimum modification scope
Built-in Tool
nanobot/agent/tools/<name>.pynanobot/config/schema.pyonly for first-class configurationtests/tools/test_<name>.py- user configuration documentation only when user-configurable
External plugin
- independent package;
- entry point;
- plugin-owned tests/readme;
- no AgentLoop change.
Self-check
- Does
async defmean a Tool can run concurrently? - Why can a parameter schema not replace path authorization?
- How do built-in and entry-point registration paths differ?
- If a Skill already solves the workflow, why should you not add a Tool immediately?
Next: Add a Skill