# How to Add Custom Built-in Tools to ML Intern: A Complete Guide

> Learn how to add custom built-in tools to ML Intern. Implement async handlers, define ToolSpecs, and register them using create_builtin_tools or ToolRouter for enhanced functionality.

- Repository: [Hugging Face/ml-intern](https://github.com/huggingface/ml-intern)
- Tags: how-to-guide
- Published: 2026-04-24

---

**To add custom built-in tools to ML Intern, implement an async handler function returning a tuple of `(output_string, success_boolean)`, define a `ToolSpec` with JSON-Schema parameters, and register it via `create_builtin_tools()` in [`agent/core/tools.py`](https://github.com/huggingface/ml-intern/blob/main/agent/core/tools.py) or dynamically through `ToolRouter.register_tool()`.**

The ML Intern framework (huggingface/ml-intern) uses a centralized **ToolRouter** to manage tool execution and LLM function-calling interfaces. By extending the built-in tool registry in [`agent/core/tools.py`](https://github.com/huggingface/ml-intern/blob/main/agent/core/tools.py), you can inject custom business logic that automatically surfaces to the LLM through OpenAI-compatible function specifications.

## Understanding the ToolRouter Architecture

At the core of ML Intern's tool system is the **ToolRouter** class defined in [`agent/core/tools.py`](https://github.com/huggingface/ml-intern/blob/main/agent/core/tools.py). This router maintains a registry of **ToolSpec** objects—dictionaries that bind a tool name and JSON-Schema parameter definition to an async handler function.

When a `ToolRouter` instance initializes, it automatically populates its registry by calling `create_builtin_tools()` (lines 82-90), which returns a list of `ToolSpec` objects. These specifications are then exposed to the LLM through `get_tool_specs_for_llm()` (lines 95-106), converting them into OpenAI function-calling format without requiring manual schema management.

## Step 1: Implement the Custom Tool Handler

Every custom tool requires an async handler that receives tool arguments as a dictionary and returns execution results in a standardized format.

Create a new file for your handler:

```python

# file: agent/tools/echo_tool.py

from typing import Any, Tuple

async def echo_handler(args: dict[str, Any]) -> Tuple[str, bool]:
    """
    Handler for the echo tool.
    Returns: (output_string, success_boolean)
    """
    message = args.get("msg", "")
    return f"Echo: {message}", True

```

The handler must accept a single `dict` argument containing the validated parameters and return a tuple where the first element is the output string (sent to the LLM) and the second is a boolean indicating success.

## Step 2: Define the ToolSpec

The **ToolSpec** binds your handler to a structured schema that describes the tool to the LLM. In [`agent/core/tools.py`](https://github.com/huggingface/ml-intern/blob/main/agent/core/tools.py), the `ToolSpec` TypedDict (lines 16-23) expects `name`, `description`, `parameters` (JSON-Schema), and `handler` keys.

Create a specification file:

```python

# file: agent/tools/echo_spec.py

from agent.core.tools import ToolSpec
from .echo_tool import echo_handler

ECHO_TOOL_SPEC = {
    "name": "echo",
    "description": "Return the supplied message verbatim.",
    "parameters": {
        "type": "object",
        "required": ["msg"],
        "additionalProperties": False,
        "properties": {
            "msg": {
                "type": "string",
                "description": "Message to echo back to the user."
            }
        },
    },
    "handler": echo_handler,
}

```

The `parameters` field follows JSON-Schema draft 7 conventions, specifying required fields and property types to enable LLM parameter validation.

## Step 3: Register the Custom Built-in Tool

ML Intern provides three distinct registration paths depending on your deployment context.

### Option A: Static Registration (Global Tools)

For tools available in all execution modes, modify the `create_builtin_tools()` function in [`agent/core/tools.py`](https://github.com/huggingface/ml-intern/blob/main/agent/core/tools.py):

```python

# Inside agent/core/tools.py, near line 82

from agent.tools.echo_spec import ECHO_TOOL_SPEC

def create_builtin_tools(local_mode: bool = False) -> list[ToolSpec]:
    tools = [
        # ... existing built-in tools ...

    ]
    
    # Append your custom tool

    tools.append(ToolSpec(**ECHO_TOOL_SPEC))
    
    return tools

```

The router automatically ingests these specifications during instantiation (lines 32-38).

### Option B: Local-Mode Registration

For CLI or local development environments where sandbox tools are unavailable, register in [`agent/tools/local_tools.py`](https://github.com/huggingface/ml-intern/blob/main/agent/tools/local_tools.py):

```python

# Inside agent/tools/local_tools.py, near line 238

_LOCAL_TOOL_SPECS["echo"] = {
    "description": "Return the supplied message verbatim.",
    "parameters": {
        "type": "object",
        "required": ["msg"],
        "additionalProperties": False,
        "properties": {
            "msg": {"type": "string", "description": "Message to echo."}
        }
    },
}

```

The `get_local_tools()` function (lines 238-260) automatically converts these entries into bound handlers for local execution.

### Option C: Dynamic Registration

For plugin architectures or runtime tool discovery, register after instantiation:

```python
from agent.core.tools import ToolRouter, ToolSpec
from agent.tools.echo_spec import ECHO_TOOL_SPEC

# Initialize router

router = await ToolRouter(mcp_servers={}, hf_token=None).__aenter__()

# Register at runtime

router.register_tool(ToolSpec(**ECHO_TOOL_SPEC))

# Tool is now available via router.get_tool_specs_for_llm()

```

## Complete Working Example

Below is a minimal implementation adding an `echo` tool to the global registry:

```python

# agent/tools/echo_tool.py

from typing import Any, Tuple

async def echo_handler(args: dict[str, Any]) -> Tuple[str, bool]:
    return f"Echo: {args.get('msg', '')}", True

```

```python

# agent/core/tools.py (modifications near line 82)

from agent.tools.echo_tool import echo_handler

def create_builtin_tools(local_mode: bool = False) -> list[ToolSpec]:
    tools = [...]  # existing tools

    
    echo_spec = ToolSpec(
        name="echo",
        description="Return the supplied message verbatim.",
        parameters={
            "type": "object",
            "required": ["msg"],
            "properties": {
                "msg": {"type": "string", "description": "Message to echo."}
            }
        },
        handler=echo_handler
    )
    tools.append(echo_spec)
    return tools

```

After restarting the agent, the LLM will receive the `echo` function definition in its available tools list, and invocations will route through your `echo_handler`.

## Summary

- **ToolRouter** manages tool registration in [`agent/core/tools.py`](https://github.com/huggingface/ml-intern/blob/main/agent/core/tools.py), automatically converting `ToolSpec` objects into LLM-compatible function schemas.
- **Handlers** must be async functions accepting `dict[str, Any]` and returning `Tuple[str, bool]`.
- **Static registration** requires modifying `create_builtin_tools()` at lines 82-90 for global availability.
- **Local-mode tools** are defined in `_LOCAL_TOOL_SPECS` within [`agent/tools/local_tools.py`](https://github.com/huggingface/ml-intern/blob/main/agent/tools/local_tools.py) (lines 238-260).
- **Dynamic registration** uses `router.register_tool()` for runtime tool injection.

## Frequently Asked Questions

### What is the exact function signature for a tool handler?

A tool handler must be an async function accepting a single dictionary argument and returning a tuple of `(str, bool)`. According to the `ToolSpec` definition in [`agent/core/tools.py`](https://github.com/huggingface/ml-intern/blob/main/agent/core/tools.py) (lines 16-23), the first return value represents the tool output sent to the LLM, while the boolean indicates execution success.

### Can I register tools after the ToolRouter is initialized?

Yes. While `ToolRouter.__init__` automatically registers tools from `create_builtin_tools()`, you can call `router.register_tool(spec)` at any time to add additional `ToolSpec` objects dynamically. The new tools immediately become available through `get_tool_specs_for_llm()`.

### What is the difference between global and local-mode tools?

Global tools are registered through `create_builtin_tools()` in [`agent/core/tools.py`](https://github.com/huggingface/ml-intern/blob/main/agent/core/tools.py) and are available in all execution environments. Local-mode tools are defined in [`agent/tools/local_tools.py`](https://github.com/huggingface/ml-intern/blob/main/agent/tools/local_tools.py) within `_LOCAL_TOOL_SPECS` and are only loaded when the agent runs in CLI or local development mode without sandbox access (see lines 238-260).

### How does the LLM receive the tool specifications?

The `ToolRouter.get_tool_specs_for_llm()` method (lines 95-106 in [`agent/core/tools.py`](https://github.com/huggingface/ml-intern/blob/main/agent/core/tools.py)) automatically transforms registered `ToolSpec` objects into OpenAI function-calling format. This method returns a list of tool definitions that the agent passes directly to the LLM API, requiring no manual schema conversion.