chat-rs
Tools

Tools in Python

Write tools as Python functions and load them into a chat at runtime.

Tools do not have to be Rust functions. If you have logic that already lives in Python, or you simply prefer writing a quick tool as a script, chat-rs can load those functions at runtime and hand them to the model just like a native #[tool].

This relies on the python cargo feature, so enable it on tools-rs first:

tools-rs = { version = "...", features = ["python"] }

Write the Python tool

Each script imports tool from tools_rs and decorates the functions you want to expose. The docstring becomes the tool's description, and the type-annotated parameters become its schema, so the model knows when and how to call it.

from tools_rs import tool


@tool()
def get_weather(city: str) -> str:
    """Get the current weather in a city.

    Args:
        city: The city to look up.
    """
    table = {
        "London": "rainy, 12C",
        "Tokyo": "sunny, 22C",
        "New York": "cloudy, 18C",
        "Madrid": "hot, 31C",
    }
    return table.get(city, f"no data for {city}")

You can put several @tool()-decorated functions in one file, and several files in one directory. Every decorated function found gets registered.

Metadata on Python tools

Python tools carry metadata too. Just like Rust's #[tool(key = value)], the @tool decorator accepts keyword arguments, so you can tag a tool with whatever your strategy needs, for example gating approval or marking a cost tier.

from tools_rs import tool


@tool(requires_approval=True, cost_tier=2)
def delete_account(user_id: str) -> str:
    """Permanently delete a user account.

    Args:
        user_id: The account to delete.
    """
    ...

That metadata travels with the tool exactly as it does for Rust tools, so the same strategy approach decides what happens on each call.

Load them and run

ToolsBuilder scans a directory for decorated functions and returns a tool collection. Point with_language at Language::Python, give from_path the folder holding your scripts, and collect does the discovery. The result drops straight into .with_tools(...).

use chat_rs::{ChatBuilder, ChatOutcome, gemini, parts, types::messages::content};
use tools_rs::{Language, ToolsBuilder};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let client = gemini::GeminiBuilder::new()
        .with_model("gemini-2.5-flash")
        .build();

    let tools = ToolsBuilder::new()
        .with_language(Language::Python)
        .from_path("examples/gemini/python_scripts")
        .collect()?;

    let mut chat = ChatBuilder::new()
        .with_tools(tools)
        .with_model(client)
        .with_max_steps(5)
        .build();

    let mut messages = content::from_user(parts!["What's the weather in Tokyo?"]);

    if let ChatOutcome::Complete(res) = chat.complete(&mut messages).await.map_err(|e| e.err)? {
        println!("{:?}", res.content.parts.last());
    }
    Ok(())
}

From here the loop behaves exactly as it does for Rust tools: the model asks for get_weather, chat-rs runs the Python function, feeds the result back, and lets the model continue. The fact that the body is Python is invisible to the rest of your code.

On this page