relay

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

get-user.ts
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.

get_user.py
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)

tools/lookup-order.ts
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

tools/weather.py
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.

tools/issue-refund.ts
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

tools/read_pdf.py
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,
)

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:

tools/get-user.test.ts
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:

callback flow
rendering diagram…

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_MS on 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_key in 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_status tool the agent can poll.
  • Loose schemas that confuse the LLM. additionalProperties: false and enum constraints 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: "..." } not null — the model needs the explanation to course-correct.