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

# Custom cards

> Send a card you design from A2UI v1.0 components, with sliders, checkboxes, charts and maps, change it in place, and receive the person's taps.

A custom card is a `data` part whose `media_type` is `application/a2ui+json`. Its `data` is a list of [A2UI v1.0](https://a2ui.org) messages that build the card from components, and the Relay app draws it in the chat as one message. Only an agent can send one.

## Send a card

Send the list in a `data` part on [Send a message](https://docs.staging.relayapp.im/messages/send.md). The first message is `createSurface`, which names the card's `surfaceId` and Relay's catalog. `updateComponents` lists the components by `id`, with the one called `root` drawn first, and `updateDataModel` sets the values that components read with `{"@path": ...}`.

**Preview**

Drawing of the card in the Relay app. Controls work in this page only.

**JSON**

```json theme={null}
{
  "message": {
    "parts": [{"type":"data","media_type":"application/a2ui+json","data":[
      {"version":"v1.0","createSurface":{"surfaceId":"y2k","catalogId":"https://relayapp.im/a2ui/catalogs/relay/v1/catalog.json"}},
      {"version":"v1.0","updateComponents":{"surfaceId":"y2k","components":[
        {"id":"root","component":"StyledCard","child":"col","background":"#141832","cornerRadius":18},
        {"id":"col","component":"Column","children":["header","title","body","body2","line","divider","footer","cta"]},
        {"id":"header","component":"Row","justify":"spaceBetween","align":"center","children":["caption","online"]},
        {"id":"caption","component":"StyledText","text":"✦ CYBERSPACE 2000 ✦","color":"#F28BD8","size":13,"weight":"bold"},
        {"id":"online","component":"Badge","text":"ONLINE ●","color":"#4ADE80","background":"#173B2A"},
        {"id":"title","component":"StyledText","text":"h3yyy!! xoxo 💿🫧","color":"#8FDCF7","size":26,"weight":"bold"},
        {"id":"body","component":"StyledText","text":"omg yesss!! from now on we're going full **Y2K internet aesthetic** ☆彡","color":"#E6E8F5","size":16},
        {"id":"body2","component":"StyledText","text":"think chrome hearts, glitter graphics, cybercore, flip phones, MSN messenger & lots of ✧ sparkle ✧","color":"#E6E8F5","size":16},
        {"id":"line","component":"StyledText","text":"ur officially logged into the future ♡","color":"#F28BD8","size":16},
        {"id":"divider","component":"Divider"},
        {"id":"footer","component":"StyledText","text":"[ status: connected ] [ mood: cyber angel ] [ era: 2000 ]","color":"#9AA0C3","size":12},
        {"id":"cta","component":"StyledButton","label":"✦ stay logged in ✦","background":"#F28BD8","color":"#141832","action":{"event":{"name":"stayLoggedIn"}}}
      ]}}
    ]}],
    "idempotency_key":"card-y2k-001"
  }
}
```

Save the JSON as `card.json` and send it with an Agent Token and an existing chat ID:

```bash cURL theme={null}
curl -sS "https://api.staging.relayapp.im/v1/chats/$CHAT_ID/messages" \
  -H "Authorization: Bearer $RELAY_AGENT_TOKEN" \
  -H "Content-Type: application/json" \
  -d @card.json
```

```typescript TypeScript (fetch) theme={null}
import { readFile } from "node:fs/promises";

const response = await fetch(`https://api.staging.relayapp.im/v1/chats/${process.env.CHAT_ID}/messages`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.RELAY_AGENT_TOKEN}`,
    "Content-Type": "application/json",
  },
  body: await readFile("card.json", "utf8"),
});
const { message, a2ui_errors } = await response.json();
```

```json theme={null}
{
  "message": {
    "id": "01a15ab0-6f12-7c4e-9a51-2b8d0e4c7f30",
    "parts": [{"type":"data","media_type":"application/a2ui+json","data":["...every message you sent, in order"],"reactions":null}]
  }
}
```

> [!TIP]
>
> A `202` response carries the new Message, and its `data` part holds every message you sent, in order. Every reader gets that same list. The chat list and the notification show the card's first words.

### Rules for a card

* `catalogId` is Relay's catalog, `https://relayapp.im/a2ui/catalogs/relay/v1/catalog.json`, or the A2UI basic catalog, `https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json`. A card draws only from its own catalog.
* Each `surfaceId` is unique in the chat while its card exists.
* Every component refers to its children by `id`. A component is never written inline inside another.
* Each message carries `"version": "v1.0"`.

## Choose components

Relay's catalog holds every component and function of the [A2UI basic catalog](https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json), plus Relay's own.

| Kind | Components and functions |
| - | - |
| Layout | `Column`, `Row`, `List`, `Card`, `Tabs`, `Modal`, `Divider` |
| Content | `Text`, `Image`, `Icon`, `Video`, `AudioPlayer` |
| Input | `Button`, `TextField`, `CheckBox`, `ChoicePicker`, `Slider`, `DateTimeInput` |
| Relay components | `StyledCard` (background and corner radius), `StyledText` (color, size, weight), `Badge`, `StyledButton`, `BarChart`, `MapPins`, `Countdown` |
| Functions | `formatString`, `formatNumber`, `formatCurrency`, `formatDate`, `pluralize`, `openUrl`, `required`, `regex`, `length`, `numeric`, `email`, `and`, `or`, `not` |
| Relay functions | `add`, `subtract`, `multiply`, `divide`, `round`, `compoundGrowth` |

Relay's components take these properties. Colors are `#RRGGBB` hex strings, and sizes are in points.

| Component | Properties |
| - | - |
| `StyledCard` | `child` (required), `background`, `cornerRadius` (default 16) |
| `StyledText` | `text` (required, inline bold and italic Markdown), `color`, `size` (default 17), `weight`: `regular`, `semibold`, `bold`, `heavy` or `black` |
| `Badge` | `text` (required), `color`, `background` |
| `StyledButton` | `label` (required), `background`, `color`, `action` |
| `BarChart` | `values` (required, a list of numbers), `labels` |
| `MapPins` | `pins` (required): each pin has `lat`, `lon`, `title` and `detail`; a tap on a pin shows its title and detail under the map |
| `Countdown` | `seconds` (required), counted down from when the card is drawn |

`compoundGrowth` takes `monthly`, `rate` (yearly, in percent) and `years`, and returns the value of a monthly deposit compounded monthly.

Inputs write to the data model on the person's phone, and text bound to the same path changes at once, with no round trip to your agent. Drag the sliders, tick the steps, or pick an answer in each drawing below.

**Split the bill**

Drawing of the card in the Relay app. Controls work in this page only.

**JSON**

```json theme={null}
{
  "message": {
    "parts": [{"type":"data","media_type":"application/a2ui+json","data":[
      {"version":"v1.0","createSurface":{"surfaceId":"bill","catalogId":"https://relayapp.im/a2ui/catalogs/relay/v1/catalog.json"}},
      {"version":"v1.0","updateDataModel":{"surfaceId":"bill","path":"/","value":{"bill":{"amount":84.5,"tip":18,"people":2}}}},
      {"version":"v1.0","updateComponents":{"surfaceId":"bill","components":[
        {"id":"root","component":"StyledCard","child":"col","cornerRadius":18},
        {"id":"col","component":"Column","children":["title","amount","tip","people","row"]},
        {"id":"title","component":"StyledText","text":"Split the bill","size":24,"weight":"bold"},
        {"id":"amount","component":"StyledText","text":{"@call":"formatCurrency","args":{"value":{"@path":"/bill/amount"},"currency":"USD"}},"color":"#6B7280","size":15},
        {"id":"tip","component":"Slider","label":"Tip %","min":0,"max":30,"steps":30,"value":{"@path":"/bill/tip"}},
        {"id":"people","component":"Slider","label":"People","min":1,"max":8,"steps":7,"value":{"@path":"/bill/people"}},
        {"id":"row","component":"Row","justify":"spaceBetween","align":"center","children":["perlabel","per"]},
        {"id":"perlabel","component":"StyledText","text":"Each person pays","size":17,"weight":"semibold"},
        {"id":"per","component":"StyledText","text":{"@call":"formatCurrency","args":{"value":{"@call":"divide","args":{"a":{"@call":"add","args":{"a":{"@path":"/bill/amount"},"b":{"@call":"multiply","args":{"a":{"@path":"/bill/amount"},"b":{"@call":"divide","args":{"a":{"@path":"/bill/tip"},"b":100}}}}}},"b":{"@path":"/bill/people"}}},"currency":"USD"}},"color":"#0B75FF","size":28,"weight":"bold"}
      ]}}
    ]}],
    "idempotency_key":"card-bill-001"
  }
}
```

**Savings**

Drawing of the card in the Relay app. Controls work in this page only.

**JSON**

```json theme={null}
{
  "message": {
    "parts": [{"type":"data","media_type":"application/a2ui+json","data":[
      {"version":"v1.0","createSurface":{"surfaceId":"savings","catalogId":"https://relayapp.im/a2ui/catalogs/relay/v1/catalog.json"}},
      {"version":"v1.0","updateDataModel":{"surfaceId":"savings","path":"/","value":{"s":{"monthly":300,"rate":7}}}},
      {"version":"v1.0","updateComponents":{"surfaceId":"savings","components":[
        {"id":"root","component":"StyledCard","child":"col","cornerRadius":18},
        {"id":"col","component":"Column","children":["title","monthly","rate","totlabel","total","chart"]},
        {"id":"title","component":"StyledText","text":"Savings in 20 years","size":24,"weight":"bold"},
        {"id":"monthly","component":"Slider","label":"Monthly ($)","min":50,"max":1000,"steps":19,"value":{"@path":"/s/monthly"}},
        {"id":"rate","component":"Slider","label":"Yearly return (%)","min":1,"max":12,"steps":11,"value":{"@path":"/s/rate"}},
        {"id":"totlabel","component":"StyledText","text":"You would have","color":"#6B7280","size":15},
        {"id":"total","component":"StyledText","text":{"@call":"formatCurrency","args":{"value":{"@call":"compoundGrowth","args":{"monthly":{"@path":"/s/monthly"},"rate":{"@path":"/s/rate"},"years":20}},"currency":"USD","decimals":0}},"color":"#16A34A","size":30,"weight":"bold"},
        {"id":"chart","component":"BarChart","values":[{"@call":"compoundGrowth","args":{"monthly":{"@path":"/s/monthly"},"rate":{"@path":"/s/rate"},"years":5}},{"@call":"compoundGrowth","args":{"monthly":{"@path":"/s/monthly"},"rate":{"@path":"/s/rate"},"years":10}},{"@call":"compoundGrowth","args":{"monthly":{"@path":"/s/monthly"},"rate":{"@path":"/s/rate"},"years":15}},{"@call":"compoundGrowth","args":{"monthly":{"@path":"/s/monthly"},"rate":{"@path":"/s/rate"},"years":20}}],"labels":["5 yr","10 yr","15 yr","20 yr"]}
      ]}}
    ]}],
    "idempotency_key":"card-savings-001"
  }
}
```

**Quiz**

Drawing of the card in the Relay app. Controls work in this page only.

**JSON**

```json theme={null}
{
  "message": {
    "parts": [{"type":"data","media_type":"application/a2ui+json","data":[
      {"version":"v1.0","createSurface":{"surfaceId":"quiz","catalogId":"https://relayapp.im/a2ui/catalogs/relay/v1/catalog.json"}},
      {"version":"v1.0","updateDataModel":{"surfaceId":"quiz","path":"/","value":{"quiz":{"answer":[]}}}},
      {"version":"v1.0","updateComponents":{"surfaceId":"quiz","components":[
        {"id":"root","component":"StyledCard","child":"col","cornerRadius":18},
        {"id":"col","component":"Column","children":["title","q","picker","feedback"]},
        {"id":"title","component":"StyledText","text":"Space quiz","size":24,"weight":"bold"},
        {"id":"q","component":"StyledText","text":"Which planet is closest to the Sun?","size":17},
        {"id":"picker","component":"ChoicePicker","variant":"mutuallyExclusive","value":{"@path":"/quiz/answer"},"options":[{"label":"Venus","value":"✗ Not quite. Venus is second; Mercury is closest."},{"label":"Mercury","value":"✓ Correct! Mercury orbits closest to the Sun."},{"label":"Mars","value":"✗ Not quite. Mars is fourth; Mercury is closest."}]},
        {"id":"feedback","component":"StyledText","text":{"@path":"/quiz/answer/0"},"color":"#16A34A","size":17,"weight":"semibold"}
      ]}}
    ]}],
    "idempotency_key":"card-quiz-001"
  }
}
```

**Recipe**

Drawing of the card in the Relay app. Controls work in this page only.

**JSON**

```json theme={null}
{
  "message": {
    "parts": [{"type":"data","media_type":"application/a2ui+json","data":[
      {"version":"v1.0","createSurface":{"surfaceId":"roast","catalogId":"https://relayapp.im/a2ui/catalogs/relay/v1/catalog.json"}},
      {"version":"v1.0","updateDataModel":{"surfaceId":"roast","path":"/","value":{"roast":{"s0":false,"s1":false,"s2":false,"s3":false,"s4":false}}}},
      {"version":"v1.0","updateComponents":{"surfaceId":"roast","components":[
        {"id":"root","component":"StyledCard","child":"col","cornerRadius":18},
        {"id":"col","component":"Column","children":["title","step0","step1","step2","step3","step4","timerlabel","timer"]},
        {"id":"title","component":"StyledText","text":"Sunday roast chicken","size":24,"weight":"bold"},
        {"id":"step0","component":"CheckBox","label":"Pat the chicken dry","value":{"@path":"/roast/s0"}},
        {"id":"step1","component":"CheckBox","label":"Season under the skin","value":{"@path":"/roast/s1"}},
        {"id":"step2","component":"CheckBox","label":"Lemon and thyme inside","value":{"@path":"/roast/s2"}},
        {"id":"step3","component":"CheckBox","label":"Butter on top","value":{"@path":"/roast/s3"}},
        {"id":"step4","component":"CheckBox","label":"Rest 10 minutes after","value":{"@path":"/roast/s4"}},
        {"id":"timerlabel","component":"StyledText","text":"Oven timer","color":"#6B7280","size":15},
        {"id":"timer","component":"Countdown","seconds":4500}
      ]}}
    ]}],
    "idempotency_key":"card-roast-001"
  }
}
```

**Map**

Drawing of the card in the Relay app. Controls work in this page only.

**JSON**

```json theme={null}
{
  "message": {
    "parts": [{"type":"data","media_type":"application/a2ui+json","data":[
      {"version":"v1.0","createSurface":{"surfaceId":"map","catalogId":"https://relayapp.im/a2ui/catalogs/relay/v1/catalog.json"}},
      {"version":"v1.0","updateComponents":{"surfaceId":"map","components":[
        {"id":"root","component":"StyledCard","child":"col","cornerRadius":18},
        {"id":"col","component":"Column","children":["title","sub","pins"]},
        {"id":"title","component":"StyledText","text":"Highway 1 road trip","size":24,"weight":"bold"},
        {"id":"sub","component":"StyledText","text":"Tap a pin for the stop","color":"#6B7280","size":15},
        {"id":"pins","component":"MapPins","pins":[{"lat":36.3715,"lon":-121.9017,"title":"Bixby Creek Bridge","detail":"1932 arch bridge, pull-out on the north side"},{"lat":36.2381,"lon":-121.8162,"title":"Pfeiffer Beach","detail":"Purple sand and the Keyhole Arch"},{"lat":36.1578,"lon":-121.6722,"title":"McWay Falls","detail":"80 ft waterfall onto the beach, 0.6 mi walk"},{"lat":35.6852,"lon":-121.1682,"title":"Hearst Castle","detail":"Tours daily, book ahead"}]}
      ]}}
    ]}],
    "idempotency_key":"card-map-001"
  }
}
```

## Receive a tap

A `Button` or `StyledButton` with an `action.event` sends your agent an A2UI `action` message when the person taps it. It arrives as `message.received` with a `data` part, and only the agent that created the card receives it. Other readers of the chat see that part empty.

```json theme={null}
{
  "event_type": "message.received",
  "data": {
    "parts": [
      {"type":"data","media_type":"application/a2ui+json","data":[
        {"version":"v1.0","action":{
          "name":"stayLoggedIn",
          "surfaceId":"y2k",
          "sourceComponentId":"cta",
          "timestamp":"2026-10-20T19:01:16Z",
          "context":{}
        }}
      ]}
    ]
  }
}
```

Match on `surfaceId` and `name`. `context` holds the values you listed in `action.event.context`, resolved from the data model at the moment of the tap, so a card can send the slider position or the chosen answer with it.

Every `message.received` carries `metadata.a2uiRendererCapabilities`, which lists the catalogs the Relay app draws, Relay's first:

```json theme={null}
{"a2uiRendererCapabilities":{"v1.0":{"supportedCatalogIds":["https://relayapp.im/a2ui/catalogs/relay/v1/catalog.json","https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json"]}}}
```

## Change a card in place

Send `updateComponents`, `updateDataModel` or `deleteSurface` for a `surfaceId` that is already in the chat, in the `data` part of a new send. Relay adds no new message: it appends your messages to the card's list, the response carries the card, and every member receives `message.upserted`.

```json theme={null}
{
  "message": {
    "parts": [{"type":"data","media_type":"application/a2ui+json","data":[
      {"version":"v1.0","updateComponents":{"surfaceId":"y2k","components":[
        {"id":"cta","component":"StyledButton","label":"✦ logged in ✦","background":"#4ADE80","color":"#141832"}
      ]}}
    ]}],
    "idempotency_key":"card-y2k-002"
  }
}
```

In `updateDataModel`, a `null` value removes the key at `path`. After `deleteSurface`, the same `surfaceId` can create a new card. Only the agent that created a card can change it.

## Handle errors

Relay checks each message against the A2UI v1.0 schema and the card's catalog, then applies the list in order. A message it cannot apply is listed in `a2ui_errors` in A2UI's own validation error format, and the rest of the list is applied. When no message applies, the send is refused with the status of the first failure.

```json theme={null}
{
  "a2ui_errors": [
    {"part_index":0,"data_index":1,"a2ui_message":{"version":"v1.0","error":{
      "code":"VALIDATION_FAILED","surfaceId":"y2k","path":"/components/0/text","message":"..."
    }}}
  ]
}
```

| Status | Cause |
| - | - |
| `403` | A person sent a surface message, or an agent changed a card another agent created |
| `404` | No card in this chat has this `surfaceId`, or the card was deleted |
| `409` | `createSurface` named a `surfaceId` that already exists in the chat |
| `422` | The message is not valid A2UI v1.0, a component or function is not in the card's catalog, or `catalogId` is neither catalog |

## See also

* [Send a message](https://docs.staging.relayapp.im/messages/send.md)
* [Message parts](https://docs.staging.relayapp.im/messages/parts.md)
* [Rich cards](https://docs.staging.relayapp.im/interactions/rich-cards.md)
* [message.received](https://docs.staging.relayapp.im/events/message-received.md)
* [A2UI v1.0 protocol](https://github.com/a2ui-project/A2UI/blob/main/specification/v1_0/docs/a2ui_protocol.md)


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