Agent Development Kit · Build

Yielding: stopping to ask, then sleeping as a row

Some tools cannot finish alone. A refund needs an approval; a booking needs the date the caller never gave. A yielding tool lets the agent stop mid-turn, hand the question out, and leave nothing running — the paused agent is a few rows in an event log, not a process holding a socket. Every cell below runs the shipped runtime with the model scripted, so none of them needs a key.

Audience · engineers building agents Needs · nothing (all cells are scripted) Package · @animahealth/adk (MIT)

Step 1

A tool that yields

A tool yields by declaring a yieldSchema: the shape of the answer it needs from outside. That is the only new field. app.tool requires an execute or a yieldSchema, so a yielding tool may have no execute at all — then the supplied input becomes the tool result.

Three optional hooks bracket the pause. prepare runs before it and can rewrite the arguments or record state. execute and finalize run after it, with ctx.input typed by the yieldSchema; otherwise they behave as Tools describes.

import { adk } from '@animahealth/adk'
import { openai } from '@animahealth/adk/openai'
import { z } from 'zod'

const app = adk()

const requestApproval = app.tool({
  name: 'request_approval',
  description: 'Ask a human to approve an action before performing it',
  schema: z.object({ action: z.string() }),
  // The answer this tool waits for. Its presence is what makes the tool yield.
  yieldSchema: z.object({ approved: z.boolean(), note: z.string().optional() }),
  prepare: (ctx) => {
    // Runs before the pause: park what a human (or another service) needs to see.
    ctx.state.pendingAction = ctx.args.action
    return ctx.args
  },
  execute: (ctx) => {
    // Runs after the pause, with the supplied answer already validated.
    const decision = ctx.input
    if (!decision?.approved) return { action: ctx.args.action, status: 'declined' }
    return { action: ctx.args.action, status: 'performed', note: decision.note }
  },
  finalize: (ctx) => {
    ctx.state.pendingAction = undefined
  },
})

const operator = app.agent({
  name: 'operator',
  model: openai('gpt-5.6-luna'),
  context: [
    app.context.system('Ask for approval before any destructive action.'),
    app.context.history(),
  ],
  tools: [requestApproval],
})

operator.name

Step 2

The run that stops

Script the model, supply no answer, and the run does not complete: it comes back yielded_tool, carrying the calls it is waiting on. Each is a tool_yield event with a callId — the handle you answer it by — plus the tool's name and the arguments prepare returned. The session says awaiting_input, and session.yieldedTools is the still-unanswered subset. State written in prepare is already durable.

import { runTest, user, model } from '@animahealth/adk/testing'

const paused = await runTest(operator, [
  user('Delete the 2019 archive'),
  model({
    toolCalls: [{ name: 'request_approval', args: { action: 'delete the 2019 archive' } }],
  }),
])

const pausedRun = paused.result

const waiting = {
  runStatus: pausedRun.status,
  sessionStatus: paused.session.status,
  askedFor:
    pausedRun.status === 'yielded_tool'
      ? pausedRun.yieldedTools.map((y) => ({ name: y.name, callId: y.callId, args: y.args }))
      : [],
  stateWhileAsleep: paused.session.state.pendingAction,
}

waiting

Before resuming anything, you can ask the ledger whether it is answerable. validateResumeState reads the events and returns one entry per yield that has no input yet; assertReadyToResume is the same check that throws. Both take events, not a live object, so a resume can be vetted anywhere the rows can be read.

import { validateResumeState, assertReadyToResume } from '@animahealth/adk'

let guard
try {
  assertReadyToResume(paused.events)
  guard = 'ready to resume'
} catch (error) {
  guard = error instanceof Error ? error.message : String(error)
}

const readiness = { unresolved: validateResumeState(paused.events), guard }

readiness

Run it again without answering and it will not resume. The runtime builds a resume context only when every pending yield has its input; with one missing it treats the call as a fresh start — a second invocation_start, a new invocation, and the pending yield still pending. Nothing throws. That is why the guard above exists: check the events, then resume.

Step 3

What a sleeping agent actually is

This is the whole of the paused agent. No process, no timer, no held connection — an append-only list of events that stops at invocation_yield. Note what is missing: there is no invocation_end. An unterminated invocation whose last event is a yield is the definition of a sleeping agent, and it costs exactly what those rows cost to store.

paused.events.map((event) => event.type)

Those rows outlive the process only if the app has a store, which is Stores, and what else the session around them carries is Sessions and state.

Step 4

Supplying the input

Answering is one call: session.input.tool({ callId, input }). It appends a tool_input event, which makes the yield resolved; the next run picks it up. The test kit has a step for exactly this — input(...), keyed by tool name, placed after the model turn that yielded. So the whole pause-and-resume fits in one script.

import { input, getToolResults } from '@animahealth/adk/testing'

const resumed = await runTest(operator, [
  user('Delete the 2019 archive'),
  model({
    toolCalls: [{ name: 'request_approval', args: { action: 'delete the 2019 archive' } }],
  }),
  // The answer a human would give — supplied here as session.input.tool would supply it.
  input({ request_approval: { approved: true, note: 'Owner signed off' } }),
  model('Archive deleted.'),
])

const settled = {
  status: resumed.status,
  unresolved: validateResumeState(resumed.events).length,
  toolResults: getToolResults(resumed.events),
  pendingStateClearedByFinalize: resumed.session.state.pendingAction === undefined,
  ledger: resumed.events.map((event) => event.type),
}

settled

The input is parsed against the yieldSchema before execute sees it. A mismatched answer does not throw and does not hang: it becomes a tool_result carrying an Invalid input error, which the model reads on the next turn like any other tool failure.

Read the tail of that ledger: tool_yield and invocation_yield close the first run, tool_input is the answer arriving, invocation_resume reopens the same invocation, and only then does tool_result appear. The agent was never running in between.

Step 5

Yielding for a message, not a tool

The other stop is conversational. An agent configured yields: true parks after each terminal reply instead of completing, waiting for the next user message — the status is yielded_message and the result carries a yieldedInvocationId rather than tool calls. Realtime models default to this. Answering is session.input.message(...); in the test kit it is another user(...) step.

const intake = app.agent({
  name: 'intake',
  model: openai('gpt-5.6-luna'),
  context: [app.context.system('Ask one question at a time.'), app.context.history()],
  tools: [],
  yields: true,
})

const asked = await runTest(intake, [user('I want to book a room'), model('Which date?')])

const answered = await runTest(intake, [
  user('I want to book a room'),
  model('Which date?'),
  user('Next Tuesday'),
  model('Booked for next Tuesday.'),
])

const conversation = {
  afterOneTurn: asked.status,
  afterTwoTurns: answered.status,
  said: answered.events.flatMap((event) => (event.type === 'assistant' ? [event.text] : [])),
  ledger: answered.events.map((event) => event.type),
}

conversation

Both runs end yielded_message: an agent that yields never completes on its own, it parks. The second ledger shows the shape of a conversation under this rule — invocation_yield, then user, then invocation_resume into the same invocation. A chat that has been idle for a month and one that replied a second ago are the same rows.

Run status The run is waiting for The result carries You answer with
yielded_tool an input for one or more tool calls yieldedTools (each with callId) session.input.tool({ callId, input })
yielded_message the next message from the user yieldedInvocationId session.input.message(...)

Step 6

The same protocol in a server

Nothing above changes when the pause spans a real request boundary: because the wait is stored rather than held, the process that asks the question and the process that receives the answer need not be the same one, or alive at the same time. The Bookings sample puts that loop behind a running app, and the request that resumes a row — the input.tools you post — belongs to Serving.