client.sendCard() puts a rich card on screen without giving up end-to-end encryption and without giving up your bot’s identity. The card is encrypted with the same conversation key and the same epoch as the message body, and it rides the ordinary message frame. The server stores it as ciphertext it cannot read. It renders under your bot’s own member identity, with the BOT badge and no “not encrypted” notice.
This is the embed lane to default to. The other one is webhook embeds, which is plaintext server-side.
Which lane do you want?
Two embed paths exist, with opposite privacy properties. Pick from this table before you write any code.
Default to
sendCard(). Reach for the webhook lane only when you need one of the three things it alone can do: many personas from one identity, posting with no bot account at all, or reaching clients older than the card rollout.
The two are impossible to confuse by accident. UnencryptedWebhookPost requires a literal unencrypted: true, and CardMessage declares unencrypted, username and avatar_url as never, so handing a webhook payload to sendCard() is a compile error. assertCardMessage() re-checks the same thing at runtime and names the other method, for callers with no compiler.
Post a card
sendCard(serverId, channelId, card) returns Promise<void>. The same method is pre-bound on the server handle as client.guild(serverId).sendCard(channelId, card).
It needs message_send and nothing else. No webhook, no manage_server, no creation quota, and no server-side configuration.
The advisory
embed_link permission is not consulted, and structurally cannot be. The server never sees an embed to gate, because it cannot read the payload.Where the card goes
CardMessage carries the same routing options as SendOptions, inside the card object rather than as a fourth argument:
string
required
Genuine human-readable text. See content is required.
BotEmbed
required
Exactly one embed. Clients render one card per message, so an array would let you ship rows that are silently discarded.
string
The channel’s group, when the SDK has not already cached it from an inbound frame.
MentionTarget[]
Who to notify. These targets ride the wire in plaintext, exactly as on
send(): the server routes notification fanout without decrypting anything.ReplyTarget
Make the card a true reply. It renders the quote header and fires a “replied to you” notification that pierces mutes. Leave it off when the card is just an answer.
Cards are server-only in v1
A direct message has no server, somsg.serverId is null on every DM. Guard before you use it:
messageCreate carrying its content, and the SDK does not decode the embed for you.
Content is required, and it is not a shim
CardMessage.content must be non-blank genuine text, and the SDK will never synthesize it. It is what history and full-text search index. It is what push notifications and channel previews are built from. It is what a reply quoting the card renders. It is what every surface not wired to the card renderer shows: an export, a new client, the next partial rollout.
A card with blank 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 on an image-only embed rather than inventing a placeholder.
Media must be Cloak-hosted
The four slots a card fetches with no user action areimage.url, thumbnail.url, author.icon_url and footer.icon_url. Each must have origin exactly https://media.cloak.chat. Anything else throws CloakEmbedError with reason: 'media-host' locally, before a frame leaves your process.
Rendering a card fetches its media, which reveals the viewer’s IP address to whoever hosts the URL. On this lane that URL is chosen by a third-party bot author, who could also serve different bytes to different viewers or swap the content after the fact, with no new message for moderation to see. Discord solves this by proxying all embed media and rewriting URLs at send time. Cloak’s server cannot rewrite anything here, because it cannot read the encrypted embed at all. Refusing third-party media is cheaper than a proxy and strictly stronger.
The link slots (url, author.url, provider.url) are unrestricted http(s). Nothing is fetched for them at render, they are tap targets. Link to your docs or your CI freely.
Where a Cloak-hosted URL comes from today: an asset a human uploaded through the Cloak app, your bot’s own avatar, or a room icon. All three are already on that origin. The SDK cannot upload one for you. The common status-card case (title, description, color, a link) needs no media at all.
Check a URL yourself with the two exported helpers:
Caps
Every cap mirrors the client renderer. Where the renderer truncates or strips a value,sendCard() rejects it. A truncated title is a real degradation you would never learn about, and the server cannot tell you anything because it cannot read the payload.
Every value is also exported as a constant. See the embeds reference.
An embed must have something renderable, or
sendCard() throws with reason: 'empty'. At least one of title, description, thumbnail, image, author.name, provider.name, footer.text or fields is required. A card carrying only url, color and timestamp draws nothing at all.
Fields are desktop-only today
inline: true renders the row side by side. false is the same as omitting it.
Three things that do not exist on this lane
Each of these is rejected outright, so you learn at development time rather than shipping a card that renders wrong.video
video
Removed from this lane entirely, not merely restricted to Cloak-hosted media. Desktop renders an embed video inside a sandboxed iframe that allows scripts and same-origin, so a bot-chosen origin would get script execution beside the app. Passing
video throws.provider.icon
provider.icon
Cards fetch it, and this lane is deliberately kept out of that fetch site. Use
author.icon_url, which is a Cloak-hosted media slot.Unknown keys
Unknown keys
The renderer drops them while they still count against the payload budget, so
sendCard() rejects them instead. BotEmbed has no index signature.Errors
sendCard() validates, encrypts and caps the card before it touches the connection cursor, so an invalid card fails without sending anything. The rejection is a CloakEmbedError carrying reason and the exact offending path in field.
A card also suppresses link unfurling
The bot embed wins, and the unfurl runner skips the row. A URL incontent gets no preview card of its own. Put the link in the embed instead, on embed.url.
A complete card bot
npm run example:card.
Next
Cards and embeds reference
Every type, helper, error reason and constant on this lane.
Webhook embeds
The other lane, which is not end-to-end encrypted.
Sending messages
Plain text sends, replies, and what fire-and-forget means.
Keys and the keystore
Why every bot you actually run needs a keystore.