Skip to main content

Add custom tools

Let's build two custom tools that allow an LLM to write / read content to / from a local file. We'll start building the tool using the @tool decorator, which provides a simple and straightforward way to create custom tools from Python functions.

Using the @tool decorator

The @tool decorator converts your functions into Portia tools. Let's create our custom tools in a separate folder called custom_tools at the root of the project directory:

custom_tools/file_reader_tool.py
from pathlib import Path
import pandas as pd
import json
from typing import Annotated
from portia import tool

@tool
def file_reader_tool(
filename: Annotated[str, "The location where the file should be read from"]
) -> str | dict:
"""Finds and reads content from a local file on Disk."""
file_path = Path(filename)
suffix = file_path.suffix.lower()

if file_path.is_file():
if suffix == '.csv':
return pd.read_csv(file_path).to_string()
elif suffix == '.json':
with file_path.open('r', encoding='utf-8') as json_file:
data = json.load(json_file)
return data
elif suffix in ['.xls', '.xlsx']:
return pd.read_excel(file_path).to_string()
elif suffix in ['.txt', '.log']:
return file_path.read_text(encoding="utf-8")

The file reader tool expects a filename argument, which includes the file path and specifies the file to be read, and then returns the contents of the file as an output. The file writer tool expects the content to be written alongside the filename tool and then returns a string summarising the successful action. If a file already exists at the specified location its content will be overwritten. In general, a custom tool can return any type that can be safely serialised to a string, though we suggest basic data types (e.g. str, int, float, bool), collections of these types (e.g. list, set, dict) or Pydantic models as they are easier to work with.

On the local file tools

If those tools look familiar it's because we actually offer them in our open source repo ready-made. We just wanted to walk you through building your own local version from scratch (Open source tools in our SDK repo ↗). We will save adding clarifications to the next section though.

Manage tool registries

Let's group our custom tools into a registry so we can import it into code afterwards. Let's create a registry.py file in the custom_tools directory and declare our registry as follow:

custom_tools/registry.py
"""Registry containing my custom tools."""

from portia import ToolRegistry

my_tool_registry = ToolRegistry([
file_reader_tool(),
file_writer_tool(),
])

Here we are loading our freshly minted local tools into a tool registry called my_tool_registry represented by the ToolRegistry class. This takes a list of instantiated tool functions as a parameter.

Bringing it together in an example

Now let's bring it all together. We can combine any number of tool registries into a single one with the + operator. This can just as well be done to combine local and Portia tools together in one fell swoop! For this example, we will combine our custom tool(s) from the my_tool_registry we created above with the example_tool_registry using complete_tool_registry = example_tool_registry + my_tool_registry.
Note: Make a demo_runs directory at this point. We will be using repeatedly.

API keys required

We will use a simple GET endpoint from OpenWeatherMap in this section. Please sign up to obtain an API key from them () and set it in the environment variable OPENWEATHERMAP_API_KEY.

We're assuming you already have a Tavily key provisioned from the previous sections in this doc. If not, then head over to their website and do so (). We will set it in the environment variable TAVILY_API_KEY.

custom_tools/main.py
from dotenv import load_dotenv
from portia import (
Portia,
example_tool_registry,
Config,
LogLevel,
)

load_dotenv()

# Load example and custom tool registries into a single one
complete_tool_registry = example_tool_registry + my_tool_registry
# Instantiate Portia with the tools above
portia = Portia(
Config.from_default(default_log_level=LogLevel.DEBUG),
tools=complete_tool_registry,
)

# Execute the plan from the user query
plan_run = portia.run('Get the weather in the town with the longest name in Welsh'
+ ' and write it to demo_runs/weather.txt.')

# Serialise into JSON and print the output
print(plan_run.model_dump_json(indent=2))

This should result in a plan and subsequent plan run automatically weaving in the WeatherTool and SearchTool from the example_tool_registry as well as our hot-off-the-press file_writer_tool from our custom_tool_registry. You should expect the weather information in Llanfairpwllgwyngyllgogerychwyrndrobwllllantysiliogogogoch to be printed in a weather.txt file inside a demo_runs folder as specified. If you're in the mood, now is a good time to practise your Welsh pronunciation.

demo_runs/weather.txt
The current weather in Llanfairpwllgwyngyllgogerychwyrndrobwllllantysiliogogogoch is broken clouds with a temperature of 6.76°C.

Class-based approach (Alternative)

For more complex scenarios requiring advanced customisation, you can also use the class-based approach. This provides more control over tool configuration and is equivalent to the decorator approach shown above:

custom_tools/file_reader_tool.py
from pathlib import Path
import pandas as pd
import json
from pydantic import BaseModel, Field
from portia.tool import Tool, ToolRunContext


class FileReaderToolSchema(BaseModel):
"""Schema defining the inputs for the FileReaderTool."""

filename: str = Field(...,
description="The location where the file should be read from",
)


class FileReaderTool(Tool[str]):
"""Finds and reads content from a local file on Disk."""

id: str = "file_reader_tool"
name: str = "File reader tool"
description: str = "Finds and reads content from a local file on Disk"
args_schema: type[BaseModel] = FileReaderToolSchema
output_schema: tuple[str, str] = ("str", "A string dump or JSON of the file content")

def run(self, _: ToolRunContext, filename: str) -> str | dict[str,any]:
"""Run the FileReaderTool."""

file_path = Path(filename)
suffix = file_path.suffix.lower()

if file_path.is_file():
if suffix == '.csv':
return pd.read_csv(file_path).to_string()
elif suffix == '.json':
with file_path.open('r', encoding='utf-8') as json_file:
data = json.load(json_file)
return data
elif suffix in ['.xls', '.xlsx']:
return pd.read_excel(file_path).to_string()
elif suffix in ['.txt', '.log']:
return file_path.read_text(encoding="utf-8")

When using the class-based approach you would be registering the tools the exact same way as the decorator approach:

my_custom_tools/registry.py (class-based)
"""Registry containing my custom tools."""

from portia import InMemoryToolRegistry
from my_custom_tools.file_reader_tool import FileReaderTool
from my_custom_tools.file_writer_tool import FileWriterTool

custom_tool_registry = InMemoryToolRegistry.from_local_tools(
[
FileReaderTool(),
FileWriterTool(),
],
)

The @tool decorator approach is recommended for most use cases due to its simplicity and ease of use, while the class-based approach provides more flexibility for advanced scenarios.