> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.astropods.com/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.astropods.com/_mcp/server.

# Ask the user for input

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](/secrets) instead.

## Ask for input

Your adapter's `stream()` receives a [`StreamOptions`](/adapters/custom-node)
object, and the bridge puts two methods on it:

| Method     | Signature                                                     |
| ---------- | ------------------------------------------------------------- |
| `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:

| Field            | Required | Meaning                                                         |
| ---------------- | -------- | --------------------------------------------------------------- |
| `message`        | yes      | The prompt, in markdown where the surface supports it           |
| `dataSchema`     | yes      | JSON Schema for the answer, and so the form the user fills in   |
| `value`          | no       | Proposed answer, prefilled for the user to edit                 |
| `allowedActions` | no       | Actions on offer. Defaults to submit and cancel                 |
| `intent`         | no       | Labels what the request is for. `tool_permission` is recognized |
| `kind`           | no       | Render strategy. Defaults to a declarative form                 |
| `id`             | no       | Your own correlation id. Generated when omitted                 |

```typescript
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:

```typescript
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:

```typescript
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:

| Property                       | Control                                          |
| ------------------------------ | ------------------------------------------------ |
| `enum`                         | Single select                                    |
| `array` with `items.enum`      | Multi-select                                     |
| `boolean`                      | Checkbox                                         |
| `number`, `integer`            | Number input, bounded by `minimum` and `maximum` |
| `x-ui: { widget: "textarea" }` | Text area                                        |
| `x-ui: { widget: "code" }`     | Code editor                                      |
| Anything else                  | Text input                                       |

Other keywords fill in the rest of the field:

| Keyword                          | Effect                                                                          |
| -------------------------------- | ------------------------------------------------------------------------------- |
| `title`                          | The field's label. Without one, the property name is humanized                  |
| `description`                    | Help text under the field                                                       |
| `required`, on the parent schema | Marks the field required. Submit stays disabled while a required field is empty |
| `enumNames`                      | Friendlier 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

| Action  | Constant                    | Result                                                                   |
| ------- | --------------------------- | ------------------------------------------------------------------------ |
| Submit  | `RENDERABLE_ACTION_SUBMIT`  | The form's values arrive in `contentJson`                                |
| Decline | `RENDERABLE_ACTION_DECLINE` | The user said no; no values                                              |
| Cancel  | `RENDERABLE_ACTION_CANCEL`  | The turn ends without an answer                                          |
| Respond | `RENDERABLE_ACTION_RESPOND` | The 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

| Framework                                        | What to write                                                                                                                                                               |
| ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Mastra                                           | Nothing platform-specific. The adapter maps `suspend()` onto the form and the answer back onto `resumeData`. See [Ask for input from a Mastra tool](/ask-for-input-mastra). |
| Your own Node adapter                            | Call `elicit()` or `render()` from `stream()`, as above.                                                                                                                    |
| LangChain, AI SDK, and Claude Agent SDK adapters | No elicitation support today. Write your own adapter if you need it.                                                                                                        |
| Python                                           | Not yet. `adapter-core-py` provides neither `render()` nor `elicit()`.                                                                                                      |

## Next steps

* [Ask for input from a Mastra tool](/ask-for-input-mastra): the suspend and resume path, with durable storage
* [Build your own adapter](/adapters/custom-node): the `StreamOptions` your adapter receives
* [Secrets](/secrets): the right way to handle credentials
* [Serve a frontend from your agent](/frontend-agents): render your own UI instead of using the chat surface