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.

Audience · engineers building agents Needs · nothing (the cells are scripted) Reads · src/clinic.ts, src/bookings.ts, src/cli.ts

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.

ask The model searches slots, offers one, and calls book_slot.
yield The call suspends instead of executing. The run returns yielded_tool.
exit The process ends. What is left is a row in bookings.db.
approve A new process answers the call. 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.