jonaki

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

FieldTypeDefaultNotes
namestring—Required. Unique per agent; ^[a-zA-Z0-9_-]{1,64}$.
descriptionstring—Required. The model reads this to decide when to call it.
inputz.ZodType—Required. Becomes JSON Schema.
run(args, ctx) => T | Promise<T>—Required. Sync or async.
timeoutMsnumber30000Enforced even if the tool ignores ctx.signal.
needsApprovalboolean | (args) => booleanfalsePauses 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 retry

The 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 } }

On this page