Skip to main content
This bot posts a build-status card with sendCard(), the encrypted embed lane. The card is encrypted with the same conversation key and the same epoch as the message body, and it renders under the bot’s own member identity with the bot badge and no “not encrypted” notice. It needs message_send and nothing else. It also has a deliberately broken command, !bad, so you can watch a third-party media URL get refused locally before anything reaches the wire. The SDK repo ships this as examples/card-bot.ts.

The full bot

card-bot.ts

Run it

Then, in any text channel the bot can see: !build posts the card, !logo posts it with a thumbnail if you set CLOAK_LOGO_URL, and !bad shows you the refusal.

How it works, piece by piece

sendCard() encrypts the card with the same conversation key and the same epoch as content, and it rides the ordinary message frame. The server stores ciphertext it cannot parse. The card renders under the bot’s own member identity, with the bot badge.That is the opposite of the webhook embed lane (sendEmbed(), postToWebhook(), and the type literally named UnencryptedWebhookPost), which is plaintext server-side and posts as a webhook persona. Reach for the webhook lane only when you need per-post personas, no bot account at all, or clients older than the card rollout. See What is encrypted, and what is not and Webhook embeds.One thing that does leave the envelope even here: mentions targets ride the wire in plaintext, because the server routes notifications without decrypting anything.
The four slots a card fetches with no user action, image.url, thumbnail.url, author.icon_url, and footer.icon_url, must have origin exactly https://media.cloak.chat. Anything else throws a CloakEmbedError with reason: 'media-host' before a frame leaves your process.The reason is the encryption. Rendering a card fetches its media, so a third-party URL hands every viewer’s IP address to a host the bot author picked, and lets that host serve different bytes to different viewers. The server cannot rewrite the URL the way other platforms do, because it cannot read the payload at all. So the SDK refuses it at development time instead.The link slots (url, author.url, provider.url) are unrestricted http(s): nothing is fetched for them, they are tap targets. Link to your CI or your docs freely.Where a legal media URL comes from today: an asset a human uploaded through the Cloak app, the bot’s own avatar, or a room icon. All three are already on that origin. isCloakMediaUrl() and CLOAK_MEDIA_ORIGIN are exported if you want to check one yourself.
CardMessage.content must be non-blank genuine text. It is what search indexes, what push notifications and channel previews are built from, what a reply quoting the card renders, and what any surface without a card renderer shows. A card with placeholder content is a permanently unfindable, unquotable, preview-less message.embedFallbackText(embed) is the opt-in helper. It returns the title, else the first non-blank line of the description, else the embed url, capped at 256 characters. It throws (reason: 'content') on an image-only embed rather than inventing something. The SDK never calls it for you, because only you know what the notification should say.
A CardMessage accepts groupId, mentions, and replyTo exactly like send() does, so a card can be a true reply and can ping. This bot passes replyTo: msg, so the card quotes the command that asked for it. A Message satisfies ReplyTarget structurally, which is why { replyTo: msg } just works.client.guild(serverId).sendCard(channelId, card) is the pre-bound form if you already hold a server handle.
messageCreate is unified, so direct messages arrive on the same handler with isDM: true and a null serverId. Cards have no DM path in v1: a DM key is unepoched by wire contract, so it would need a second encryption site. The guard at the top of the handler is what keeps !build from misfiring in a DM. Reading inbound cards back is also not in v1.
embed.fields render on desktop only today. Mobile drops the rows silently, with no error anywhere. A card whose data lives in a fields grid is invisible to every mobile user, and nothing tells the author.
Until the mobile port lands, put anything a reader must see in description and treat fields as decoration. description is plain text, not markdown: v1 renders it literally, which is why the example joins its lines with \n.
CloakEmbedError carries reason ('shape', 'content', 'cap', 'media-host', 'empty', 'wire-cap') and field, the exact offending path such as embed.image.url or embed.fields[3].value.Every cap mirrors the client renderer, and where the renderer would truncate or strip, sendCard() rejects instead. That is deliberate: the server cannot tell you anything about a payload it cannot read, so a silently truncated title would reach you weeks later as “your card looks wrong” from a user. The whole payload caps at 8 KiB of JSON. See Rich cards for the full table.

Next

Rich cards

Every slot, every cap, and the lane comparison.

Embeds reference

BotEmbed, CardMessage, and CloakEmbedError.

Webhook embeds

The other lane, and what it gives up.

Command bot

Trigger a card from a slash command.