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.
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.