Ask the user for input

Pause mid-turn, collect typed input as a form, and resume with the answer

View as Markdown

An agent can stop mid-turn, ask the user a typed question, and continue once it has the answer. Astropods renders the question as a form in the chat surface and hands the response back to your code as data, not prose.

MCP calls this elicitation. Frameworks call it human-in-the-loop. The platform treats it as one primitive, so the same call works whether you are confirming a destructive action or collecting a value the agent could not infer.

When to use it

Ask for input when the agent cannot proceed correctly on its own judgment:

  • Confirm before an irreversible action. Deleting records, posting to a shared tracker, spending money.
  • Collect a value you cannot infer. A target environment, a date range, a recipient.
  • Offer a choice. Several valid options, one decision.

A read-only tool does not need to ask.

Confirmations are also a defense against prompt injection. An agent that reads issue text, web pages, or user-supplied documents is reading content it does not control. Putting a person between that content and an irreversible action means injected instructions cannot act on their own.

Never collect passwords, API keys, or other secrets this way. The response is stored with the conversation and may appear in logs and traces. Use secrets instead.

Ask for input

Your adapter’s stream() receives a StreamOptions object, and the bridge puts two methods on it:

MethodSignature
render()(input: RenderableInput) => Promise<RenderableResponse>
elicit()(message, dataSchema, opts?) => Promise<RenderableResponse>

render() is the primitive. elicit() calls it with a positional signature shaped like MCP’s elicitation, and is the one most agents want. Both are optional, so check before calling: a caller that drives the gRPC stream itself receives neither.

Either way the promise resolves with the user’s response, or rejects if the turn is stopped or superseded, so await it or attach a .catch. If the user’s surface cannot render a form and you did not allow a prose reply, the promise rejects with UnsupportedRenderableError rather than coercing the question to text. Audio surfaces never render forms.

render()

One object describes the whole request:

FieldRequiredMeaning
messageyesThe prompt, in markdown where the surface supports it
dataSchemayesJSON Schema for the answer, and so the form the user fills in
valuenoProposed answer, prefilled for the user to edit
allowedActionsnoActions on offer. Defaults to submit and cancel
intentnoLabels what the request is for. tool_permission is recognized
kindnoRender strategy. Defaults to a declarative form
idnoYour own correlation id. Generated when omitted
const answer = await options.render({
message: `Archive "${record.name}"? This cannot be undone.`,
dataSchema: { type: "object", properties: { confirm: { type: "boolean" } } },
value: { confirm: true },
intent: "tool_permission",
});

elicit()

The same call, with message and dataSchema as positional arguments and the rest in a third options argument. It accepts every render() field except kind, and it offers submit, decline, and cancel rather than submit and cancel:

async stream(prompt, hooks, options) {
if (!options.elicit) {
hooks.onChunk("This surface cannot collect input.");
return;
}
const dataSchema = {
type: "object",
properties: {
environment: { type: "string", enum: ["staging", "production"] },
notes: { type: "string", "x-ui": { widget: "textarea" } },
},
required: ["environment"],
};
const answer = await options.elicit(
"Which environment should I deploy to?",
dataSchema,
);
if (answer.action !== "RENDERABLE_ACTION_SUBMIT") {
hooks.onChunk("Nothing deployed.");
return;
}
const { environment } = JSON.parse(answer.contentJson!);
await deploy(environment);
}

The optional fields go in the third argument: value, allowedActions, intent, and id. Here value proposes an answer the user can edit, and intent labels the request as a tool permission:

const answer = await options.elicit(
`Archive "${record.name}"? This cannot be undone.`,
{ type: "object", properties: { confirm: { type: "boolean" } } },
{ value: { confirm: true }, intent: "tool_permission" },
);

How a schema becomes a form

The dataSchema in either call is the form. It must be an object schema: its properties become the fields, in the order you declare them, and a schema with no properties renders no fields at all.

Each property’s shape picks its control:

PropertyControl
enumSingle select
array with items.enumMulti-select
booleanCheckbox
number, integerNumber input, bounded by minimum and maximum
x-ui: { widget: "textarea" }Text area
x-ui: { widget: "code" }Code editor
Anything elseText input

Other keywords fill in the rest of the field:

KeywordEffect
titleThe field’s label. Without one, the property name is humanized
descriptionHelp text under the field
required, on the parent schemaMarks the field required. Submit stays disabled while a required field is empty
enumNamesFriendlier labels for enum values, positionally matched
x-ui: { placeholder }Placeholder text

So the elicit() call above renders two fields: Environment, a required select of staging and production, and Notes, an optional text area. Neither carries a title, so each label comes from humanizing its property name.

A prefilled value seeds the form. Fields it does not mention start empty: a multi-select as [], a checkbox as unchecked, everything else blank.

Name the specifics in message: name the record, not just its id, so the person is answering about the thing they think they are.

What the user can do

ActionConstantResult
SubmitRENDERABLE_ACTION_SUBMITThe form’s values arrive in contentJson
DeclineRENDERABLE_ACTION_DECLINEThe user said no; no values
CancelRENDERABLE_ACTION_CANCELThe turn ends without an answer
RespondRENDERABLE_ACTION_RESPONDThe user replied in prose, carried in text, when you allow this action

Treat anything other than submit as a decline. Cancel is always available, so a user can never be trapped by a pending question.

Behavior worth designing for

A pending question never expires. It waits as long as the thread is open, gating only that thread. A user who walks away and answers tomorrow gets the same result.

Re-check before you write. State you read before asking may be stale by the time the answer arrives. Between the question and the answer, the record may already be archived.

Say what you are about to do, not what you did. What you stream while waiting is not the result of the action.

A restart may abandon an in-flight question. The answer is still recorded, but an agent holding the await in memory loses it. Frameworks that checkpoint their runs, such as Mastra, resume after a restart.

Framework support

FrameworkWhat to write
MastraNothing platform-specific. The adapter maps suspend() onto the form and the answer back onto resumeData. See Ask for input from a Mastra tool.
Your own Node adapterCall elicit() or render() from stream(), as above.
LangChain, AI SDK, and Claude Agent SDK adaptersNo elicitation support today. Write your own adapter if you need it.
PythonNot yet. adapter-core-py provides neither render() nor elicit().

Next steps