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 seesimport { 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 hint | JSON Schema |
|---|---|
str, int, float, bool | string, integer, number, boolean |
Literal["a", "b"] | enum: ["a", "b"] |
List[str] / list[str] | array of string |
Dict[...] / dict | object |
Optional[X] | X or null |
| A Pydantic model | the model's own JSON Schema; the function receives an instance |
| a default value | the argument is optional |
no hint, or Any | anything |
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 passname=. - 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 happens | The model sees | The run |
|---|---|---|
| The model's arguments do not match the schema | invalid_arguments with the problems; your function is not called | goes on |
| The model names a tool the agent does not have | unknown_tool with the tools it has | goes on |
Your tool raises ToolError(message) | your message (tool_error) | goes on |
| Your tool raises anything else | depends on on_tool_exception (below) | goes on, unless fail |
| Your tool runs past its time limit | a timeout | goes 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):
| Setting | The model sees | Use when |
|---|---|---|
"return_to_model" / "returnToModel" (default) | The exception's type and message | Development, 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 model | Exceptions may contain data that must not leave your systems |
"fail" | Nothing: the run fails with tool_failed, your exception as the error's cause | A 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:
| Flag | Means | Effect |
|---|---|---|
read_only / readOnly | Reads, changes nothing | Retried after a retryable ToolError or a timeout |
idempotent | Doing it twice is the same as once | Retried the same way |
destructive | Changes or deletes something | Never retried by the engine; a good candidate for approve |
| (none) | Unknown | Never 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 / callId | The model's id for this call; the same on every retry of it |
run_id / runId | The 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#
| Tool | How |
|---|---|
| Another agent | other_agent.as_tool(name, description) (Agents) |
| An MCP server's tools | Pass the server in tools (MCP) |
| Search over your documents | collection.as_tool(...) (Collections) |
| Web search and page reading | criogenai.web (Patterns) |
| Memory | remember, forget and search_memory come with memory= (Memory) |
Next#
- Human approval: pause before risky calls.
- Hooks: change, block or cache tool calls.
- Testing without a model: script tool calls with
FakeModel. - API: Python tools, TypeScript tools.