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
!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
This lane is end-to-end encrypted
This lane is end-to-end encrypted
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.Media must be Cloak-hosted
Media must be Cloak-hosted
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.content is required, and it is not a shim
content is required, and it is not a shim
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.Full SendOptions parity
Full SendOptions parity
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.Cards are server-only in v1
Cards are server-only in v1
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.Put the substance in description
Put the substance in description
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.Refusals are local, loud, and typed
Refusals are local, loud, and typed
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.A card suppresses link unfurling
A card suppresses link unfurling
The bot embed wins, and the unfurl runner skips the row, so a URL in
content gets no preview card of its own. Put the link in the embed’s url slot instead. The advisory embed_link permission is not consulted here, and structurally cannot be: the server never sees an embed to gate. message_send is the whole requirement.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.