Skip to content

elicit_selection

Ask a human to choose one or more options from a caller-supplied list (disambiguation), on the hosted review page. The chosen ids come back via elicit_result.

The question is “which of these did you mean?” — the agent already has the candidates and needs the human to pick one (or several). The reviewer can also reject the whole list (“none of these”), telling the agent to refine its search. For approve/decline of a single payload, or free-form field input, see Choosing a tool.

Parameter Type Required Description
message string yes Human-readable question shown to the reviewer.
options array yes 1–500 candidate objects — see Options below.
minSelections / maxSelections number no Default 1 / 1 (single choice). maxSelections is clamped down to the number of options. Widen the range (e.g. maxSelections: 3) to turn on multi-select (checkboxes instead of radio buttons).
rowTemplate / headingTemplate / footerTemplate / hoverTemplate string no LiquidJS templates that control how the option table renders. Max 20 KB each. See Row and column templates.
css string no Stock CSS injected into the sandboxed document’s <head>, so your templates can use classes, @media queries, and :hover rules. (hoverTemplate’s reveal-on-hover is built in and works without it.) Tailwind is not available here — only plain CSS you supply yourself.
labels object no { submit?, decline? }, each 1–40 characters; renames the action buttons: submit is the submit button (default Submit), decline the decline button (default Decline). Labels only rename — button placement and styling stay fixed.
expiresInSeconds number no Default 3600 (1 hour), clamped 60604800 (1 minute to 7 days) — how long the request stays decidable.
{
"message": "Which Paris are you referring to?",
"options": [
{ "id": "paris-fr", "label": "Paris, FR" },
{ "id": "paris-on", "label": "Paris, ON" },
{ "id": "paris-tx", "label": "Paris, TX" }
]
}

Each entry in options is an object with:

  • id (string, required) — must be unique and non-empty within the request. This is the only value that ever comes back.
  • label (string, optional) — used by the zero-config fallback rendering (see below).
  • any other fields you like — arbitrary extra data available to your templates as {{ option.<field> }} (e.g. domain, city, updatedAt).

Without any templates, elicit_selection renders a minimal two-column table: a selector cell plus one cell showing option.label (falling back to option.id if there’s no label). That zero-config path is enough for a simple pick-list.

For anything richer — an extra column, styling, a heading row — supply your own LiquidJS templates. See Templates for the shared engine rules — JavaScript-style truthiness, escaping, and limits. The framework always injects the selector cell itself (the radio button or checkbox); your templates supply everything else:

  • rowTemplate — the <td> cells for one option row. Rendered once per option with { option } in context (so {{ option.label }}, {{ option.domain }}, etc.).
  • headingTemplate — the <th> cells for the table header. Rendered once, with no option context (there’s no single option to bind to a header).
  • footerTemplate — a <tfoot> row, e.g. for a count or a note. Also rendered with no option context.
  • hoverTemplate — extra detail shown on hover over a row. The reveal-on-hover behavior is built into the base stylesheet the framework always injects, so it works with zero caller css. Rendered per option with { option } in context, same as rowTemplate.
{
"message": "Which environment?",
"options": [
{
"id": "staging",
"label": "Staging"
},
{
"id": "prod",
"label": "Production"
}
]
}
What the reviewer sees

Which environment?

No templates needed — renders a simple radio-button list of labels.

Example 2: template table (the “which Paris?” case)

Section titled “Example 2: template table (the “which Paris?” case)”
{
"message": "Which Paris are you referring to?",
"options": [
{
"id": "paris-fr",
"name": "Paris",
"region": "Île-de-France",
"country": "France"
},
{
"id": "paris-on",
"name": "Paris",
"region": "Ontario",
"country": "Canada"
},
{
"id": "paris-tx",
"name": "Paris",
"region": "Texas",
"country": "United States"
}
],
"headingTemplate": "<th>City</th><th>Region</th><th>Country</th>",
"rowTemplate": "<td>{{ option.name }}</td><td class=\"muted\">{{ option.region }}</td><td class=\"muted\">{{ option.country }}</td>",
"css": "td.muted{color:#666}tr:hover{background:rgba(0,0,0,.05)}"
}
rowTemplate — expanded for readability
<td>{{ option.name }}</td>
<td class="muted">{{ option.region }}</td>
<td class="muted">{{ option.country }}</td>
What the reviewer sees

Which Paris are you referring to?

Every candidate has the same name — which is exactly when a bare label stops working. The region and country columns give the reviewer something to tell the rows apart at a glance.

Example 3: multi-select (maxSelections: 3)

Section titled “Example 3: multi-select (maxSelections: 3)”
{
"message": "Pick up to 3 launch candidates to promote",
"options": [
{
"id": "v1",
"label": "v1.2.0"
},
{
"id": "v2",
"label": "v1.3.0-rc1"
},
{
"id": "v3",
"label": "v1.3.0-rc2"
},
{
"id": "v4",
"label": "v1.4.0-beta"
}
],
"minSelections": 1,
"maxSelections": 3
}
What the reviewer sees

Pick up to 3 launch candidates to promote

Because maxSelections (3) is greater than minSelections (1), the review page renders checkboxes instead of radio buttons and accepts 1–3 selections.

The option table is attacker-adjacent input — a caller can put almost anything into options, rowTemplate, css, etc. The review page is designed so none of that can forge a decision or exfiltrate data:

  • The table is rendered server-side and displayed inside a sandboxed, null-origin iframe with sandbox="allow-forms"no scripts allowed, not even allow-scripts. Anything a template renders (including any stray <script> tag) is inert markup; it cannot run.
  • The actual selection is a native <input type="radio"> / <input type="checkbox"> control that the framework injects, not something the template can draw or fake. A malicious rowTemplate can make a row look however it wants, but it cannot forge which id gets submitted.
  • The submit is authorized by a signed, time-limited token minted for that request (not cookies or ambient session state), so the sandboxed (null-origin) form can still submit a valid decision despite having no access to the parent page’s session. A submission can only take effect once because the underlying request resolves a single time, not because the token itself is stateful or single-use.
  • The server re-validates every submitted id against the request’s own option set and enforces minSelections/maxSelections before recording a decision; anything out of range or referencing an unknown id is rejected, and the reviewer sees the table again with a fresh token.
  • Interpolated {{ option.* }} values are HTML-escaped, so option data can’t break the layout or inject markup — your template’s own tags render as-is, the data does not.
{
"elicitationId": "",
"status": "pending",
"reviewUrl": "https://…/review?elicitationId=…",
"expiresAt": "2026-07-30T12:00:00.000Z",
"elicitationDelivered": true
}
  • elicitationDelivered is true only when the host advertised the elicitation: { url: {} } capability on a legacy (pre-2026-07-28) connection — that revision removed server-initiated push, so it is always false there. The prompt is non-blocking either way; reviewUrl is always returned so any host can reach the review page.
  • For a completed selection, elicit_result returns a selectedIds string array (the chosen option ids) alongside the usual decision.

A bare “run elicit_selection with …” validates creation and delivery only — the tool is async, so the agent has no reason to wait for or fetch your choice unless you ask. To exercise the whole round trip (creation, url-mode delivery, the review page, retrieval), give your agent the full instruction:

Run elicit_selection with message “Which Paris are you referring to?” and options paris-fr (Paris, FR), paris-on (Paris, ON), paris-tx (Paris, TX). Then call elicit_await with the returned elicitationId until it reports a decided status — a pending return just means the polling window elapsed, so call it again — and finally fetch my choice with elicit_result and tell me which option I picked.

What you should observe, in order: on a host with url-mode elicitation a native “Action required”-style prompt offering to open the review URL (on other hosts the agent surfaces reviewUrl as a link); the hosted page records your choice; the agent reports the selectedIds. The completion notification back to the host is best-effort by design — url-mode is async and the host often has no live stream open when the human decides — so elicit_await / elicit_result are the authoritative path. An agent that appears to “just wait” after delivery isn’t broken; it wasn’t asked to check.