Collect missing inputs
Pause for typed values, retain the workflow position, and resume the same request.
requestInput() gives missing information an explicit awaiting_input state.
It belongs in a dedicated collection tool; the actual query runs after the
inputs have arrived. It is data collection, separate from checkIn and
middleware ask, which collect approval decisions.
InputRequestDeclaration is the collection tool's declaration; each InputField
names a supported InputValue type and optional choices. AwaitingInput is its
runtime projection with accepted values, missing fields and workflow origin.
isInputPause narrows a run result to the typed input arm. An InputResponse
supplies values, while InputCancellation explicitly ends a hosted request.
A complete response becomes an InputResponseResult in the original tool's
result slot. InputRequestError rejects malformed declarations or replies with
ERR_INPUT_REQUEST_INVALID; it does not consume the paused checkpoint.
import { Agent, defineTool, requestInput, isInputPause } from 'agentfootprint';
import { mock } from 'agentfootprint/providers';
const collect = defineTool({
name: 'collect_window',
description: 'Collect the timezone needed to interpret the query window.',
inputSchema: { type: 'object', properties: {} },
execute: () => requestInput({
id: 'query-window',
question: 'Which timezone should this window use?',
fields: [
{ id: 'year', type: 'number', required: false },
{ id: 'timezone', type: 'string', description: 'An IANA timezone or UTC.' },
],
supplied: { year: 2026 },
context: { operation: 'query_records', yearPolicy: 'application-default' },
}),
});
const agent = Agent.create({ provider: mock({ replies: [
{ toolCalls: [{ id: 'input-1', name: 'collect_window', args: {} }] },
{ content: 'The inputs are ready for the query.' },
] }), model: 'mock' }).tool(collect).build();
const asked = await agent.run({ message: 'Inspect the requested window.' });
if (isInputPause(asked)) {
await agent.resume(asked.checkpoint, {
requestId: asked.awaitingInput.requestId,
values: { timezone: 'UTC' },
});
}The example year is an author-supplied value, not a library date default.
The application owns date, timezone, site and other domain policies.
context holds bounded JSON metadata from the collection tool. The answer
cannot replace it. Domain validation, such as whether a timezone exists,
belongs to that application's adapter; the library validates field types,
declared choices, unknown fields and the matching request token.
When a lookup does raise about its own miss (the dedicated collection tool
stays the documented shape), it still declares what it looked at: pass the
envelope absent() returns as absence. It is filed when the request is
raised, before the checkpoint — the same tools.absent event and
coverageDeclared row that absent(…), returned, would file. It never enters
the pending value and nothing reads it on resume, so say the miss in
question if the person should hear it now. A value the absence recognizer
cannot read is refused when the request is raised (absence: null is the
field omitted); an envelope whose lists cannot be read errors the call instead
of pausing, as it would returned. The paused call is answered by the person,
so it is never delivered an 'absent' status.
Fields accept string, finite number, or boolean, optional description
and optional enum. Fields are required unless required: false. There are
at most 32 fields; strings and descriptions are bounded to 4096 characters,
choice lists to 100 values, and context to 16384 JSON characters.
At least one required field must be missing to request input; when all are
already known, the collecting tool should continue without raising a pause.
The pending value contains missing, supplied, and origins. Origins are
declaration for values the collecting tool supplied and response for values
accepted from a reply. Runtime-stamped origin identifies the original
request, tool call, selected skill and any outstanding skill candidates.
This is workflow context, not evidence that a collector observed those values.
Partial answers return another input pause without a model call, query or step advance. Persist the returned checkpoint: it contains the newly accepted values. A complete answer becomes the collecting tool's result:
{
status: 'input_received',
requestId: 'runtime-generated-token',
values: { year: 2026, timezone: 'UTC' },
origins: { year: 'declaration', timezone: 'response' },
context: { operation: 'query_records', yearPolicy: 'application-default' },
origin: { originalRequest: 'Inspect the requested window.', toolCallId: 'input-1' }
}The existing result middleware, placement, redaction and size limits still
process that result. The collection tool is not executed again. A complete
FlowchartCheckpoint retains the selected skill, history and step position
across a process restart; the resuming agent must mount the same chart.
Invalid or stale replies leave the checkpoint available for correction.
describedResult({ clarify: ... }) remains an informational result and does not
automatically pause a run.
When your app refuses an answer — refused and repeat
Field types are the library's check; whether a year is in range or a window
ends after it starts is your app's. When your app refuses an answer and the
collecting tool asks again, tell the person why: pass the refused values and
your reason, in your own words, as refused. The library never writes a
reason for you. Its type is InputRefusal; the runtime's re-ask mark below is
InputRepeat.
execute: () => requestInput({
id: 'query-window',
question: 'Complete the query time window: year and timezone.',
fields: [
{ id: 'year', type: 'number' },
{ id: 'timezone', type: 'string' },
],
// Set by your validation when it turned the last answer down.
...(lastRefusal && { refused: lastRefusal }),
// e.g. { answer: { year: 2019, timezone: 'UTC' },
// reason: 'The year 2019 is before the retained data (2020 onwards).' }
}),refused rides awaitingInput.refused wherever the question already travels
— the pause outcome, the checkpoint and the pause.request event — so a UI can
render "Your answer was not accepted: …". Its answer is judged against
fields like any answer, its reason must be non-blank, and null is the
field omitted.
Even without a declaration a re-ask is never silent. When the ask a resume
answered is raised again with the same id in the same turn, the pending value
carries repeat:
{ count: 1, previousAnswer: { year: 2019, timezone: 'UTC' } }count is how many times the person already answered this ask this turn and
survives checkpoints. previousAnswer is read from the record — the result
that landed after your result middleware and redaction ran — and holds only the
fields the person supplied; when your middleware replaced that result, only
count is carried. repeat states facts, never a reason: say "your previous
answer was not accepted" at most. A new turn starts from nothing, and a first
ask carries neither key.
When the library asks — a tool's ask rule
A collection tool is the shape for a question only your app can frame (a calendar date, a year and a timezone together). For a single ARGUMENT only the person can give — the period a log search covers, the site a report is for — declare it on the tool that takes it, and the library asks for you:
function searchLogsTool(ran: Record<string, unknown>[]) { return defineTool({ name: 'search_logs', description: 'Error lines for one service over a look-back period.', inputSchema: { type: 'object', required: ['service', 'window'], // the author's contract — never edited properties: { service: { type: 'string', description: 'Service name.' }, window: { type: 'string', enum: ['1h', '24h', '7d'], description: 'Look-back.' }, }, }, // The PERSON gives the period: a missing one is asked, once per batch. askOrAssume: { window: { ask: 'Which period should the error search cover?', choices: ['1h', '24h', '7d'] }, }, period: { argument: 'window', spelling: 'lookback' }, execute: async (args) => { ran.push({ ...args }); return { service: args.service, window: args.window, errors: 0 }; }, });}function buildAgent(ran: Record<string, unknown>[]) { return Agent.create({ provider: mock({ replies: [ // Two calls, neither with a period — the omission this example is about. { toolCalls: [ { id: 'call-1', name: 'search_logs', args: { service: 'checkout' } }, { id: 'call-2', name: 'search_logs', args: { service: 'payments' } }, ], }, { content: 'No errors on checkout or payments in the last 7 days.' }, ], }), model: 'small-model', }) .tool(searchLogsTool(ran)) .build();}When a batch of calls leaves window out, nothing in the batch runs. The run
pauses on ONE typed input for everything the batch needs — the same
isInputPause / awaitingInput / resume shape as above, and standingAgent
renders, answers and cancels it like any other:
const paused = await agent.run({ message: input });if (!isInputPause(paused)) throw new Error('expected the library to ask for the period');const ask = paused.awaitingInput;console.log('asked:', ask.question);console.log('field:', JSON.stringify(ask.fields));console.log('for: ', JSON.stringify(ask.context));const waiting = (await agent.assessment())!;console.log( 'standing while it waits:', waiting.standing, waiting.reasons.map((r) => r.reason),);// The person answers — the host stored the checkpoint; any process can resume it.const answer = await agent.resume(paused.checkpoint, { requestId: ask.requestId, values: { f1: '7d' },});- One field per argument, bound to every call that needs it. Field ids are
positional (
f1,f2, …). Each field'sdescriptionis the tool author's question and itsenumthe author's choices; the question itself is a fixed library sentence.awaitingInput.context.agentfootprint—{ ask: 'arguments', fields: [{ id, tool, argument, calls }] }— says what each field is for;origin.toolCallIdnames the batch's first asked call. - Nothing the model proposed rides the ask. No value it wrote, no choice.
- One period, one question. Two tools whose period arguments are spelled
differently (
24hand-24h) share one field when their choices are the same periods; each call runs with the answer in its own spelling. - The answer is re-checked against the tool's own schema for that argument
(an integer answered
2.5, a string that breaks itspattern). An answer that does not fit is asked again with a second fixed question — at most three times; then the calls that needed it are refused, and the model reads why. - The resume adds no model call. Each call runs on its ordinary path —
permission, middleware, validation — with the answer filled in, and its
result tells the model the value was the person's answer.
agent.findings()holds anargumentrowasked: 'missing'per value (from the moment of the ask), thensource: 'answered'(free: truefor a free-text field).agent.assessment()reads ask while the question is out. - One human question per resume — and the answer is kept. In the batch that
asked, a later call that would need a person — its check-in, a middleware
ask, a credential consent, the tool's ownrequestInput— is refused by name instead of pausing a second time. When that call carried the person's answers, they are kept for the rest of the turn and the model is told so: proposed again with the argument left out, the call runs with the kept answer (nobody is asked again) and pauses for its own step then. A tool with both anaskrule and a check-in costs the person two round trips. A kept answer is used once; a later call of the same tool in another batch is asked again. - A credential consent in the batch that asked (under the default
onAuthorizationRequired: 'pause'): the model reads the credential's own refusal, and if the turn ends without a later call obtaining the credential, the turn raisesCredentialConsentRequiredError— carrying the authorization URL to your app — instead of returning the model's answer. - The resume event keeps the answer out. The
agentfootprint.pause.resumeevent of this ask carries the reply's shape — its request id and field ids — with every value read as'REDACTED', because an answer may fill an argument the tool's own view hides; theansweredrows carry what each tool's view allows. - A value the model did send runs as sent and is recorded as the model's own — the library does not ask about it, unless declared sources are on (next section).
Your app can keep its own state on the ask (a routing step, a screen id):
Agent.create({ …, argumentAskContext: () => ({ routing: { step: 'metrics' } }) })
spreads the object into context beside the library's key, which it may not use.
Where each value came from — declared sources
With .inputsLayer({ argumentSources: true }), the model says, for each argument
of a ruled call, where the value came from — the person's words (copied as a
quote), a tool result (its id), the person's earlier answer, the app's own
text, or 'assumed' — and the library checks each claim before anything in
the batch runs. (.findings({ argumentSources: true }) arms the same checks on
an agent that also keeps the findings ledger.) A choice can carry words you
vouch for as meaning it ({ value: '7d', said: ['last week'] }), so "over the
last week" checks out as 7d without the library ever parsing a time phrase:
function searchLogsTool(ran: Record<string, unknown>[]) { return defineTool({ name: 'search_logs', description: 'Error lines for one service over a look-back period.', inputSchema: { type: 'object', required: ['service', 'window'], properties: { service: { type: 'string', description: 'Service name.' }, window: { type: 'string', enum: ['1h', '24h', '7d'], description: 'Look-back.' }, }, }, askOrAssume: { window: { ask: 'Which period should the error search cover?', // Words the author vouches for — matched as whole tokens, only inside a quote. choices: [{ value: '7d', said: ['last week', 'past week'] }, '1h', '24h'], }, }, execute: async (args) => { ran.push({ ...args }); return { service: args.service, window: args.window, errors: 0 }; }, });}function buildAgent(ran: Record<string, unknown>[]) { return Agent.create({ provider: mock({ replies: [ // Turn 1 — the model quotes the person's words for the period. { toolCalls: [ { id: 'call-1', name: 'search_logs', args: { service: 'checkout', window: '7d', _findings: { from: [{ argument: 'window', source: 'user', quote: 'over the last week' }], }, }, }, ], }, { content: 'No errors on checkout in the last 7 days.' }, // Turn 2 — the quote is the person's, but "24h" is not in it: a reading. { toolCalls: [ { id: 'call-2', name: 'search_logs', args: { service: 'payments', window: '24h', _findings: { from: [{ argument: 'window', source: 'user', quote: 'and on payments' }], }, }, }, ], }, { content: 'No errors on payments in the last hour.' }, // Turn 3 — the same period again, cited as the person's earlier answer. { toolCalls: [ { id: 'call-3', name: 'search_logs', args: { service: 'search', window: '1h', _findings: { from: [{ argument: 'window', source: 'turn' }] }, }, }, ], }, { content: 'No errors on search in the last hour.' }, ], }), model: 'small-model', }) .tool(searchLogsTool(ran)) .inputsLayer({ argumentSources: true }) .build();}const first = await agent.run({ message: input });const standing = (await agent.assessment())!;console.log('turn 1:', first, '—', standing.standing);const paused = await agent.followUp('And on payments?');if (!isInputPause(paused)) throw new Error('expected the reading to be asked about');console.log( 'asked, showing the person their words:', JSON.stringify(paused.awaitingInput.context),);const second = await agent.resume(paused.checkpoint, { requestId: paused.awaitingInput.requestId, values: { f1: '1h' },});// Turn 3 cites that answer (`source: 'turn'`) — it runs, and nobody is asked again.const third = await agent.followUp('And on search?');- A checked value runs. The quote must be in the person's own messages, and
the value in the quote or one of your phrases for it; a result id must name a
result the model was served, and the value must be in that result's own bytes
(a note the library added never counts); an earlier answer must be the
person's answer to the library's ask — the note on the result of the call that
ran with it tells the model it may cite that answer later. The row says what
was checked:
source: 'said'withmatched: 'phrase','result','answered','app'. - An unchecked value on an
askargument is asked — nothing declared, a failed claim, or a READING (the words are the person's, the value is not in them). The ask shows the person their own words (quoted, unless a tool may hide arguments — below), never the model's value; the answer replaces it, and the row keeps the model's value asproposed. - A failed claim is recorded as written, never repaired
(
failed: 'quote-not-found','not-in-result', …), and the answer's standing says the value is not traced. - Checked is not verified. Matching words cannot see a negation or another sentence, so a checked value keeps a reason from firing and supports nothing: the answer reads "consistent with the record", never "known".
- Another runner's message is not the person's.
Sequence,Loop,workflow()andgraph()mark a step's (or node's) message that is an earlier one's output, and a quote from it fails; code that composes runners by hand passesmessageFrom: 'composed'torun(). - A quote stays off the record while a tool may hide arguments. A quote is
the person's free text and may hold a value another tool keeps out of the
record (a password given in the same sentence). So on an agent that registers
such a tool (
flowchartAsTool({ redact }),runbookAsTool({ redact })) or wires a tool provider (.toolProvider(), or.selfExplain(), which serves its tools through one — a provider is asked for its tools each time the model is called, so any provider counts), every row'squotereads'REDACTED'and the ask shows noquoted. This is decided when the agent is built, so a quote written before the hiding tool is called, or first listed, is hidden too. The check itself still runs. - It costs tokens, and it is off by default. The
fromproperty rides only on tools that declareaskOrAssume— every other tool and the system prompt are served as they would be without it..findings({ argumentSources: true })needs the inputs layer and is refused at build without it;.inputsLayer(...)is the inputs layer. _findingsis the library's name on a ruled tool. With the arm on, a tool that declaresaskOrAssumeand also takes an argument of its own named_findingsis refused when the agent is built — rename the argument. A tool a provider serves with its own_findingsruns as written: nothing it is served asks the model where its values came from.
Hosted chat
standingAgent stores the paused run under its session and returns its usual
awaiting response, with awaiting.awaitingInput for this arm. The checkpoint
stays on the server. The existing generic decision transport also carries
data replies:
{ "input": "", "sessionId": "current-session", "decision": { "requestId": "runtime-generated-token", "values": { "timezone": "UTC" } } }Reload the current question without a model call:
SESSION_PENDING_OP, exported by agentfootprint/hosting, names this read-only
operation in the shared transport grammar.
{ "op": "session-pending", "sessionId": "current-session" }The response is { pending: PendingAsk | null }. It uses the same session
ownership rule as invoking the session, and returns no checkpoint or transcript.
Cancelling is explicit:
{ "input": "", "sessionId": "current-session", "decision": { "requestId": "runtime-generated-token", "cancel": true } }Hosted cancellation records input_cancelled, closes the pending input request
and retains the conversation. Run-scoped resources for that session are released;
session-scoped resources and other paused sessions remain open. This happens
without calling a model or running remaining
tools. It applies only to this data-collection arm, never a consent gate.
Direct runner users use abandonPause() before starting another request;
discard the old checkpoint if it must no longer be resumable. If resources were
registered under a hosting session, end the abandoned turn explicitly with
await agent.closeToolSessions({ scope: 'run', sessionId }).
An ordinary message does not implicitly approve, cancel, or supply typed fields.
