Documentation
Tools
Built-in tools, custom function tools, the long-poll callback protocol, schema validation, and recipes for the patterns you'll actually build.
Two kinds of tools
Built-in tools execute server-side in the Go runtime. Custom function tools execute in your process — the runtime calls back to your SDK over an open stream. Same wire format, different execution location.
import { createAgent, builtin, tool } from "@relayhq/sdk";
const agent = createAgent({
model: "claude-sonnet-4-6",
tools: [
builtin.calculator, // runs server-side, in the runtime
myCustomTool, // runs in your process via callback
],
});Built-in tools
Built-ins are useful when the operation is fast, common, and shouldn't cost a round-trip. The registry is intentionally small — most useful tools depend on YOUR business logic, so they belong in your SDK code.
builtin.calculator
Performs a single arithmetic operation on two numbers. Useful as a control for testing tool calls without writing your own.
{
"type": "object",
"properties": {
"a": { "type": "number" },
"b": { "type": "number" },
"op": { "type": "string", "enum": ["+", "-", "*", "/"] }
},
"required": ["a", "b", "op"]
}Custom function tools
A function tool = schema + handler. The schema goes to the LLM. The handler runs in your process. The output is shipped back as the tool result.
TypeScript
import { tool } from "@relayhq/sdk";
export const getUser = tool({
name: "get_user",
description: "Look up a user by id. Returns name, tier, balance.",
inputSchema: {
type: "object",
properties: {
id: { type: "string", description: "user id like u_001" },
},
required: ["id"],
additionalProperties: false,
},
async handler({ id }: { id: string }) {
const user = await db.users.findById(id);
if (!user) return { error: `no user with id ${id}` };
return user;
},
});Python
Identical surface — same args, same wire format.
from relayhq import tool
async def _get_user_handler(input):
user = await db.users.find_by_id(input["id"])
if not user:
return {"error": f"no user with id {input['id']}"}
return user
get_user = tool(
name="get_user",
description="Look up a user by id. Returns name, tier, balance.",
input_schema={
"type": "object",
"properties": {
"id": {"type": "string", "description": "user id like u_001"},
},
"required": ["id"],
"additionalProperties": False,
},
handler=_get_user_handler,
)Naming & descriptions
Tool names should be snake_case, 1–64 characters. Descriptions are part of the prompt — write them like you're briefing a colleague. Be specific about inputs, outputs, and side effects (e.g. "sends an email", "writes to the database").
Return values & errors
Whatever your handler returns gets JSON-serialized as the tool result. Throwing turns into "error: <message>"; the model sees it and almost always self-corrects on the next iteration.
Input schema validation
The SDK validates the LLM's arguments against your inputSchema before invoking your handler. If validation fails, the handler isn't called — the model gets back { "error": "invalid tool input: ..." } and usually self-corrects on the next iteration.
This catches the most common LLM mistakes: missing required fields, wrong types, extra fields when additionalProperties: false, enum violations. No need to bring in zod or pydantic just for tool args.
// validation runs automatically when the tool is invoked.
// If you also want to validate elsewhere (e.g. a manual replay), the
// validator is exported:
import { validateAgainstSchema } from "@relayhq/sdk";
const err = validateAgainstSchema({ id: 123 }, getUser.inputSchema);
// ^^^^^^^^
// err === 'field "id": expected string, got number'from relayhq import validate_against_schema
err = validate_against_schema({"id": 123}, get_user["inputSchema"])
# err == 'field "id": expected string, got number'Tool context (run + workflow IDs)
Handlers can accept an optional second argument with the IDs of the run that decided to call the tool. This is what subagent() uses internally — sub-runs inherit the parent's workflow ID so the dashboard can render the tree.
import { tool, type ToolContext } from "@relayhq/sdk";
export const logEvent = tool({
name: "log_event",
description: "Persist an analytics event tied to the calling run.",
inputSchema: {
type: "object",
properties: { kind: { type: "string" } },
required: ["kind"],
},
async handler(input: { kind: string }, ctx?: ToolContext) {
await analytics.track({
runId: ctx?.runId, // ← the run that called this tool
workflowId: ctx?.workflowId, // ← the root workflow it belongs to
event: input.kind,
});
return { ok: true };
},
});async def _log_event(input, ctx=None):
await analytics.track(
run_id=(ctx or {}).get("run_id"),
workflow_id=(ctx or {}).get("workflow_id"),
event=input["kind"],
)
return {"ok": True}
log_event = tool(
name="log_event",
description="Persist an analytics event tied to the calling run.",
input_schema={
"type": "object",
"properties": {"kind": {"type": "string"}},
"required": ["kind"],
},
handler=_log_event,
)Recipes
Patterns from real codebases. Each one is a complete, copy-pasteable example.
Database lookup (Postgres)
import { tool } from "@relayhq/sdk";
import { pool } from "../db.js";
export const lookupOrder = tool({
name: "lookup_order",
description: "Fetch an order by id. Returns user, items, total, status.",
inputSchema: {
type: "object",
properties: {
order_id: { type: "string", description: "order id, e.g. o_1001" },
},
required: ["order_id"],
additionalProperties: false,
},
async handler({ order_id }: { order_id: string }) {
const { rows } = await pool.query(
"SELECT user_id, items, total_usd, status FROM orders WHERE id = $1",
[order_id],
);
return rows[0] ?? { error: `no order ${order_id}` };
},
});External HTTP API
import os
import httpx
from relayhq import tool
async def _weather(input):
async with httpx.AsyncClient(timeout=10) as client:
r = await client.get(
"https://api.openweathermap.org/data/2.5/weather",
params={"q": input["city"], "appid": os.environ["OWM_KEY"]},
)
if r.status_code != 200:
return {"error": f"weather api {r.status_code}"}
data = r.json()
return {
"city": data["name"],
"temp_c": data["main"]["temp"] - 273.15,
"conditions": data["weather"][0]["description"],
}
weather = tool(
name="weather",
description="Current weather in a city. Returns temp in Celsius + conditions.",
input_schema={
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"],
"additionalProperties": False,
},
handler=_weather,
)Side-effecting action with confirmation
For tools that mutate external state (refund, send email, spend money), encode the confirmation requirement in the schema and log the action with full context.
import Stripe from "stripe";
import { tool, type ToolContext } from "@relayhq/sdk";
const stripe = new Stripe(process.env.STRIPE_KEY!);
export const issueRefund = tool({
name: "issue_refund",
description:
"Refund a Stripe charge. ALWAYS confirm the amount and reason " +
"with the user before calling.",
inputSchema: {
type: "object",
properties: {
charge_id: { type: "string", description: "Stripe charge id (ch_...)" },
amount_cents: { type: "integer", description: "amount to refund in cents" },
reason: {
type: "string",
enum: ["duplicate", "fraudulent", "requested_by_customer"],
},
},
required: ["charge_id", "amount_cents", "reason"],
additionalProperties: false,
},
async handler(input, ctx?: ToolContext) {
const refund = await stripe.refunds.create({
charge: input.charge_id,
amount: input.amount_cents,
reason: input.reason,
metadata: {
relay_run: ctx?.runId ?? "unknown",
relay_workflow: ctx?.workflowId ?? "unknown",
},
});
return { ok: true, refund_id: refund.id, status: refund.status };
},
});File read / processing
from pathlib import Path
from pypdf import PdfReader
from relayhq import tool
def _read_pdf(input):
path = Path(input["path"])
if not path.is_file():
return {"error": f"no file at {path}"}
if path.stat().st_size > 10 * 1024 * 1024:
return {"error": "file >10MB, refusing"}
text = "\n".join(page.extract_text() or "" for page in PdfReader(path).pages)
return {"path": str(path), "chars": len(text), "text": text[:5000]}
read_pdf = tool(
name="read_pdf",
description="Extract plain text from a local PDF file. Returns up to 5000 chars.",
input_schema={
"type": "object",
"properties": {"path": {"type": "string"}},
"required": ["path"],
"additionalProperties": False,
},
handler=_read_pdf,
)Vector / semantic search over your data
Combine your own embeddings index with a tool the agent can query. (Relay also has a built-in memory subsystem if you don't want to manage your own.)
import { tool } from "@relayhq/sdk";
import { pgvector } from "../db.js";
export const searchDocs = tool({
name: "search_docs",
description: "Semantic search over the internal knowledge base.",
inputSchema: {
type: "object",
properties: {
query: { type: "string" },
limit: { type: "integer", description: "1-20, default 5" },
},
required: ["query"],
additionalProperties: false,
},
async handler({ query, limit = 5 }) {
const embedding = await openai.embeddings.create({
model: "text-embedding-3-small",
input: query,
});
const { rows } = await pgvector.query(
`SELECT title, url, snippet
FROM docs
ORDER BY embedding <=> $1::vector
LIMIT $2`,
[embedding.data[0].embedding, Math.min(20, limit)],
);
return { results: rows };
},
});Testing tools locally
Tools are plain functions. Test them like any other code, no Relay server needed:
import { getUser } from "./get-user.js";
test("returns the user when found", async () => {
const result = await getUser.handler({ id: "u_001" });
expect(result).toMatchObject({ id: "u_001", name: expect.any(String) });
});
test("returns an error for unknown ids", async () => {
const result = await getUser.handler({ id: "u_does_not_exist" });
expect(result).toEqual({ error: expect.stringContaining("no user") });
});
test("validates against the schema", async () => {
const { validateAgainstSchema } = await import("@relayhq/sdk");
// Missing required field
expect(validateAgainstSchema({}, getUser.inputSchema)).toMatch(/missing required/);
// Wrong type
expect(validateAgainstSchema({ id: 1 }, getUser.inputSchema)).toMatch(/expected string/);
});How the callback works
Custom tools work through a long-poll callback — the runtime orchestrates, but execution stays in your process. End-to-end:
The runtime stays stateless. The SDK never talks to the runtime directly. Every event is persisted in run_events on the way through. The broker in the control plane is backed by NATS JetStream KV when NATS_URL is set, so the dance works across multiple control-plane replicas.
Limits & timeouts
- Tool result timeout: 30 seconds by default. Configure with
RELAY_TOOL_RESULT_TIMEOUT_MSon the control plane. - Max iterations per run: 8. The agent loop bails after 8 tool round-trips to prevent infinite cycles.
- Parallel tool calls: supported. The SDK dispatches all custom tool handlers concurrently when the model fires more than one in a turn. Make handlers idempotent if the same one might fire twice.
- Tool input size: bounded by provider — Anthropic and OpenAI both cap at ~100K characters per call.
- Subagent depth: capped at 5 by default to prevent runaway recursion. Configurable per
subagent()call.
Common pitfalls
- Returning massive payloads. The whole tool result is fed back into the next LLM call as a message. A 50KB JSON blob will eat your context window. Trim or summarize before returning.
- Side effects without idempotency. The runtime retries on transient failures and the LLM might re-call the same tool twice. Tools that send emails / charge cards / create records should accept an
idempotency_keyin the schema or dedupe internally. - Long-running handlers. 30s default timeout — anything longer (file uploads, batch jobs) should kick off async work and return a job id immediately, then expose a
check_job_statustool the agent can poll. - Loose schemas that confuse the LLM.
additionalProperties: falseandenumconstraints help the model self-correct faster. Hand-waving schemas get hand-waving args. - Forgetting to handle the not-found case. If a lookup returns nothing, return
{ error: "..." }notnull— the model needs the explanation to course-correct.