Skip to content

Adding Custom Tools

Create a text-case tool, check it locally, and enable it in a chat. Start from a working development setup.

Build a custom tool, test its result, and activate it for a conversation.

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:

uv run pytest tests/test_text_transform.py

Enable discovery and chat selection

There are two separate controls:

  1. Append text_transform to the existing TOOLS_ALLOWLIST in your deployment configuration, preserving the other names. Ensure it is not in TOOLS_DENYLIST.
  2. Add "text_transform", to the existing CUSTOM_TOOL_NAMES set in src/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

  1. Open a chat and open the composer's custom-tools picker.
  2. Enable Other. This enables all tools currently in that group.
  3. Send: Use text_transform to convert "Hello World" to uppercase.
  4. 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 optional availability_reason() method can explain a skipped tool in the startup log.
  • Set concurrency_safe = False if the tool must run serially within an execution batch.
  • Set register_in_registry = False for 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.