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.
When to use
Section titled “When to use”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 60–604800 (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" } ]}Options
Section titled “Options”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).
Row and column templates
Section titled “Row and column templates”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 callercss. Rendered per option with{ option }in context, same asrowTemplate.
Example 1: zero-config (id + label)
Section titled “Example 1: zero-config (id + label)”{ "message": "Which environment?", "options": [ { "id": "staging", "label": "Staging" }, { "id": "prod", "label": "Production" } ]}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)}"}<td>{{ option.name }}</td> <td class="muted">{{ option.region }}</td> <td class="muted">{{ option.country }}</td>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}Because maxSelections (3) is greater than minSelections (1), the review
page renders checkboxes instead of radio buttons and accepts 1–3 selections.
Safety model
Section titled “Safety model”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 evenallow-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 maliciousrowTemplatecan make a row look however it wants, but it cannot forge whichidgets 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/maxSelectionsbefore 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.
Output
Section titled “Output”{ "elicitationId": "…", "status": "pending", "reviewUrl": "https://…/review?elicitationId=…", "expiresAt": "2026-07-30T12:00:00.000Z", "elicitationDelivered": true}elicitationDeliveredistrueonly when the host advertised theelicitation: { url: {} }capability on a legacy (pre-2026-07-28) connection — that revision removed server-initiated push, so it is alwaysfalsethere. The prompt is non-blocking either way;reviewUrlis always returned so any host can reach the review page.- For a completed selection,
elicit_resultreturns aselectedIdsstring array (the chosen optionids) alongside the usualdecision.
Validate the full loop
Section titled “Validate the full loop”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
pendingreturn 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.
Related
Section titled “Related”elicit_await/elicit_status— wait for the decisionelicit_result— fetchselectedIds- Selection Flow — the full lifecycle
- Templates — the shared LiquidJS engine rules