Agent Development Kit · Build
The Bookings sample — the agent that stops and asks
A slot-booking assistant in three files. It offers an appointment, calls a yielding tool, and the process exits — the session is now a row in a SQLite file. A later command supplies the approval and the run continues from exactly where it stopped. This chapter walks that code: the demo first, then the three sources in the order they are worth reading, then the test suite that proves the pause with no credentials at all.
Step 1
The demo, end to end
Four commands — ask, pending, approve,
deny. The first one starts a run and deliberately does not finish it.
book_slot.
yielded_tool.
bookings.db.
execute runs, once, and books it.
Start it:
$ npx tsx src/cli.ts ask "I need physio on Tuesday afternoon, for Alex Doe"
session session_8c1f…
agent Tuesday 14:30 with R. Ellis is open — shall I book it?
paused book_slot {"slotId":"slot-02","bookedFor":"Alex Doe"}
npx tsx src/cli.ts approve session_8c1f…
npx tsx src/cli.ts deny session_8c1f… "why not"
The paused line is the yield. The model asked for book_slot; the
runtime recorded the request and stopped the run rather than executing it. Then the process
exited. No held connection — the session is rows on disk, and the pause is one of them.
Come back tomorrow, from another terminal. pending asks the store what is still
unanswered; approve answers it.
$ npx tsx src/cli.ts pending
session_8c1f… book_slot {"slotId":"slot-02","bookedFor":"Alex Doe"}
$ npx tsx src/cli.ts approve session_8c1f…
agent Booked — Tuesday 14:30 with R. Ellis.
booked CLINIC-001 — physiotherapy with R. Ellis, Tuesday 14:30, for Alex Doe
approve read the session out of SQLite, handed { approved: true } to
the suspended call, and the tool's execute ran for the first time — in a process
that did not exist when the model decided to call it. The booked line is typed
state: session.state.confirmation, declared in the app's schema and read back
intact after a round trip through the file.
Declining takes the same path, and the reason reaches the model:
$ npx tsx src/cli.ts deny session_8c1f… "Alex cannot do afternoons"
agent Understood — 09:15 on Monday is the other physio slot. Shall I take it?
The agent's lines are the model's; the printed lines are not. A live run
will not match these transcripts word for word. The session,
paused and booked lines are printed by src/cli.ts,
and those are exact.
Step 2
The world it books against — src/clinic.ts
The clinic is a constant and a Map. That is deliberate: the sample needs no
database, no container and no network, and swapping these functions for real queries changes
nothing above them. The agent only ever sees tools.
/** The week, as `[id, service, clinician, day, time]`. Weekday names never go stale. */
const TIMETABLE = [
['slot-01', 'physiotherapy', 'R. Ellis', 'Monday', '09:15'],
['slot-02', 'physiotherapy', 'R. Ellis', 'Tuesday', '14:30'],
['slot-03', 'physiotherapy', 'J. Okafor', 'Tuesday', '16:00'],
['slot-04', 'dental-hygiene', 'M. Haas', 'Tuesday', '11:00'],
['slot-05', 'dental-hygiene', 'M. Haas', 'Thursday', '15:45'],
['slot-06', 'eye-test', 'S. Vance', 'Wednesday', '10:30'],
['slot-07', 'eye-test', 'S. Vance', 'Friday', '13:00'],
] as const satisfies ReadonlyArray<readonly [string, Service, string, string, string]>
const SLOTS: readonly Slot[] = TIMETABLE.map(([id, service, clinician, day, time]) => ({
id,
service,
clinician,
day,
time,
}))
Three readers come off that list. openSlots(filter) returns the unbooked slots
matching an optional service and weekday; findSlot and isBooked are
the two lookups the booking tool needs. One function writes — bookSlot — and it
is the side effect a human approves.
Step 3
The whole program — src/bookings.ts
One app, one state schema, two tools, one agent. Everything the ADK contributes to this sample is in this file. Start here; the rest is scaffolding around it.
import { fileURLToPath } from 'node:url'
import { z } from 'zod'
import { adk } from '@animahealth/adk'
import { openai } from '@animahealth/adk/openai'
import { sqliteStore } from '@animahealth/adk/stores/sqlite'
import { SERVICES, bookSlot, findSlot, isBooked, openSlots } from './clinic.js'
Three imports off the package: the core, the OpenAI model factory, the SQLite store. Providers and stores are subpath exports and optional peers — importing the core pulls in neither.
/**
* What a completed booking looks like. Declaring it in the app schema is what makes
* `session.state.confirmation` typed everywhere downstream, including after it is read back out of
* SQLite in a different process.
*/
const confirmation = z.object({
reference: z.string(),
slotId: z.string(),
service: z.string(),
clinician: z.string(),
day: z.string(),
time: z.string(),
bookedFor: z.string(),
})
export type Confirmation = z.infer<typeof confirmation>
export const app = adk({
name: 'bookings',
schema: { session: { confirmation: confirmation.optional() } },
store: sqliteStore(DB_PATH),
})
schema.session is what makes state typed downstream. confirmation is
optional because a session has none until a booking is approved. store decides
where the session sleeps: one file, created on first use, safe to delete.
The deterministic tool. The model picks the arguments; this code decides the answer.
/** Deterministic. The model chooses the arguments; this code decides the answer. */
const searchSlots = app.tool({
name: 'search_slots',
description: 'List the open appointment slots at the clinic, optionally filtered.',
schema: z.object({
service: z.enum(SERVICES).optional().describe('Only slots for this service'),
day: z.string().optional().describe('Only slots on this weekday, e.g. "Tuesday"'),
}),
execute: (ctx) => ({ slots: openSlots(ctx.args) }),
})
And the yielding one — the only structural difference is yieldSchema.
/**
* The yielding tool. `yieldSchema` is the contract for the human's answer; because it is present,
* ADK suspends the run at the call instead of running `execute`. `execute` runs later, once, with
* that answer in `ctx.input`.
*/
const bookSlotTool = app.tool({
name: 'book_slot',
description:
'Book one open slot. A human reviews every call to this tool before it takes effect, so propose a single slot and call it once.',
schema: z.object({
slotId: z.string().describe('The id of an open slot, from search_slots'),
bookedFor: z.string().describe('The name the appointment is under'),
}),
yieldSchema: z.object({
approved: z.boolean().describe('true to book the slot, false to decline it'),
note: z.string().optional().describe('Why it was declined — the agent reads this'),
}),
execute: (ctx) => {
const slot = findSlot(ctx.args.slotId)
if (!slot) {
return { booked: false as const, reason: `There is no slot called ${ctx.args.slotId}.` }
}
if (isBooked(slot.id)) {
return { booked: false as const, reason: `${slot.id} has already been booked.` }
}
if (!ctx.input?.approved) {
return {
booked: false as const,
reason: ctx.input?.note ?? 'A human declined this booking. Offer a different slot.',
}
}
const booking = bookSlot(slot, ctx.args.bookedFor)
// Typed, and durable: this write becomes a state_change event, committed to SQLite with the
// rest of the session, and readable as `session.state.confirmation` forever after.
ctx.state.confirmation = booking
return { booked: true as const, ...booking }
},
})
ctx.args is what the model asked for; ctx.input is what the human
answered, already validated against yieldSchema.
The agent is the rest of the file.
export const bookingAgent = app.agent({
name: 'bookings',
model: openai('gpt-5.6-luna'),
context: [
app.context.system(
[
'You book appointments for a small clinic.',
'Call search_slots before you offer anything. Never invent a slot or a time.',
'Offer one slot at a time, then call book_slot for it. That call pauses for a human.',
'If a booking is declined, read the note and offer the next best open slot.',
'Answer in one or two sentences.',
].join('\n'),
),
app.context.history(),
],
tools: [searchSlots, bookSlotTool],
})
The system prompt does the work a schema cannot: it tells the model to search before it
offers, and to propose one slot at a time — because every book_slot call spends a
human's attention. The mechanism itself, in isolation, is
Stopping to ask: prepare, finalize, the
resume guard, and yielding for a message rather than a tool.
Step 4
Four commands, two processes — src/cli.ts
Nothing here is special machinery. ask is the whole lifecycle: create a session,
put a message in, run, commit, print.
async function ask(text: string): Promise<void> {
requireKey()
const session = await app.sessions.create()
session.input.message(text)
const result = await app.run(bookingAgent, { session })
await app.sessions.commit(session)
console.log(`session ${session.id}`)
report(session, result.output.text)
}
app.run returns when the run stops — completed or yielded, it is the same call
either way, and the status says which. The commit is what makes a pause durable:
events buffer in memory until it is called.
Printing is where the pause becomes visible.
/** Print what the agent said, and what it is now waiting for. */
function report(session: Session<typeof app.schema>, text: string | undefined): void {
if (text) console.log(`\nagent ${text}`)
const confirmation = session.state.confirmation
if (confirmation) {
console.log(
`booked ${confirmation.reference} — ${confirmation.service} with ${confirmation.clinician}, ` +
`${confirmation.day} ${confirmation.time}, for ${confirmation.bookedFor}`,
)
}
const [waiting] = session.yieldedTools
if (waiting) {
console.log(`\npaused ${waiting.name} ${JSON.stringify(waiting.args)}`)
console.log(` npx tsx src/cli.ts approve ${session.id}`)
console.log(` npx tsx src/cli.ts deny ${session.id} "why not"`)
}
}
session.yieldedTools is the unanswered set — empty after a completed run, one
entry after this one. session.state.confirmation is typed by the app schema, so
the fields interpolated into the booked line are checked at compile time, not
hoped for at runtime.
The queue of things waiting for a human is therefore a query, not a broker:
async function pending(): Promise<void> {
const sessions = await app.sessions.list()
let found = 0
for (const { id } of sessions) {
const session = await app.sessions.get(id)
const [waiting] = session?.yieldedTools ?? []
if (!session || !waiting) continue
found++
console.log(`${session.id} ${waiting.name} ${JSON.stringify(waiting.args)}`)
}
if (found === 0) console.log(`nothing is waiting for a human (${DB_PATH})`)
}
And the resume is one line of protocol wrapped in the same run-and-commit:
async function decide(sessionId: string, approved: boolean, note?: string): Promise<void> {
requireKey()
const session = await app.sessions.get(sessionId)
if (!session) {
throw new Error(`No session ${sessionId}. Try: npx tsx src/cli.ts pending`)
}
const [waiting] = session.yieldedTools
if (!waiting) {
throw new Error(`Session ${sessionId} is not waiting on anything.`)
}
// The resume. `callId` ties the answer to the exact suspended call; `input` is validated
// against the tool's yieldSchema before execute() ever sees it.
session.input.tool({ callId: waiting.callId, input: { approved, note } })
const result = await app.run(bookingAgent, { session })
await app.sessions.commit(session)
report(session, result.output.text)
}
callId ties the answer to the exact suspended call. After that it is
ask again — run, commit, print — except this run starts inside a tool call that a
previous process left open, and it is approve that pays for the booking's side
effect.
Step 5
The same arc, with no key — test/bookings.test.ts
runTest replaces exactly one thing: the model. The tools still run, the ledger
still accrues, the yield still happens. So the assertion worth making is available on any
fork, with no credentials.
import { beforeEach, describe, expect, test } from 'vitest'
import type { Runnable } from '@animahealth/adk'
import { getToolCalls, getToolResults, input, model, runTest, user } from '@animahealth/adk/testing'
import { bookingAgent } from '../src/bookings.js'
import { isBooked, openSlots, resetClinic } from '../src/clinic.js'
/**
* `runTest` is typed against the schema-erased `Runnable`, so an agent built on an app that
* declares a state schema needs this cast. Types only — at runtime it is the same object
* `app.run()` takes.
*/
const agent = bookingAgent as unknown as Runnable
beforeEach(resetClinic)
First the pause. Script the model into calling book_slot, then stop.
describe('book_slot yields', () => {
test('the run stops at the call and nothing is booked', async () => {
const run = await runTest(agent, [
user('Book slot-02 for Alex Doe.'),
model({
toolCalls: [{ name: 'book_slot', args: { slotId: 'slot-02', bookedFor: 'Alex Doe' } }],
}),
])
expect(run.status).toBe('yielded_tool')
expect(run.session.yieldedTools).toHaveLength(1)
expect(run.session.yieldedTools[0]?.name).toBe('book_slot')
// The pause is real: execute() has not run, so the world is untouched.
expect(isBooked('slot-02')).toBe(false)
expect(run.session.state.confirmation).toBeUndefined()
})
})
Status yielded_tool, one entry waiting, the slot still open and no confirmation
in state. That is the pause asserted rather than asserted-about.
Then the two resume cases. input(...) stands in for what a human sends hours
later from another process — in a script it is one line in the right place.
test('approval executes the booking and returns a typed confirmation', async () => {
const run = await runTest(agent, [
user('Book slot-02 for Alex Doe.'),
model({
toolCalls: [{ name: 'book_slot', args: { slotId: 'slot-02', bookedFor: 'Alex Doe' } }],
}),
// The human's answer, shaped by the tool's yieldSchema. In the CLI this arrives from a
// separate process, minutes or days later.
input({ book_slot: { approved: true } }),
model('Booked — Tuesday 14:30 with R. Ellis.'),
])
expect(run.status).toBe('completed')
expect(isBooked('slot-02')).toBe(true)
expect(run.session.state.confirmation).toEqual({
reference: 'CLINIC-001',
slotId: 'slot-02',
service: 'physiotherapy',
clinician: 'R. Ellis',
day: 'Tuesday',
time: '14:30',
bookedFor: 'Alex Doe',
})
})
And the demo's other branch, where the reason travels back to the model:
test('a refusal leaves the slot open and hands the reason back to the agent', async () => {
const run = await runTest(agent, [
user('Book slot-02 for Alex Doe.'),
model({
toolCalls: [{ name: 'book_slot', args: { slotId: 'slot-02', bookedFor: 'Alex Doe' } }],
}),
input({ book_slot: { approved: false, note: 'Alex cannot do afternoons.' } }),
model('Understood — 09:15 on Monday is the other physio slot. Shall I take it?'),
])
expect(run.status).toBe('completed')
expect(isBooked('slot-02')).toBe(false)
expect(run.session.state.confirmation).toBeUndefined()
expect(getToolResults(run.events)[0]?.result).toEqual({
booked: false,
reason: 'Alex cannot do afternoons.',
})
})
The whole file runs under:
npm test
The same script, running here
The cells below are that test, adapted only as far as this page requires: the substrate serves
the ADK, its test kit and zod, so the clinic module is inlined and the SQLite
store is dropped. The schema, both tools, the prompt and the agent are the sample's own.
Persistence is the one thing a cell cannot demonstrate here, so src/cli.ts above
stands as the evidence for it.
import { adk } from '@animahealth/adk'
import type { Runnable } from '@animahealth/adk'
import { openai } from '@animahealth/adk/openai'
import { z } from 'zod'
// src/clinic.ts and src/bookings.ts, joined and trimmed to what this page can serve: the clinic
// module is inlined, the SQLite store is dropped, two of the seven slots are kept. The schema,
// both tools and the agent are the sample's.
const confirmation = z.object({
reference: z.string(),
slotId: z.string(),
service: z.string(),
clinician: z.string(),
day: z.string(),
time: z.string(),
bookedFor: z.string(),
})
const SLOTS = [
{ id: 'slot-01', service: 'physiotherapy', clinician: 'R. Ellis', day: 'Monday', time: '09:15' },
{ id: 'slot-02', service: 'physiotherapy', clinician: 'R. Ellis', day: 'Tuesday', time: '14:30' },
]
const ledger = new Map<string, z.infer<typeof confirmation>>()
let nextReference = 1
const openSlots = (filter: { service?: string; day?: string }) =>
SLOTS.filter(
(slot) =>
!ledger.has(slot.id) &&
(filter.service === undefined || slot.service === filter.service) &&
(filter.day === undefined || slot.day.toLowerCase() === filter.day.toLowerCase()),
)
const findSlot = (slotId: string) => SLOTS.find((slot) => slot.id === slotId)
const isBooked = (slotId: string) => ledger.has(slotId)
const resetClinic = () => {
ledger.clear()
nextReference = 1
}
const bookings = adk({
name: 'bookings',
schema: { session: { confirmation: confirmation.optional() } },
})
const searchSlots = bookings.tool({
name: 'search_slots',
description: 'List the open appointment slots at the clinic, optionally filtered.',
schema: z.object({
service: z.string().optional().describe('Only slots for this service'),
day: z.string().optional().describe('Only slots on this weekday, e.g. "Tuesday"'),
}),
execute: (ctx) => ({ slots: openSlots(ctx.args) }),
})
const bookSlotTool = bookings.tool({
name: 'book_slot',
description:
'Book one open slot. A human reviews every call to this tool before it takes effect, so propose a single slot and call it once.',
schema: z.object({
slotId: z.string().describe('The id of an open slot, from search_slots'),
bookedFor: z.string().describe('The name the appointment is under'),
}),
yieldSchema: z.object({
approved: z.boolean().describe('true to book the slot, false to decline it'),
note: z.string().optional().describe('Why it was declined — the agent reads this'),
}),
execute: (ctx) => {
const slot = findSlot(ctx.args.slotId)
if (!slot) {
return { booked: false as const, reason: `There is no slot called ${ctx.args.slotId}.` }
}
if (isBooked(slot.id)) {
return { booked: false as const, reason: `${slot.id} has already been booked.` }
}
if (!ctx.input?.approved) {
return {
booked: false as const,
reason: ctx.input?.note ?? 'A human declined this booking. Offer a different slot.',
}
}
const booking = {
reference: `CLINIC-${String(nextReference++).padStart(3, '0')}`,
slotId: slot.id,
service: slot.service,
clinician: slot.clinician,
day: slot.day,
time: slot.time,
bookedFor: ctx.args.bookedFor,
}
ledger.set(slot.id, booking)
ctx.state.confirmation = booking
return { booked: true as const, ...booking }
},
})
const bookingAgent = bookings.agent({
name: 'bookings',
model: openai('gpt-5.6-luna'),
context: [
bookings.context.system(
[
'You book appointments for a small clinic.',
'Call search_slots before you offer anything. Never invent a slot or a time.',
'Offer one slot at a time, then call book_slot for it. That call pauses for a human.',
'If a booking is declined, read the note and offer the next best open slot.',
'Answer in one or two sentences.',
].join('\n'),
),
bookings.context.history(),
],
tools: [searchSlots, bookSlotTool],
})
// The sample's test file makes this same cast: runTest takes the schema-erased Runnable.
const agent = bookingAgent as unknown as Runnable
bookingAgent.name
Now the first run, with nothing supplied. It should stop at the call and leave the world untouched.
import { model, runTest, user } from '@animahealth/adk/testing'
resetClinic()
const paused = await runTest(agent, [
user('Book slot-02 for Alex Doe.'),
model({ toolCalls: [{ name: 'book_slot', args: { slotId: 'slot-02', bookedFor: 'Alex Doe' } }] }),
])
const stopped = {
status: paused.status,
waitingOn: paused.session.yieldedTools.map((y) => ({ name: y.name, args: y.args })),
slotStillOpen: !isBooked('slot-02'),
confirmation: paused.session.state.confirmation,
}
stopped
Add the human's answer and the same script completes. The printed ledger carries the whole pause: the yield, the answer arriving, the invocation reopening, and only then the tool result. Between those events, in the sample, is a process boundary.
import { input } from '@animahealth/adk/testing'
resetClinic()
const resumed = await runTest(agent, [
user('Book slot-02 for Alex Doe.'),
model({ toolCalls: [{ name: 'book_slot', args: { slotId: 'slot-02', bookedFor: 'Alex Doe' } }] }),
// What a human sends, hours later, from anywhere. In the sample it arrives from another process.
input({ book_slot: { approved: true } }),
model('Booked — Tuesday 14:30 with R. Ellis.'),
])
const settled = {
status: resumed.status,
confirmation: resumed.session.state.confirmation,
ledger: resumed.events.map((event) => event.type),
}
settled
Edit the script and run again — decline it, change the slot id, book a slot that does not exist. The tool's real branches answer, because the tool is real. The kit itself is Testing agents without a model.
Step 6
Clone and run it
Four steps. The first is the one a fresh clone cannot skip: the sample depends on the ADK by
path — "@animahealth/adk": "file:.." — so the package in the directory above
sample/ has to be built before the sample can resolve it.
pnpm install && pnpm run build
The other three happen inside sample/:
cd sample
npm install
export OPENAI_API_KEY=... # only the live run needs this; the tests do not
npx tsx src/cli.ts ask "I need physio on Tuesday afternoon, for Alex Doe"
The key is only for the three commands that call a model: ask,
approve, deny. pending reads the store and needs
nothing, and neither does npm test. The only thing written to disk is
bookings.db; delete it to start over.
SQLite here; Postgres and DynamoDB implement the same interface, compared in Where a sleeping agent lives. And Serving it puts this same resume behind an HTTP handler instead of a CLI.