Skip to main content

Cards

FieldValue
StatusDraft. The SDK reads cards; clients are being built.
Format version1
Event kinds14 and 1010 from the event profile, 21235 new
Tagscard, action, and imeta for media
Builds onNIP-01, NIP-17, NIP-44, NIP-59
Draft

This page is written as a complete specification. Names and limits can still change until the first release that writes cards. Screenshots from Android and the web will be added here once those clients draw cards. The previews on this page are a model in the browser, not Softchat's own drawing.

Abstract​

A card is a structured message: a title, a state, facts, a bar, media, and buttons, written as tags on a normal encrypted message. Each client draws the parts natively with its own theme. A card carries no code. A client that does not know cards shows the plain text and the attachments the message carries anyway.

["card","1"],
["card","state","warn","To pay"],
["card","title","Sample Insurance"],
["card","until","2026-11-30"],
["card","fact","Amount","CHF 1'949.75","big"],
["card","fact","IBAN","CH44 3199 9123 0008 8901 2","copy"],
["card","fact","Reference","21 00000 00003 13947 14300 09017","copy"],
["imeta","url https://images.example.org/q/Zm9pQx2mLr7w.png","m image/png","dim 1035x1035","alt Invoice QR code: CHF 1'949.75","card body qr"],
["card","reply","Mark as paid","paid","main"],
["card","open","Open letter","mail.example.org/123"],
["card","push","To pay: CHF 1'949.75","Sample Insurance, by 30 November"]
Mail service
To pay
Sample Insurance
in 25 days
CHF 1'949.75
Amount
IBAN
CH44 3199 9123 0008 8901 2
Reference
21 00000 00003 13947 14300 09017
Invoice QR code: CHF 1'949.75
16:00

Motivation​

Services write to people in Softchat: a monitor reports an outage, a mail service announces a letter, a backup asks a question. Today each of those is a block of text, and most alerts are two messages, one when a problem opens and one when it closes, often far apart in the chat.

The obvious way to make these messages richer is to let them hold HTML. We tried it on a Pixel 9 and in Chrome, in test apps, and decided against it.

  • Memory is the smaller problem. The first WebView costs over a hundred megabytes, and later ones little. Rows keep their cost even when nothing needs it.
  • Height is the real one. A row that learns its height after it appears moves everything around it, and every wrong guess is a visible jump in the list.
  • Faults are shared. WebViews share one renderer: a card stuck in a loop froze every other card, and a renderer crash closed them all.
  • It does not contain itself. blockNetworkLoads stops images but not fetch or WebSocket, and WebRTC reached a STUN server past a strict content security policy. A card could report that it was read.
  • Nothing needs it. We collected 33 real and planned messages. None of them needs code in the message. Eight kinds of part cover 27 of them, and three more cover invoices, new series and progress.

Cards keep the list as predictable as text: the app knows every part, so it knows each height before it draws.

Goals​

  • A sender composes a message from known parts, and updates it instead of sending another.
  • A button can send an answer that a service can read, end to end encrypted.
  • A message stays small enough to fit a push payload.
  • Clients without cards keep working, with no change.

Non-goals​

  • HTML, scripts, or anything else that runs.
  • Free colours, fonts or layout. The theme belongs to the reader's app.
  • Forms and text fields. A card can ask with buttons, and the composer is right below it.
  • Data that a card fetches by itself, apart from its media.
  • Different content per person. A rumor is the same for every member of a room.

Terminology​

The key words MUST, MUST NOT, SHOULD, SHOULD NOT and MAY are to be read as in RFC 2119.

TermMeaning
RumorThe unsigned inner event of NIP-59. A card lives here, encrypted.
WrapThe NIP-59 gift wrap around the rumor, the only thing a relay sees.
PartOne element of a card, written as one tag.
FieldA value of a part, by position.
WriterThe code that builds a card: the SDK's builder, or a service's.
ReaderThe SDK code that reads tags into a typed card. Clients draw that model and never see the tags.
Sender on the listA sender the person has marked as trusted for full cards.
Trusted domainA domain in the list the SDK ships, treated as ours for links and QR codes.

How it fits together​

  1. WriterBuilds the tagsA service's template or code calls the builder, which refuses what a reader would skip.
  2. WireOne tag per partInside the rumor, next to the fallback text, encrypted end to end.
  3. SDKReads and validatesLimits, addresses and trust are applied once, in one place.
  4. ClientDraws the modelAndroid and the web draw parts with their theme, and never parse tags.

Try it. Pick a card, switch parts on and off, change who sent it, or write the tags by hand and see what a reader keeps. Misleading links shows what the rules do with an address that imitates a trusted one.

Mail service
To pay
Sample Insurance
in 25 days
CHF 1'949.75
Amount
IBAN
CH44 3199 9123 0008 8901 2
Reference
21 00000 00003 13947 14300 09017
Invoice QR code: CHF 1'949.75
16:00

Tap the card, its buttons and its rows. Images are drawn here, nothing is downloaded.

Weight

962 BRumor
3.2 KBGift wrap
574 BRoom left
Tags
962 B
TOML
1056 B
JSON
1128 B

What the reader says

Valid: every tag is drawn.

Parts

The tags, editable

Message format​

A card is carried by tags on a kind-14 rumor, sealed and wrapped as NIP-17 and NIP-59 describe:

{
"kind": 14,
"content": "Web app: http_503, 3 checks in a row. status.example.org/web",
"tags": [
["p", "<recipient>"],
["card", "1"],
["card", "state", "crit", "Not responding"],
["card", "title", "Web app"],
["card", "since", "1793887320"],
["card", "fact", "Error", "http_503"],
["card", "fact", "Checks", "3 in a row"],
["card", "open", "Open", "status.example.org/web"],
["card", "reply", "Known"]
]
}
  1. A message with a card MUST carry exactly one version tag, ["card","1"].
  2. Every part is one tag. Its first value is card, its second the part's name, and the rest are its fields.
  3. Every value MUST be a string.
  4. content MUST NOT be empty and MUST make sense alone: it is what clients without cards show, what search finds, and what the chat list previews. It SHOULD be short, and SHOULD end with the address if the card has a main link, written without a scheme, since clients add https:// to a bare domain.
  5. A trailing optional field MAY be left out. An optional field in the middle is an empty string.
  6. Readers MUST ignore fields beyond those they know, as the event profile already requires.

Names​

  • A name of one letter is reserved, for tags and for parts alike. In Nostr these tags have a special role on relays. Inside a rumor no relay sees them, and we keep them free anyway.
  • The first value is always card. It avoids the names the SDK reserves for tags (p, e, q, subject, imeta, image, emoji), and has the shape of imeta.
  • Part names are short words a person can read in a log.

Order​

Readers of the event profile do not depend on the order between kinds of tag. Order matters only among card tags. The profile keeps extension tags in their original order, so writers put parts in the order they want them drawn, and the reader draws:

  1. the state, wherever it was written;
  2. an image written before the title;
  3. the title, subtitle and time, as one block;
  4. the other parts, in their written order;
  5. the buttons, in their written order;
  6. the control that expands the card, if it has one.

Value types​

TypeFormExampleRule
TextUTF-8 stringTo payNo control characters, and no characters that change text direction.
WordA value of a closed listwarnAn unknown value counts as the neutral one.
DateUnix seconds, or a day1793887320, 2026-11-30A day holds in the reader's time zone and does not shift.
NumberDecimal, with a point412, 18.5Not negative, at most 15 digits.
AddressWithout a schememail.example.org/123See below.
SignsWords separated by a spacebig copyAn unknown sign is ignored.

An address has no scheme: the reader adds https://, so http:, intent: or javascript: cannot be written at all. A reader MUST refuse an address that holds a scheme, a name before @, a fragment, spaces or control characters, or a host without a domain, and MUST apply the same public-address rules it applies to link previews before fetching one.

Parts​

Version 1 has a closed list of parts. Limits are in Limits.

Content​

title​

["card","title",<text>]

Required, once. A card without a valid title is no card: the reader shows the fallback text.

sub​

["card","sub",<text>]

A smaller line under the title.

state​

["card","state",<level>,<word>]

The strip at the top of the card. The level is info, ok, warn or crit, and the theme decides its colour. The word is what the card says about itself, such as Not responding or Paid.

since and until​

["card","since",<date>]
["card","until",<date>]

A moment that keeps counting by itself: "for 12 min", "in 3 days". A card has one. The app owns language, time zone and refresh.

fact​

["card","fact",<label>,<value>,<signs>?]

A pair of label and value. The sign big draws the value large, once per card. The sign copy adds a copy control next to the value, which copies it as written.

["card","1"],
["card","title","Sample Insurance"],
["card","fact","Amount","CHF 1'949.75","big"],
["card","fact","IBAN","CH44 3199 9123 0008 8901 2","copy"],
["card","fact","Due","30 November"]
Mail service
Sample Insurance
CHF 1'949.75
Amount
IBAN
CH44 3199 9123 0008 8901 2
Due
30 November
16:00

text​

["card","text",<markdown>]

Text in the same Markdown messages use, read by the SDK. Links read as written, since Softchat's Markdown has no [word](address) form.

row​

["card","row",<title>,<detail>?,<value>?,<address>?,<signs>?]

A row of a list. With an address it can be tapped, and follows the rules for links. The signs todo and done show a check mark. Consecutive rows form one list. Above the rows in view, the app adds "Show all".

bar​

["card","bar",<label>,<value>,<maximum>,<threshold>?,<text>?]

A value on a scale, with an optional mark at a threshold and the text to show beside the label. The app draws it, at a fixed height, in the colour of the state.

["card","1"],
["card","state","warn","Disk almost full"],
["card","title","node-1"],
["card","bar","Disk","412","480","408","412 GB of 480"]
Monitoring
Disk almost full
node-1
Disk412 GB of 480
16:00

Media​

Media is not a part of its own: it is a normal imeta attachment that says it belongs in the card. See Media.

Buttons​

["card","open",<label>,<address>,<tone>?]
["card","reply",<label>,<word>?,<tone>?]
["card","confirm",<label>,<word>,<question>,<tone>?]
["card","copy",<label>,<value>,<tone>?]

See Buttons and replies. The tone is main, good or bad.

Life of the card​

["card","exp",<date>]
["card","push",<title>,<body>?]
["card","rev",<number>]
["card","done",<word>]
  • exp: after this moment the buttons that answer turn off, and the card says it expired.
  • push: the text of the notification. Without it, the title and the fallback text.
  • rev and done appear only in updates. See Updating a card.

Compact and expanded​

A + before a part's name means it shows only when the card is expanded, a - only when it is compact. If at least one part is marked, the card starts compact and expands on a tap. If none is, it shows whole and there is nothing to tap.

["card","1"],
["card","state","warn","To pay"],
["card","title","Sample Insurance"],
["card","until","2026-11-30"],
["card","fact","Amount","CHF 1'949.75","big"],
["card","+fact","IBAN","CH44 3199 9123 0008 8901 2","copy"],
["card","+fact","Reference","21 00000 00003 13947 14300 09017","copy"],
["imeta","url https://images.example.org/q/Zm9pQx2mLr7w.png","m image/png","dim 1035x1035","alt Invoice QR code: CHF 1'949.75","card body qr more"],
["card","reply","Mark as paid","paid","main"],
["card","+open","Open letter","mail.example.org/123"]
Mail service
To pay
Sample Insurance
in 25 days
CHF 1'949.75
Amount
16:00
  • Writers MAY mark sub, fact, text, row, bar and buttons. Media takes more or less in its card field instead of a prefix.
  • Readers MUST ignore the prefix on any other part, and on a main button. Sender, state, title, time and the main action are always in view.
  • - is for a summary that gives way to the details. Open Eight bills in the playground above.
  • A hidden part travels in the message. It counts towards the limits and is not secret.
  • Hidden media SHOULD NOT download until the card opens. Media that must work offline, such as a ticket, must not be hidden.
  • An update does not close an open card. Open or closed is kept per message and per device, and is not synchronized.
  • The notification, the fallback text and search do not depend on it.

Android today gives a single tap on a message no action on purpose, because a tap meant for a link or to stop a scroll used to open a thread, and a double tap adds a reaction, so a single tap waits to see if a second follows. So clients:

  • MUST draw a visible control, "Show more" and "Show less", which responds at once;
  • SHOULD also expand on a tap on the rest of the card, unless the list was scrolling;
  • MUST keep buttons, links and rows acting as themselves.

The control SHOULD sit at the bottom of the card. The Android list is reversed, so a card should grow upwards and leave the control under the finger. That is reasoned from the code and not yet measured.

Media​

A card's media are ordinary imeta attachments, with everything Softchat already supports: images, video, voice messages and files, plain or encrypted. A card field tells the reader to draw one inside the card. Without that field it stays an attachment below the card, as on any message.

["imeta", "url https://images.example.org/c/7Hq2xKv9pT3n.webp", "m image/webp", "dim 1440x480", "alt Disk, last 24 hours: from 61% to 85%", "card body"]

The card field holds signs, separated by a space:

SignMeaning
topAbove the title.
sideBeside the title, small.
bodyIn the card, after the other content and before the buttons.
qrDrawn on white with a margin and without smoothing, enlarged on a tap. Honoured only from a trusted domain.
more, lessShown only expanded, or only compact.

Clients without cards ignore the unknown field and show the attachment as usual, so a card's media reach them too. That also means media follows the rules attachments already have:

  • Replacing it. Media changes through ["attachments","replace"] and the complete imeta set, as today. A card update without that marker keeps the media, and media can change without touching the card. See Saved updates.
  • Private content. With enc and x an attachment is encrypted before upload, so its host cannot read it. This is the way to put private content in a card.
  • Downloading. A reader uses its normal attachment path, with its caches and viewers.

Reserving the space​

  • An image or video in the card MUST declare dim. Without it, it stays an attachment below the card, so the row cannot jump.
  • The height follows from the card's width and dim, before any download, and does not change afterwards. A wrong size is cropped around the centre, not resized.
  • The shape stays between 1:2 and 4:1, and an image is at most 240 dp tall.
  • A writer that does not know the exact size writes the shape, such as dim 2x3. Below 64 on a side, the value counts only as a shape.
  • The app decodes only the size it needs, and does not enlarge beyond twice the declared size.
  • alt SHOULD be present on an image or video. It stays in the reserved space when the media is missing, and it is what a screen reader reads.
  • At most two attachments go in a card. Others stay below it.

Formats​

The formats are those of attachments. For an image in a card, PNG, WebP and JPEG are safe in every client. SVG is not: Android has no decoder for it in this path, and each client would draw it its own way.

Following the theme​

An image can be drawn by a server in the theme the person is using, without the card describing colours. The vars field names what the image wants to know about the display, and a reader that knows it adds those values to the address as a query.

["imeta", "url https://images.example.org/c/7Hq2xKv9pT3n.webp", "m image/webp", "dim 1440x480", "alt Disk, last 24 hours", "card body", "vars dp,scheme,look,palette,contrast"]

The url stays complete without them, so a client without cards simply gets the server's default.

VariableValuesSent when
dp320, 480, 720Always: the band of the card's width.
schemelight, darkAlways, so a server that does not know the look can still choose.
lookThe name of the lookNot the default look.
paletteThe name of the paletteChosen by the person, and used by the look. Never the system palette, which comes from the wallpaper.
contrasthighNot the default.
text130, 160Not the default, and only if the image has words.

A reader fills only the variables the tag names and this list holds, and ignores the others. It MUST send them only to a trusted domain, and to other hosts only dp. It MUST NOT add anything else: no key, account, device identifier, cookie or referrer, and the same User-Agent for everyone. See Privacy.

Buttons and replies​

A button opens, answers or copies. Only answering reaches the sender.

Answering​

reply sends the word, or the label in lower case if there is no word. confirm first shows the question in a dialog, and no client sends it from a notification. Tapping sends a normal reply with one more tag:

{
"kind": 14,
"content": "paid",
"tags": [
["p", "<sender>"],
["e", "<card id>", "", "reply"],
["action"]
]
}

A client with cards sees action, hides the bubble and writes the answer on the card. A client without cards shows a normal reply. A service reads the word from the text, as services do today, so it needs no change. Two buttons of a card MUST NOT send the same word.

The card never says by itself that something is done:

StateWhenWhat the person sees
SendingAt onceThe button stays pressed.
QueuedNo networkIt leaves when the network returns, from the SDK's queue.
SentA relay accepted it"Sent, waiting for the sender".
ConfirmedAn update carries ["card","done",<word>]"You replied", and the other buttons turn off.
Expiredexp has passed"Expired", and the buttons that answer turn off.

The state belongs to the account: the reply also reaches the person's other devices. If two devices tap different buttons, the first reply the sender receives counts, and its done tells everyone. In a room of more than two, every member can answer, and the card shows who did.

Opening​

Softchat has no [word](address) Markdown, so today a link reads as written. A button is the first place where a word hides an address, and so:

Where it goesWhat happens
A trusted domain, from a sender on the listIt opens at once.
Any other siteThe button shows the site's name. A tap asks "Continue to example.net?" with the whole address, Cancel, Continue and Copy address.
Any site, from a contactIt always asks, even for a trusted domain.
  • The reader MUST take the site's name from the address, never from the label or from anything the sender writes. For mail.example.org.pay-now.example.net the dialog says example.net, using the public suffix list.
  • A host with letters outside the Latin alphabet MUST show in its xn-- form.
  • The same rules apply to everything that opens without showing its address: buttons and rows. Links written in text stay as they are, since the address is visible.
  • A notification button never opens a link, because the dialog cannot show there. It opens the chat.
  • The dialog sees only the first address. A site can still redirect.

Copying​

Prefer the sign copy on a fact: the value is not written twice, and the action sits next to it. The copy button is for a value the card does not show.

In view​

At most four buttons are in view. Above that, the app shows the first three and "More". One button can be main: a second one is drawn as a normal button.

Updating a card​

A card can change in two ways.

Saved updateLive update
EventKind 1010 in a wrap of kind 1059Kind 21235 in a wrap of kind 21059
On relaysStored, and copied to other relaysPassed to whoever is connected, never stored
Phone offReceived on returnNever received
On the deviceA row in the databaseIn memory, gone on restart
NotificationOnly if the sender asksNever
CountedYesNo
What it can changeThe whole cardValues that do not change the height

Use a saved update when the state changes, and a live update for the steps in between.

CaseSavedLive
DownloadStart, every 25%, endPercentage and speed
Service downOpening, closing, getting worseNumber of failed checks
MaintenanceStart, end, each errorCurrent step
Job in progressStart and resultProgress
Disk over a thresholdEach threshold crossedNothing
Invoice, question, approvalEvery changeNothing
Media service
Downloading
The Lighthouse, season 1
Downloaded25%
16:00

What it cost

0Saved
0Live, seen here
0Live, lost offline
0 BStored, per relay

Each saved update is two encrypted copies, kept by every relay and every device. A live update leaves nothing behind, and reopening the app returns the card to its last saved state.

The last event

Press a button under the phone.

Saved updates​

An update is a kind-1010 edit with the complete new card and fallback text. It follows the event profile's existing rule for replacing attachments: a marker and the complete set.

{
"kind": 1010,
"content": "node-1: disk above 90%. status.example.org/node-1",
"tags": [
["p", "<recipient>"],
["e", "<card id>"],
["card", "1"],
["card", "rev", "3"],
["card", "state", "crit", "Disk almost full"],
["card", "title", "node-1"],
["card", "bar", "Disk", "436", "480", "408", "436 GB of 480"],
["card", "open", "Open", "status.example.org/node-1"]
]
}
  1. Who. Only the card's author, under message.edit.latest-authorized.
  2. Text and card are chosen apart. The text comes from the latest authorized edit. The card comes from the latest authorized edit holding a valid card. A text-only edit keeps the card, as it keeps attachments today.
  3. Complete replacement. ["card","1"] marks it: all card tags of the edit replace all earlier ones. What is not there is gone.
  4. Removing. ["card","0"] removes the card, and the text stays. Only an edit may carry it.
  5. Invalid card. Only the text changes. The card stays as it was, never half applied.
  6. Deleting an update. A deletion of the winning edit brings back the one before, by the rule that holds today.
  7. Order. The later rumor created_at wins, and on a tie the lowest event ID. An edit arriving late does not overwrite newer ones. created_at counts seconds, so a writer MUST NOT send two updates in one second.
  8. What stays. Replies, forwards and media do not change. Replacing media keeps the attachments' own marker, and can travel in the same edit.

rev is a number the writer raises with each saved update. It does not decide which edit wins, so there are not two rules for order. It orders live updates, tells whether a confirmation came after a tap, and checks the count below.

How much it is feltWhat is sentWho notices
Silent, the defaultThe edit in a wrap with no alert, as a silent send todayWhoever looks at the chat. A notification still in the tray changes its text.
With an alertThe edit in a wrap with alertIt rings again on the same notification.
With a new messageThe edit, and a kind 14 quoting the cardEveryone, even if the card is far up the chat.

The update count​

The card shows how often it changed, next to the time: "updated 12 times, last at 16:12". The count covers saved updates, the ones that cost something, and helps find a sender that writes too often.

  • The SDK counts the stored edits, so the number costs no bytes and cannot be forged.
  • If rev and the count disagree, the message's Info says "received 12 of 14".
  • Info also shows the first and last update, and the live updates seen in this session.
  • Above the limit in Limits, the number is drawn as a warning.
  • Writers SHOULD report senders above that limit to whoever runs them. A card updated hundreds of times is a bug in the sender, or a case for a web page.

Live updates​

A live update is a rumor of kind 21235, next to the 21234 of typing, in an ephemeral gift wrap. The relay passes it on and does not store it, and the SDK keeps it in memory after authenticating it.

{
"kind": 21235,
"content": "",
"tags": [
["p", "<recipient>"],
["e", "<card id>"],
["card", "rev", "3"],
["card", "tick", "17"],
["card", "bar", "Downloaded", "67", "100", "", "67%"]
]
}
  1. It overlays. It applies on top of the saved card, and changes only the parts it names. A bar or fact is matched by its label, and state is unique.
  2. Only values. A live update MAY carry bar, an existing fact, and state. A reader MUST ignore anything else, in particular buttons, addresses and media: what a person can act on changes only through a saved update, so that a record remains.
  3. Order. It applies only if its rev equals the card's and its tick is higher than the last one seen. A newer saved update clears the overlay.
  4. It fades. After the lifetime in Limits with no update, the card returns to its saved state instead of pretending a bar is moving.
  5. The first and last state are saved. A live update is never the last word.
  6. Authorization. The reader MUST drop one from anyone but the card's author, from a sender not on the list, or for a card it does not hold.
  7. No push. The wrap MUST NOT carry alert.

A writer repeats the current state while it works, so that someone who connects sees it. It cannot know who is connected, so it encrypts even for no one. A lost live update does not come back, so nothing important may travel this way.

Notifications​

A saved card with alert shows a notification from push, or from the title and the fallback text. The existing setting for notification content still applies. A notification offers at most one card button, the main one, and only if it answers. An open or confirm button never appears there.

Versioning and extension​

ChangeNew version?What an older reader does
A new part that can be skippedNoSkips it.
A new field at the end of a partNoIgnores it.
A new signNoIgnores it.
A new value in a closed listNoUses the neutral value.
A part that cannot be skipped without misleading, such as a payment buttonYesShows the fallback text.
A new meaning for an existing fieldYesShows the fallback text.

A reader:

  • MUST draw a card whose version it knows;
  • MUST show the fallback text for a higher version, or a version that is not a number;
  • MUST skip unknown parts and fields without error.

Writers do not need to know which client will read, because the fallback text is always there.

Reader rules​

What the reader findsWhat it does
No version, or an unknown oneNo card: the fallback text.
No valid titleNo card.
Too many card tagsNo card.
An unknown part, field or signSkips it, with no error.
A part repeated past its limitKeeps the first ones.
A value past its limitSkips that part.
An invalid address, date, number or sizeSkips that part, so a button with a bad address does not appear.
Control or text-direction charactersSkips that part. A direction character can show an address backwards.
A second main buttonDraws it as a normal button.
A second button with the same wordSkips it.
An update with an invalid cardChanges the text, and keeps the card.

A broken part is skipped rather than the whole card refused, so that a long label does not hide an invoice, and so that different versions overlap. Writers MUST refuse in the builder what a reader would skip, so that a missing part is a bug that shows up early.

Who may send what​

The person decides how far to trust a sender. The reader applies it.

SenderWhat the person sees
On the listThe whole card.
A contactThe card without copy and without qr images, and a warning on every link.
UnknownThe fallback text and the attachments, as for any message.

Limits​

These are starting numbers, and the examples on this page are checked against them.

WhatLimitWhy
Card tags in one message40A card is a summary. The SDK keeps up to 256 extension tags.
Rumor, target1024 BWriters warn above this: the next wrap step costs 684 B.
Rumor, maximum1536 BAbove this the wrap no longer fits the 4 KiB push payload.
Title, subtitle120 B eachOne line, two at most.
State word32 BFits one line on a 360 dp phone.
Fact8 per card, label 32 B, value 128 BMore than that is a document.
Text2 per card, 1024 B eachLonger text belongs in a message.
Row16 per card, 8 in view, 64 B / 64 B / 32 BThe rest waits behind Show all.
Bar2 per card, label 32 BA fixed height, so a live update cannot move the row.
Media in the card2 per cardA card is not a gallery. Other attachments stay below it, with the limits every attachment has.
Image shapeBetween 1:2 and 4:1, at most 240 dp tallNo image takes over the screen.
Buttons8 per card, 4 in view, 1 main, label 32 BAbove four, the first three and More.
Confirm question200 BShown in the dialog.
Copy value128 BA code or an account number.
NotificationTitle 64 B, body 200 BShown as written.
Saved updatesAt most 1 per minute, flagged above 20Each one stays on every relay and device.
Live updatesAt most 1 every 5 s, repeated at least every 30 s, valid for 60 sReaders ignore more than one per second per card.

Security considerations​

A card holds no code, so no sender can run anything on the phone. These risks remain.

  • A well-drawn fake invoice. The app always draws the sender's name, outside the card. Payment data does not show for a sender not on the list.
  • A button that lies. A label can say anything, so the site's name comes from the address, and any site outside the trusted domains asks first.
  • A QR code that differs from the written data. A QR from an image cannot be checked against the text, so the qr sign counts only from a trusted domain. The IBAN and amount remain written, and a bank shows the payee before paying.
  • Fallback text that differs from the card. A sender can write one thing in content and another in the card, and old clients will see the first. This is another reason full cards need a sender on the list.
  • Consumption. The limits bound the work: few tags, Markdown already linear in the SDK, and a reader ignores more than one live update per second per card.

There is nothing to sandbox and nothing to decompress.

Privacy considerations​

  • The card is end to end encrypted, a plain attachment is not. Its address reveals an IP address and a moment to whoever hosts it, and its content passes through their network. Private content belongs in the message, or in an attachment encrypted with enc.
  • When media loads. As for any attachment, today. Cards add no new exposure, and an old client would fetch the same address anyway. A reader SHOULD fetch the media of a sender on the list on arrival, so that the host sees when the message arrived and not when it was read.
  • A token per image, never per person. A rumor is the same for all members of a room, so the address is too. In a chat of two, a token already points to one person, and only a host the person trusts should be used for that.
  • What a template reveals. The variables are values in bands, not an identity, but with few people few values are enough to tell them apart. So they go only to trusted domains. Other hosts get the width band alone. An IP address and a moment say far more than a theme.
  • Text scale. A large text setting can suggest poor sight, so text is sent only for an image with words. Words are better left to the card, which already follows font and size.
  • Live updates. The sender cannot tell who received them.

Compatibility​

No relay change is needed: stored wraps already carry any rumor, and kind 21059 already reaches connected clients without being stored.

ClientOriginal messageSaved updateLive updateButton reply
NewDraws the card.Replaces the card.Overlays it.Shown on the card.
Android and web, oldShows content and the attachments. Unknown tags are kept.Applies content, marks it edited. Tags stay as compatibility_tags, ignored.Ignored.A normal reply.
iOS todayShows content and the attachments.Should apply content.Should be ignored.A normal reply.

The iOS behaviour is expected and not yet verified, as it does not use the SDK. A service that reads replies needs no change.

Message size​

Nothing here is measured on a device: the sizes below follow the NIP-44 padding and wrap format, computed from the examples in your browser.

ExampleOne tag per partGift wrapLines in one tagTOML in a tagJSON in a tag
Invoice962 B3.2 KB832 B1056 B1128 B
Invoice, compact896 B2.9 KB780 B980 B1095 B
Question554 B2.6 KB490 B568 B632 B
Service down639 B2.6 KB535 B675 B733 B
Disk and chart794 B2.9 KB720 B856 B939 B
New series752 B2.6 KB672 B784 B814 B
Eight bills1232 B3.9 KB1022 B1462 B1807 B, too big
Video and a file751 B2.6 KB697 B771 B829 B
Average823 B719 B894 B997 B

The padding works in blocks and the rumor is encrypted twice, so the wrap grows in steps. A few bytes only matter when they cross one.

Rumor up toGift wrapFits the push
512 B2.2 KBYes
768 B2.6 KBYes
896 B2.9 KBYes
1024 B3.2 KBYes
1536 B3.9 KBYes
1792 B4.6 KBNo
2048 B5.3 KBNo

A push payload holds up to 4 KiB, and a larger wrap goes as a reference instead. Today, Android shows no notification for that case when the app is closed. This was read in the code and not reproduced, and must be verified and fixed before the first card ships.

Rationale​

One tag per part​

FormForAgainst
One tag per partNo nesting and only strings, so there is little to validate. Every client already reads tags, and the SDK keeps those it does not know.Fields go by position. Awkward to write by hand, but nobody does.
JSON in one tagEveryone reads it, and it has structure.A JSON string inside a JSON event writes every quote twice. The long list leaves the push.
TOML in one tagPleasant to write, with comments.Each line break costs two bytes, and it needs a parser in the SDK and on iOS.
A name per part, ["card.fact",…]Two bytes less per tag.Parts no longer share a tag name to find them by.
A one-letter prefix, ["c:fact",…]Five bytes less per tag.Harder to read, and it brushes against the rule on one-letter names.
Lines in one tagThe smallest readable form.A format of our own, with escape rules and a parser to defend. Possible in a version 2 if weight becomes the problem.
CBOR, MessagePackCompact before encoding.Base64 eats the gain, and they cannot be read.
ProtobufAs small as tags.Schema and generated code on every client, and it cannot be read.
CompressionThe smallest of all.It cannot be read, needs a dictionary kept forever, and a decompressor to defend.

TOML still has a place, on the writer's side: a service can keep templates in its own files, with comments and placeholders, and the builder turns them into tags.

[card]
state = ["crit", "Not responding"]
title = "{service}"
since = "{since}"
facts = [["Error", "{error}"], ["Checks", "{count} in a row"]]
open = ["Open", "{url}"]
reply = ["Known"]

Media as imeta​

WayForAgainst
imeta with a card fieldEvery kind of media Softchat supports, encrypted ones too, with existing downloads, viewers and players. Old clients show it. Replacing it already has rules.About 40 bytes more per item than a part of our own. Media cannot sit between two other parts.
A part of our own, ["card","img",…]Shorter, and it can sit anywhere in the card.Images only, a second path to download and cache them, and nothing for old clients.

Replacing the whole card​

WayForAgainst
Whole card in kind 1010Each edit stands alone: one lost does not break the next. Same rules as attachments.Rewrites the card each time.
Only the parts that changedMuch smaller.Needs the whole chain in order. A lost edit leaves a wrong card that nobody notices.
A replaceable event, kind 30000 and upThe relay would keep only the latest.Impossible: a relay cannot see the kind inside a wrap, and showing it would reveal who writes to whom.
Delete and send againNo new rule.Loses the place in the chat, the replies and the notification.
A new message each timeWhat happens today.These are the pairs of messages we want to remove.

Marking parts as compact or expanded​

WayForAgainst
A prefix on the name, +factOne byte, on any part, in any order.The name is no longer one word, but the reader splits it in one line.
A divider, ["card","more"]Fewer bytes with many hidden parts.Hidden parts must come last, so a main button after them is impossible.
A sign at the endReads well.Clashes with optional fields, and weighs more.

No identifier on buttons​

The word a button sends already identifies it, and services read that word today. An identifier would be written twice, on the card and in the sender's code, for cases we do not have. If one is needed later, a field can be added at the end of the tag.

A complete example​

An invoice from start to finish. Tap "Mark as paid" in the phone, or use the steps, and watch what a client without cards shows at each step, next to the event on the wire.

A kind 14 with the card's tags. The fallback text is what a client without cards shows.

A client with cards

Mail service
To pay
Sample Insurance
in 25 days
CHF 1'949.75
Amount
16:00

A client without cards

Mail service
Invoice from Sample Insurance: CHF 1'949.75, due 30.11.2026. mail.example.org/123
16:00
{"kind":14,
 "content":"Invoice from Sample Insurance: CHF 1'949.75, due 30.11.2026. mail.example.org/123",
 "tags":[
  ["p","<you>"],
  ["card","1"],
  ["card","state","warn","To pay"],
  ["card","title","Sample Insurance"],
  ["card","until","2026-11-30"],
  ["card","fact","Amount","CHF 1'949.75","big"],
  ["card","+fact","IBAN","CH44 3199 9123 0008 8901 2","copy"],
  ["card","+fact","Reference","21 00000 00003 13947 14300 09017","copy"],
  ["imeta","url https://images.example.org/q/Zm9pQx2mLr7w.png","m image/png","dim 1035x1035","alt Invoice QR code: CHF 1'949.75","card body qr more"],
  ["card","reply","Mark as paid","paid","main"],
  ["card","+open","Open letter","mail.example.org/123"]]}

Implementation status​

WhereState
SDK: read_card, CardBuilder, create_edit_replacing_card, create_card_live, send_card_answer, AccountMessageView.cardDone
AndroidIn progress
WebIn progress
iOSStays on the fallback text
RelayNo change needed
Writers: services' builders and templatesNot started

Before this can leave draft, these need trying on real devices:

  • iOS with many unknown tags, and with an unknown kind in an ephemeral wrap;
  • card heights in the Android list, opening and closing included;
  • pasting an IBAN and a reference with spaces into a bank's app;
  • live updates between different relays, where nothing recovers a lost one;
  • notifications for wraps above 4 KiB with the app closed.

Open questions​

  • The exact limits, which are starting numbers.
  • Whether a qr image in plain may hold payment data with a person's name and address, since its host and the network in between can read it. An encrypted attachment avoids that.
  • Whether palette is worth sending, or whether charts should use one colour tinted by the app.

History​

  • Draft 1, 2026-11-06. First version.