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 convertingToolSpecobjects into LLM-compatible function schemas. - Handlers must be async functions accepting
dict[str, Any]and returningTuple[str, bool]. - Static registration requires modifying
create_builtin_tools()at lines 82-90 for global availability. - Local-mode tools are defined in
_LOCAL_TOOL_SPECSwithinagent/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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →