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

# Cards

> Send a card built from A2UI components, read the person's taps, and change the card in place.

export const A2uiPreview = ({messages, reply, media, label, icons = "/images/cards/icons"}) => {
  const V = "v0.9.1";
  const HEAD = ["h1", "h2", "h3", "h4", "h5"];
  const TEXT_CLS = {
    h1: "a2-h1",
    h2: "a2-h2",
    h3: "a2-h3",
    h4: "a2-t",
    h5: "a2-h5"
  };
  const JUST = {
    start: "flex-start",
    center: "center",
    end: "flex-end",
    spaceBetween: "space-between",
    spaceAround: "space-around",
    spaceEvenly: "space-evenly",
    stretch: "flex-start"
  };
  const ALIGN = {
    start: "flex-start",
    center: "center",
    end: "flex-end",
    stretch: "stretch"
  };
  const TEXTISH = ["heading", "subtitle", "body", "caption", "image", "video", "audio", "field", "slider", "datetime", "iconrow", "icon"];
  const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
  const TAIL = "M 0 0 C 0 0.306 1.415 4.498 4.028 7.924 C 5.087 9.312 6.309 10.536 7.660 11.577 C 9.585 13.078 10.418 14.644 10.418 16.404 C 10.418 17.587 10.209 18.756 8.510 20.988 C 7.695 22.058 8.513 23.150 9.787 22.666 C 12.407 21.671 15.391 19.860 18.005 17.927 C 20.347 16.195 20.971 16.020 22.070 16.013 L 22.070 0 Z";
  const clone = value => value === undefined ? undefined : JSON.parse(JSON.stringify(value));
  const segments = path => path.split("/").filter(part => part !== "");
  const ptrGet = (obj, path) => {
    let cur = obj;
    for (const part of segments(path)) {
      if (Array.isArray(cur)) cur = (/^\d+$/).test(part) ? cur[Number(part)] : undefined; else if (cur && typeof cur === "object") cur = cur[part]; else return undefined;
      if (cur === undefined || cur === null) return undefined;
    }
    return cur;
  };
  const ptrSet = (obj, path, value) => {
    const parts = segments(path);
    if (parts.length === 0) return clone(value) || ({});
    const root = clone(obj) || ({});
    let cur = root;
    parts.slice(0, -1).forEach((part, index) => {
      if (cur[part] === undefined || cur[part] === null || typeof cur[part] !== "object") {
        cur[part] = (/^\d+$/).test(parts[index + 1]) ? [] : {};
      }
      cur = cur[part];
    });
    const last = parts[parts.length - 1];
    if (value === undefined) delete cur[last]; else cur[last] = clone(value);
    return root;
  };
  const applyData = (data, list) => list.reduce((model, message) => {
    const update = message.updateDataModel;
    if (!update) return model;
    return ptrSet(model, update.path || "/", ("value" in update) ? update.value : undefined);
  }, data);
  const surfaceOf = list => {
    const out = {
      comps: {},
      sid: null
    };
    for (const message of list) {
      if (message.createSurface) out.sid = message.createSurface.surfaceId;
      if (message.updateComponents) {
        out.sid = out.sid || message.updateComponents.surfaceId;
        for (const component of message.updateComponents.components) out.comps[component.id] = component;
      }
      if (message.deleteSurface) out.deleted = true;
    }
    return out;
  };
  const [shown, setShown] = useState(messages);
  const [data, setData] = useState(() => applyData({}, messages));
  const [taps, setTaps] = useState([]);
  const [open, setOpen] = useState(null);
  const [tabs, setTabs] = useState({});
  const [pages, setPages] = useState({});
  const [aspects, setAspects] = useState({});
  const [playing, setPlaying] = useState({});
  const [answered, setAnswered] = useState({});
  const [pending, setPending] = useState(null);
  const timer = useRef(null);
  useEffect(() => () => clearTimeout(timer.current), []);
  const surface = surfaceOf(shown);
  const comps = surface.comps;
  const resolve = (path, scope) => path.startsWith("/") ? path : (scope || "") + "/" + path;
  const get = (path, scope) => ptrGet(data, resolve(path, scope));
  const setBound = (binding, value, scope) => {
    if (binding && typeof binding === "object" && typeof binding.path === "string") {
      const target = resolve(binding.path, scope);
      setData(model => ptrSet(model, target, value));
    }
  };
  const format = (template, scope) => String(template).replace(/(\\?)\$\{([^}]*)\}/g, (whole, escaped, expression) => {
    if (escaped) return whole.slice(1);
    const x = get(expression.trim(), scope);
    if (Array.isArray(x)) return x.length ? String(x[0]) : "";
    return x === undefined || x === null ? "" : String(x);
  });
  const fn = (v, scope) => {
    const a = v.args || ({});
    const val = () => dyn(a.value, scope);
    switch (v.call) {
      case "required":
        {
          const x = val();
          return !(x === undefined || x === null || x === "" || x === false || Array.isArray(x) && x.length === 0);
        }
      case "email":
        return (/^[^@\s]+@[^@\s]+\.[^@\s]+$/).test(String(val() || ""));
      case "regex":
        try {
          return new RegExp(a.pattern).test(String(val() ?? ""));
        } catch (error) {
          return false;
        }
      case "length":
        {
          const n = String(val() ?? "").length;
          return (a.min === undefined || n >= a.min) && (a.max === undefined || n <= a.max);
        }
      case "numeric":
        {
          const raw = val();
          const x = Number(raw);
          if (raw === "" || raw === null || raw === undefined || Number.isNaN(x)) return false;
          return (a.min === undefined || x >= a.min) && (a.max === undefined || x <= a.max);
        }
      case "not":
        return !dyn(a.value, scope);
      case "and":
        return (a.values || []).every(x => dyn(x, scope));
      case "or":
        return (a.values || []).some(x => dyn(x, scope));
      case "formatString":
        return format(dyn(a.value, scope) ?? "", scope);
      case "formatNumber":
        {
          const decimals = dyn(a.decimals, scope);
          return Number(val() || 0).toLocaleString("en-US", {
            minimumFractionDigits: decimals,
            maximumFractionDigits: decimals ?? 2,
            useGrouping: dyn(a.grouping, scope) !== false
          });
        }
      case "formatCurrency":
        {
          const decimals = dyn(a.decimals, scope);
          return Number(val() || 0).toLocaleString("en-US", {
            style: "currency",
            currency: dyn(a.currency, scope) || "USD",
            minimumFractionDigits: decimals,
            maximumFractionDigits: decimals
          });
        }
      case "pluralize":
        {
          const n = Number(val());
          if (n === 0 && a.zero !== undefined) return dyn(a.zero, scope);
          return n === 1 ? dyn(a.one, scope) : dyn(a.other, scope);
        }
      default:
        return undefined;
    }
  };
  const dyn = (v, scope) => {
    if (v && typeof v === "object" && !Array.isArray(v)) {
      if (typeof v.path === "string" && !v.call) return get(v.path, scope);
      if (v.call) return fn(v, scope);
    }
    return v;
  };
  const failing = (c, scope) => (c.checks || []).filter(rule => !dyn(rule.condition, scope)).map(rule => rule.message);
  const md = (s, keyBase = "m") => {
    const out = [];
    const pattern = /\*\*(.+?)\*\*|\*(\S.*?)\*|\[(.+?)\]\((.+?)\)/g;
    let last = 0;
    let match;
    const text = String(s ?? "");
    while (match = pattern.exec(text)) {
      if (match.index > last) out.push(text.slice(last, match.index));
      const key = keyBase + match.index;
      if (match[1] !== undefined) out.push(<b key={key}>{match[1]}</b>); else if (match[2] !== undefined) out.push(<i key={key}>{match[2]}</i>); else out.push(<span key={key} className="a2-link">{match[3]}</span>);
      last = pattern.lastIndex;
    }
    if (last < text.length) out.push(text.slice(last));
    return out;
  };
  const src = url => media && media[url] || url;
  const aspect = url => Math.min(2, Math.max(0.8, aspects[url] || 2));
  const measure = url => element => {
    if (element && element.complete && element.naturalWidth && !aspects[url]) {
      const ratio = element.naturalWidth / element.naturalHeight;
      setAspects(current => current[url] ? current : {
        ...current,
        [url]: ratio
      });
    }
  };
  const loaded = url => event => {
    const element = event.currentTarget;
    if (element.naturalWidth) {
      const ratio = element.naturalWidth / element.naturalHeight;
      setAspects(current => current[url] ? current : {
        ...current,
        [url]: ratio
      });
    }
  };
  const icon = (name, cls = "") => <i className={"a2-ic " + cls} style={{
    "--m": "url(" + icons + "/" + name + ".png)"
  }} aria-hidden="true" />;
  const items = (c, scope) => {
    const children = c.children;
    if (Array.isArray(children)) return children.map(id => ({
      id,
      scope
    }));
    if (children && children.path) {
      const base = resolve(children.path, scope);
      const list = ptrGet(data, base) || [];
      return list.map((_, index) => ({
        id: children.componentId,
        scope: base + "/" + index
      }));
    }
    return [];
  };
  const kind = cid => {
    const c = comps[cid];
    if (!c) return "missing";
    const t = c.component;
    if (t === "Text") {
      const v = c.variant || "body";
      return HEAD.includes(v) ? "heading" : v;
    }
    if (t === "Button" || t === "Modal") return "button";
    if (t === "Row") {
      const ks = (Array.isArray(c.children) ? c.children : []).map(k => comps[k] && comps[k].component);
      if (ks.length && ks.every(k => k === "Button" || k === "Modal")) return "buttons";
      if (ks.length && ks[0] === "Icon" && ks.includes("Text")) return "iconrow";
      return "row";
    }
    return ({
      Image: "image",
      Video: "video",
      AudioPlayer: "audio",
      Column: "col",
      List: "list",
      Card: "card",
      Tabs: "tabs",
      Divider: "divider",
      TextField: "field",
      CheckBox: "checkbox",
      ChoicePicker: "choice",
      Slider: "slider",
      DateTimeInput: "datetime",
      Icon: "icon"
    })[t] || "missing";
  };
  const margin = (prev, k) => {
    if (prev === null) return 0;
    if (k === "subtitle") return 4;
    if (k === "heading") return 12;
    if (["body", "caption", "iconrow", "col", "list", "checkbox", "icon"].includes(k)) return 8;
    if (k === "buttons" || k === "button") return TEXTISH.includes(prev) ? 20 : 14;
    if (k === "choice") return 10;
    return 12;
  };
  const isCarousel = c => c && c.component === "List" && c.direction === "horizontal" && Array.isArray(c.children) && c.children.every(k => comps[k] && comps[k].component === "Image");
  const cardColumn = (cid, scope, top) => {
    const kids = items(comps[cid], scope);
    const out = [];
    let prev = null;
    let i = 0;
    if (kids.length && top) {
      const first = comps[kids[0].id];
      if (first && first.component === "Image" && ["mediumFeature", "largeFeature", "header"].includes(first.variant || "mediumFeature")) {
        out.push(<div key="hero">{hero(first, kids[0].scope)}</div>);
        i = 1;
      } else if (isCarousel(first)) {
        out.push(<div key="hero">{carousel(first, true, kids[0].scope)}</div>);
        i = 1;
      }
    }
    while (i < kids.length) {
      let k = kind(kids[i].id);
      let html;
      let next;
      if (k === "button") {
        let j = i;
        while (j < kids.length && kind(kids[j].id) === "button") j += 1;
        html = buttonGroup(kids.slice(i, j), false);
        next = j;
      } else {
        if (k === "caption" && prev === "heading") k = "subtitle";
        html = block(kids[i].id, k, kids[i].scope);
        next = i + 1;
      }
      const group = k === "button" ? "buttons" : k;
      out.push(<div key={"b" + i} className="a2-blk" style={{
        marginTop: margin(prev, group)
      }}>{html}</div>);
      prev = group;
      i = next;
    }
    return out;
  };
  const block = (cid, k, scope, ctx = "card") => {
    const c = comps[cid];
    if (!c) return null;
    const t = c.component;
    if (t === "Text") return text(c, k, scope, ctx);
    if (t === "Image") return image(c, scope, ctx);
    if (t === "Icon") return icon(dyn(c.name, scope));
    if (t === "Video") return video(c, scope);
    if (t === "AudioPlayer") return audio(c, scope);
    if (t === "Row") return row(c, scope, ctx);
    if (t === "Column") return column(c, scope, ctx);
    if (t === "List") return list(c, scope);
    if (t === "Card") {
      const child = comps[c.child];
      const inner = child && child.component === "Column" ? cardColumn(child.id, scope, false) : block(c.child, kind(c.child), scope);
      return <div className="a2-box">{inner}</div>;
    }
    if (t === "Tabs") return tabbed(c, scope);
    if (t === "Divider") return c.axis === "vertical" ? <div className="a2-vd" role="separator" aria-orientation="vertical" /> : <div className="a2-hr" role="separator" />;
    if (t === "Button" || t === "Modal") return buttonGroup([{
      id: cid,
      scope
    }], false);
    if (t === "TextField") return field(c, scope);
    if (t === "CheckBox") return checkbox(c, scope);
    if (t === "ChoicePicker") return choice(c, scope);
    if (t === "Slider") return slider(c, scope);
    if (t === "DateTimeInput") return datetime(c, scope);
    return null;
  };
  const text = (c, k, scope, ctx) => {
    const s = md(dyn(c.text, scope) ?? "", c.id);
    const v = c.variant || "body";
    if (ctx === "row" || ctx === "listrow") {
      if (HEAD.includes(v)) return <div className={ctx === "listrow" ? "a2-on1 a2-bd" : TEXT_CLS[v]}>{s}</div>;
      if (v === "caption") return <div className="a2-os">{s}</div>;
      return <div className={ctx === "listrow" ? "a2-on1" : "a2-b"}>{s}</div>;
    }
    if (ctx === "hitem") return <div className={v === "caption" ? "a2-hp" : "a2-hn"}>{s}</div>;
    if (HEAD.includes(v)) return <div className={TEXT_CLS[v]} role="heading" aria-level={Number(v.slice(1)) + 2}>{s}</div>;
    if (k === "subtitle") return <div className="a2-s">{s}</div>;
    if (v === "caption") return <div className="a2-tc">{s}</div>;
    return <div className="a2-b">{s}</div>;
  };
  const fitOf = c => ({
    scaleDown: "scale-down"
  })[c.fit || "cover"] || c.fit || "cover";
  const image = (c, scope, ctx) => {
    const url = dyn(c.url, scope);
    const v = c.variant || "mediumFeature";
    const alt = dyn(c.description, scope) || "";
    const common = {
      src: src(url),
      alt,
      ref: measure(url),
      onLoad: loaded(url)
    };
    const cls = ({
      icon: "ic",
      avatar: "av",
      smallFeature: "sm",
      mediumFeature: "md",
      largeFeature: "lg",
      header: "hd"
    })[v];
    const feature = ["mediumFeature", "largeFeature", "header"].includes(v);
    if (ctx === "hitem") return <img className="a2-hli-img" {...common} />;
    if ((ctx === "row" || ctx === "listrow") && feature) return <img className="a2-im sq" style={{
      objectFit: fitOf(c)
    }} {...common} />;
    if (feature) return <img className="a2-im fe" style={{
      aspectRatio: aspect(url).toFixed(4),
      objectFit: fitOf(c)
    }} {...common} />;
    return <img className={"a2-im " + cls} style={{
      objectFit: fitOf(c)
    }} {...common} />;
  };
  const hero = (c, scope) => {
    const url = dyn(c.url, scope);
    return <img className="a2-hero" src={src(url)} alt={dyn(c.description, scope) || ""} ref={measure(url)} onLoad={loaded(url)} style={{
      aspectRatio: aspect(url).toFixed(4),
      objectFit: fitOf(c)
    }} />;
  };
  const carousel = (c, isHero, scope) => {
    const imgs = c.children.map(k => comps[k]);
    const n = imgs.length;
    const page = Math.min(pages[c.id] || 0, n - 1);
    const firstURL = dyn(imgs[0].url, scope);
    const go = (event, index) => {
      const track = event.currentTarget.closest(".a2-car").querySelector(".a2-car-track");
      track.scrollTo({
        left: index * track.clientWidth,
        behavior: "smooth"
      });
      setPages(current => ({
        ...current,
        [c.id]: index
      }));
    };
    return <div className={"a2-car " + (isHero ? "hero" : "inset")} style={{
      aspectRatio: aspect(firstURL).toFixed(4)
    }}>
        <div className="a2-car-track" tabIndex={0} role="group" aria-roledescription="carousel" aria-label={"Photo " + (page + 1) + " of " + n} onScroll={event => {
      const el = event.currentTarget;
      const p = Math.round(el.scrollLeft / el.clientWidth);
      if (p !== (pages[c.id] || 0)) setPages(current => ({
        ...current,
        [c.id]: p
      }));
    }}>
          {imgs.map((img, index) => {
      const url = dyn(img.url, scope);
      return <img key={img.id} src={src(url)} alt={dyn(img.description, scope) || ""} ref={measure(url)} onLoad={loaded(url)} style={{
        objectFit: fitOf(img)
      }} />;
    })}
        </div>
        {n > 1 ? <div className="a2-pc">
            {imgs.slice(0, 7).map((img, index) => {
      const size = index < 5 || n <= 5 ? 8 : index === 5 ? 6 : 4;
      return <button type="button" key={img.id} className={index === page ? "on" : ""} style={{
        width: size,
        height: size
      }} aria-label={"Show photo " + (index + 1)} aria-current={index === page ? "true" : undefined} onClick={event => go(event, index)} />;
    })}
          </div> : null}
      </div>;
  };
  const video = (c, scope) => {
    const on = playing[c.id];
    const toggle = event => {
      const el = event.currentTarget.closest(".a2-vid").querySelector("video");
      if (on) el.pause(); else el.play();
      setPlaying(current => ({
        ...current,
        [c.id]: !on
      }));
    };
    return <div className="a2-vid">
        <video src={src(dyn(c.url, scope))} preload="metadata" playsInline muted />
        <button type="button" className="a2-pd" aria-label={on ? "Pause video" : "Play video"} onClick={toggle}>{icon(on ? "pause" : "play", "w")}</button>
      </div>;
  };
  const audio = (c, scope) => {
    const on = playing[c.id];
    const description = dyn(c.description, scope) || "";
    const toggle = event => {
      const el = event.currentTarget.closest(".a2-au").querySelector("audio");
      if (on) el.pause(); else el.play();
      setPlaying(current => ({
        ...current,
        [c.id]: !on
      }));
    };
    return <div className="a2-au">
        <div className="a2-ot"><div className="a2-on1">{description}</div></div>
        <button type="button" className="a2-pb" aria-label={(on ? "Pause" : "Play") + " " + description} onClick={toggle}>{on ? icon("pause") : icon("play", "pl1")}</button>
        <audio src={src(dyn(c.url, scope))} preload="none" />
      </div>;
  };
  const row = (c, scope, ctx) => {
    const kids = items(c, scope);
    const cs = kids.map(k => comps[k.id]).filter(Boolean);
    const ks = cs.map(x => x.component);
    if (ks.length && ks.every(k => k === "Button" || k === "Modal")) return buttonGroup(kids, true, c.justify);
    const al = ALIGN[c.align || "stretch"];
    if (ks.length && ks[0] === "Icon" && ks.includes("Text")) {
      const iconsIn = cs.filter(x => x.component === "Icon");
      const texts = cs.filter(x => x.component === "Text");
      const glyphs = iconsIn.map(x => <span key={x.id} style={{
        display: "contents"
      }}>{icon(dyn(x.name, scope))}</span>);
      const words = texts.map((x, index) => <span key={x.id}>{index ? " " : ""}{md(dyn(x.text, scope), x.id)}</span>);
      if (iconsIn.length === 1 && HEAD.includes(texts[0].variant)) {
        return <div className="a2-ir" style={{
          alignItems: al
        }}>{glyphs}<div className={TEXT_CLS[texts[0].variant]}>{words}</div></div>;
      }
      if (iconsIn.length === 1 && texts[0].variant === "caption") return <div className="a2-note" style={{
        alignItems: al
      }}>{glyphs}<span>{words}</span></div>;
      if (iconsIn.length > 1) return <div className="a2-ir" style={{
        gap: 0,
        alignItems: al
      }}>{glyphs}<span style={{
        marginLeft: 8
      }}>{words}</span></div>;
      return <div className="a2-ir" style={{
        alignItems: al
      }}>{glyphs}<span>{words}</span></div>;
    }
    const j = c.justify || "start";
    const texts = cs.filter(x => x.component === "Text");
    if (j === "spaceBetween" && cs.length === 2 && texts.length === 2 && cs[0].variant === "caption") {
      return <div className="a2-dr">{cs.map(x => <span key={x.id}>{md(dyn(x.text, kids[0].scope), x.id)}</span>)}</div>;
    }
    const hasImg = ks.includes("Image");
    const onlyImg = ks.every(k => k === "Image");
    const gap = onlyImg ? 8 : hasImg ? 12 : ["spaceAround", "spaceEvenly"].includes(j) ? 0 : j === "spaceBetween" ? 8 : 16;
    const sub = ctx === "listrow" ? "listrow" : "row";
    const inner = kids.map(k => {
      const x = comps[k.id];
      if (!x) return null;
      const w = x.weight;
      const style = j === "stretch" && x.component !== "Divider" ? {
        flex: 1,
        minWidth: 0
      } : w ? {
        flex: w,
        minWidth: 0
      } : null;
      if (x.component === "Column") {
        return <div key={k.id + (k.scope || "")} style={{
          display: "flex",
          flexDirection: "column",
          ...style || ({})
        }}>{column(x, k.scope, sub)}</div>;
      }
      if (x.component === "Button" || x.component === "Modal") return <div key={k.id + (k.scope || "")} className="a2-fxb">{btn(k.id, k.scope).el}</div>;
      const h = block(k.id, kind(k.id), k.scope, sub);
      return style ? <div key={k.id + (k.scope || "")} style={style}>{h}</div> : <span key={k.id + (k.scope || "")} style={{
        display: "contents"
      }}>{h}</span>;
    });
    return <div className="a2-fx" style={{
      justifyContent: JUST[j],
      alignItems: ALIGN[c.align || "stretch"],
      gap
    }}>{inner}</div>;
  };
  const column = (c, scope, ctx) => {
    const j = c.justify || "start";
    const a = c.align || "stretch";
    let prev = null;
    const out = items(c, scope).map((k, index) => {
      let kk = kind(k.id);
      if (kk === "caption" && prev === "heading") kk = "subtitle";
      const h = block(k.id, kk, k.scope, ["row", "listrow", "hitem"].includes(ctx) ? ctx : "card");
      let m = 0;
      if (prev !== null && ["start", "center", "end", "stretch"].includes(j)) {
        m = kk === "subtitle" ? 4 : ctx === "card" ? 8 : 0;
        if (prev === "button") m = 4;
        if (kk === "row") m = 8;
      }
      prev = kk;
      return <div key={k.id + index} style={{
        marginTop: m
      }}>{h}</div>;
    });
    return <div className="a2-fx col" style={{
      justifyContent: JUST[j],
      alignItems: ALIGN[a],
      textAlign: ({
        center: "center",
        end: "right"
      })[a] || "left",
      flex: "1 1 auto"
    }}>
        {out}
      </div>;
  };
  const isDetail = r => {
    const kids = Array.isArray(r.children) ? r.children.map(k => comps[k]).filter(Boolean) : [];
    return r.justify === "spaceBetween" && kids.length === 2 && kids.every(k => k.component === "Text") && kids[0].variant === "caption";
  };
  const list = (c, scope) => {
    const entries = items(c, scope);
    if (c.direction === "horizontal" && isCarousel(c)) return carousel(c, false, scope);
    if (c.direction === "horizontal") {
      return <div className="a2-hl">
          {entries.map((k, index) => {
        const x = comps[k.id];
        if (!x) return null;
        return <div key={index} className="a2-hli">{x.component === "Column" ? column(x, k.scope, "hitem") : block(k.id, kind(k.id), k.scope, "hitem")}</div>;
      })}
        </div>;
    }
    const a = c.align || "stretch";
    const kinds = entries.map(k => comps[k.id] && comps[k.id].component);
    if (kinds.length && kinds.every(k => k === "Row")) {
      const detail = entries.every(k => isDetail(comps[k.id]));
      const rows = entries.map((k, index) => {
        const r = comps[k.id];
        const h = row(r, k.scope, "listrow");
        if (detail) return <div key={index} style={{
          display: "contents"
        }}>{h}</div>;
        const hasImg = (Array.isArray(r.children) ? r.children : []).some(x => comps[x] && comps[x].component === "Image");
        return <div key={index} className={"a2-lr" + (hasImg ? " th" : "")}>{h}</div>;
      });
      return <div className={detail ? "a2-dt" : "a2-fx col"} style={{
        alignItems: ALIGN[a]
      }}>{rows}</div>;
    }
    return <div className="a2-fx col" style={{
      alignItems: ALIGN[a],
      gap: 0
    }}>
        {entries.map((k, index) => <div key={index} style={{
      padding: "4px 0"
    }}>{block(k.id, kind(k.id), k.scope, "listrow")}</div>)}
      </div>;
  };
  const tabbed = (c, scope) => {
    const on = tabs[c.id] || 0;
    const child = c.tabs[on] && c.tabs[on].child;
    return <div>
        <div className="a2-seg" role="tablist">
          {c.tabs.map((tab, index) => <button type="button" role="tab" key={index} aria-selected={index === on} className={index === on ? "on" : ""} onClick={() => setTabs(current => ({
      ...current,
      [c.id]: index
    }))}>{dyn(tab.title, scope)}</button>)}
        </div>
        <div style={{
      marginTop: 8
    }} role="tabpanel">{child ? block(child, kind(child), scope) : null}</div>
      </div>;
  };
  const tap = (cid, scope) => {
    const c = comps[cid];
    const event = c && c.action && c.action.event;
    if (!event) return;
    const context = {};
    Object.entries(event.context || ({})).forEach(([key, value]) => {
      context[key] = dyn(value, scope);
    });
    const message = [{
      version: V,
      action: {
        name: event.name,
        surfaceId: surface.sid,
        sourceComponentId: cid,
        timestamp: new Date().toISOString(),
        context
      }
    }];
    setTaps(current => [...current, message]);
    setOpen(null);
    const key = cid + (scope || "");
    setPending(key);
    const first = [].concat(Object.values(context)[0])[0];
    const answer = reply && reply[event.name];
    const update = Array.isArray(answer) ? answer : answer && answer[first];
    if (update && !answered[event.name]) {
      clearTimeout(timer.current);
      timer.current = setTimeout(() => {
        setAnswered(current => ({
          ...current,
          [event.name]: true
        }));
        setShown(current => [...current, ...update]);
        setData(model => applyData(model, update));
        setPending(null);
      }, 700);
    }
  };
  const spinner = <svg className="a2-spin" viewBox="0 0 20 20" aria-hidden="true" focusable="false">
      {[0, 1, 2, 3, 4, 5, 6, 7].map(i => <line key={i} x1="10" y1="2.5" x2="10" y2="6" transform={"rotate(" + i * 45 + " 10 10)"} style={{
    opacity: 0.25 + i / 7 * 0.75
  }} />)}
    </svg>;
  const labelWidth = label => String(label).length * 8.4;
  const btn = (cid, scope) => {
    const c = comps[cid];
    if (!c) return {
      el: null,
      variant: "default",
      w: 0
    };
    if (c.component === "Modal") {
      const inner = btn(c.trigger, scope);
      const trigger = comps[c.trigger];
      const child = trigger && comps[trigger.child];
      const labelText = child && child.component === "Text" ? dyn(child.text, scope) : child ? dyn(child.name, scope) : "";
      const cls = ({
        primary: "p",
        default: "",
        borderless: "bl"
      })[trigger && trigger.variant || "default"];
      return {
        ...inner,
        el: <button type="button" className={"a2-btn " + cls} aria-haspopup="dialog" onClick={() => setOpen(cid)}>{labelText}</button>
      };
    }
    const child = comps[c.child];
    const v = c.variant || "default";
    const cls = ({
      primary: "p",
      default: "",
      borderless: "bl"
    })[v] || "";
    const dis = failing(c, scope).length > 0;
    const spins = pending === cid + (scope || "");
    const busy = spins ? " is-pending" : "";
    if (child && child.component === "Icon") {
      const name = dyn(child.name, scope);
      return {
        el: <button type="button" className={"a2-btn icn " + cls + (dis ? " dis" : "") + busy} disabled={dis} aria-label={name} aria-busy={spins || undefined} onClick={() => tap(cid, scope)}><span className="a2-lbl">{icon(name, v === "primary" ? "w" : "k")}</span>{spins ? spinner : null}</button>,
        variant: v,
        w: 0
      };
    }
    const labelText = child ? dyn(child.text, scope) ?? "" : "";
    return {
      el: <button type="button" className={"a2-btn " + cls + (dis ? " dis" : "") + busy} disabled={dis} aria-busy={spins || undefined} onClick={() => tap(cid, scope)}><span className="a2-lbl">{labelText}</span>{spins ? spinner : null}</button>,
      variant: v,
      w: labelWidth(labelText)
    };
  };
  const buttonGroup = (kids, isRow, justify) => {
    let bs = kids.map(k => ({
      ...btn(k.id, k.scope),
      key: k.id + (k.scope || "")
    }));
    const keyed = list => list.map(b => <span key={b.key} style={{
      display: "contents"
    }}>{b.el}</span>);
    if (bs.every(b => b.w === 0)) {
      return <div className="a2-btns" style={{
        justifyContent: JUST[justify || "start"],
        gap: justify === "center" ? 24 : undefined
      }}>{keyed(bs)}</div>;
    }
    if (isRow && bs.length > 1) {
      const half = (242 - 8 * (bs.length - 1)) / bs.length - 28;
      if (bs.every(b => b.w <= half)) return <div className="a2-btns">{keyed(bs)}</div>;
      bs = [...bs].sort((x, y) => (x.variant === "primary" ? 0 : 1) - (y.variant === "primary" ? 0 : 1));
    }
    return <div className="a2-btns v">{keyed(bs)}</div>;
  };
  const field = (c, scope) => {
    const v = c.variant || "shortText";
    const raw = dyn(c.value, scope);
    const val = raw === undefined || raw === null ? "" : String(raw);
    const labelText = dyn(c.label, scope) || "";
    let errs = val ? failing(c, scope) : [];
    let bad = errs.length > 0;
    if (val && c.validationRegexp) {
      try {
        if (!new RegExp(c.validationRegexp).test(val)) bad = true;
      } catch (error) {}
    }
    const cls = ({
      longText: "long",
      obscured: "pin"
    })[v] || "";
    const props = {
      className: "a2-fld " + cls + (bad ? " bad" : ""),
      value: val,
      placeholder: labelText,
      "aria-label": labelText,
      "aria-invalid": bad || undefined,
      onChange: event => setBound(c.value, event.target.value, scope)
    };
    return <div>
        <div className="a2-fl" hidden={!val} aria-hidden="true">{labelText}</div>
        {v === "longText" ? <textarea key="f" {...props} /> : <input key="f" type={v === "obscured" ? "password" : "text"} inputMode={v === "number" ? "decimal" : undefined} {...props} />}
        {errs.length ? <div className="a2-err" role="alert">{errs[0]}</div> : null}
      </div>;
  };
  const checkbox = (c, scope) => {
    const on = Boolean(dyn(c.value, scope));
    return <button type="button" role="switch" aria-checked={on} className="a2-cb" onClick={() => setBound(c.value, !on, scope)}>
        <span>{dyn(c.label, scope)}</span>
        <span className={"a2-sw" + (on ? "" : " off")} aria-hidden="true" />
      </button>;
  };
  const choice = (c, scope) => {
    const rawSel = dyn(c.value, scope);
    const sel = Array.isArray(rawSel) ? rawSel : rawSel ? [rawSel] : [];
    const multi = c.variant === "multipleSelection";
    const labelText = c.label ? dyn(c.label, scope) : null;
    const pick = value => {
      const next = multi ? sel.includes(value) ? sel.filter(x => x !== value) : [...sel, value] : [value];
      setBound(c.value, next, scope);
    };
    return <div>
        {labelText ? <div className="a2-cl">{labelText}</div> : null}
        <div className="a2-opts" style={{
      marginTop: labelText ? 2 : 0
    }} role={multi ? "group" : "radiogroup"} aria-label={labelText || undefined}>
          {c.options.map(o => {
      const on = sel.includes(o.value);
      return <button type="button" key={o.value} role={multi ? "checkbox" : "radio"} aria-checked={on} className="a2-opt" onClick={() => pick(o.value)}>
                <span className={"a2-rc" + (on ? " on" : "")} aria-hidden="true" />
                <span className="a2-ot"><span className="a2-on1">{dyn(o.label, scope)}</span></span>
              </button>;
    })}
        </div>
      </div>;
  };
  const slider = (c, scope) => {
    const lo = c.min ?? 0;
    const hi = c.max;
    const raw = Number(dyn(c.value, scope));
    const v = Number.isNaN(raw) ? lo : raw;
    const pct = Math.min(100, Math.max(0, (v - lo) / (hi - lo) * 100));
    const labelText = dyn(c.label, scope) || "";
    const step = hi - lo >= 10 ? 1 : (hi - lo) / 100;
    return <div className="a2-sl">
        <div className="a2-slh"><span>{labelText}</span><span>{String(Number(v.toFixed(2)))}</span></div>
        <div className="a2-slt">
          <i style={{
      width: pct.toFixed(1) + "%"
    }} />
          <b style={{
      left: "calc(18.5px + (100% - 37px) * " + (pct / 100).toFixed(3) + ")"
    }} />
          <input type="range" min={lo} max={hi} step={step} value={v} aria-label={labelText} onChange={event => setBound(c.value, Number(event.target.value), scope)} />
        </div>
      </div>;
  };
  const datetime = (c, scope) => {
    const v = String(dyn(c.value, scope) || "");
    const labelText = dyn(c.label, scope) || "";
    const hasDate = Boolean(c.enableDate);
    const hasTime = Boolean(c.enableTime);
    const datePart = hasDate ? v.slice(0, 10) : "";
    const timeAt = hasDate ? 11 : 0;
    const timePart = hasTime ? v.slice(timeAt, timeAt + 5) : "";
    const pills = [];
    const openPicker = event => {
      try {
        event.currentTarget.showPicker();
      } catch (error) {}
    };
    if (hasDate && (/^\d{4}-\d{2}-\d{2}$/).test(datePart)) {
      const [y, m, d] = datePart.split("-").map(Number);
      pills.push(<label key="d" className="a2-pl">
          <span>{MONTHS[m - 1] + " " + d + ", " + y}</span>
          <input type="date" value={datePart} min={c.min ? String(c.min).slice(0, 10) : undefined} max={c.max ? String(c.max).slice(0, 10) : undefined} aria-label={labelText + ", date"} onClick={openPicker} onChange={event => event.target.value && setBound(c.value, event.target.value + v.slice(10), scope)} />
        </label>);
    }
    if (hasTime && (/^\d{2}:\d{2}$/).test(timePart)) {
      const h = Number(timePart.slice(0, 2));
      pills.push(<label key="t" className="a2-pl">
          <span>{(h + 11) % 12 + 1 + ":" + timePart.slice(3) + " " + (h < 12 ? "AM" : "PM")}</span>
          <input type="time" value={timePart} aria-label={labelText + ", time"} onClick={openPicker} onChange={event => event.target.value && setBound(c.value, v.slice(0, timeAt) + event.target.value + v.slice(timeAt + 5), scope)} />
        </label>);
    }
    return <div>
        <div className="a2-fl">{labelText}</div>
        <div className="a2-pls">{pills}</div>
      </div>;
  };
  const root = comps.root;
  let body = null;
  if (root && !surface.deleted) {
    const top = root.component === "Card" ? comps[root.child] : root;
    body = top && top.component === "Column" ? cardColumn(top.id, undefined, true) : top ? block(top.id, kind(top.id)) : null;
  }
  const modal = open ? comps[open] : null;
  const modalTitle = (() => {
    const content = modal && comps[modal.content];
    const kids = content && Array.isArray(content.children) ? content.children.map(k => comps[k]) : [];
    const heading = kids.find(k => k && k.component === "Text" && HEAD.includes(k.variant));
    return heading ? String(dyn(heading.text) ?? "") : "Details";
  })();
  const closeSheet = () => setOpen(null);
  const reset = () => {
    setShown(messages);
    setData(applyData({}, messages));
    setTaps([]);
    setOpen(null);
    setTabs({});
    setPages({});
    setPlaying({});
    setAnswered({});
    setPending(null);
    clearTimeout(timer.current);
  };
  const lastTap = taps.length ? taps[taps.length - 1][0].action : null;
  const said = v => Array.isArray(v) ? v.join(", ") : typeof v === "object" && v !== null ? JSON.stringify(v) : String(v);
  const caption = lastTap ? "Your agent receives " + lastTap.name + (Object.keys(lastTap.context || ({})).length ? " with " + Object.entries(lastTap.context).map(([k, v]) => k + " " + said(v)).join(", ") : "") + "." : undefined;
  return <Frame className="relay-preview" caption={caption}>
    <div className="a2" role="group" aria-label={label || "Interactive card preview"}>
      <div className={"a2-frame" + (modal ? " is-sheet-open" : "")}>
        <div className="a2-stage" aria-hidden={modal ? "true" : undefined}>
          <div className="a2-column">
            {body ? <div className={"a2-card" + (pending ? " is-locked" : "")}>
                {body}
                <svg className="a2-tail" width="23" height="24" viewBox="0 0 23 24" aria-hidden="true" focusable="false"><path d={TAIL} /></svg>
              </div> : null}
          </div>
        </div>
        {taps.length > 0 && !modal ? <button type="button" className="relay-preview-reset" aria-label="Reset demo" title="Reset demo" onClick={reset}>
            <Icon icon="rotate-left" size={16} />
          </button> : null}
        {modal ? <div className="a2-sheet-layer">
            <div className="a2-dimmer" onClick={closeSheet} />
            <div className="a2-sh" role="dialog" aria-modal="true" aria-label={modalTitle} onKeyDown={event => {
    if (event.key === "Escape") closeSheet();
  }}>
              <button type="button" className="a2-grab" autoFocus aria-label="Close" onClick={closeSheet} />
              <div className="a2-sheet-body">
                {comps[modal.content] && comps[modal.content].component === "Column" ? cardColumn(modal.content, undefined, false) : block(modal.content, kind(modal.content))}
              </div>
            </div>
          </div> : null}
      </div>
    </div>
    </Frame>;
};

A card is an [A2UI](https://a2ui.org) v0.9.1 surface sent as a `data` part: `{"type":"data","media_type":"application/a2ui+json","data":[…]}`. The `data` array holds A2UI messages in order. Relay draws the card in the chat, and each tap on one of its buttons comes back to your agent as a Message.

Pick a ride, tap **Review ride**, then tap the new button to book it. Under the card, the preview names each tap your agent receives, and it applies each [update](#update-or-remove-a-card) in place. It stays in your browser and sends no request.

<Tabs>
  <Tab title="Preview">
    <A2uiPreview
      label="A ride card: pick a ride, tap Review ride, then book it"
      messages={[
    {"version":"v0.9.1","createSurface":{"surfaceId":"ride-1042","catalogId":"https://relayapp.im/a2ui/catalog/v1"}},
    {"version":"v0.9.1","updateComponents":{"surfaceId":"ride-1042","components":[{"id":"root","component":"Card","child":"body"},{"id":"body","component":"Column","children":["title","rides","review"]},{"id":"title","component":"Text","text":"Rides to SFO, Terminal 2","variant":"h3"},{"id":"rides","component":"ChoicePicker","label":"Ride","variant":"mutuallyExclusive","options":[{"label":"UberX, $42, 4 min away","value":"uberx"},{"label":"Comfort, $51, 6 min away","value":"comfort"}],"value":{"path":"/ride"}},{"id":"review_label","component":"Text","text":"Review ride"},{"id":"review","component":"Button","child":"review_label","variant":"primary","action":{"event":{"name":"review_ride","context":{"ride":{"path":"/ride"}}}}}]}},
    {"version":"v0.9.1","updateDataModel":{"surfaceId":"ride-1042","value":{"ride":["uberx"]}}}
  ]}
      reply={{
    "review_ride": {
      "comfort": [
        {"version":"v0.9.1","updateComponents":{"surfaceId":"ride-1042","components":[{"id":"body","component":"Column","children":["title","request"]},{"id":"request_label","component":"Text","text":"Request Comfort for $51"},{"id":"request","component":"Button","child":"request_label","variant":"primary","action":{"event":{"name":"request_ride","context":{"ride":"comfort"}}}}]}}
      ],
      "uberx": [
        {"version":"v0.9.1","updateComponents":{"surfaceId":"ride-1042","components":[{"id":"body","component":"Column","children":["title","request"]},{"id":"request_label","component":"Text","text":"Request UberX for $42"},{"id":"request","component":"Button","child":"request_label","variant":"primary","action":{"event":{"name":"request_ride","context":{"ride":"uberx"}}}}]}}
      ]
    },
    "request_ride": {
      "comfort": [
        {"version":"v0.9.1","updateComponents":{"surfaceId":"ride-1042","components":[{"id":"body","component":"Column","children":["title","booked"]},{"id":"booked","component":"Text","text":"Your Comfort ride is booked. Marco arrives in 6 minutes."}]}}
      ],
      "uberx": [
        {"version":"v0.9.1","updateComponents":{"surfaceId":"ride-1042","components":[{"id":"body","component":"Column","children":["title","booked"]},{"id":"booked","component":"Text","text":"Your UberX ride is booked. Marco arrives in 4 minutes."}]}}
      ]
    }
  }}
    />
  </Tab>

  <Tab title="JSON">
    ```json theme={null}
    {
      "message": {
        "parts": [{
          "type": "data",
          "media_type": "application/a2ui+json",
          "data": [
            {"version":"v0.9.1","createSurface":{"surfaceId":"ride-1042","catalogId":"https://relayapp.im/a2ui/catalog/v1"}},
            {"version":"v0.9.1","updateComponents":{"surfaceId":"ride-1042","components":[
              {"id":"root","component":"Card","child":"body"},
              {"id":"body","component":"Column","children":["title","rides","review"]},
              {"id":"title","component":"Text","text":"Rides to SFO, Terminal 2","variant":"h3"},
              {"id":"rides","component":"ChoicePicker","label":"Ride","variant":"mutuallyExclusive","options":[{"label":"UberX, $42, 4 min away","value":"uberx"},{"label":"Comfort, $51, 6 min away","value":"comfort"}],"value":{"path":"/ride"}},
              {"id":"review_label","component":"Text","text":"Review ride"},
              {"id":"review","component":"Button","child":"review_label","variant":"primary","action":{"event":{"name":"review_ride","context":{"ride":{"path":"/ride"}}}}}
            ]}},
            {"version":"v0.9.1","updateDataModel":{"surfaceId":"ride-1042","value":{"ride":["uberx"]}}}
          ]
        }],
        "idempotency_key": "ride-1042-card"
      }
    }
    ```

    ```json Your agent receives theme={null}
    [
      {"version":"v0.9.1","action":{"name":"review_ride","surfaceId":"ride-1042","sourceComponentId":"review","timestamp":"2026-09-26T00:45:07.452Z","context":{"ride":["uberx"]}}}
    ]
    ```
  </Tab>
</Tabs>

## Send a card

A new card is one `createSurface`, one `updateComponents` and, optionally, one `updateDataModel`. The first `updateComponents` must hold the component with the id `root`. `catalogId` names a catalog Relay draws: Relay's catalog (the A2UI basic catalog plus `PaymentRequest` and [`Browser`](/interactions/browser)) or the A2UI basic catalog.

This card offers two rides and a button that sends the choice back:

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

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

  await sendA2uiSurface(relay, "CHAT_ID", {
    surfaceId: "ride-1042",
    catalogId: RELAY_A2UI_CATALOG_ID,
    components: [
      { id: "root", component: "Card", child: "body" },
      { id: "body", component: "Column", children: ["title", "rides", "review"] },
      { id: "title", component: "Text", text: "Rides to SFO, Terminal 2", variant: "h3" },
      {
        id: "rides",
        component: "ChoicePicker",
        label: "Ride",
        variant: "mutuallyExclusive",
        options: [
          { label: "UberX, $42, 4 min away", value: "uberx" },
          { label: "Comfort, $51, 6 min away", value: "comfort" },
        ],
        value: { path: "/ride" },
      },
      { id: "review_label", component: "Text", text: "Review ride" },
      {
        id: "review",
        component: "Button",
        child: "review_label",
        variant: "primary",
        action: { event: { name: "review_ride", context: { ride: { path: "/ride" } } } },
      },
    ],
    dataModel: { ride: ["uberx"] },
  }, { idempotency_key: "ride-1042-card" });
  ```

  ```python Python SDK theme={null}
  import asyncio
  import os

  from relaymessenger import Relay
  from relaymessenger.a2ui import send_a2ui_surface

  relay = Relay(os.environ["RELAY_AGENT_TOKEN"], base_url="https://api.relayapp.im")

  RIDE_CARD = [
      {"id": "root", "component": "Card", "child": "body"},
      {"id": "body", "component": "Column", "children": ["title", "rides", "review"]},
      {"id": "title", "component": "Text", "text": "Rides to SFO, Terminal 2", "variant": "h3"},
      {
          "id": "rides",
          "component": "ChoicePicker",
          "label": "Ride",
          "variant": "mutuallyExclusive",
          "options": [
              {"label": "UberX, $42, 4 min away", "value": "uberx"},
              {"label": "Comfort, $51, 6 min away", "value": "comfort"},
          ],
          "value": {"path": "/ride"},
      },
      {"id": "review_label", "component": "Text", "text": "Review ride"},
      {
          "id": "review",
          "component": "Button",
          "child": "review_label",
          "variant": "primary",
          "action": {"event": {"name": "review_ride", "context": {"ride": {"path": "/ride"}}}},
      },
  ]

  asyncio.run(
      send_a2ui_surface(
          relay,
          "CHAT_ID",
          "ride-1042",
          RIDE_CARD,
          data_model={"ride": ["uberx"]},
          idempotency_key="ride-1042-card",
      )
  )
  ```

  ```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": "data",
          "media_type": "application/a2ui+json",
          "data": [
            {"version":"v0.9.1","createSurface":{"surfaceId":"ride-1042","catalogId":"https://relayapp.im/a2ui/catalog/v1"}},
            {"version":"v0.9.1","updateComponents":{"surfaceId":"ride-1042","components":[
              {"id":"root","component":"Card","child":"body"},
              {"id":"body","component":"Column","children":["title","rides","review"]},
              {"id":"title","component":"Text","text":"Rides to SFO, Terminal 2","variant":"h3"},
              {"id":"rides","component":"ChoicePicker","label":"Ride","variant":"mutuallyExclusive",
               "options":[{"label":"UberX, $42, 4 min away","value":"uberx"},{"label":"Comfort, $51, 6 min away","value":"comfort"}],
               "value":{"path":"/ride"}},
              {"id":"review_label","component":"Text","text":"Review ride"},
              {"id":"review","component":"Button","child":"review_label","variant":"primary",
               "action":{"event":{"name":"review_ride","context":{"ride":{"path":"/ride"}}}}}
            ]}},
            {"version":"v0.9.1","updateDataModel":{"surfaceId":"ride-1042","value":{"ride":["uberx"]}}}
          ]
        }],
        "idempotency_key": "ride-1042-card"
      }
    }'
  ```
</CodeGroup>

The send returns `202` with the card's Message:

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

Keep the `surfaceId`: every later change to the card names it. The Python SDK installs from the Relay SDK repository with `pip install "git+https://github.com/RelayMessenger/Relay-SDK@main#subdirectory=python/relaymessenger"` and needs Python 3.10 or newer.

## Receive a tap

A tap reaches your agent as [`message.received`](/events/message-received), through its webhook or the Agent WebSocket. The Message holds a `data` part with the A2UI `action`: the Button's event name, the surface, and the context with every binding resolved.

```json theme={null}
{
  "event_type": "message.received",
  "data": {
    "parts": [
      {"type":"data","media_type":"application/a2ui+json","data":[
        {"version":"v0.9.1","action":{"name":"review_ride","surfaceId":"ride-1042","sourceComponentId":"review","timestamp":"2026-09-26T00:45:07.452Z","context":{"ride":["comfort"]}}}
      ],"reactions":null}
    ]
  }
}
```

Read the tap with `readA2uiAction` or `read_a2ui_action`; each returns nothing for a Message without one:

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

  export async function onEvent(event: RelayWebhookEvent) {
    if (event.event_type !== "message.received") return;
    const tap = readA2uiAction(event);
    if (tap?.action.name !== "review_ride") return;
    const [ride] = tap.action.context.ride as string[];
    // ride is "comfort"; update the card, as shown below.
  }
  ```

  ```python Python SDK theme={null}
  from relaymessenger.a2ui import read_a2ui_action


  async def on_event(event: dict) -> None:
      tap = read_a2ui_action(event)
      if tap is None or tap.name != "review_ride":
          return
      ride = tap.context["ride"][0]
      # ride is "comfort"; update the card, as shown below.
  ```
</CodeGroup>

A tap reaches only the person who tapped and the agent that created the card. When the card's `createSurface` sets `sendDataModel: true`, each tap also carries the card's data model, and it goes only to that same agent. `readA2uiAction` returns it as `dataModel`; in Python it is `tap.data_model`.

## Update or remove a card

`updateA2uiSurface` changes the same card in place for everyone in the chat and adds no Message. Components replace their namesakes by `id`. Inside the tap handler, this update turns the choice into a review step with one button that commits. The JSON tab also shows the next update, which answers that button's `request_ride` tap: it books the ride and removes the button.

<Tabs>
  <Tab title="Preview">
    <A2uiPreview
      label="The ride card after the update: tap Request Comfort for $51 to book it"
      messages={[
    {"version":"v0.9.1","createSurface":{"surfaceId":"ride-1042","catalogId":"https://relayapp.im/a2ui/catalog/v1"}},
    {"version":"v0.9.1","updateComponents":{"surfaceId":"ride-1042","components":[{"id":"root","component":"Card","child":"body"},{"id":"body","component":"Column","children":["title","rides","review"]},{"id":"title","component":"Text","text":"Rides to SFO, Terminal 2","variant":"h3"},{"id":"rides","component":"ChoicePicker","label":"Ride","variant":"mutuallyExclusive","options":[{"label":"UberX, $42, 4 min away","value":"uberx"},{"label":"Comfort, $51, 6 min away","value":"comfort"}],"value":{"path":"/ride"}},{"id":"review_label","component":"Text","text":"Review ride"},{"id":"review","component":"Button","child":"review_label","variant":"primary","action":{"event":{"name":"review_ride","context":{"ride":{"path":"/ride"}}}}}]}},
    {"version":"v0.9.1","updateDataModel":{"surfaceId":"ride-1042","value":{"ride":["uberx"]}}},
    {"version":"v0.9.1","updateComponents":{"surfaceId":"ride-1042","components":[{"id":"body","component":"Column","children":["title","request"]},{"id":"request_label","component":"Text","text":"Request Comfort for $51"},{"id":"request","component":"Button","child":"request_label","variant":"primary","action":{"event":{"name":"request_ride","context":{"ride":"comfort"}}}}]}}
  ]}
      reply={{
    "request_ride": [
      {"version":"v0.9.1","updateComponents":{"surfaceId":"ride-1042","components":[{"id":"body","component":"Column","children":["title","booked"]},{"id":"booked","component":"Text","text":"Your Comfort ride is booked. Marco arrives in 6 minutes."}]}}
    ]
  }}
    />
  </Tab>

  <Tab title="JSON">
    ```json theme={null}
    {
      "message": {
        "parts": [{
          "type": "data",
          "media_type": "application/a2ui+json",
          "data": [
            {"version":"v0.9.1","updateComponents":{"surfaceId":"ride-1042","components":[
              {"id":"body","component":"Column","children":["title","request"]},
              {"id":"request_label","component":"Text","text":"Request Comfort for $51"},
              {"id":"request","component":"Button","child":"request_label","variant":"primary","action":{"event":{"name":"request_ride","context":{"ride":"comfort"}}}}
            ]}}
          ]
        }]
      }
    }
    ```

    ```json The next update, after request_ride theme={null}
    {
      "message": {
        "parts": [{
          "type": "data",
          "media_type": "application/a2ui+json",
          "data": [
            {"version":"v0.9.1","updateComponents":{"surfaceId":"ride-1042","components":[
              {"id":"body","component":"Column","children":["title","booked"]},
              {"id":"booked","component":"Text","text":"Your Comfort ride is booked. Marco arrives in 6 minutes."}
            ]}}
          ]
        }]
      }
    }
    ```
  </Tab>
</Tabs>

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

  await updateA2uiSurface(relay, event.data.chat.id, tap.action.surfaceId, {
    components: [
      { id: "body", component: "Column", children: ["title", "request"] },
      { id: "request_label", component: "Text", text: "Request Comfort for $51" },
      {
        id: "request",
        component: "Button",
        child: "request_label",
        variant: "primary",
        action: { event: { name: "request_ride", context: { ride: "comfort" } } },
      },
    ],
  });
  ```

  ```python Python SDK theme={null}
  from relaymessenger.a2ui import update_a2ui_surface

  await update_a2ui_surface(
      relay,
      tap.chat_id,
      tap.surface_id,
      components=[
          {"id": "body", "component": "Column", "children": ["title", "request"]},
          {"id": "request_label", "component": "Text", "text": "Request Comfort for $51"},
          {
              "id": "request",
              "component": "Button",
              "child": "request_label",
              "variant": "primary",
              "action": {"event": {"name": "request_ride", "context": {"ride": "comfort"}}},
          },
      ],
  )
  ```

  ```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": "data",
          "media_type": "application/a2ui+json",
          "data": [
            {"version":"v0.9.1","updateComponents":{"surfaceId":"ride-1042","components":[
              {"id":"body","component":"Column","children":["title","request"]},
              {"id":"request_label","component":"Text","text":"Request Comfort for $51"},
              {"id":"request","component":"Button","child":"request_label","variant":"primary",
               "action":{"event":{"name":"request_ride","context":{"ride":"comfort"}}}}
            ]}}
          ]
        }]
      }
    }'
  ```
</CodeGroup>

The update returns `202` with the card's own Message, its `data` now ending in the update:

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

`deleteA2uiSurface(relay, chatId, "ride-1042")`, or `delete_a2ui_surface` in Python, retracts the card for everyone. When every card in a Message is deleted, the Message reads back with no parts and a non-null `unsent_at`, and its `surfaceId` can be created again. Any agent in the chat can update or delete any card in it. A person who sends `createSurface`, `updateComponents`, `updateDataModel` or `deleteSurface` gets `403` ([2003](/api-reference/errors#2003)).

## Errors

Relay applies each A2UI message on its own and checks it against A2UI's schemas and the card's catalog. Each message it could not apply comes back in the response's `a2ui_errors`, with `part_index` and `data_index` for its place in your request and `a2ui_message`, A2UI's own `error` message. Its `path` points inside the failing message's body.

<CodeGroup>
  ```json Button with no action theme={null}
  {"part_index":0,"data_index":1,"a2ui_message":{"version":"v0.9.1","error":{"code":"VALIDATION_FAILED","surfaceId":"bad-484756","path":"/components/1/action","message":"Instance does not have required property \"action\"."}}}
  ```

  ```json Missing root theme={null}
  {"part_index":0,"data_index":1,"a2ui_message":{"version":"v0.9.1","error":{"code":"VALIDATION_FAILED","surfaceId":"noroot-484756","path":"/components","message":"A surface's first updateComponents must hold the component with id \"root\", the root of its tree."}}}
  ```
</CodeGroup>

The rest of the send is still applied and returns `202`. A send that applies nothing is refused with the first failure's status, and the error body's `a2ui_errors` lists each message:

| Status | Cause |
| - | - |
| `404` | The first failure names an unknown or deleted `surfaceId`. |
| `409` | The first failure is a `createSurface` for a `surfaceId` already live in the chat. |
| `422` | Anything else, for example a component, property or value the catalog does not define ([2006](/api-reference/errors#2006)). |

## Design guidelines

* Put at most two actions in a card, and make one of them primary.
* Name the action and the price on the primary button, for example "Book JetBlue 916 for \$168".

<Tabs>
  <Tab title="Preview">
    <A2uiPreview
      label="Two actions stacked, the primary first"
      messages={[
    {"version":"v0.9.1","createSurface":{"surfaceId":"flight-916","catalogId":"https://relayapp.im/a2ui/catalog/v1"}},
    {"version":"v0.9.1","updateComponents":{"surfaceId":"flight-916","components":[{"id":"root","component":"Card","child":"body"},{"id":"body","component":"Column","children":["title","when","actions"]},{"id":"title","component":"Text","text":"JetBlue 916, SFO to JFK","variant":"h4"},{"id":"when","component":"Text","text":"Thu, Oct 2, 7:05 AM, nonstop","variant":"caption"},{"id":"actions","component":"Row","children":["book","others"]},{"id":"book_label","component":"Text","text":"Book JetBlue 916 for $168"},{"id":"book","component":"Button","child":"book_label","variant":"primary","action":{"event":{"name":"book_flight","context":{"flight":"B6 916"}}}},{"id":"others_label","component":"Text","text":"See other flights"},{"id":"others","component":"Button","child":"others_label","action":{"event":{"name":"see_other_flights"}}}]}}
  ]}
    />
  </Tab>

  <Tab title="JSON">
    ```json theme={null}
    {
      "message": {
        "parts": [{
          "type": "data",
          "media_type": "application/a2ui+json",
          "data": [
            {"version":"v0.9.1","createSurface":{"surfaceId":"flight-916","catalogId":"https://relayapp.im/a2ui/catalog/v1"}},
            {"version":"v0.9.1","updateComponents":{"surfaceId":"flight-916","components":[
              {"id":"root","component":"Card","child":"body"},
              {"id":"body","component":"Column","children":["title","when","actions"]},
              {"id":"title","component":"Text","text":"JetBlue 916, SFO to JFK","variant":"h4"},
              {"id":"when","component":"Text","text":"Thu, Oct 2, 7:05 AM, nonstop","variant":"caption"},
              {"id":"actions","component":"Row","children":["book","others"]},
              {"id":"book_label","component":"Text","text":"Book JetBlue 916 for $168"},
              {"id":"book","component":"Button","child":"book_label","variant":"primary","action":{"event":{"name":"book_flight","context":{"flight":"B6 916"}}}},
              {"id":"others_label","component":"Text","text":"See other flights"},
              {"id":"others","component":"Button","child":"others_label","action":{"event":{"name":"see_other_flights"}}}
            ]}}
          ]
        }]
      }
    }
    ```
  </Tab>
</Tabs>

* Give every commit its own step: show a review, then commit on the next tap.
* Show a result by updating the same card in place, and send a new card only for new content.
* Keep tabs and deep navigation out of a card.
* Keep scrolling areas out of a card.
* Send a normal message when a card would hold only text.
* Put several photos of one thing in a horizontal List of Images, which Relay draws as a swipeable carousel.

<Tabs>
  <Tab title="Preview">
    <A2uiPreview
      label="A photo carousel: swipe or use the page dots"
      messages={[
    {"version":"v0.9.1","createSurface":{"surfaceId":"cabin-77","catalogId":"https://relayapp.im/a2ui/catalog/v1"}},
    {"version":"v0.9.1","updateComponents":{"surfaceId":"cabin-77","components":[{"id":"root","component":"Card","child":"body"},{"id":"body","component":"Column","children":["photos","title","about","dates"]},{"id":"photos","component":"List","direction":"horizontal","children":["porch","living","nook"]},{"id":"porch","component":"Image","url":"https://example.com/cabin/porch.jpg","description":"The cabin's front porch"},{"id":"living","component":"Image","url":"https://example.com/cabin/living-room.jpg","description":"The living room"},{"id":"nook","component":"Image","url":"https://example.com/cabin/reading-nook.jpg","description":"A reading nook with plants"},{"id":"title","component":"Text","text":"Cabin in Big Sur","variant":"h4"},{"id":"about","component":"Text","text":"2 beds, 1 bath, $420 a night","variant":"caption"},{"id":"dates_label","component":"Text","text":"Check dates"},{"id":"dates","component":"Button","child":"dates_label","variant":"primary","action":{"event":{"name":"check_dates","context":{"listing":"cabin-77"}}}}]}}
  ]}
      media={{"https://example.com/cabin/porch.jpg":"/images/cards/cabin-porch.jpg","https://example.com/cabin/living-room.jpg":"/images/cards/room.jpg","https://example.com/cabin/reading-nook.jpg":"/images/cards/room-plants.jpg"}}
    />
  </Tab>

  <Tab title="JSON">
    ```json theme={null}
    {
      "message": {
        "parts": [{
          "type": "data",
          "media_type": "application/a2ui+json",
          "data": [
            {"version":"v0.9.1","createSurface":{"surfaceId":"cabin-77","catalogId":"https://relayapp.im/a2ui/catalog/v1"}},
            {"version":"v0.9.1","updateComponents":{"surfaceId":"cabin-77","components":[
              {"id":"root","component":"Card","child":"body"},
              {"id":"body","component":"Column","children":["photos","title","about","dates"]},
              {"id":"photos","component":"List","direction":"horizontal","children":["porch","living","nook"]},
              {"id":"porch","component":"Image","url":"https://example.com/cabin/porch.jpg","description":"The cabin's front porch"},
              {"id":"living","component":"Image","url":"https://example.com/cabin/living-room.jpg","description":"The living room"},
              {"id":"nook","component":"Image","url":"https://example.com/cabin/reading-nook.jpg","description":"A reading nook with plants"},
              {"id":"title","component":"Text","text":"Cabin in Big Sur","variant":"h4"},
              {"id":"about","component":"Text","text":"2 beds, 1 bath, $420 a night","variant":"caption"},
              {"id":"dates_label","component":"Text","text":"Check dates"},
              {"id":"dates","component":"Button","child":"dates_label","variant":"primary","action":{"event":{"name":"check_dates","context":{"listing":"cabin-77"}}}}
            ]}}
          ]
        }]
      }
    }
    ```
  </Tab>
</Tabs>

* Put several different items in a Row of Images, each with weight 1.

<Tabs>
  <Tab title="Preview">
    <A2uiPreview
      label="Three different places in a Row of Images"
      messages={[
    {"version":"v0.9.1","createSurface":{"surfaceId":"stays-12","catalogId":"https://relayapp.im/a2ui/catalog/v1"}},
    {"version":"v0.9.1","updateComponents":{"surfaceId":"stays-12","components":[{"id":"root","component":"Card","child":"body"},{"id":"body","component":"Column","children":["title","places","names"]},{"id":"title","component":"Text","text":"Three places open this weekend","variant":"h4"},{"id":"places","component":"Row","children":["loft","studio","cabin"]},{"id":"loft","component":"Image","url":"https://example.com/stays/loft.jpg","description":"Mission loft","weight":1},{"id":"studio","component":"Image","url":"https://example.com/stays/studio.jpg","description":"Noe Valley studio","weight":1},{"id":"cabin","component":"Image","url":"https://example.com/stays/cabin.jpg","description":"Big Sur cabin","weight":1},{"id":"names","component":"Text","text":"Mission loft, Noe Valley studio, Big Sur cabin","variant":"caption"}]}}
  ]}
      media={{"https://example.com/stays/loft.jpg":"/images/cards/room.jpg","https://example.com/stays/studio.jpg":"/images/cards/room-plants.jpg","https://example.com/stays/cabin.jpg":"/images/cards/cabin-porch.jpg"}}
    />
  </Tab>

  <Tab title="JSON">
    ```json theme={null}
    {
      "message": {
        "parts": [{
          "type": "data",
          "media_type": "application/a2ui+json",
          "data": [
            {"version":"v0.9.1","createSurface":{"surfaceId":"stays-12","catalogId":"https://relayapp.im/a2ui/catalog/v1"}},
            {"version":"v0.9.1","updateComponents":{"surfaceId":"stays-12","components":[
              {"id":"root","component":"Card","child":"body"},
              {"id":"body","component":"Column","children":["title","places","names"]},
              {"id":"title","component":"Text","text":"Three places open this weekend","variant":"h4"},
              {"id":"places","component":"Row","children":["loft","studio","cabin"]},
              {"id":"loft","component":"Image","url":"https://example.com/stays/loft.jpg","description":"Mission loft","weight":1},
              {"id":"studio","component":"Image","url":"https://example.com/stays/studio.jpg","description":"Noe Valley studio","weight":1},
              {"id":"cabin","component":"Image","url":"https://example.com/stays/cabin.jpg","description":"Big Sur cabin","weight":1},
              {"id":"names","component":"Text","text":"Mission loft, Noe Valley studio, Big Sur cabin","variant":"caption"}
            ]}}
          ]
        }]
      }
    }
    ```
  </Tab>
</Tabs>

* Use the mediumFeature variant for a big photo at the top of a card.

<Tabs>
  <Tab title="Preview">
    <A2uiPreview
      label="A mediumFeature photo at the top of a card"
      messages={[
    {"version":"v0.9.1","createSurface":{"surfaceId":"nopa-730","catalogId":"https://relayapp.im/a2ui/catalog/v1"}},
    {"version":"v0.9.1","updateComponents":{"surfaceId":"nopa-730","components":[{"id":"root","component":"Card","child":"body"},{"id":"body","component":"Column","children":["photo","title","when","book"]},{"id":"photo","component":"Image","url":"https://example.com/nopa/dinner.jpg","description":"A dinner plate at Nopa","variant":"mediumFeature"},{"id":"title","component":"Text","text":"Nopa","variant":"h4"},{"id":"when","component":"Text","text":"Tonight at 7:30 PM, party of 2","variant":"caption"},{"id":"book_label","component":"Text","text":"Book a table for 2"},{"id":"book","component":"Button","child":"book_label","variant":"primary","action":{"event":{"name":"book_table","context":{"time":"19:30","party":2}}}}]}}
  ]}
      media={{"https://example.com/nopa/dinner.jpg":"/images/cards/food.jpg"}}
    />
  </Tab>

  <Tab title="JSON">
    ```json theme={null}
    {
      "message": {
        "parts": [{
          "type": "data",
          "media_type": "application/a2ui+json",
          "data": [
            {"version":"v0.9.1","createSurface":{"surfaceId":"nopa-730","catalogId":"https://relayapp.im/a2ui/catalog/v1"}},
            {"version":"v0.9.1","updateComponents":{"surfaceId":"nopa-730","components":[
              {"id":"root","component":"Card","child":"body"},
              {"id":"body","component":"Column","children":["photo","title","when","book"]},
              {"id":"photo","component":"Image","url":"https://example.com/nopa/dinner.jpg","description":"A dinner plate at Nopa","variant":"mediumFeature"},
              {"id":"title","component":"Text","text":"Nopa","variant":"h4"},
              {"id":"when","component":"Text","text":"Tonight at 7:30 PM, party of 2","variant":"caption"},
              {"id":"book_label","component":"Text","text":"Book a table for 2"},
              {"id":"book","component":"Button","child":"book_label","variant":"primary","action":{"event":{"name":"book_table","context":{"time":"19:30","party":2}}}}
            ]}}
          ]
        }]
      }
    }
    ```
  </Tab>
</Tabs>

* Expect Relay to ignore the A2UI theme and draw every card in Relay's own style.

## Next steps

* [Interactions](/interactions/index)
* [Buttons](/interactions/buttons)
* [Payments](/interactions/payments)
* [Receive `message.received`](/events/message-received)
* [A2UI specification](https://a2ui.org)
