Tutorial 3 — Add a custom tool¶
Tools are the functions your agent can call during a turn. BOS discovers them
automatically when you drop a .py file into .bos/extensions/ and decorate
a function with @ep_tool. No restart of the process is needed during
development — just restart the gateway after adding a new file.
This tutorial assumes you have the my-agent/ project from
Tutorial 2.
The generated starter tool¶
boscli init already placed an example tool in .bos/extensions/project_tools.py:
from bos.core import ep_tool
@ep_tool(
name="WordCount",
description="Count the words in a text.",
parameters={
"type": "object",
"properties": {"text": {"type": "string", "description": "Text to count."}},
"required": ["text"],
},
)
async def word_count(text: str) -> str:
return f"{len(text.split())} words"
Ask the agent to use it:
boscli ask "How many words are in 'the quick brown fox'? Use the WordCount tool." \
--model openai/gpt-4o
Anatomy of @ep_tool¶
Every parameter of the decorator is described below. The only mandatory one is
parameters.
@ep_tool(
name="ToolName", # (str) identifier — must be unique across all registered tools
description="...", # (str) short description shown in the OpenAI tool schema
parameters={ # (dict) JSON Schema for the tool's arguments — REQUIRED
"type": "object",
"properties": {
"arg": {"type": "string", "description": "What this arg means."},
},
"required": ["arg"],
},
usage="...", # (str, optional) longer guidance surfaced in the system prompt
parallel_safe=True, # (bool, optional) allow concurrent calls with other parallel-safe tools
result_serializer="auto", # "auto" | "json" | "str"
)
async def my_tool(arg: str) -> str:
...
Key points¶
name— the identifier the LLM uses when it decides to call the tool. Duplicate names across the registry crash startup, so be specific.description— keep it short; it appears in the schema the model sees.parameters— a JSON Schemaobjectwhose property names must match the function's parameters (unless the function accepts**kwargs).usage— longer, richer guidance (when to use the tool, edge cases, examples). Shown in the agent's system prompt. Use it to reduce misuse.result_serializer— controls how the return value becomes the string the model sees:"auto"usesjson.dumpsfor dict/list/int/float andstr()otherwise;"json"always serialises;"str"always callsstr().- Async preferred — blocking/CPU work should go through
asyncio.to_thread. Sync functions also work; BOS awaits as needed.
Add a second, richer tool¶
Open .bos/extensions/project_tools.py and append:
import asyncio
import pathlib
@ep_tool(
name="CountLines",
description="Count the lines in a file.",
parameters={
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Absolute or workspace-relative path to the file.",
},
},
"required": ["path"],
},
usage=(
"Use CountLines when the user asks how long a file is, wants to know "
"its line count, or needs a quick size estimate before reading it. "
"Pass an absolute path or a path relative to the workspace root."
),
parallel_safe=True,
)
async def count_lines(path: str) -> str:
p = pathlib.Path(path)
if not p.exists():
return f"File not found: {path}"
lines = await asyncio.to_thread(lambda: len(p.read_text(encoding="utf-8").splitlines()))
return f"{lines} lines in {p.name}"
Blocking I/O
pathlib.Path.read_text is blocking. Wrapping it in asyncio.to_thread
keeps the event loop responsive — important when the agent is running
multiple tools in parallel.
Enable and disable tools per agent¶
By default enabled = ["*"] gives every agent access to all registered tools.
You can narrow that per agent in config.toml:
[agents.main.tools]
enabled = ["WordCount", "CountLines", "ReadFile"]
# disabled = ["WriteFile"] # alternative: start from "*" and exclude
You can also override the usage text shown in the system prompt without
touching the Python source:
[agents.main.tools]
enabled = ["*"]
[agents.main.tools.usages]
WordCount = "Only use WordCount when explicitly asked to count words — not for general length questions."
Per-tool runtime configuration¶
Any key under [exts.ep_tool.<Name>] is merged into the tool's defaults and
passed as keyword arguments when BOS calls it. This lets you externalise
constants without touching the source:
Then accept it in the function:
@ep_tool(
name="CountLines",
...
)
async def count_lines(path: str, max_size_mb: int = 5) -> str:
p = pathlib.Path(path)
if p.stat().st_size > max_size_mb * 1_000_000:
return f"File too large (limit {max_size_mb} MB)."
...
Restart and verify¶
After editing project_tools.py, restart the gateway so it picks up the
changes:
Then ask the agent to use the new tool:
What you learned¶
- Drop a
.pyfile into.bos/extensions/and decorate functions with@ep_toolto register custom tools — no config edit required. name,description, andparameters(JSON Schema) are the core fields;usageadds richer system-prompt guidance.- Enable or disable tools per agent with
[agents.<name>.tools].enabled / .disabled; override guidance with.usages. - Configure tool behaviour externally via
[exts.ep_tool.<Name>]. - Wrap blocking I/O in
asyncio.to_threadto keep the event loop free.
Next: Delegate to sub-agents — create specialist agents and let the main agent delegate tasks to them.