> ## 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.

# Form

> Collect answers across native form pages and receive values keyed by field ID.

export const InteractionExamples = () => ({
  "card": {
    "message": {
      "parts": [{
        "type": "text",
        "value": "This one fits."
      }, {
        "type": "rich_card",
        "media": {
          "type": "image",
          "url": "https://example.com/lagoon-house.jpg",
          "height": "medium"
        },
        "title": "Lagoon House, Railay",
        "description": "Pool villa, 4 min to the beach. $212 a night.",
        "suggestions": [{
          "type": "reply",
          "label": "Book",
          "id": "book_lagoon"
        }, {
          "type": "open_url",
          "label": "Details",
          "url": "https://example.com/lagoon-house"
        }]
      }],
      "idempotency_key": "krabi-card-001"
    }
  },
  "carousel": {
    "message": {
      "parts": [{
        "type": "text",
        "value": "Three that fit. Swipe to compare."
      }, {
        "type": "carousel",
        "card_width": "medium",
        "cards": [{
          "media": {
            "type": "image",
            "url": "https://example.com/lagoon-house.jpg"
          },
          "title": "Lagoon House",
          "description": "$212 a night",
          "suggestions": [{
            "type": "reply",
            "label": "Book",
            "id": "book_lagoon"
          }]
        }, {
          "media": {
            "type": "image",
            "url": "https://example.com/cliffside.jpg"
          },
          "title": "Cliffside Villas",
          "description": "$238 a night",
          "suggestions": [{
            "type": "reply",
            "label": "Book",
            "id": "book_cliffside"
          }]
        }]
      }, {
        "type": "buttons",
        "items": [{
          "label": "Cheaper"
        }, {
          "label": "Closer to the beach"
        }]
      }]
    }
  },
  "form": {
    "message": {
      "parts": [{
        "type": "text",
        "value": "Please share your visit preferences."
      }, {
        "type": "form",
        "title": "Your visit",
        "show_summary": true,
        "splash": {
          "title": "Plan your visit",
          "text": "Share a few details before you arrive.",
          "button_title": "Continue"
        },
        "received_message": {
          "title": "Visit details",
          "subtitle": "Complete two pages"
        },
        "reply_message": {
          "title": "Form sent",
          "subtitle": "Tap to review your answers"
        },
        "pages": [{
          "id": "about",
          "title": "About you",
          "fields": [{
            "id": "name",
            "type": "text",
            "label": "Name",
            "placeholder": "Your name",
            "required": true
          }, {
            "id": "meal",
            "type": "select",
            "label": "Meal",
            "required": true,
            "options": [{
              "value": "veg",
              "label": "Vegetarian"
            }, {
              "value": "fish",
              "label": "Fish"
            }]
          }, {
            "id": "extras",
            "type": "select",
            "label": "Extras",
            "multiple": true,
            "options": [{
              "value": "tea",
              "label": "Tea"
            }, {
              "value": "cake",
              "label": "Cake"
            }]
          }]
        }, {
          "id": "visit",
          "title": "Visit preferences",
          "fields": [{
            "id": "region",
            "type": "picker",
            "label": "Region",
            "options": [{
              "value": "east",
              "label": "East"
            }, {
              "value": "west",
              "label": "West"
            }]
          }, {
            "id": "date",
            "type": "date",
            "label": "Visit date",
            "required": true,
            "min_date": "2026-10-01",
            "max_date": "2026-12-31"
          }, {
            "id": "notes",
            "type": "text",
            "label": "Notes",
            "multiline": true,
            "max_length": 500
          }, {
            "id": "updates",
            "type": "select",
            "label": "Visit updates",
            "multiple": true,
            "options": [{
              "value": "yes",
              "label": "Send me visit updates"
            }]
          }]
        }]
      }],
      "idempotency_key": "visit-form-001"
    }
  }
});

export const MessageBubble = ({text, rows, side = "trailing", chevron = false, width = 260, measure}) => {
  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) => {
    const wrap = row => {
      if (!measure) return [row.text];
      const available = Math.max(1, w - CARD_INSET * 2 - (showsChevron ? CHEVRON.width + 8 : 0) - (row.kind === "label" ? CARD_MARK_WIDTH : 0));
      const lines = [];
      let line = "";
      for (const word of row.text.split(/\s+/)) {
        const candidate = line ? line + " " + word : word;
        if (measure(candidate, row.kind) <= available) {
          line = candidate;
          continue;
        }
        if (line) {
          lines.push(line);
          line = "";
        }
        for (const {segment} of new Intl.Segmenter(undefined, {
          granularity: "grapheme"
        }).segment(word)) {
          if (line && measure(line + segment, row.kind) > available) {
            lines.push(line);
            line = "";
          }
          line += segment;
        }
      }
      if (line || !lines.length) lines.push(line);
      return lines;
    };
    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;
      const lines = wrap(row);
      cursor += line * lines.length;
      return {
        ...row,
        lines,
        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}
              {row.lines.map((text, lineIndex) => <text key={lineIndex} className={`selection-card-${row.kind}`} dominantBaseline="central" x={row.kind === "label" ? CARD_INSET + CARD_MARK_WIDTH : CARD_INSET} y={middle + lineIndex * row.line}>{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 FormPreview = ({request, bubble}) => {
  const parts = request.message.parts;
  const partIndex = parts.findIndex(part => part.type === "form");
  const form = parts[partIndex];
  const [answers, setAnswers] = useState({});
  const [sent, setSent] = useState(false);
  const [open, setOpen] = useState(false);
  const [step, setStep] = useState(form.splash ? -1 : 0);
  const [openDate, setOpenDate] = useState(null);
  const [month, setMonth] = useState("");
  const [bubbleLayout, setBubbleLayout] = useState({
    width: 276,
    measure: null
  });
  const transcript = useRef(null);
  const dialog = useRef(null);
  useEffect(() => {
    const canvas = document.createElement("canvas").getContext("2d");
    const measure = (text, kind) => {
      canvas.font = `${kind === "title" ? "600 15" : "400 13"}px -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif`;
      return canvas.measureText(text).width;
    };
    const observer = new ResizeObserver(([entry]) => setBubbleLayout({
      width: Math.max(40, Math.min(350, entry.target.getBoundingClientRect().width - 126)),
      measure
    }));
    if (transcript.current) observer.observe(transcript.current);
    return () => observer.disconnect();
  }, []);
  useEffect(() => {
    if (open && dialog.current && !dialog.current.open) dialog.current.showModal();
    if (!open && dialog.current?.open) dialog.current.close();
  }, [open]);
  const summary = sent || step === form.pages.length;
  const page = form.pages[step];
  const fields = form.pages.flatMap(item => item.fields);
  const put = (id, value) => setAnswers(previous => ({
    ...previous,
    [id]: value
  }));
  const accepts = field => {
    const value = answers[field.id];
    if (value === undefined || value === "" || Array.isArray(value) && !value.length) return !field.required;
    if (field.type === "select" || field.type === "picker") {
      const values = Array.isArray(value) ? value : [value];
      return values.every(v => field.options.some(option => option.value === v));
    }
    if (field.type === "date") return (/^\d{4}-\d{2}-\d{2}$/).test(value) && value >= (field.min_date || "1900-01-01") && value <= (field.max_date || "2100-12-31") && new Date(value + "T12:00:00Z").toISOString().slice(0, 10) === value;
    return (!field.required || value.trim().length > 0) && (field.multiline || !(/[\r\n]/).test(value)) && [...value].length <= (field.max_length || (field.multiline ? 300 : 30)) && (field.keyboard !== "email" || (/^(?!\.)(?!.*\.\.)([A-Za-z0-9_'+\-.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9-]*\.)+[A-Za-z]{2,}$/).test(value)) && (field.keyboard !== "phone" || (/^\+[1-9][0-9]{1,14}$/).test(value));
  };
  const cap = (field, value) => {
    if (field.keyboard === "phone") value = value.replace(/[ \-\.()\u00a0]/g, "");
    let kept = "";
    for (const {segment} of new Intl.Segmenter(undefined, {
      granularity: "grapheme"
    }).segment(value)) {
      if ([...kept + segment].length > (field.max_length || (field.multiline ? 300 : 30))) break;
      kept += segment;
    }
    return kept;
  };
  const display = field => {
    const value = answers[field.id];
    if (value === undefined || value === "" || Array.isArray(value) && !value.length) return null;
    if (field.options) return field.options.filter(o => Array.isArray(value) ? value.includes(o.value) : value === o.value).map(o => o.label).join(", ");
    if (field.type === "date") return new Date(value + "T12:00:00").toLocaleDateString("en-US", {
      month: "short",
      day: "numeric",
      year: "numeric"
    });
    return value;
  };
  const choose = (field, option) => {
    if (!field.multiple) return put(field.id, answers[field.id] === option.value ? "" : option.value);
    const chosen = new Set(answers[field.id] || []);
    if (chosen.has(option.value)) chosen.delete(option.value); else chosen.add(option.value);
    put(field.id, field.options.filter(o => chosen.has(o.value)).map(o => o.value));
  };
  const showCalendar = field => {
    setOpenDate(openDate === field.id ? null : field.id);
    const today = new Date();
    const localDay = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
    const min = field.min_date || "1900-01-01", max = field.max_date || "2100-12-31";
    setMonth((answers[field.id] || (localDay < min ? min : localDay > max ? max : localDay)).slice(0, 7));
  };
  const calendar = field => {
    const date = new Date(month + "-01T12:00:00");
    const start = date.getDay();
    const days = new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate();
    const shift = delta => {
      const next = new Date(date.getFullYear(), date.getMonth() + delta, 1, 12);
      return `${next.getFullYear()}-${String(next.getMonth() + 1).padStart(2, "0")}`;
    };
    return <div className="form-calendar" role="group" aria-label={`${field.label} calendar`}>
      <div className="form-calendar-heading">
        <strong>{date.toLocaleDateString("en-US", {
      month: "long",
      year: "numeric"
    })}</strong>
        <button type="button" aria-label="Previous month" disabled={shift(-1) < (field.min_date || "1900-01-01").slice(0, 7)} onClick={() => setMonth(shift(-1))}>‹</button>
        <button type="button" aria-label="Next month" disabled={shift(1) > (field.max_date || "2100-12-31").slice(0, 7)} onClick={() => setMonth(shift(1))}>›</button>
      </div>
      <div className="form-calendar-grid">
        {["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"].map(day => <span key={day} aria-hidden="true">{day}</span>)}
        {Array.from({
      length: start
    }, (_, i) => <span key={`empty-${i}`} />)}
        {Array.from({
      length: days
    }, (_, i) => {
      const value = month + "-" + String(i + 1).padStart(2, "0");
      return <button type="button" key={value} aria-label={value} aria-pressed={answers[field.id] === value} disabled={value < (field.min_date || "1900-01-01") || value > (field.max_date || "2100-12-31")} onClick={() => put(field.id, value)}>{i + 1}</button>;
    })}
      </div>
    </div>;
  };
  const title = sent ? "Form sent" : summary ? "Summary" : step === -1 ? form.splash.title || form.title : page.title;
  const canAdvance = step === -1 || (summary ? fields : page.fields).every(accepts);
  const sendStep = summary || step === form.pages.length - 1 && !form.show_summary;
  const response = {
    parts: [{
      type: "text",
      value: "Form sent"
    }, {
      type: "form_response",
      answers
    }],
    reply_to: {
      message_id: "01993d50-ef7b-7b37-886b-23fd80c7ec13",
      part_index: partIndex
    }
  };
  return <Frame className="relay-preview" caption="Interactive web preview. Answers stay in this page.">
      <div className="native-preview form-preview" role="group" aria-label="Interactive form preview">
        <div className="native-transcript" ref={transcript}>
          {parts.filter(part => part.type === "text").map((part, index) => <div key={index} className="native-text">{part.value}</div>)}
          <button type="button" className="native-form-prompt" aria-haspopup="dialog" onClick={() => setOpen(true)} aria-label={`Open ${form.received_message?.title || form.title}`}>
            {bubble({
    rows: [{
      kind: "title",
      text: form.received_message?.title || form.title
    }, {
      kind: "subtitle",
      text: sent ? "Form sent" : form.received_message?.subtitle ?? "Open form"
    }],
    side: "leading",
    chevron: true,
    ...bubbleLayout
  })}
          </button>
          {sent ? <button type="button" className="native-form-answer" aria-haspopup="dialog" onClick={() => setOpen(true)} aria-label="Review sent form">
            {bubble({
    rows: [{
      kind: "title",
      text: "Form sent"
    }, {
      kind: "subtitle",
      text: form.reply_message?.subtitle ?? "Tap to view answers"
    }],
    side: "trailing",
    chevron: true,
    ...bubbleLayout
  })}
          </button> : null}
        </div>
        {sent ? <>
          <button type="button" className="relay-preview-reset" aria-label="Reset demo" onClick={() => {
    setAnswers({});
    setSent(false);
    setStep(form.splash ? -1 : 0);
    setOpenDate(null);
  }}><Icon icon="rotate-left" size={16} /></button>
          <details className="native-response"><summary>Reply data (local preview)</summary><pre>{JSON.stringify(response, null, 2)}</pre></details>
        </> : null}
        <dialog ref={dialog} className="native-dialog native-form-sheet" aria-label={title} onClose={() => setOpen(false)}>
          <div className="native-grabber" aria-hidden="true" />
          <div className="native-sheet-bar">
            {!sent && step > (form.splash ? -1 : 0) ? <button type="button" className="native-back" aria-label="Back" onClick={() => {
    setStep(step - 1);
    setOpenDate(null);
  }}>‹</button> : <span />}
            <strong>{title}</strong>
            <button type="button" aria-label="Close form" onClick={() => {
    if (!sent && step === form.pages.length) setStep(form.pages.length - 1);
    setOpen(false);
    setOpenDate(null);
  }}>×</button>
          </div>
          <div className="native-form-content" key={title}>
            {summary ? form.pages.map(item => {
    const answered = item.fields.filter(field => display(field) !== null);
    return answered.length ? <section key={item.id}><h3>{item.title}</h3><div className="native-field-group">{answered.map(field => <div className="form-answer-row" key={field.id}><span>{field.label}</span><div>{display(field)}</div></div>)}</div></section> : null;
  }) : step === -1 ? <div className="native-field-group form-intro">{form.splash.text}</div> : page.fields.map(field => <section key={field.id}>
                {field.type === "text" || field.type === "select" || !field.required ? <h3><span>{field.type === "text" || field.type === "select" ? field.label : ""}</span>{!field.required ? <span>Optional</span> : null}</h3> : null}
                <div className="native-field-group">
                  {field.type === "text" ? field.multiline ? <textarea aria-label={field.label} aria-required={!!field.required} rows={3} placeholder={field.placeholder || ""} value={answers[field.id] || ""} onChange={e => put(field.id, cap(field, e.target.value))} /> : <input aria-label={field.label} aria-required={!!field.required} inputMode={({
    email: "email",
    phone: "tel",
    number: "decimal",
    url: "url"
  })[field.keyboard] || "text"} placeholder={field.placeholder || ""} value={answers[field.id] || ""} onChange={e => put(field.id, cap(field, e.target.value))} /> : null}
                  {field.type === "picker" ? <label className="form-picker">{field.label}<select aria-label={field.label} aria-required={!!field.required} value={answers[field.id] || ""} onChange={e => put(field.id, e.target.value)}><option value="">{field.placeholder || ""}</option>{field.options.map(o => <option value={o.value} key={o.value}>{o.label}</option>)}</select></label> : null}
                  {field.type === "select" ? field.options.map(option => {
    const chosen = field.multiple ? (answers[field.id] || []).includes(option.value) : answers[field.id] === option.value;
    const toggle = field.multiple && field.options.length === 1;
    return <button type="button" className="form-choice" key={option.value} role={toggle ? "switch" : undefined} aria-checked={toggle ? chosen : undefined} aria-pressed={toggle ? undefined : chosen} onClick={() => choose(field, option)}>
                      <span>{option.label}</span>{toggle ? <span className={"form-switch" + (chosen ? " is-on" : "")} aria-hidden="true"><span /></span> : <span className="form-check" aria-hidden="true">{chosen ? "✓" : ""}</span>}
                    </button>;
  }) : null}
                  {field.type === "date" ? <>
                    <button type="button" className="form-date" aria-label={`${field.label}, ${display(field) || "Choose date"}`} aria-expanded={openDate === field.id} onClick={() => showCalendar(field)}><span>{field.label}</span><span>{display(field) || "Choose date"}</span></button>
                    {openDate === field.id ? calendar(field) : null}
                    {answers[field.id] && !field.required ? <button type="button" className="form-clear-date" onClick={() => {
    put(field.id, "");
    setOpenDate(null);
  }}>Clear Date</button> : null}
                  </> : null}
                </div>
                {field.type === "text" && answers[field.id] && !accepts(field) ? <p className="form-hint" role="status">{field.keyboard === "phone" ? "Enter the number with its country code, starting with +." : field.keyboard === "email" ? "Enter an email address." : "Enter a valid answer."}</p> : null}
              </section>)}
          </div>
          {!sent ? <div className="native-form-bottom"><button type="button" className="native-capsule is-primary" disabled={!canAdvance} onClick={() => {
    if (!canAdvance) return;
    if (sendStep) {
      setSent(true);
      setOpen(false);
    } else setStep(step + 1);
    setOpenDate(null);
  }}>
            {step === -1 ? form.splash.button_title : sendStep ? "Send" : "Next"}
          </button></div> : null}
        </dialog>
      </div>
    </Frame>;
};

Send a `form` part to collect text, choices, and dates across pages in a native sheet.

## Send a form

Use an Agent Token and an existing chat ID. This example sends ordinary text followed by a form with two pages, an introduction, and a summary.

<Tabs>
  <Tab title="Preview">
    <FormPreview request={InteractionExamples().form} bubble={MessageBubble} />
  </Tab>

  <Tab title="Code">
    <CodeGroup>
      ```typescript TypeScript SDK theme={null}
      import Relay, { type MessageSendParams } from "@relaymessenger/sdk";

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

      const request = {
        "message": {
          "parts": [
            {"type": "text", "value": "Please share your visit preferences."},
            {
              "type": "form",
              "title": "Your visit",
              "show_summary": true,
              "splash": {
                "title": "Plan your visit",
                "text": "Share a few details before you arrive.",
                "button_title": "Continue"
              },
              "received_message": {"title": "Visit details", "subtitle": "Complete two pages"},
              "reply_message": {"title": "Form sent", "subtitle": "Tap to review your answers"},
              "pages": [
                {
                  "id": "about",
                  "title": "About you",
                  "fields": [
                    {"id": "name", "type": "text", "label": "Name", "placeholder": "Your name", "required": true},
                    {"id": "meal", "type": "select", "label": "Meal", "required": true, "options": [
                      {"value": "veg", "label": "Vegetarian"}, {"value": "fish", "label": "Fish"}
                    ]},
                    {"id": "extras", "type": "select", "label": "Extras", "multiple": true, "options": [
                      {"value": "tea", "label": "Tea"}, {"value": "cake", "label": "Cake"}
                    ]}
                  ]
                },
                {
                  "id": "visit",
                  "title": "Visit preferences",
                  "fields": [
                    {"id": "region", "type": "picker", "label": "Region", "options": [
                      {"value": "east", "label": "East"}, {"value": "west", "label": "West"}
                    ]},
                    {"id": "date", "type": "date", "label": "Visit date", "required": true, "min_date": "2026-10-01", "max_date": "2026-12-31"},
                    {"id": "notes", "type": "text", "label": "Notes", "multiline": true, "max_length": 500},
                    {"id": "updates", "type": "select", "label": "Visit updates", "multiple": true, "options": [
                      {"value": "yes", "label": "Send me visit updates"}
                    ]}
                  ]
                }
              ]
            }
          ],
          "idempotency_key": "visit-form-001"
        }
      } satisfies MessageSendParams;

      await relay.chats.messages.send("CHAT_ID", request);
      ```

      ```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": "Please share your visit preferences."},
              {
                "type": "form",
                "title": "Your visit",
                "show_summary": true,
                "splash": {
                  "title": "Plan your visit",
                  "text": "Share a few details before you arrive.",
                  "button_title": "Continue"
                },
                "received_message": {"title": "Visit details", "subtitle": "Complete two pages"},
                "reply_message": {"title": "Form sent", "subtitle": "Tap to review your answers"},
                "pages": [
                  {
                    "id": "about",
                    "title": "About you",
                    "fields": [
                      {"id": "name", "type": "text", "label": "Name", "placeholder": "Your name", "required": true},
                      {"id": "meal", "type": "select", "label": "Meal", "required": true, "options": [
                        {"value": "veg", "label": "Vegetarian"}, {"value": "fish", "label": "Fish"}
                      ]},
                      {"id": "extras", "type": "select", "label": "Extras", "multiple": true, "options": [
                        {"value": "tea", "label": "Tea"}, {"value": "cake", "label": "Cake"}
                      ]}
                    ]
                  },
                  {
                    "id": "visit",
                    "title": "Visit preferences",
                    "fields": [
                      {"id": "region", "type": "picker", "label": "Region", "options": [
                        {"value": "east", "label": "East"}, {"value": "west", "label": "West"}
                      ]},
                      {"id": "date", "type": "date", "label": "Visit date", "required": true, "min_date": "2026-10-01", "max_date": "2026-12-31"},
                      {"id": "notes", "type": "text", "label": "Notes", "multiline": true, "max_length": 500},
                      {"id": "updates", "type": "select", "label": "Visit updates", "multiple": true, "options": [
                        {"value": "yes", "label": "Send me visit updates"}
                      ]}
                    ]
                  }
                ]
              }
            ],
            "idempotency_key": "visit-form-001"
          }
        }'
      ```
    </CodeGroup>
  </Tab>
</Tabs>

Relay returns `202` after storing the Message. Save its ID to associate the answer with this form, and reuse the same request and idempotency key after an uncertain send.

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

The card shows `received_message.title` when supplied, otherwise `form.title`. Tapping it opens the sheet. Each page uses its page title.

The user opens the introduction, fills the pages in order with **Next** and **Back**, reviews the summary, and presses **Send**. The answered bubble reads **Form sent**; tapping it or reopening the prompt shows the answers read-only.

## Choose fields and limits

Each page has an `id`, a `title`, and 1 to 50 fields. Page IDs are unique within the form and contain 1 to 19 characters; field IDs are unique across all pages and contain 1 to 100 characters. Both use `^[A-Za-z0-9][A-Za-z0-9._:-]*$`.

| Field type | Label limit | Options | Answer |
| - | - | - | - |
| `text` | 20 | Single line by default; set `multiline: true` for multiple lines, and `keyboard` for the keyboard | String |
| `select` | 30 | 1 to 20; single choice by default, or `multiple: true` | String, or an array of strings for multiple choices |
| `picker` | 20 | 1 to 200; one choice | String |
| `date` | 40 | `min_date` and `max_date` bound the calendar | A valid calendar date in `YYYY-MM-DD` format |

Limits count Unicode scalar characters. Each field requires a `label` with no leading or trailing spaces that is not only spaces or invisible characters such as a zero-width space; titles follow the same rule. `placeholder` is optional text shown in an empty field. `required` defaults to `false`; set it to `true` to require a nonempty answer before advancing or sending.

Text `max_length` defaults to 30 for single-line text and 300 for multiline text. **These are defaults**: set a positive integer `max_length` to choose another length, such as the 500-character Notes field above. Single-line answers contain no line breaks. An explicit `max_length` can be at most `9007199254740991`, the API's safe-integer bound. Only text fields take `max_length`.

A text field's `keyboard` chooses the keyboard the app shows: `default`, `email`, `phone`, `number` or `url`. Relay also checks two of them. An `email` answer must be an email address, and a `phone` answer must be an E.164 number such as `+13135550123`; any other answer is refused with [2006](/api-reference/errors#2006). `number` and `url` only choose the keyboard.

Select and picker options have only `value` and `label`. An option's stable, case-sensitive `value` contains 1 to 100 characters using the field-ID pattern, and must be unique within that field; its label contains 1 to 30 characters.

A date field's calendar runs from `min_date` to `max_date`, both `YYYY-MM-DD`. They default to `1900-01-01` and `2100-12-31`, and `min_date` must not be after `max_date`. An answer outside the range is refused.

### Form presentation

| Setting | Meaning |
| - | - |
| `title` | Required form title, 1 to 80 characters. Page titles use the same range. |
| `pages` | At least one page, displayed in array order. |
| `show_summary` | Show the answers for review before Send. `show_summary` defaults to `false`. |
| `splash` | Optional introduction with a required `button_title` of 1 to 35 characters, an optional title of 1 to 80, and optional text up to 4096. |
| `received_message` | Optional card text: a required title of 1 to 512 characters and an optional subtitle up to 512. |
| `reply_message` | Optional answered card text: title must be `"Form sent"`; subtitle is optional, up to 512 characters. |

Only an agent sends a form, and each Message carries at most one. **Only text may sit beside a form**: a Message with a form and media, a link, a place, a `data` card, buttons, a selection, a rich card or a carousel is refused with `400`. Send those in their own Messages. Text beside the form is an ordinary message shown above the card.

## Receive answers

**Read `answers` by field ID, together with `reply_to`, rather than parsing the visible text.** Your existing signed [webhook](/webhooks/verify-signatures) or [WebSocket](/websocket/index) handler receives an ordinary `message.received` event.

This excerpt shows the answer to the example above. The submission has exactly two ordered parts: the plain text `Form sent`, then `form_response` metadata. `reply_to.part_index` is `1` because the source Message has text at index `0` and the form at index `1`.

```json theme={null}
{
  "event_type": "message.received",
  "data": {
    "parts": [
      {"type": "text", "value": "Form sent"},
      {
        "type": "form_response",
        "answers": {
          "name": "Ada",
          "meal": "veg",
          "extras": ["tea", "cake"],
          "region": "east",
          "date": "2026-10-02",
          "notes": "Window seat, please.",
          "updates": ["yes"]
        }
      }
    ],
    "reply_to": {
      "message_id": "01993d50-ef7b-7b37-886b-23fd80c7ec13",
      "part_index": 1
    }
  }
}
```

Text, date, picker, and single-select answers are strings. Multi-select answers are arrays of unique option values in source-option order. A one-option multi-select represents opt-in, as `updates` does here.

Omit optional unanswered fields; an empty string or empty array of the appropriate answer type is also accepted. Required fields must have a nonempty answer. The server checks field IDs, value types, lengths, dates, and choices against the source form.

Use the SDK's event type to keep the structured values intact:

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

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

Use your existing event deduplication and durable processing path before acknowledging delivery.

## Read whether someone answered

A read-back `form` part includes its definition plus `value`, `has_responded`, `answers`, and `reactions: null`. `value` is the form's `received_message.title`, else its `title`: the words a client that does not draw the form shows in its place. The answer fields belong to the authenticated viewer.

* Before a user answers, `has_responded` is `false` and `answers` is `null`.
* After the user answers, `has_responded` is `true` and `answers` holds that user's field-keyed values. A `message.upserted` for the prompt refreshes the user's other devices.
* If the answer Message no longer exists, `answers` is `null`.
* For an agent, `has_responded` is always `false` and `answers` is always `null`. Read the incoming `form_response` Message to get the user's answers.

A user submits one answer to a form. Reopening it shows the answers read-only. The `form_response` metadata adds no visible text and has no `reactions` key.

## When it fails

The reply must target a form the user can see in the same chat, using both `reply_to.message_id` and `reply_to.part_index`. A swipe-reply that quotes the form card with text only, such as a question about the form, is an ordinary reply: it succeeds and does not answer the form. Only a Message with `form_response` answers it.

| Status | Code | Cause |
| - | - | - |
| `400` | [1005](/api-reference/errors#1005) | Invalid form fields, duplicate IDs or option values, extra fields, multiple forms, any part other than text beside a form, or a malformed answer envelope. |
| `403` | [2003](/api-reference/errors#2003) | A user sends a form, or an agent submits an answer. |
| `404` | [2001](/api-reference/errors#2001) | The source Message is not visible to the user in this chat. |
| `409` | [1005](/api-reference/errors#1005) | The user already answered this form under a different idempotency key. |
| `422` | [2006](/api-reference/errors#2006) | The answer does not match the source fields, required values, dates, lengths, choices, or option order; the reply targets another part type; or a reaction targets either form part. |

Keep the same body and idempotency key when retrying an uncertain submission. A matching retry returns the original answer.

## Next steps

* [Selection](/interactions/selection)
* [Build message parts](/messages/parts)
* [Replies](/messages/replies)
* [Receive webhook events](/webhooks/index)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.