Adding Custom Tools¶
Create a text-case tool, check it locally, and enable it in a chat. Start from a working development setup.
Create the tool¶
Add src/numi_chat/tools/text_transform.py:
from typing import Any
from numi_chat.tools.base import BaseTool
class TextTransformTool(BaseTool):
@property
def name(self) -> str:
return "text_transform"
@property
def description(self) -> str:
return "Convert text to uppercase or lowercase."
@property
def parameters(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {
"text": {"type": "string", "minLength": 1, "maxLength": 10000},
"case": {"type": "string", "enum": ["upper", "lower"]},
},
"required": ["text", "case"],
"additionalProperties": False,
}
def execute(self, **kwargs: Any) -> str:
text = kwargs.get("text")
case = kwargs.get("case")
if not isinstance(text, str) or not 1 <= len(text) <= 10000:
return self._error("text must contain 1 to 10000 characters")
if case not in ("upper", "lower"):
return self._error("case must be upper or lower")
result = text.upper() if case == "upper" else text.lower()
return self._ok(data={"transformed": result})
BaseTool requires name, description, parameters, and execute.
The JSON schema tells the model which arguments to send; validate those
arguments in execute as well. _ok and _error return JSON strings.
Keep **kwargs or declare only the arguments your tool accepts. The executor
can supply server-owned context such as chat_id, chat_uuid, and
user_id; these should not be model-controlled schema fields.
Check the result¶
Add tests/test_text_transform.py:
import json
from numi_chat.tools.text_transform import TextTransformTool
def test_text_transform():
tool = TextTransformTool()
for case, expected in (("upper", "HELLO"), ("lower", "hello")):
result = json.loads(tool.execute(text="Hello", case=case))
assert result == {"status": "success", "data": {"transformed": expected}}
for args in (
{},
{"text": 123, "case": "upper"},
{"text": "", "case": "upper"},
{"text": "x" * 10001, "case": "upper"},
{"text": "Hello", "case": "unknown"},
):
assert json.loads(tool.execute(**args))["status"] == "error"
Run it before connecting the tool to the agent:
Enable discovery and chat selection¶
There are two separate controls:
- Append
text_transformto the existingTOOLS_ALLOWLISTin your deployment configuration, preserving the other names. Ensure it is not inTOOLS_DENYLIST. - Add
"text_transform",to the existingCUSTOM_TOOL_NAMESset insrc/numi_chat/tools/registry.py.
The registry discovers the Python class automatically. The second step makes the registered tool selectable per chat. Without it, the tool can appear in the schema list while remaining unavailable to the web agent.
Leave CUSTOM_TOOL_GROUPS unchanged for this example. Custom tools outside
a named group appear under Other in the picker.
Restart and verify¶
Stop the server with Ctrl+C, run make run-web, and reload the browser.
Check the server log for Registered tool: text_transform.
For a deterministic registration check, run:
uv run python -c 'from numi_chat.tools.registry import tool_registry; assert "text_transform" in tool_registry.registered_tool_names(); assert "text_transform" in tool_registry.custom_tool_names(); print("text_transform is available for chat selection")'
The authenticated GET /api/v1/tools response also lists the schema in
tools, its name in custom_tool_names, and the Other group in
groups.
Use it in a chat¶
- Open a chat and open the composer's custom-tools picker.
- Enable Other. This enables all tools currently in that group.
- Send:
Use text_transform to convert "Hello World" to uppercase. - Inspect the tool call and result. Its data should contain
{"transformed": "HELLO WORLD"}.
The model chooses whether to call a tool; a plain-text answer is not proof that your implementation ran. The local test and registration check cover those steps independently.
Optional tool behavior¶
- Override
is_available()for a dependency or credential requirement. An optionalavailability_reason()method can explain a skipped tool in the startup log. - Set
concurrency_safe = Falseif the tool must run serially within an execution batch. - Set
register_in_registry = Falsefor helper classes that should not be discovered. - Use
@tool_system_hints({...})only when the agent needs a reminder after execution. Hints are added to tool results, not the input schema.
Custom Python tools execute as the server process. The code interpreter's sandbox does not automatically protect a new tool. Use timeouts for network calls, bound output sizes, and validate file access against the server-supplied chat context if your tool reads user files.
Troubleshooting¶
| Symptom | Check |
|---|---|
| No registration log | File is directly in src/numi_chat/tools/, ends in .py, imports successfully, and exposes a concrete tool class |
| Tool is skipped | Allowlist, denylist, and is_available() |
| Registered but absent from picker | Name is also in CUSTOM_TOOL_NAMES; restart server and reload browser |
| Picker shows the tool but chat cannot use it | Its group is enabled for that chat |
| Wrong arguments or result | Run the local test and inspect the tool call payload |
For the full contract, see Tool Schema. For execution and discovery details, see Tool System.