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

# Rich cards

> Send a card with a picture, a title, a description and up to four buttons, or a carousel of 2 to 10 cards the person swipes.

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 RichCardPreview = ({request, bubble}) => {
  const [replies, setReplies] = useState([]);
  const [opened, setOpened] = useState(null);
  const dialog = useRef(null);
  const shelf = useRef(null);
  const parts = request.message.parts;
  const partIndex = parts.findIndex(part => part.type === "rich_card" || part.type === "carousel");
  const part = parts[partIndex];
  const cards = part.type === "carousel" ? part.cards : [part];
  const photos = ["/images/interactions/lagoon.jpg", "/images/interactions/cliffside.jpg"];
  useEffect(() => {
    if (opened && dialog.current && !dialog.current.open) dialog.current.showModal();
    if (!opened && dialog.current?.open) dialog.current.close();
  }, [opened]);
  const reply = suggestion => setReplies(previous => [...previous, {
    parts: [{
      type: "text",
      value: suggestion.label
    }, {
      type: "suggestion_response",
      id: suggestion.id,
      label: suggestion.label
    }],
    reply_to: {
      message_id: "01993d50-ef7b-7b37-886b-23fd80c7ec13",
      part_index: partIndex
    }
  }]);
  return <Frame className="relay-preview" caption="Interactive web preview. Sample photos; actions stay in this page.">
      <div className="native-preview rich-preview" role="group" aria-label={part.type === "carousel" ? "Interactive carousel preview" : "Interactive rich card preview"}>
        <div className="native-transcript">
          {parts.filter(item => item.type === "text").map((item, index) => <div className="native-text" key={index}>{item.value}</div>)}
          <div className={"rich-shelf" + (part.type === "carousel" ? " is-carousel" : "")} ref={shelf} tabIndex={part.type === "carousel" ? 0 : undefined} role={part.type === "carousel" ? "region" : undefined} aria-label={part.type === "carousel" ? "Cards, scroll sideways to compare" : undefined}>
            {cards.map((card, index) => <div className={"rich-balloon" + (part.card_width === "small" ? " is-small" : "")} key={index}>
                <div className="rich-card">
                  {card.media ? <button className="rich-media" type="button" style={{
    height: ({
      short: 112,
      medium: 168,
      tall: 264
    })[card.media.height || "medium"]
  }} aria-label={`Open sample photo for ${card.title || "card"}`} onClick={() => setOpened({
    photo: photos[index],
    title: card.title
  })}>
                      <img src={photos[index]} alt="" />
                    </button> : null}
                  <div className="rich-copy">
                    {card.title ? <div className="rich-title">{card.title}</div> : null}
                    {card.description ? <div className="rich-description">{card.description}</div> : null}
                  </div>
                  <div className="rich-suggestions">
                    {card.suggestions.map((suggestion, i) => <button type="button" key={i} className={"native-capsule" + (i === 0 ? " is-primary" : "")} onClick={() => suggestion.type === "reply" ? reply(suggestion) : setOpened({
    url: suggestion.url
  })}>
                        {suggestion.label}{suggestion.type === "open_url" ? <span aria-hidden="true"> ↗</span> : null}
                      </button>)}
                  </div>
                </div>
                {part.type !== "carousel" ? <svg className="native-tail" viewBox="0 0 23 24" aria-hidden="true"><path d="M0 0C0 .306 1.415 4.498 4.028 7.924C5.087 9.312 6.309 10.536 7.66 11.577C9.585 13.078 10.418 14.644 10.418 16.404C10.418 17.587 10.209 18.756 8.51 20.988C7.695 22.058 8.513 23.15 9.787 22.666C12.407 21.672 15.391 19.86 18.005 17.927C20.347 16.195 20.971 16.02 22.07 16.013L22.07 0Z" /></svg> : null}
              </div>)}
          </div>
          {part.type === "carousel" ? <div className="rich-scroll-controls">
            <button type="button" aria-label="Previous card" onClick={() => shelf.current.scrollBy({
    left: -shelf.current.clientWidth,
    behavior: "auto"
  })}>‹</button>
            <button type="button" aria-label="Next card" onClick={() => shelf.current.scrollBy({
    left: shelf.current.clientWidth,
    behavior: "auto"
  })}>›</button>
          </div> : null}
          {replies.length === 0 ? parts.filter(item => item.type === "buttons").map((item, index) => <div className="rich-followups" key={index}>{item.items.map((button, i) => <button className="native-capsule" type="button" key={i} onClick={() => setReplies([{
    parts: [{
      type: "text",
      value: button.label
    }]
  }])}>{button.label}</button>)}</div>) : null}
          <div className="native-replies" aria-live="polite">
            {replies.map((response, index) => <div className="buttons-reply" key={index} aria-label={`Reply: ${response.parts[0].value}`}>{bubble({
    text: response.parts[0].value
  })}</div>)}
          </div>
        </div>
        {replies.length ? <>
          <button type="button" className="relay-preview-reset" aria-label="Reset demo" onClick={() => {
    setReplies([]);
    if (shelf.current) shelf.current.scrollLeft = 0;
  }}><Icon icon="rotate-left" size={16} /></button>
          <details className="native-response"><summary>Reply data (local preview)</summary><pre>{JSON.stringify(replies[replies.length - 1], null, 2)}</pre></details>
        </> : null}
        <dialog ref={dialog} className={"native-dialog" + (opened?.photo ? " is-photo" : "")} aria-label={opened?.photo ? "Sample photo" : "In-app browser preview"} onClose={() => setOpened(null)}>
          <div className="native-sheet-bar"><span>{opened?.photo ? opened.title : "In-app browser preview"}</span><button type="button" aria-label="Close" onClick={() => setOpened(null)}>×</button></div>
          {opened?.photo ? <img className="rich-photo" src={opened.photo} alt={`Sample photo for ${opened.title}`} /> : <div className="rich-url"><strong>{opened?.url}</strong><p>The app opens the page in its browser. This preview does not navigate or send a reply.</p></div>}
        </dialog>
      </div>
    </Frame>;
};

A `rich_card` part is one card: a picture or video across the top, a title, a description, and up to four buttons called suggestions. A `carousel` part is 2 to 10 cards in a row that the person swipes sideways. Only an agent can send either, and a Message carries at most one.

A suggestion is a reply or an action. A reply comes back to your agent as the person's own message, carrying the reply's `id`. An action is done by the person's phone: it opens a page, calls a number, opens a map, shares a location, or adds an event to the calendar, and sends nothing back.

## Send a card

This request sends a text part and then one card. The text shows as a normal message above the card.

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

  <Tab title="JSON">
    ```json theme={null}
    {
      "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"
      }
    }
    ```
  </Tab>
</Tabs>

The app draws the card as one message bubble. The picture fills the top of the bubble, the title and description sit under it, and the suggestions are full-width buttons at the bottom; the first is filled in blue and the rest are tinted. A tap on the picture opens it full screen.

Use an Agent Token and an existing chat ID to send it:

<CodeGroup>
  ```typescript TypeScript SDK theme={null}
  import Relay 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: [
        { 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",
    },
  });
  ```

  ```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":"This one fits."},
          {"type":"rich_card",
           "media":{"type":"image","url":"https://example.com/lagoon-house.jpg","height":"medium"},
           "title":"Lagoon House, Railay",
           "suggestions":[{"type":"reply","label":"Book","id":"book_lagoon"}]}
        ],
        "idempotency_key":"krabi-card-001"
      }
    }'
  ```
</CodeGroup>

### Rules for a card

* A card needs at least one of `media`, `title` or `description`.
* `title` is 1 to 200 characters and `description` is 1 to 2,000 characters.
* `media.type` is `image` or `video`, and `media.url` is a public `https` address. A video can carry a `thumbnail_url`, shown before it plays.
* `media.height` is `short` (112 pt), `medium` (168 pt, the default) or `tall` (264 pt).
* `suggestions` holds 1 to 4 suggestions. Each `label` is 1 to 25 characters.
* A Message carries at most one `rich_card` or `carousel`, and never a `selection` beside it.

## Send a carousel

A `carousel` holds 2 to 10 cards. Each card has the same fields as a `rich_card`, without `type`. `card_width` is `small` (180 pt) or `medium` (the default), which is as wide as a single card: up to 350 pt, and never wider than a text bubble on the screen. Every card is drawn as tall as the tallest one, and the next card peeks in from the edge so the person knows to swipe.

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

  <Tab title="JSON">
    ```json theme={null}
    {
      "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"}]}
        ]
      }
    }
    ```
  </Tab>
</Tabs>

A reply `id` must be unique across the whole part, so one `id` names one card's button. A [`buttons`](/interactions/buttons) part may follow the card or carousel in the same Message: its buttons are drawn under the cards, and they go away once the person answers the card or taps one of them.

## Suggestions

| `type` | Fields | What a tap does |
| - | - | - |
| `reply` | `label`, `id` (1 to 256 characters) | Sends the person's reply to your agent. |
| `open_url` | `label`, `url` (`http` or `https` only), optional `application` (`browser` or `webview`) | Opens the page inside the app. |
| `dial` | `label`, `phone_number` in E.164 form, such as `+12223334444` | Opens the phone's dialer with the number filled in. |
| `view_location` | `label`, then `latitude` and `longitude` together, or a `query`; optional `name` for the pin | Opens Apple Maps at the place, or searches for the query. |
| `share_location` | `label` | Opens the person's location picker. What they pick arrives as their own [`place`](/messages/places) message. |
| `create_calendar_event` | `label`, `start_time`, `end_time` (RFC 3339), `title` (1 to 100 characters), optional `description` (up to 500) | Opens the phone's new-event sheet, filled in. The person saves it. |

A card's suggestions stay on the card after the person taps one, so the person can tap them again.

## Receive a reply

A tap on a `reply` suggestion arrives as an ordinary `message.received`. Its parts are a `text` part equal to the reply's `label`, then a `suggestion_response` part with the reply's `id` and `label`. Its `reply_to` names the card's message and part.

```json theme={null}
{
  "event_type":"message.received",
  "data": {
    "parts": [
      {"type":"text","value":"Book"},
      {"type":"suggestion_response","id":"book_lagoon","label":"Book"}
    ],
    "reply_to": {
      "message_id":"01993d50-ef7b-7b37-886b-23fd80c7ec13",
      "part_index":1
    }
  }
}
```

The person sees their reply as a normal message bubble that reads "Book". Dispatch on `id`, never on the words. The SDKs read it for you:

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

  function replyFrom(event: RelayWebhookEvent) {
    if (event.event_type !== "message.received") return;
    return suggestionReply(event.data.parts, event.data.reply_to);
    // { id: "book_lagoon", label: "Book", reply_to: { message_id, part_index: 1 } }
  }
  ```

  ```python Python SDK theme={null}
  from relaymessenger.rich_cards import suggestion_reply

  def reply_from(event):
      if event["event_type"] != "message.received":
          return None
      data = event["data"]
      return suggestion_reply(data["parts"], data.get("reply_to"))
  ```
</CodeGroup>

## When it fails

| Status | Code | Cause |
| - | - | - |
| `400` | [1005](/api-reference/errors#1005) | A field is missing, too long or unknown; a card has no media, title or description; a URL is not `https` media or an `http`/`https` page; a number is not E.164; a carousel has fewer than 2 or more than 10 cards; two reply suggestions share an `id`; or the Message has two cards or a selection beside a card. A reply is not exactly a text part then a `suggestion_response` part, or its `reply_to.part_index` is missing. |
| `403` | [2003](/api-reference/errors#2003) | Someone other than an agent sent a card, or someone other than a person answered one. |
| `404` | [2001](/api-reference/errors#2001) | The reply's `reply_to.message_id` is not a Message the person can see in this chat. |
| `422` | [2006](/api-reference/errors#2006) | The reply's `id` is not a reply suggestion on the named part, its text does not equal that suggestion's label, `reply_to.part_index` does not name a card or carousel, or a reaction targets a `suggestion_response` part. |

## Next steps

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


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