> ## Documentation Index
> Fetch the complete documentation index at: https://docs.relayapp.im/llms.txt
> Use this file to discover all available pages before exploring further.

# Selection

> Ask a person to choose several options and receive stable values beside a readable reply.

export const MessageBubble = ({text, rows, side = "trailing", chevron = false, width = 260}) => {
  const WIDE = 20;
  const TAIL_DEPTH_FACTOR = 0.33925;
  const P = (x, y) => ({
    x,
    y
  });
  const corner = (start, c1, c2, p1, c3, c4, p2, c5, c6, end) => ({
    start,
    c1,
    c2,
    p1,
    c3,
    c4,
    p2,
    c5,
    c6,
    end
  });
  const KEYS = ["start", "c1", "c2", "p1", "c3", "c4", "p2", "c5", "c6", "end"];
  const lerp = (a, b, t) => P(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t);
  const mix = (a, b, t) => Object.fromEntries(KEYS.map(k => [k, lerp(a[k], b[k], t)]));
  const scaleCorner = (c, s) => Object.fromEntries(KEYS.map(k => [k, P(c[k].x * s, c[k].y * s)]));
  const swap = p => P(p.y, p.x);
  const samples = [{
    extent: 40,
    upper: corner(P(0, 20), P(0, 17.465204710537), P(0.481850397518, 14.953513771483), P(1.419892543171, 12.598675328518), P(3.452798428579, 7.495317284242), P(7.45648, 3.452798428579), P(12.62988, 1.498228), P(17.36814, 0), P(21.7698, 0), P(30.5733, 0)),
    lower: corner(P(30.5733, 0), P(21.7698, 0), P(17.36814, 0), P(12.62988, 1.498228), P(7.45648, 3.3812), P(3.3812, 7.495317284242), P(1.419892543171, 12.598675328518), P(0.481850397518, 14.953513771483), P(0, 17.465204710537), P(0, 20))
  }, {
    extent: 48,
    upper: corner(P(0, 24), P(0, 19.093682211209), P(0.299560895653, 15.866994417456), P(1.449527740065, 12.610480411693), P(3.425711926322, 7.480624696595), P(7.45648, 3.425711926322), P(12.62988, 1.498228), P(17.36814, 0), P(21.7698, 0), P(30.5733, 0)),
    lower: corner(P(30.5733, 0), P(21.7698, 0), P(17.36814, 0), P(12.62988, 1.498228), P(7.45648, 3.3812), P(3.3812, 7.480624696595), P(1.449527740065, 12.610480411693), P(0.299560895653, 15.866994417456), P(0, 19.093682211209), P(0, 24))
  }, {
    extent: 60,
    upper: corner(P(0, 30), P(0, 21.536398462216), P(0.026126642855, 17.237215386416), P(1.493980535405, 12.628188036454), P(3.385082172936, 7.458585815124), P(7.45648, 3.385082172936), P(12.62988, 1.498228), P(17.36814, 0), P(21.7698, 0), P(30.5733, 0)),
    lower: corner(P(30.5733, 0), P(21.7698, 0), P(17.36814, 0), P(12.62988, 1.498228), P(7.45648, 3.3812), P(3.3812, 7.458585815124), P(1.493980535405, 12.628188036454), P(0.026126642855, 17.237215386416), P(0, 21.536398462216), P(0, 30))
  }, {
    extent: 61.1466,
    upper: corner(P(0, 30.5733), P(0, 21.7698), P(0, 17.36814), P(1.498228, 12.62988), P(3.3812, 7.45648), P(7.45648, 3.3812), P(12.62988, 1.498228), P(17.36814, 0), P(21.7698, 0), P(30.5733, 0)),
    lower: corner(P(30.5733, 0), P(21.7698, 0), P(17.36814, 0), P(12.62988, 1.498228), P(7.45648, 3.3812), P(3.3812, 7.45648), P(1.498228, 12.62988), P(0, 17.36814), P(0, 21.7698), P(0, 30.5733))
  }];
  const profile = extent => {
    const first = samples[0];
    const last = samples[samples.length - 1];
    if (extent <= first.extent) return first;
    if (extent >= last.extent) return last;
    for (let i = 0; i < samples.length - 1; i += 1) {
      const lo = samples[i];
      const hi = samples[i + 1];
      if (extent >= lo.extent && extent <= hi.extent) {
        const t = (extent - lo.extent) / (hi.extent - lo.extent);
        return {
          upper: mix(lo.upper, hi.upper, t),
          lower: mix(lo.lower, hi.lower, t)
        };
      }
    }
    return last;
  };
  const corners = (w, h) => {
    const radius = Math.max(0, Math.min(WIDE, w / 2, h / 2));
    const s = radius / WIDE;
    const v = profile(h / s);
    const hz = profile(w / s);
    const vu = scaleCorner(v.upper, s);
    const vl = scaleCorner(v.lower, s);
    const hu = scaleCorner(hz.upper, s);
    const hl = scaleCorner(hz.lower, s);
    return {
      radius,
      upper: corner(vu.start, vu.c1, vu.c2, vu.p1, vu.c3, P(hu.c3.y, vu.c3.x), swap(hu.p1), swap(hu.c2), swap(hu.c1), swap(hu.start)),
      lower: corner(swap(hl.end), swap(hl.c6), swap(hl.c5), swap(hl.p2), swap(hl.c4), vl.c4, vl.p2, vl.c5, vl.c6, vl.end)
    };
  };
  const bubblePath = (w, h) => {
    const {radius, upper, lower} = corners(w, h);
    const s = radius / WIDE;
    const f = n => n.toFixed(3);
    const up = (p, mirrored) => P(mirrored ? w - p.x : p.x, p.y);
    const low = p => P(p.x, h - p.y);
    const tail = (xFromRight, yFromBottom) => P(w - xFromRight * s, h + yFromBottom * s);
    const d = [];
    const move = p => d.push(`M ${f(p.x)} ${f(p.y)}`);
    const curve = (c1, c2, to) => d.push(`C ${f(c1.x)} ${f(c1.y)} ${f(c2.x)} ${f(c2.y)} ${f(to.x)} ${f(to.y)}`);
    move(up(upper.start));
    curve(up(upper.c1), up(upper.c2), up(upper.p1));
    curve(up(upper.c3), up(upper.c4), up(upper.p2));
    curve(up(upper.c5), up(upper.c6), up(upper.end));
    const topRightStart = up(lower.start, true);
    curve(up(upper.end), topRightStart, topRightStart);
    curve(up(lower.c1, true), up(lower.c2, true), up(lower.p1, true));
    curve(up(lower.c3, true), up(lower.c4, true), up(lower.p2, true));
    curve(up(lower.c5, true), up(lower.c6, true), up(lower.end, true));
    const tailSideStart = up(lower.end, true);
    const tailFlowStart = P(tailSideStart.x, Math.max(tailSideStart.y, low(lower.end).y));
    curve(tailSideStart, tailFlowStart, tailFlowStart);
    curve(tail(0, -15.6938174), tail(1.4149, -11.5018174), tail(4.0279, -8.0758174));
    curve(tail(5.0867, -6.687757), tail(6.3092, -5.4643142), tail(7.66, -4.4234174));
    curve(tail(9.585, -2.9224174), tail(10.418, -1.3564174), tail(10.418, 0.4035826));
    curve(tail(10.418, 1.5865826), tail(10.209, 2.7555826), tail(8.51, 4.9875826));
    curve(tail(7.695, 6.0575826), tail(8.513, 7.1495826), tail(9.787, 6.6655826));
    curve(tail(12.407, 5.6705826), tail(15.391, 3.8595826), tail(18.005, 1.9265826));
    const rejoin = tail(22.07, 0.0125826);
    curve(tail(20.347, 0.1945826), tail(20.971, 0.0195826), rejoin);
    const bottomLeftStart = low(lower.start);
    curve(rejoin, bottomLeftStart, bottomLeftStart);
    curve(low(lower.c1), low(lower.c2), low(lower.p1));
    curve(low(lower.c3), low(lower.c4), low(lower.p2));
    curve(low(lower.c5), low(lower.c6), low(lower.end));
    d.push("Z");
    return d.join(" ");
  };
  const tapBubble = text => {
    const lines = text.split("\n");
    const h = 40 + (lines.length - 1) * 24;
    const w = Math.max(2 * 20, Math.round(Math.max(...lines.map(line => line.length)) * 8.6 + 28));
    const radius = Math.min(WIDE, w / 2, h / 2);
    const total = Math.ceil(h + radius * TAIL_DEPTH_FACTOR);
    return <svg className="buttons-preview-tap" width={w} height={total} viewBox={`0 0 ${w} ${total}`} aria-hidden="true">
        <path d={bubblePath(w, h)} />
        {lines.map((line, index) => <text key={index} x={lines.length === 1 ? w / 2 : 14} y={20 + index * 24} dominantBaseline="central" textAnchor={lines.length === 1 ? "middle" : "start"}>{line}</text>)}
      </svg>;
  };
  const CARD_INSET = 14;
  const CARD_VERTICAL = 10;
  const CARD_TITLE_GAP = 2;
  const CARD_LINE_GAP = 3;
  const CARD_MARK_WIDTH = 17;
  const CHEVRON = {
    width: 8,
    height: 13
  };
  const ROW_METRICS = {
    title: 20,
    subtitle: 18,
    label: 20
  };
  const cardBubble = (rows, side, showsChevron, w) => {
    let cursor = 0;
    const placed = rows.map((row, index) => {
      if (index > 0) cursor += rows[index - 1].kind === "title" ? CARD_TITLE_GAP : CARD_LINE_GAP;
      const line = ROW_METRICS[row.kind];
      const top = cursor;
      cursor += line;
      return {
        ...row,
        line,
        top
      };
    });
    const interior = Math.max(cursor, showsChevron ? CHEVRON.height : 0);
    const h = Math.ceil(interior + CARD_VERTICAL * 2);
    const radius = Math.min(WIDE, w / 2, h / 2);
    const total = Math.ceil(h + radius * TAIL_DEPTH_FACTOR);
    const stackTop = Math.round(CARD_VERTICAL + (interior - cursor) / 2);
    const f = n => Number(n.toFixed(2));
    const chevronX = w - CARD_INSET - CHEVRON.width;
    const chevronY = h / 2;
    return <svg className={`selection-card selection-card-${side}`} width={w} height={total} viewBox={`0 0 ${w} ${total}`} aria-hidden="true" focusable="false">
        <g transform={side === "leading" ? `translate(${w},0) scale(-1,1)` : undefined}>
          <path className="selection-card-shape" d={bubblePath(w, h)} />
        </g>
        {placed.map((row, index) => {
      const middle = f(stackTop + row.top + row.line / 2);
      return <g key={index}>
              {row.kind === "label" ? <path className="selection-card-mark" d={`M ${CARD_INSET} ${f(stackTop + row.top + row.line / 2 + 0.6)} l 3.6 3.7 l 6.9 -8.5`} /> : null}
              <text className={`selection-card-${row.kind}`} dominantBaseline="central" x={row.kind === "label" ? CARD_INSET + CARD_MARK_WIDTH : CARD_INSET} y={middle}>{row.text}</text>
            </g>;
    })}
        {showsChevron ? <path className="selection-card-chevron" d={`M ${chevronX} ${f(chevronY - 5.6)} L ${chevronX + 6.2} ${f(chevronY)} L ${chevronX} ${f(chevronY + 5.6)}`} /> : null}
      </svg>;
  };
  return rows ? cardBubble(rows, side, chevron, width) : tapBubble(text);
};

export const SelectionPreview = ({title, options, received, bubble}) => {
  const receivedValues = received ? options.filter(option => received.split("\n").includes("\u2022 " + option.label)).map(option => option.value) : null;
  const [draft, setDraft] = useState([]);
  const [answered, setAnswered] = useState(receivedValues);
  const [opener, setOpener] = useState(null);
  const isStatic = received !== undefined && received !== null;
  const isAnswered = answered !== null;
  const isReadOnly = isAnswered;
  const open = opener !== null;
  const chosen = options.filter(option => (answered || []).includes(option.value));
  const labels = chosen.map(option => option.label);
  const focusWithin = (event, selector) => {
    const root = event && event.currentTarget && event.currentTarget.closest(".selection-preview");
    const target = root && root.querySelector(selector);
    if (target) target.focus();
  };
  const openSheet = from => {
    setDraft(answered || []);
    setOpener(from);
  };
  const close = event => {
    const from = opener;
    setOpener(null);
    focusWithin(event, "." + (from || "selection-prompt"));
  };
  const send = event => {
    setAnswered(draft);
    setOpener(null);
    focusWithin(event, ".selection-prompt");
  };
  const reset = () => {
    setAnswered(receivedValues);
    setDraft([]);
    setOpener(null);
  };
  const promptRows = [{
    kind: "title",
    text: title
  }, {
    kind: "subtitle",
    text: "Pick options"
  }];
  const answerRows = [{
    kind: "title",
    text: title
  }, ...labels.map(label => ({
    kind: "label",
    text: label
  }))];
  return <Frame className="relay-preview">
    <div className={"buttons-preview selection-preview" + (open ? " is-sheet-open" : "")} role="group" aria-label={isStatic ? "Selection reply preview" : "Interactive selection preview"}>
      <div className="buttons-preview-frame">
      <div className="buttons-preview-stage">
        <div className="buttons-preview-message">
          {isStatic ? null : <button type="button" className="selection-prompt" tabIndex={open ? -1 : 0} aria-haspopup="dialog" aria-expanded={opener === "selection-prompt"} aria-label={isAnswered ? `${title}. Opens the options you chose` : `${title}. Opens the options`} onClick={() => openSheet("selection-prompt")}>
              {bubble({
    rows: promptRows,
    side: "leading",
    chevron: true
  })}
            </button>}
          {isAnswered ? <button type="button" className="selection-answer" tabIndex={open ? -1 : 0} aria-haspopup="dialog" aria-expanded={opener === "selection-answer"} aria-label={`${title}. ${labels.join(", ")}. Opens the options you chose`} onClick={() => openSheet("selection-answer")}>
              {bubble({
    rows: answerRows,
    side: "trailing",
    chevron: true
  })}
            </button> : null}
        </div>
      </div>
      {open ? <div className="selection-sheet-layer">
          <div className="selection-sheet-scrim" onClick={close} />
          <div className={"selection-sheet" + (isReadOnly ? " is-read-only" : "")} role="dialog" aria-modal="true" aria-label={title} onKeyDown={event => {
    if (event.key === "Escape") close(event);
  }}>
            <button type="button" className="selection-sheet-grabber" autoFocus aria-label="Close options" onClick={close} />
            {}
            <div className="selection-sheet-title">{title}</div>
            <div className="selection-sheet-list">
              {options.map(option => {
    const checked = (isReadOnly ? answered : draft).includes(option.value);
    return <button type="button" key={option.value} role="checkbox" aria-checked={checked} className="selection-sheet-option" disabled={isReadOnly} onClick={() => setDraft(current => current.includes(option.value) ? current.filter(value => value !== option.value) : [...current, option.value])}>
                    <svg className="selection-box" viewBox="0 0 22 22" aria-hidden="true" focusable="false">
                      <circle cx="11" cy="11" r="10" />
                      <path d="m6.4 11.2 3 3 6.2-6.8" />
                    </svg>
                    <span className="selection-sheet-label">{option.label}</span>
                  </button>;
  })}
            </div>
            {isReadOnly ? null : <div className="selection-sheet-footer">
                <button type="button" className="selection-send" disabled={draft.length === 0} onClick={send}>Send</button>
              </div>}
          </div>
        </div> : null}
      {isAnswered && !isStatic && !open ? <button type="button" className="relay-preview-reset" aria-label="Reset demo" title="Reset demo" onClick={reset}>
          <Icon icon="rotate-left" size={16} />
        </button> : null}
      </div>
    </div>
    </Frame>;
};

Send a `selection` part with a short `title` and 1 to 25 options. Each option has a readable `label` and a stable `value`. The app draws the selection as one card that opens a sheet holding the whole option list, and the person checks several options there and submits them in one reply.

## Send a selection

This request contains an optional `text` part followed by a `selection` part. The text shows as a normal message above the card. The selection's `title` is the card's title and the sheet's title; a second line reads "Pick options", and a chevron marks the card as something to open. Open the prompt below, check options in the sheet, then press **Send**; this demo stays in your browser and sends no request.

<Tabs>
  <Tab title="Preview">
    <SelectionPreview bubble={MessageBubble} title="Topics" options={[{ "value": "research", "label": "Research" }, { "value": "design", "label": "Design" }]} />
  </Tab>

  <Tab title="JSON">
    ```json theme={null}
    {
      "message": {
        "parts": [
          {"type":"text","value":"Which topics interest you?"},
          {"type":"selection","title":"Topics","options":[
            {"value":"research","label":"Research"},
            {"value":"design","label":"Design"}
          ]}
        ],
        "idempotency_key":"topics-prompt-001"
      }
    }
    ```
  </Tab>
</Tabs>

Checking a row sends nothing. The sheet lists every option in source order with a checkbox on the leading edge, and its Send stays disabled until at least one row is checked, so one submission carries the whole answer.

### Rules for the prompt

* Only an agent can send a selection.
* A Message carries at most one `selection` part. A `text` part is optional; when present it shows as a normal message above the card.
* `title` is required: 1 to 60 characters after surrounding whitespace is trimmed, a few words such as "Pizza toppings". It is the card's title and the sheet's title.
* A selection cannot share a Message with a `buttons` part.
* `options` holds 1 to 25 options.
* Each `value` is 1 to 100 characters, matches `^[A-Za-z0-9][A-Za-z0-9._:-]*$`, is case-sensitive, and is unique within the selection. Your application keys on it, so keep it stable.
* Each `label` is 1 to 80 characters after surrounding whitespace is trimmed. The app shows labels verbatim.
* An option has only `value` and `label`; any other field is rejected.

Use an Agent Token and an existing chat ID to send the same request:

<CodeGroup>
  ```typescript TypeScript SDK theme={null}
  import Relay, { partsWithSelection } from "@relaymessenger/sdk";

  const relay = new Relay({
    apiKey: process.env.RELAY_AGENT_TOKEN!,
    baseURL: "https://api.relayapp.im",
  });

  await relay.chats.messages.send("CHAT_ID", {
    message: {
      parts: partsWithSelection("Which topics interest you?", {
        type: "selection",
        title: "Topics",
        options: [
          { value: "research", label: "Research" },
          { value: "design", label: "Design" },
        ],
      }),
      idempotency_key: "topics-prompt-001",
    },
  });
  ```

  ```bash HTTPS theme={null}
  curl -sS "https://api.relayapp.im/v1/chats/$CHAT_ID/messages" \
    -H "Authorization: Bearer $RELAY_AGENT_TOKEN" \
    -H "Content-Type: application/json" \
    -d '{
      "message": {
        "parts": [
          {"type":"text","value":"Which topics interest you?"},
          {"type":"selection","title":"Topics","options":[
            {"value":"research","label":"Research"},
            {"value":"design","label":"Design"}
          ]}
        ],
        "idempotency_key":"topics-prompt-001"
      }
    }'
  ```
</CodeGroup>

The send path returns `202` after storing the message. Keep the returned message ID so your application can associate responses with this question. Reuse the same request and idempotency key after an uncertain send.

```http theme={null}
HTTP/1.1 202 Accepted
```

## Receive stable values

The user's submission contains exactly two ordered parts: readable text, then structured metadata. Its `reply_to` identifies the source message and selection part.

<Tabs>
  <Tab title="Preview">
    <SelectionPreview bubble={MessageBubble} title="Topics" options={[{ "value": "research", "label": "Research" }, { "value": "design", "label": "Design" }]} received={"• Research\n• Design"} />
  </Tab>

  <Tab title="What you receive">
    ```json theme={null}
    {
      "event_type":"message.received",
      "data": {
        "parts": [
          {"type":"text","value":"• Research\n• Design"},
          {"type":"selection_response","selected_values":["research","design"]}
        ],
        "reply_to": {
          "message_id":"01993d50-ef7b-7b37-886b-23fd80c7ec13",
          "part_index":1
        }
      }
    }
    ```
  </Tab>
</Tabs>

The event example shows the response fields used by your handler. The preview draws the reply as the selection's title over one line per chosen label; metadata adds no visible text.

New text equals literal `• ` + each selected source label joined with `\n`, in source-option order. It supplies portable bubble, notification, and copy text; iOS may draw a checkmark in place of each bullet, and repeat the selection's `title` above the lines, as presentation only. The `selection_response` part carries metadata only.

A person answers a given selection once. Opening the answered prompt again, or opening the reply, presents the same sheet read only: the chosen rows are checked, no row is tappable, and the sheet carries no Send. Try it in the preview above.

## Read whether someone answered

When you read a Message back, its `selection` part carries `has_responded` and `selected_values`, and its `reactions` is always `null`:

```json theme={null}
{"type":"selection","title":"Topics","options":[{"value":"research","label":"Research"},{"value":"design","label":"Design"}],"has_responded":false,"selected_values":null,"reactions":null}
```

Both fields belong to whoever is reading. `has_responded` is `true` once that person has answered this selection, and `selected_values` holds the values they chose, in option order, so every one of their devices shows the same answer even when it has not loaded the answer Message. `selected_values` is `null` until they answer, and also if their answer Message no longer exists. For an agent, `has_responded` is always `false` and `selected_values` always `null`, so an agent learns about answers from the `selection_response` Messages it receives, not from these fields. When a person answers, their own devices receive a `message.upserted` for the prompt with both fields set.

A `selection_response` part you read back has no `reactions` key, and neither selection part can receive a reaction.

Your existing signed [webhook](/webhooks/verify-signatures) or [WebSocket](/websocket/index) handler receives the ordinary `message.received` event. The SDK's default event types expose the metadata:

```typescript Application handler theme={null}
import type { RelayWebhookEvent } from "@relaymessenger/sdk";

function selectionFrom(event: RelayWebhookEvent) {
  if (event.event_type !== "message.received") return;
  const response = event.data.parts.find(
    (part) => part.type === "selection_response",
  );
  if (!response || !event.data.reply_to) return;
  return {
    source: event.data.reply_to,
    selectedValues: response.selected_values,
  };
}
```

Dispatch your own application handler using the source message ID, part index, and stable values. Preserve the metadata when forwarding events to a runtime. Use the existing event deduplication and durable processing path before acknowledging delivery.

## Send from a runtime

Selections use the runtime's existing send path: a backend includes a `selection` part, with optional text before it, in `parts`; a runtime with a send tool takes a `selection` argument; and a runtime that answers in text ends its answer with a fenced code block tagged `selection` containing the selection's `title` and `options`. Responses carry `selected_values` and `reply_to` alongside the readable text. Each [integration](/integrations/index) describes its send and receive format.

## When it fails

The server checks an answer against the prompt it names: the reply must come from a person who can see that prompt in the same chat, its text must match the chosen labels exactly, and its values must be known and in the prompt's option order. For compatibility it also accepts the exact selected source labels joined with `, `; new clients send bullet lines. Dispatch on `selected_values` and `reply_to`, never by parsing the text.

| Status | Code | Cause |
| - | - | - |
| `400` | [1005](/api-reference/errors#1005) | An option is malformed, has an unknown field, or repeats a value; the title is missing or longer than 60 characters, or the Message has two selections or buttons beside the selection. An answer is not exactly a text part then a `selection_response` part, its text carries a mention, or `reply_to.part_index` is missing. |
| `403` | [2003](/api-reference/errors#2003) | Someone other than an agent sent a selection, or someone other than a person answered one. |
| `404` | [2001](/api-reference/errors#2001) | The answer's `reply_to.message_id` is not a Message the person can see in this chat. |
| `409` | [1005](/api-reference/errors#1005) | The person already answered this selection with a different idempotency key. A retry with the same key and body returns the original answer instead. |
| `422` | [2006](/api-reference/errors#2006) | The answer's text or value order does not match the prompt, `reply_to.part_index` does not name a selection part, or a reaction targets a selection part. |

Keep the original body and idempotency key when retrying an uncertain submission.

## Next steps

* [Interactions](/interactions/index)
* [Buttons](/interactions/buttons)
* [Replies](/messages/replies)
