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

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 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, 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. 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:


# 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, the ToolSpec TypedDict (lines 16-23) expects name, description, parameters (JSON-Schema), and handler keys.

Create a specification file:


# 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:


# 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:


# 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:

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:


# 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

# 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, 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 (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 (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 and are available in all execution environments. Local-mode tools are defined in 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) 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.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →