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.

Builds on · the quickstart's one-tool agent Most cells · no key needed Surface · 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.

  • retry is a RetryConfig: { maxAttempts, initialDelayMs, maxDelayMs, backoffMultiplier }, plus an optional retryableErrors(error) predicate that bails out of retrying the errors it rejects. It re-runs execute with randomized exponential backoff. The retries are internal — the session ledger gets one tool_result for the call, not one per attempt.
  • timeout is 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 carries timedOut: true and an error naming the tool and the budget.
  • An exception out of execute becomes that same result event, with error set to its message. It is not swallowed and it is not thrown at your app.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.