Skip to content

Cards

A card sits on a wisp’s line in a channel, or on an aside. It is built from a small set of blocks that Woosh draws natively. Nothing in a card is CSS or HTML: you choose a tone or a size, and Woosh makes it look right in every theme, with contrast checked once, by us. What you get in exchange for that is room to make really good things: boards, reels, keypads, shops, leaderboards, rank cards.

The desktop app and the web draw cards. The phone app does not yet: it shows the line’s text.

{
"color": "#f97316",
"blocks": [
{ "type": "text", "text": "**Spin**", "size": "large" },
{ "type": "grid", "rows": [[{ "emoji": "🍒" }, { "emoji": "🍒" }, { "emoji": "🍋" }]] },
{ "type": "fields", "fields": [{ "name": "Bet", "value": "50" }, { "name": "Balance", "value": "1,150" }] },
{ "type": "actions", "actions": [{ "action": "spin", "label": "Spin again", "kind": "press", "tone": "accent" }] }
]
}
Key
color the card’s edge, written #rrggbb, such as #f97316. Optional: left out, the edge is Woosh’s accent
blocks one or more blocks, top to bottom

A card holds at most:

  • 40 parts: every block (those on pages too), and every cell, field, picture and action in them, counted together. A menu’s options are not parts.
  • 4,000 characters of words, all of them together: texts, names and values, labels, cells, alt words.
  • 8 KB of JSON.

A card past a limit, or with a part out of place, is refused, and w says what is wrong:

{"r":12,"e":"refused","w":"A grid has 1 to 5 rows of 1 to 5 cells."}

A card goes on a channel’s line, not in a thread. A line with a card always carries text as well: a notification shows it, and so does any app that does not draw cards. Say the same thing in words.

Steps Where
tone neutral, accent, good, warn, bad a text, a section, a progress bar, a grid cell, an action
size small, normal, large, huge: steps of Woosh’s own type scale a text
size small, normal, large a media picture

Left out, a part is neutral and normal, and a progress bar is accent. On an action, accent marks the main one and bad a risky one; good and warn draw it plain.

Each block holds only what its type does. Anything else is refused: a fields block with a tone answers “A fields block holds no tone.”

1 to 4,000 characters of words, with a few marks, and an optional size and tone.

{ "type": "text", "text": "**Win a monitor**", "size": "large" }
{ "type": "text", "text": "Entries close {in:1792353600000000}. Good luck, {user:3f1c9a52-6d1e-4c8b-9a51-2b7e0c4d8f11}!", "tone": "accent" }
Mark Written Drawn
bold **words** words
italic *words* words
code `words` words
a person {user:<id>} @their name
a channel {channel:<id>} #its-name
an emoji :fire: (a standard emoji’s name), or the emoji itself the emoji
a time, counting {in:<time>} “in 45s”, “in 5m”, “in 3h”, “in 2d”, counting down; “now” once it passes
a time, on the clock {at:<time>} “20:00” today, “Mar 5 20:00” on another day, in the reader’s own time

A time is in microseconds since 1970, as every time on the line. Ids are lowercase, as on the line. Marks do not nest, and an emoji name Woosh does not know stays as written. Times are drawn in the reader’s own language.

Marks are drawn in a text block and in a section’s text. Every other word on a card (a field, a cell, a label) is drawn as written.

A title is a large text in bold; a footnote is a small one.

1 to 10 name and value pairs, in columns: stats, bets, balances. A name is 1 to 256 characters, a value 1 to 1,024.

{ "type": "fields", "fields": [{ "name": "Bet", "value": "50" }, { "name": "Coins", "value": "1,150" }] }

1 to 5 rows of 1 to 5 cells. A cell shows one thing: words (text, 1 to 80 characters), an emoji (1 to 64 characters), or a picture (url and alt). It may carry a tone and a key. With an action it can be pressed, and only then may it carry a value and disabled. Boards, reels, keypads, calendars, bingo.

{
"type": "grid",
"rows": [
[{ "text": "X", "tone": "accent" }, { "text": "O" }, { "text": "·", "action": "move", "value": "3|XO..X...O" }],
[{ "text": "·", "action": "move", "value": "4|XO..X...O" }, { "text": "X", "tone": "accent" }, { "text": "·", "action": "move", "value": "6|XO..X...O" }],
[{ "text": "·", "action": "move", "value": "7|XO..X...O" }, { "text": "·", "action": "move", "value": "8|XO..X...O" }, { "text": "O" }]
]
}

A picture, still or animated (PNG, APNG, JPEG, WebP or GIF), that your wisp uploaded, with alt words (1 to 256 characters) for people who cannot see it and an optional size (small, normal, large). Art, prizes, rank cards your wisp renders.

{
"type": "media",
"url": "<media>/wisps/d2b6f4a8-3e1c-4b7d-9a05-6c8e0f2a4b19/7c8d9eaf-b0c1-4d2e-8f30-4a5b6c7d8e93",
"alt": "A 27 inch monitor, the prize",
"size": "large"
}

2 to 10 pictures together as items, each a url and its alt: screenshots, prizes.

{
"type": "gallery",
"items": [
{ "url": "<media>/wisps/d2b6f4a8-3e1c-4b7d-9a05-6c8e0f2a4b19/1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "alt": "First prize" },
{ "url": "<media>/wisps/d2b6f4a8-3e1c-4b7d-9a05-6c8e0f2a4b19/2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e", "alt": "Second prize" }
]
}

A text (1 to 4,000 characters, with marks) and, beside it, one picture (media: url and alt) or one action, never both. An optional tone colours the text. List rows, a shop’s items.

{
"type": "section",
"text": "**Golden reel**\nPays double for a day. 500 coins.",
"action": { "action": "buy", "label": "Buy", "kind": "press", "tone": "accent", "value": "golden-reel" }
}

A bar: value of max, where max is more than 0 and value is 0 to max. An optional label (1 to 256 characters) sits above it, and an optional tone colours it. Quests, levels, a giveaway filling.

{ "type": "progress", "value": 340, "max": 500, "label": "Level 7: 340 of 500", "tone": "good" }

A row of 1 to 5 actions.

{
"type": "actions",
"actions": [
{ "action": "spin", "label": "Spin", "kind": "press", "tone": "accent" },
{ "action": "bet", "label": "Bet", "kind": "menu", "options": [{ "value": "10", "label": "10 coins" }, { "value": "50", "label": "50 coins" }] },
{ "action": "enter", "label": "Enter", "kind": "tally" },
{ "action": "rules", "label": "Rules", "kind": "link", "url": "https://lucky.example.com/rules" }
]
}
Key
action your own name for it, which a press brings back: 1 to 32 of a to z, 0 to 9, _ and -, starting with a letter or a digit
label the words on it, 1 to 80 characters
kind press, menu, tally or link (below)
tone optional
value a press only, optional: 1 to 100 bytes of anything, brought back with the press
disabled true: drawn switched off, and a press on it refused
kind With Pressing it
press value? your wisp hears act, with the value
menu options: 1 to 25, each a value (written as an action’s name: blackjack, bet-50) and a label (1 to 80 characters) your wisp hears act with the value chosen
tally count? (Woosh’s own), tell? (true) Woosh counts one press a person, a second press takes it back, and draws the count within a second. Your wisp hears act only with tell. A card’s tallies each have a name of their own (Tallies)
link url, an https address opens it. Nobody hears it

2 to 10 pages, each a label (1 to 80 characters) and one or more blocks of its own, turned in the app without asking your wisp. A page holds no pages. Leaderboards, help, shops.

{
"type": "pages",
"pages": [
{ "label": "1 to 3", "blocks": [{ "type": "fields", "fields": [{ "name": "1. ana", "value": "4,210 coins" }, { "name": "2. dani", "value": "3,980 coins" }, { "name": "3. bo", "value": "1,150 coins" }] }] },
{ "label": "4 to 6", "blocks": [{ "type": "fields", "fields": [{ "name": "4. caio", "value": "990 coins" }] }] }
]
}

Space between parts. It holds nothing.

{ "type": "divider" }

A pressable grid cell, a section’s action and an action in a row all press the same way: your wisp hears act with the action and the value you gave it. A value is 1 to 100 bytes, and yours to choose, so your wisp can keep a game’s state in its own cards and stay stateless: the board above carries the square and the whole board in each cell’s value. Several parts may share a name, as the board’s cells share move: a press is matched by its name and its value. Presses and private cards covers the rest.

Woosh fetches nothing from outside addresses: an outside picture would tell its host who looked. A card’s picture is one your wisp uploaded; an outside address is refused (“A card’s picture is a file the wisp uploaded.”). Upload it first with your wisp’s own token, the one thing besides buying that token your wisp does over HTTP:

POST https://api.woosh.no/api/wisps/files
Authorization: Bearer <the wisp's token>
Content-Type: multipart/form-data; boundary=…
(one part named "file", its own Content-Type image/png, image/jpeg, image/webp or image/gif)
{ "url": "<media>/wisps/d2b6f4a8-3e1c-4b7d-9a05-6c8e0f2a4b19/7c8d9eaf-b0c1-4d2e-8f30-4a5b6c7d8e93" }

<media> stands for the media address your line’s ready names. Use the url exactly as it comes back, as many times as you like: a media block, a gallery’s items, a section’s media, or a grid cell’s url.

Formats PNG (APNG too, as image/png), JPEG, WebP, GIF
Size up to 2 MB
Rate two a second for each wisp
Answer Why
409 {"error":"A card's picture is a PNG (APNG too), JPEG, WebP or GIF.", …} the part says another type
409 {"error":"A card's picture is up to 2 MB.", …} empty, or past 2 MB
429, with Retry-After in seconds past two a second: upload again after it

Keep pictures small: everyone looking loads each one once.

An animated picture (an APNG, an animated WebP or a GIF) loops in the app at no cost on the network after its first load, and is cached for every card that uses it. A slot’s spinning reel is your own animated picture in a grid cell; the result is one edit. Never send frames as fast as you can: there is no need, and the edits budget would drop them.

A time mark in text ({in:…}, {at:…}) is drawn by the app on its own clock, and a countdown moves on by itself. A giveaway’s countdown costs nothing after the card is sent: no edits every second.

Any block or cell may carry a key of your choosing, written as an action’s name (reel-1, purse), each once in a card. Wherever a line’s card can be sent again (an edit, a card.live frame, a respond with edit), you may send a patch instead of the whole card: keys, and what replaces each, a block for a block’s key and a cell for a cell’s. The new part takes the key’s place and keeps the key; null takes it out. A key the card does not hold is refused (“The card holds no part keyed ‘reel-4’.”), and the card the patch makes is held to every rule above.

The slots machine, with its parts keyed:

{
"color": "#f97316",
"blocks": [
{ "type": "text", "text": "**bo's slots**", "size": "large" },
{ "type": "grid", "rows": [[{ "emoji": "❔", "key": "reel-1" }, { "emoji": "❔", "key": "reel-2" }, { "emoji": "❔", "key": "reel-3" }]] },
{ "type": "fields", "key": "purse", "fields": [{ "name": "Bet", "value": "10" }, { "name": "Coins", "value": "1,000" }] },
{
"type": "actions",
"key": "buttons",
"actions": [
{ "action": "spin", "label": "Spin", "kind": "press", "tone": "accent" },
{ "action": "cashout", "label": "Cash out", "kind": "press", "tone": "bad" }
]
}
]
}

bo presses Spin. One frame starts the reels turning and switches the buttons off:

{
"i": 68,
"t": "card.live",
"d": {
"message": "0199dea4-1d31-7b4c-9d5e-6f7a8b9c0d21",
"patch": {
"reel-1": { "url": "<media>/wisps/d2b6f4a8-3e1c-4b7d-9a05-6c8e0f2a4b19/3c4d5e6f-7a8b-4c9d-8e0f-2a3b4c5d6e7f", "alt": "Spinning" },
"reel-2": { "url": "<media>/wisps/d2b6f4a8-3e1c-4b7d-9a05-6c8e0f2a4b19/3c4d5e6f-7a8b-4c9d-8e0f-2a3b4c5d6e7f", "alt": "Spinning" },
"reel-3": { "url": "<media>/wisps/d2b6f4a8-3e1c-4b7d-9a05-6c8e0f2a4b19/3c4d5e6f-7a8b-4c9d-8e0f-2a3b4c5d6e7f", "alt": "Spinning" },
"buttons": {
"type": "actions",
"actions": [
{ "action": "spin", "label": "Spin", "kind": "press", "tone": "accent", "disabled": true },
{ "action": "cashout", "label": "Cash out", "kind": "press", "tone": "bad", "disabled": true }
]
}
}
}
}

Then one answer lands the result and switches them on again:

{
"i": 69,
"t": "respond",
"d": {
"to": "0199dea4-2e42-7c5d-ae6f-7a8b9c0d1e32",
"text": "cherry, cherry, lemon: bo wins 20, 1,010 coins.",
"patch": {
"reel-1": { "emoji": "🍒" },
"reel-2": { "emoji": "🍒" },
"reel-3": { "emoji": "🍋" },
"purse": { "type": "fields", "fields": [{ "name": "Bet", "value": "10" }, { "name": "Coins", "value": "1,010" }] },
"buttons": {
"type": "actions",
"actions": [
{ "action": "spin", "label": "Spin", "kind": "press", "tone": "accent" },
{ "action": "cashout", "label": "Cash out", "kind": "press", "tone": "bad" }
]
}
},
"edit": true
}
}

A frame that changes three cells is three cells long. For an edit, Woosh merges the patch into the card as stored, and the people looking get the whole card. For a frame, Woosh merges it onto the card as it shows now, to check presses against, and the people looking get the patch and merge it into the card on screen (an app that does not hold a key passes it over).

An edit’s patch starts from the stored card, never from a frame: patch again every part your frames changed, as the answer above does with the reels and the buttons. To change the edge’s color, send the whole card.

A private card is always sent whole, never patched: one person looks, and Woosh keeps no card of theirs to patch, only what it offers to press.

A card is stored once with its line (at most 8 KB) and sent once to each person looking. Pictures are fetched once and cached. A patch frame is tens of bytes. Countdowns, pages and animation cost the network nothing. That is why the limits are where they are, and why a busy channel full of cards stays fast.

© 2026 Woosh AS. All rights reserved.