Tools
Tools give agents the ability to call external functions. defineTool wraps a typed execute function with a Zod input schema so the model knows what to pass and what to expect back.
Defining a tool
Section titled “Defining a tool”import { z } from 'zod';import { defineTool } from '@kuralle-agents/core';
const echo = defineTool({ name: 'echo', description: 'Echo back the provided text', input: z.object({ text: z.string() }), execute: async ({ text }) => ({ echoed: text }),});name and description go directly into the model's tool schema. Keep descriptions accurate — the model uses them to decide when to call the tool.
input is a Zod object schema. Kuralle validates the model's arguments against it before calling execute.
execute receives the validated input and returns any value. The return value is serialized and passed back to the model as the tool result.
Wiring a tool to an agent
Section titled “Wiring a tool to an agent”Pass your effect tools as a single tools record on the agent. The runtime makes them model-visible (deriving the AI SDK ToolSet for you) and registers the executors so calls — whether the model issues them or a flow node calls them by name — route through the effect log for exactly-once replay.
import { defineAgent } from '@kuralle-agents/core';
const tools = { echo };
const agent = defineAgent({ id: 'support', instructions: 'Use the echo tool when asked.', model: openai('gpt-4o-mini'), tools,});What buildToolSet does
Section titled “What buildToolSet does”defineTool returns a Kuralle effect tool — { name, description, input, execute }, where input is a Standard Schema and execute is your durable handler. The model, however, speaks the Vercel AI SDK tool format, so the runtime derives an AI SDK ToolSet from your tools record automatically — you don't call anything to make a tool model-visible.
buildToolSet is that bridge, exported for the cases where you need the ToolSet yourself — most commonly a flow node's tools field (which takes an AI SDK ToolSet):
import { buildToolSet } from '@kuralle-agents/core';
buildToolSet({ echo, lookup_order })// → an AI SDK ToolSet: each entry has the tool's name, description, and// inputSchema — the shape the model needs to know a tool exists and how to call it.It copies each tool's name, description, and input schema into the ToolSet. It deliberately does not copy execute — the AI SDK entry is schema-only; the actual execution runs through the runtime, which routes every model-issued call through the durable effect log for exactly-once replay.
Durable execution
Section titled “Durable execution”Every tool call is keyed in an append-only effect log. The runtime checks that key before calling execute — if the call already ran, it returns the logged result without calling execute again.
This means a payment tool won't charge twice if the turn fails mid-execution, and a booking tool won't double-book a slot.
Parallel execution
Section titled “Parallel execution”By default, tools in the same model turn run one at a time through the durable effect log. Mark a tool parallelSafe: true to let it run concurrently with other parallel-safe tools called in the same turn — safe for independent reads, not for tools that mutate shared state:
const get_weather = defineTool({ name: 'get_weather', description: 'Current weather for a city', parallelSafe: true, input: z.object({ city: z.string() }), execute: async ({ city }) => fetchWeather(city),});parallelSafe also accepts a predicate over the arguments, for a tool whose safety depends on
what it was asked to do — a read mode and a write mode behind one name:
const storage = defineTool({ name: 'storage', description: 'Read or write a key', // Evaluated on the RAW model arguments, before schema validation, so it must be total. // Any throw or non-boolean return fails closed to serial. parallelSafe: (args) => (args as { op?: string }).op === 'read', input: z.object({ op: z.enum(['read', 'write']), key: z.string() }), execute: async ({ op, key }) => (op === 'read' ? read(key) : write(key)),});The model cannot influence the decision either way — the predicate is yours, and it runs before anything the model supplied has been validated.
replay: false does not imply parallel-safe. They are unrelated properties: one means "do not
journal this step", the other means "safe to run concurrently with siblings". Declare parallelSafe
explicitly. The runtime pre-assigns journal ordinals for the batch (reserveSteps) so replay stays
aligned no matter which parallel call finishes first, and results are appended to the transcript in
source order rather than completion order, so a replayed run rebuilds the same layout.
A batch is bounded at 8 concurrent calls by default. That is measured, not arbitrary: above
roughly eight-way in-process concurrency the run store's optimistic-concurrency check starts
rejecting writes, and against the durable store 20 unbounded parallel calls executed only 8 — the
rest threw Stale write for session … without ever reaching their executor. Lower it with
limits.maxToolConcurrency when tools open sockets, spawn subprocesses, or hit a rate-limited API:
const runtime = createRuntime({ agents: [agent], limits: { maxToolConcurrency: 4 },});Result size
Section titled “Result size”A tool result is capped where it enters the transcript, so one large payload cannot be re-sent to
the model on every subsequent turn. Truncation is middle-out — the end of a payload usually holds
the error, the total and the last record — and ctx.tool() and the durable journal keep the full
untruncated value. Only what the model reads is bounded.
Timeouts and cancellation
Section titled “Timeouts and cancellation”timeoutMs bounds a call. The timeout arrives at the tool as an abort on ctx.abortSignal, so a tool that watches the signal stops working when the runtime stops waiting:
const fetch_report = defineTool({ name: 'fetch_report', description: 'Fetch a report from the reporting service', timeoutMs: 5_000, input: z.object({ id: z.string() }), execute: async ({ id }, ctx) => fetch(`/reports/${id}`, { signal: ctx?.abortSignal }).then((r) => r.json()),});The call rejects with ToolTimeoutError either way. Passing the signal through is what stops the work; without it the request keeps running to completion after its result has been discarded.
Set interruptible: false to opt a tool out of caller-driven aborts (barge-in). It does not opt out of timeoutMs.
Recovering from tool errors
Section titled “Recovering from tool errors”A tool that throws fails the call, and the model sees a generic failure. Give it an onError to return something the model can act on instead:
const check_stock = defineTool({ name: 'check_stock', description: 'Check warehouse stock', input: z.object({ sku: z.string() }), execute: async ({ sku }) => warehouse.stock(sku), onError: (error, { sku }) => ({ sku, available: null, reason: error.message }),});The recovered value is validated against output and journaled as the success it became, so a replay returns it without re-running the tool.
onError is deliberately not called for a timeout, an abort, a schema violation, or an approval decision. Those are facts about the run, not results a tool may reinterpret.
Streaming progress from a tool
Section titled “Streaming progress from a tool”A tool can be an async generator. Each yield is emitted immediately as an internal tool-result part marked preliminary: true; the aggregate is still the tool's result and the only thing journaled:
const migrate = defineTool({ name: 'migrate', description: 'Run a migration, reporting progress', input: z.object({ table: z.string() }), execute: async function* ({ table }) { for (const batch of batches(table)) { yield { migrated: batch.count }; } },});Because only the aggregate is journaled, a replayed call emits no preliminary parts — replay stays deterministic.
Overriding the idempotency key
Section titled “Overriding the idempotency key”Every tool call is keyed automatically — a hash of the run, its position in the effect log, and { name, args }. That's a stable identity as long as args uniquely identifies the call. When it doesn't (for example, args include a fresh nonce), override the key with idempotencyKey so a retry still dedups on the field that actually identifies the operation:
const chargeCard = defineTool({ name: 'charge_card', description: 'Charge a customer for an amount', input: z.object({ amount: z.number(), nonce: z.string() }), idempotencyKey: ({ amount }) => `charge:${amount}`, execute: async ({ amount }) => processCharge(amount),});See Durable Execution for how the effect log and idempotency keys work end to end.
Multiple tools
Section titled “Multiple tools”const tools = { lookup_order: lookupOrder, cancel_order: cancelOrder, create_ticket: createTicket,};
const agent = defineAgent({ id: 'support', instructions: '...', model: openai('gpt-4o-mini'), tools: tools,});Example
Section titled “Example”import { openai } from '@ai-sdk/openai';import { z } from 'zod';import { defineAgent, defineTool, createRuntime } from '@kuralle-agents/core';
// Define a tool with a Zod input schema and an async execute functionconst echo = defineTool({ name: 'echo', description: 'Echo back the provided text', input: z.object({ text: z.string() }), execute: async ({ text }) => ({ echoed: text }),});
// Wire durable tools on the agent; flow nodes use buildToolSet for model-visible schema.const agent = defineAgent({ id: 'support', instructions: 'Use the echo tool when asked.', model: openai('gpt-4o-mini'), tools: { echo },});
const runtime = createRuntime({ agents: [agent], defaultAgentId: 'support' });
const handle = runtime.run({ input: 'Echo "hello world"' });for await (const part of handle.events) { if (part.type === 'text-delta') process.stdout.write(part.payload.delta); if (part.type === 'done') console.log('\nDone.');}await handle;