Tools
Zod in, JSON Schema out — with inferred argument types, timeouts, and automatic repair.
A tool is a Zod schema plus a function. Jonaki converts the schema to JSON Schema for the model, validates the model's arguments against it, and only then calls your code.
import { tool } from "@rohhitrz/jonaki";
import * as z from "zod";
const getWeather = tool({
name: "get_weather",
description: "Get the current weather for a city.",
input: z.object({ city: z.string() }),
run: async ({ city }) => `${city}: 22°C, clear skies`,
});Options
| Field | Type | Default | Notes |
|---|---|---|---|
name | string | — | Required. Unique per agent; ^[a-zA-Z0-9_-]{1,64}$. |
description | string | — | Required. The model reads this to decide when to call it. |
input | z.ZodType | — | Required. Becomes JSON Schema. |
run | (args, ctx) => T | Promise<T> | — | Required. Sync or async. |
timeoutMs | number | 30000 | Enforced even if the tool ignores ctx.signal. |
needsApproval | boolean | (args) => boolean | false | Pauses the run for a human. |
Argument types are inferred
Never annotate args. It comes from input:
import { run, tool } from "@rohhitrz/jonaki";
import * as z from "zod";
const search = tool({
name: "search",
description: "Search the catalogue.",
input: z.object({
query: z.string(),
limit: z.number().int().min(1).max(50).default(10),
tags: z.array(z.string()).optional(),
}),
run: (args) => {
args.query; // string
args.limit; // number — required, because .default() fills it
args.tags; // string[] | undefined
return `searched ${args.query}`;
},
});Do not annotate
Writing run: (args: any) => ... or restating the shape by hand breaks
inference and drifts from the schema. Let TypeScript do it.
Descriptions matter
The model chooses tools from description and the field descriptions. Be
specific:
import { run, tool } from "@rohhitrz/jonaki";
import * as z from "zod";
const refund = tool({
name: "issue_refund",
description:
"Issue a refund for an order. Only use after confirming the order id with the customer.",
input: z.object({
orderId: z.string().describe("The order id, like 'A-7741'"),
amountUSD: z.number().positive().describe("Amount in US dollars"),
}),
run: ({ orderId, amountUSD }) => `Refunded $${amountUSD} for ${orderId}.`,
});The tool context
Every tool receives a second argument:
import { run, tool } from "@rohhitrz/jonaki";
import * as z from "zod";
const longJob = tool({
name: "long_job",
description: "Does something slow.",
input: z.object({}),
timeoutMs: 60_000,
run: async (_args, ctx) => {
ctx.runId; // string — the run this belongs to
ctx.agentName; // string — the agent that called it
ctx.log("starting", { attempt: 1 });
const response = await fetch("https://example.com", {
signal: ctx.signal, // aborts on timeout or run cancellation
});
return response.ok ? "done" : "failed";
},
});Failure is not fatal
A tool that throws never crashes the run. Jonaki reports the failure to the model as a tool result and lets it recover:
import { run, tool } from "@rohhitrz/jonaki";
import * as z from "zod";
const flaky = tool({
name: "flaky",
description: "Sometimes fails.",
input: z.object({}),
run: () => {
throw new Error("upstream 503");
},
});The trace records status: "error", and the model sees the message and can try
something else. Look for it afterwards:
import { tool } from "@rohhitrz/jonaki";
const failures = result.steps.filter(
(step) => step.type === "tool" && step.status === "error",
);Invalid arguments are repaired
If the model sends arguments that fail validation, Jonaki hands the error back and lets it retry — up to twice — instead of failing the run:
JONAKI_TOOL_INPUT_INVALID
tool: get_weather
received: { "cty": "Guwahati" }
expected: { city: string }
issues: [ "city: Invalid input: expected string, received undefined" ]
hint: the model sent a malformed argument; jonaki will repair and retryThe trace marks each attempt status: "repair". After the second, the run
fails with TOOL_INPUT_INVALID.
Timeouts
timeoutMs defaults to 30 seconds and is raced, not merely signalled — a
tool that ignores ctx.signal still times out:
import { run, tool } from "@rohhitrz/jonaki";
import * as z from "zod";
const slow = tool({
name: "slow",
description: "Takes too long.",
input: z.object({}),
timeoutMs: 5_000,
run: async () => {
await new Promise((resolve) => setTimeout(resolve, 60_000));
return "never";
},
});That yields TOOL_TIMEOUT.
Approval gates
needsApproval pauses the run before the tool executes:
import { run, tool } from "@rohhitrz/jonaki";
import * as z from "zod";
const wire = tool({
name: "wire_transfer",
description: "Send money.",
input: z.object({ amountUSD: z.number() }),
needsApproval: ({ amountUSD }) => amountUSD > 1_000,
run: ({ amountUSD }) => `Sent $${amountUSD}.`,
});The run returns with status: "awaiting_approval" — it does not throw. See
Resume & approvals.
Returning structured data
Return anything JSON-serializable. Objects are stringified for the model and stored in the trace:
import { run, tool } from "@rohhitrz/jonaki";
import * as z from "zod";
const lookup = tool({
name: "lookup_order",
description: "Look up an order.",
input: z.object({ orderId: z.string() }),
run: ({ orderId }) => ({
orderId,
status: "shipped",
items: [{ sku: "WL-220", qty: 2 }],
}),
});Non-JSON values are normalized
A Date becomes an ISO string, and values that cannot be serialized become
their String() form. This keeps RunState losslessly JSON round-trippable.
Using a tool outside an agent
Tools are ordinary objects. You can call one directly, which is handy in tests:
const result = await getWeather.execute(
{ city: "Oslo" },
{
runId: "test",
agentName: "test",
signal: new AbortController().signal,
log: () => {},
},
);toSpec() gives you exactly what the model is shown:
getWeather.toSpec();
// { name: "get_weather", description: "...", parameters: { ...JSON Schema } }