Custom Tools
Give an agent tools that run in your backend, with access to your APIs and secrets.
A custom tool is a function the model can call. It runs in the agent Actor on your worker, next to your server code, so it can use your APIs and secrets.
Tool parameters are TypeBox schemas. Pi validates every call against them, so build them with the Type that pi-ai exports:
npm add @earendil-works/pi-ai
import { Type } from "@earendil-works/pi-ai";
import { defineTool } from "@earendil-works/pi-durable";
export const getOrder = defineTool({
name: "get_order",
description: "Look up an order's status by its id.",
parameters: Type.Object({ orderId: Type.String() }),
// A lookup changes nothing, so running it twice is harmless.
replay: "safe",
execute: async ({ orderId }, _api, context) => {
const order = await fetchOrder(orderId, context.abortSignal);
const text = `Status: ${order.status}. Total: $${order.total}.`;
return { content: [{ type: "text", text }], details: order };
},
});
async function fetchOrder(orderId: string, signal: AbortSignal | undefined) {
const response = await fetch(`https://api.example.com/orders/${encodeURIComponent(orderId)}`, {
headers: { authorization: `Bearer ${process.env.ORDERS_API_TOKEN}` },
signal,
});
if (response.status === 404) throw new Error(`No order with id ${orderId}.`);
if (!response.ok) throw new Error(`The orders API returned ${response.status}.`);
return (await response.json()) as { status: string; total: number };
}
import { createRegistry, defineExtension } from "@earendil-works/pi-durable";
import { pi } from "@rivet-dev/pi";
import { setup } from "rivetkit";
import { getOrder } from "./get-order";
const extensions = createRegistry();
extensions.install(defineExtension({ name: "orders", tools: [getOrder] }));
const agent = pi({
model: "anthropic/claude-opus-5-5",
registry: extensions,
});
export const registry = setup({ use: { agent } });
registry.start();
Both defineTool and defineExtension come from @earendil-works/pi-durable. A tool is installed through an extension, and by default every conversation uses every installed extension.
- The model reads the tool’s
descriptionand calls it with arguments that matchparameters. Pi rejects a call whose arguments don’t match. - The model reads
contentas the result. Clients getdetailsfor your UI. - A thrown error, like the 404 in
fetchOrder, becomes an error result. The model reads the message and can try something else. - The
context.abortSignalaborts when the run is cancelled or the Actor stops, so a Stop from the client also cancels the request. Until the tool returns, the Actor can’t sleep, and an upgrade waits for it.
After a crash
If the Actor restarts while a tool runs, the agent runs that tool again on wake only if the tool sets replay: "safe". The get_order tool only reads, so it sets it. See Resuming a run for every case.
Leave replay unset for a tool with side effects, such as one that deploys a preview. For work that must finish exactly once, launch a task from the tool.
Secrets
The orders API token stays in your backend. The tool reads it from the worker’s environment as ORDERS_API_TOKEN, and nothing else sees it:
| Sees the token | |
|---|---|
| Your tool code | Yes, from process.env on the worker. |
| The model | No. It sees the tool’s name, description, parameters, and results. |
| The conversation and connected clients | No. They store and receive tool arguments and results. |
| The sandbox | No. Commands run with the sandbox’s own environment. |
Tool results and error messages are stored in the conversation and sent to every watching client, so keep secrets out of both. For model keys, see LLM API Keys.
Custom tools need no sandbox. To also give the model file and shell tools, see Built-in Tools.
Next: Session Lifecycle, what happens while the agent answers a prompt.