Tools

A tool is a function the model may call: look an order up, book a visit, send a message. You write the function and describe its arguments; the engine checks every call against that description before your code runs, runs the calls of one turn concurrently, and gives the results back to the model. This page covers writing tools well. How tool calls fit into the loop is on Agents and the loop.

Define a tool#

In Python, decorate a function with @tool: the type hints become the JSON Schema, and the docstring becomes the description. In TypeScript, call tool({...}) with a JSON Schema in input (or a Zod schema, below).

from criogenai import tool


class Address(BaseModel):
    street: str
    city: str
    postcode: str


@tool
def schedule_visit(
    patient_ref: str,                              # "type": "string", required
    kind: Literal["home", "clinic", "video"],      # "enum": [...]
    slots: List[str],                              # "type": "array" of strings
    duration_minutes: int = 30,                    # "type": "integer", optional
    note: Optional[str] = None,                    # string or null, optional
    address: Optional[Address] = None,             # a Pydantic model: its JSON Schema
) -> dict:
    """Book a visit in the first free slot. Slots are ISO 8601 times."""
    return {"booked": slots[0], "kind": kind, "minutes": duration_minutes}


print(schedule_visit.schema)  # the JSON Schema the model sees
import { tool } from "criogenai";

const scheduleVisit = tool<{
  patientRef: string;
  kind: "home" | "clinic" | "video";
  slots: string[];
  durationMinutes?: number;
  note?: string | null;
}>({
  name: "schedule_visit",
  description: "Book a visit in the first free slot. Slots are ISO 8601 times.",
  input: {
    type: "object",
    properties: {
      patientRef: { type: "string" },
      kind: { enum: ["home", "clinic", "video"] },
      slots: { type: "array", items: { type: "string" }, minItems: 1 },
      durationMinutes: { type: "integer", minimum: 5, maximum: 240 },
      note: { anyOf: [{ type: "string" }, { type: "null" }] },
    },
    required: ["patientRef", "kind", "slots"],
    additionalProperties: false,
  },
  // The type parameter above is what `run` receives: keep it in step with the schema.
  run: ({ slots, kind, durationMinutes = 30 }) => ({ booked: slots[0], kind, minutes: durationMinutes }),
});

How Python type hints become the schema:

Type hintJSON Schema
str, int, float, boolstring, integer, number, boolean
Literal["a", "b"]enum: ["a", "b"]
List[str] / list[str]array of string
Dict[...] / dictobject
Optional[X]X or null
A Pydantic modelthe model's own JSON Schema; the function receives an instance
a default valuethe argument is optional
no hint, or Anyanything

Every schema made from hints forbids extra arguments (additionalProperties: false). Any other type raises TypeError when the tool is defined, so a schema problem shows at start-up, not in production. tool.schema (Python) and tool.jsonSchema (TypeScript) show what the model will see.

In TypeScript, the type parameter (tool<{ orderId: string }>) types run's argument. It is not checked against the schema: keep the two in step, or use Zod.

Names and descriptions#

  • The name is what the model calls: letters, digits, _ and -, at most 64 characters. In Python it is the function's name unless you pass name=.
  • The description is what the model reads to choose the tool. Say what it does, when to use it, and what the arguments look like ("ids look like LAB-12345"). This matters more than any other wording in your prompt.
  • Constrain arguments in the schema, not in prose: minimum, maximum, enum, pattern, minItems. The engine enforces them; a model may not.

Zod and other Standard Schema libraries (TypeScript)#

A tool's input can be a Zod schema, or any Standard Schema (ArkType, Valibot). The engine checks the JSON Schema the library makes; then the library checks what JSON Schema cannot say (refinements), and its issues go back to the model as invalid arguments. run receives the library's output, with defaults and transforms applied, and its type is inferred. Valibot cannot make a JSON Schema itself: pass jsonSchema: toJsonSchema(schema) (from @valibot/to-json-schema).

import { z } from "zod";

const refund = tool({
  name: "issue_refund",
  description: "Refund an order.",
  // The model sees the JSON Schema Zod makes; the engine checks it, then
  // Zod checks the rest (the refinement) and its issues go back to the model.
  input: z
    .object({ orderId: z.string(), amount: z.number().positive(), reason: z.string().default("customer request") })
    .refine((r) => r.amount <= 500, { message: "over 500 needs a manager", path: ["amount"] }),
  // args is { orderId: string; amount: number; reason: string }, with the default applied.
  run: async ({ orderId, amount, reason }) => ({ refunded: amount, orderId, reason }),
});

What a tool returns#

Return any JSON value: a dict or object, a list, a string, a number. It goes back to the model as the tool's result (Pydantic models are converted to JSON). Keep results small and to the point: the model reads all of it, and it is stored with the thread. Results longer than 100,000 characters are cut.

Tools may be sync or async in both languages. In Python, async tools run on your event loop and sync tools on worker threads, so a slow sync tool does not block the others.

When a tool fails#

from criogenai import ToolError


@tool(read_only=True)
def get_lab_result(test_id: str) -> dict:
    """Read one lab result by its id."""
    if not test_id.startswith("LAB-"):
        # Goes back to the model as the tool's result; the run goes on.
        raise ToolError("Lab ids look like LAB-12345")
    if lab_system_is_down():
        # retryable=True: calling again may work. A read_only or idempotent
        # tool is then retried by the engine before the model sees the error.
        raise ToolError("The lab system is not answering", retryable=True)
    return {"test_id": test_id, "status": "final"}
import { ToolError } from "criogenai";

const getLabResult = tool<{ testId: string }>({
  name: "get_lab_result",
  description: "Read one lab result by its id.",
  readOnly: true,
  input: { type: "object", properties: { testId: { type: "string" } }, required: ["testId"] },
  run: async ({ testId }) => {
    // Goes back to the model as the tool's result; the run goes on.
    if (!testId.startsWith("LAB-")) throw new ToolError("Lab ids look like LAB-12345");
    // retryable: calling again may work. A readOnly or idempotent tool is
    // then retried by the engine before the model sees the error.
    if (await labSystemIsDown()) throw new ToolError("The lab system is not answering", { retryable: true });
    return { testId, status: "final" };
  },
});
What happensThe model seesThe run
The model's arguments do not match the schemainvalid_arguments with the problems; your function is not calledgoes on
The model names a tool the agent does not haveunknown_tool with the tools it hasgoes on
Your tool raises ToolError(message)your message (tool_error)goes on
Your tool raises anything elsedepends on on_tool_exception (below)goes on, unless fail
Your tool runs past its time limita timeoutgoes on

Use ToolError for failures the model can act on: "no such order", "that slot is taken", "refunds over 200 need a manager". The text goes to the model, so do not put secrets or other people's data in it.

An exception you did not expect (a bug, a database outage) follows the agent's on_tool_exception (onToolException):

SettingThe model seesUse when
"return_to_model" / "returnToModel" (default)The exception's type and messageDevelopment, and tools whose errors hold nothing sensitive
"return_generic" / "returnGeneric"Only that the tool failed; the text is kept in the result's artifact, stored with the thread but never sent to a modelExceptions may contain data that must not leave your systems
"fail"Nothing: the run fails with tool_failed, your exception as the error's causeA failed tool means the whole task is wrong; a thread can retry() it

Retries, timeouts and side effects#

from criogenai import Agent, Retry

agent = Agent(
    "openai:gpt-5-mini",
    tools=[get_lab_result, send],
    # Read-only and idempotent tools are retried after ToolError(retryable=True)
    # or a timeout; other tools never are. This sets how often and how patiently.
    tool_retry=Retry(max_attempts=4, initial_backoff=1.0),
    limits={"tool_timeout_secs": 20},  # each call; the default is 60
    on_tool_exception="return_generic",  # unexpected exceptions: hide their text from the model
)
import { Agent } from "criogenai";

const agent = new Agent({
  model: "openai:gpt-5-mini",
  tools: [getLabResult, sendMessage, scheduleVisit],
  // Read-only and idempotent tools are retried after a retryable ToolError
  // or a timeout; other tools never are. This sets how often and how patiently.
  toolRetry: { maxAttempts: 4, initialBackoffSecs: 1 },
  limits: { toolTimeoutSecs: 20 }, // each call; the default is 60
  onToolException: "returnGeneric", // unexpected exceptions: hide their text from the model
});

Mark each tool for what it does:

FlagMeansEffect
read_only / readOnlyReads, changes nothingRetried after a retryable ToolError or a timeout
idempotentDoing it twice is the same as onceRetried the same way
destructiveChanges or deletes somethingNever retried by the engine; a good candidate for approve
(none)UnknownNever retried

The retry policy (tool_retry / toolRetry) defaults to 3 attempts, waiting 0.5 s, then 1 s, with jitter. The time limit per call defaults to 60 s.

Retries inside one call are only half the story. On a thread, finished tool calls are saved one by one, so when a run fails and you retry() it, tools that already finished are not run again. A tool that was running when the process died may run again: for anything that sends or pays, use the call id as an idempotency key.

The tool context#

from criogenai import ToolContext


@tool(name="send_message", description="Send a message to the care team.", destructive=True)
async def send(text: str, ctx: ToolContext) -> str:
    # ctx is filled by CriogenAI and is not part of the schema the model sees.
    # call_id is stable across retries of the same call: use it as an idempotency key.
    await outbox_put(key=ctx.call_id, text=text)
    return "sent"
const sendMessage = tool<{ text: string }>({
  name: "send_message",
  description: "Send a message to the care team.",
  destructive: true,
  input: { type: "object", properties: { text: { type: "string" } }, required: ["text"] },
  // The second argument is filled by CriogenAI. callId is stable across
  // retries of the same call: use it as an idempotency key.
  run: async ({ text }, ctx) => {
    await outboxPut(ctx.callId, text);
    return "sent";
  },
});
Field (Python / TypeScript)Meaning
call_id / callIdThe model's id for this call; the same on every retry of it
run_id / runIdThe run's id, as in the result, traces and audit records

In Python, ask for it with a parameter annotated ToolContext (it is left out of the schema). In TypeScript it is run's second argument.

Tools that need a person#

Put a tool's name in the agent's approve list and the run pauses before it runs, until someone approves, edits or rejects the call. See Human approval. To block or change calls by rule instead (refunds over 500, calls outside working hours), use a before_tool hook.

Other kinds of tools#

ToolHow
Another agentother_agent.as_tool(name, description) (Agents)
An MCP server's toolsPass the server in tools (MCP)
Search over your documentscollection.as_tool(...) (Collections)
Web search and page readingcriogenai.web (Patterns)
Memoryremember, forget and search_memory come with memory= (Memory)

Next#