Agent Development Kit · Build
Tools: the code a model is allowed to run
A tool is a name, a description, a Zod schema, and a function. The model reads the name and description and decides; the schema is the border it has to cross; your function does the work. This chapter runs every part of that — the execute context, the prepare and finalize steps around it, timeouts and retries, and what the model sees when the whole thing fails.
app.tool
Step 1
The whole config, in one object
app.tool takes one object and returns a tool. Three fields are the contract the
model sees — name, description, schema — and
execute is the work. The rest are optional and covered below. Every cell here but
the last runs with no key: the model's turns are scripted and the tools themselves really
execute. The last cell asks a real model to choose, so paste a key if you want it — it stays
in this browser.
import { adk } from '@animahealth/adk'
import { z } from 'zod'
const app = adk()
const WAREHOUSE: Record<string, number> = { 'ADK-1': 12, 'ADK-2': 0 }
const checkStock = app.tool({
name: 'check_stock',
description: 'Look up how many units of a SKU the warehouse holds',
schema: z.object({
sku: z.string().describe('Warehouse SKU, e.g. ADK-1'),
quantity: z.number().int().positive().describe('Units the customer wants'),
}),
execute: (ctx) => {
const onHand = WAREHOUSE[ctx.args.sku] ?? 0
return { sku: ctx.args.sku, onHand, canFulfil: onHand >= ctx.args.quantity }
},
})
// Every key app.tool reads. There is no tenth.
Object.keys(checkStock)
name and description are prompt: they are the only reason the model
reaches for this tool rather than another, so write the description for a reader who cannot
see your code. schema is a Zod schema — it becomes the JSON Schema the provider
is given, so .describe() on a field is prompt too. yieldSchema
belongs to tools that stop and ask a human, at the end of this page.
app.tool throws at build time if a config has neither execute nor
yieldSchema: a tool that can do nothing is a mistake, not a runtime surprise.
Everything here is a function tool: besides these and MCP servers, an agent's
tools may hold a provider tool such as
{ type: 'web_search' }, which runs inside the model provider and has no
execute of yours.
Step 2
The schema is the border, not a suggestion
Arguments arrive as JSON a language model wrote, so they are wrong often enough to design for.
Each call is coerced toward the schema first — a stringy number becomes a number — and then
parsed by Zod. execute only ever sees arguments that parsed. When they do not
parse, the run does not throw: the call becomes a tool_result whose
error reads Invalid arguments: …, the model reads it on the next
round-trip, and it can correct itself.
Three calls below: one clean, one where quantity arrives as the string
'5', one missing quantity entirely. Only the third is an error, and
the agent survives it.
import { openai } from '@animahealth/adk/openai'
import { runTest, user, model, findEventsByType } from '@animahealth/adk/testing'
const shopkeeper = app.agent({
name: 'shopkeeper',
model: openai('gpt-5.6-luna'),
context: [app.context.system('Check stock before promising anything.'), app.context.history()],
tools: [checkStock],
})
const stockRun = await runTest(shopkeeper, [
user('Can I have 3 of ADK-1 and 5 of ADK-2?'),
model({ toolCalls: [{ name: 'check_stock', args: { sku: 'ADK-1', quantity: 3 } }] }),
model({ toolCalls: [{ name: 'check_stock', args: { sku: 'ADK-2', quantity: '5' } }] }),
model({ toolCalls: [{ name: 'check_stock', args: { sku: 'ADK-1' } }] }),
model('ADK-1 has 12 on hand; ADK-2 is out of stock.'),
])
findEventsByType(stockRun.events, 'tool_result').map((event) =>
event.error
? { name: event.name, error: `${event.error.slice(0, 44)}…` }
: { name: event.name, result: event.result },
)
Section 5 shows the same mechanism catching an exception your own code threw.
Step 3
What ctx carries
execute takes exactly one argument. ctx.args is the parsed input,
and the rest of the context is the run around it: toolName and
callId identify this call, invocationId and
runnable identify the turn and the agent, session is the ledger,
state reads and writes it, and signal is the run's
AbortSignal to pass to your own fetch. Rather than list them, ask
the context itself.
const inspect = app.tool({
name: 'inspect_context',
description: 'Report what a tool can see while it runs',
schema: z.object({ note: z.string() }),
execute: (ctx) => {
// State writes are events too: this lands on the session ledger, not on a local variable.
ctx.state.update({ lastNote: ctx.args.note })
return {
carries: Object.keys(ctx).sort(),
toolName: ctx.toolName,
agent: ctx.runnable.name,
session: ctx.session.id,
aborted: ctx.signal?.aborted ?? false,
lastNote: ctx.state.lastNote,
}
},
})
const inspector = app.agent({
name: 'inspector',
model: openai('gpt-5.6-luna'),
context: [app.context.history()],
tools: [inspect],
})
const inspectRun = await runTest(inspector, [
user('Write down that the shelf is dusty.'),
model({ toolCalls: [{ name: 'inspect_context', args: { note: 'shelf is dusty' } }] }),
model('Noted.'),
])
findEventsByType(inspectRun.events, 'tool_result')[0]?.result
Four of those keys — output, run, spawn, and
dispatch — change the shape of the run rather than just answering it, and they
belong to Many agents. (call in that list is the
former name of run, kept deprecated — use run.)
Step 4
prepare and finalize: the two optional halves
prepare runs after the arguments parse and before execute. Return a
value and it replaces the arguments execute will see; return nothing and
they pass through. It is where normalisation, defaulting, and authorization lookups belong, so
execute can be about the work.
finalize runs after execute returns, with the output on
ctx.result. Return a value and it replaces the result the model is
shown; return nothing and the output stands. It is where redaction, truncation, and logging
belong. It runs only on a plain return — a control signal such as ctx.output(),
or a thrown error, skips it.
type Booking = { room: string; minutes: number; reference: string }
const trace: string[] = []
const bookRoom = app.tool({
name: 'book_room',
description: 'Reserve a meeting room for a number of minutes',
schema: z.object({ room: z.string(), minutes: z.number() }),
prepare: (ctx) => {
trace.push(`prepare(${JSON.stringify(ctx.args)})`)
// Normalise once, here — execute never has to wonder about casing or absurd durations.
return { room: ctx.args.room.trim().toUpperCase(), minutes: Math.min(ctx.args.minutes, 60) }
},
execute: (ctx): Booking => {
trace.push(`execute(${JSON.stringify(ctx.args)})`)
return { room: ctx.args.room, minutes: ctx.args.minutes, reference: 'REF-8891-KLM' }
},
finalize: (ctx): Booking => {
trace.push(`finalize(${String(ctx.result?.reference)})`)
// The model gets a booking it can talk about, not the reference it could leak.
return { room: ctx.result!.room, minutes: ctx.result!.minutes, reference: 'REF-•••' }
},
})
const receptionist = app.agent({
name: 'receptionist',
model: openai('gpt-5.6-luna'),
context: [app.context.history()],
tools: [bookRoom],
})
const bookingRun = await runTest(receptionist, [
user('Book me orion for four hours.'),
model({ toolCalls: [{ name: 'book_room', args: { room: ' orion ', minutes: 240 } }] }),
model('Booked ORION for 60 minutes.'),
])
const lifecycle = {
trace,
modelSaw: findEventsByType(bookingRun.events, 'tool_result')[0]?.result,
}
lifecycle
Read the trace top to bottom: the model asked for ' orion ' and 240 minutes,
prepare handed execute 'ORION' and 60, and the model
was told a reference that is not the real one. Three functions, one call, and the only thing
the model ever learns is what finalize allowed.
Step 5
Timeouts, retries, and what the model is told
Two optional fields make a tool survivable, and one rule makes it honest.
-
retryis aRetryConfig:{ maxAttempts, initialDelayMs, maxDelayMs, backoffMultiplier }, plus an optionalretryableErrors(error)predicate that bails out of retrying the errors it rejects. It re-runsexecutewith randomized exponential backoff. The retries are internal — the session ledger gets onetool_resultfor the call, not one per attempt. -
timeoutis milliseconds, and it wraps the retrying execution as a whole — it is a budget for the call, not for one attempt. On expiry the result event carriestimedOut: trueand an error naming the tool and the budget. -
An exception out of
executebecomes that same result event, witherrorset to its message. It is not swallowed and it is not thrown at yourapp.run: it is handed to the model as the outcome of the call, which is the only place that can do something about it. Whatever your error message says, the model reads. Guardrails covers what a throw does in full.
One agent, three tools, three failures — a flaky call that recovers on its third attempt, a slow call that blows a 25ms budget, and a call that simply throws.
let upstreamCalls = 0
const fetchRate = app.tool({
name: 'fetch_rate',
description: 'Fetch a currency exchange rate from the upstream feed',
schema: z.object({ pair: z.string() }),
retry: { maxAttempts: 3, initialDelayMs: 1, maxDelayMs: 8, backoffMultiplier: 2 },
execute: async (ctx) => {
upstreamCalls++
if (upstreamCalls < 3) throw new Error(`upstream 503 (attempt ${upstreamCalls})`)
return { pair: ctx.args.pair, rate: 1.27, attempts: upstreamCalls }
},
})
const slowReport = app.tool({
name: 'slow_report',
description: 'Build the quarterly report',
schema: z.object({}),
timeout: 25,
execute: async (): Promise<{ report: string }> => {
await new Promise((resolve) => setTimeout(resolve, 400))
return { report: 'never arrives' }
},
})
const chargeCard = app.tool({
name: 'charge_card',
description: 'Charge the customer their balance',
schema: z.object({ amount: z.number() }),
execute: (ctx): { charged: number } => {
// Say something the model can act on: it is the audience for this string.
throw new Error(`Card declined for ${ctx.args.amount} — ask for another payment method`)
},
})
const teller = app.agent({
name: 'teller',
model: openai('gpt-5.6-luna'),
context: [app.context.history()],
tools: [fetchRate, slowReport, chargeCard],
})
const failureRun = await runTest(teller, [
user('Get me the GBPUSD rate, run the report, and charge 40.'),
model({ toolCalls: [{ name: 'fetch_rate', args: { pair: 'GBPUSD' } }] }),
model({ toolCalls: [{ name: 'slow_report', args: {} }] }),
model({ toolCalls: [{ name: 'charge_card', args: { amount: 40 } }] }),
model('Rate is 1.27. The report timed out and the card was declined.'),
])
findEventsByType(failureRun.events, 'tool_result').map((event) => ({
name: event.name,
result: event.result,
error: event.error,
timedOut: event.timedOut,
}))
fetch_rate reports attempts: 3 — proof that
retry re-ran execute, and that only the surviving attempt reached
the ledger. The other two failed, the run did not, and the agent still produced its answer.
That is the whole failure contract: a tool can fail, and the model finds out.
Step 6
A real model choosing between two tools
Everything above scripted the decision so the tools could be exercised without a key. The decision itself is the model's, and it is made from the names and descriptions alone. Paste a key in the box at the top, then run this: two tools, one question that only one of them answers. Change the question and watch the choice change.
const ZONES: Record<string, string> = { Tokyo: 'Asia/Tokyo', London: 'Europe/London' }
const localTime = app.tool({
name: 'local_time',
description: 'Get the current wall-clock time in a named city',
schema: z.object({ city: z.string().describe('City name, e.g. Tokyo') }),
execute: (ctx) => {
const zone = ZONES[ctx.args.city]
if (!zone) throw new Error(`No timezone on file for ${ctx.args.city} — try Tokyo or London`)
return { city: ctx.args.city, time: new Date().toLocaleTimeString('en-GB', { timeZone: zone }) }
},
})
const currentWeather = app.tool({
name: 'current_weather',
description: 'Get the current temperature and conditions in a named city',
schema: z.object({ city: z.string().describe('City name, e.g. Tokyo') }),
// A stub, so the cell needs no second API key: only the description above decides whether the
// model reaches for this tool or the one beside it.
execute: (ctx) => ({ city: ctx.args.city, celsius: 19, conditions: 'light rain' }),
})
const concierge = app.agent({
name: 'concierge',
model: openai('gpt-5.6-luna'),
context: [
app.context.system('Answer with the tools. Never guess a time or a temperature.'),
app.context.history(),
],
tools: [localTime, currentWeather],
})
const conciergeRun = await app.run(concierge, 'What time is it in Tokyo right now?')
const chosen = {
called: findEventsByType(conciergeRun.session.events, 'tool_call').map((event) => event.name),
answer: conciergeRun.output.text,
}
chosen
The only thing separating the two tools is a sentence each: the description is what decides whether your tool gets called at all.
Next
Tools that stop and ask
A tool with a yieldSchema and no execute does not compute an answer
— it suspends the run until a human supplies one, and the agent sleeps as a database row until
they do. That is Stopping to ask.