Server environment variables
BOT_REST_CREDENTIALS
client.rest is off by default server-side, and cloak-backend ships the flag unset. Until an operator turns it on, the first call rejects with a message that says so by name. That is the backend’s own gate, not a bug in your bot, so do not read a -7 as your problem.
Two things stay true even once it is on:
- The credential is scoped behind a default-deny allowlist. File upload, delete and permissions are classified as bot-usable. The two obvious read surfaces, custom emoji and the audit log, are classified as unauthorized because neither route does per-resource authorization today.
- That is why the SDK ships no
fetchCustomEmojis()and nofetchAuditLog(). Named methods that always fail would be worse than none. (client.fetchEmojis()is unrelated and works: it returns the static unicode catalog, never per-server custom emoji.)
The webhook ingress variables
The webhook embed lane needs an ingress the server actually runs and a public base it can build absolute URLs from. IfWEBHOOK_PUBLIC_BASE is empty, the backend returns a relative path and the SDK refuses to post to it rather than guessing an origin. The SDK also refuses plain http:// to anything but localhost, 127.0.0.1 and ::1, because the webhook secret is a path component and would travel in cleartext.
The image proxy variables, and the silent strip
This is the one that costs people an afternoon. Every URL a viewer’s client would auto-fetch is rewritten through the server’s signed image proxy so viewers never hit a third-party origin:image.url, thumbnail.url, video.url, author.icon_url, footer.icon_url, provider.icon, and a per-post avatar_url. When the proxy is unconfigured, or a URL is not plain http(s), the field is stripped. The POST still returns success. There is no error, no warning, and nothing in the response to read.
Quick check against a live deployment: set avatar_url on a webhook post. If the avatar does not change, the proxy is off, and your image will vanish too.
One more consequence: a proxied URL is longer than the original, and the 6000-byte embed budget is measured after the rewrite. Your real budget is smaller than 6000. The SDK deliberately does not pre-check it, so the server answers 400 embeds too large.
This applies to the webhook embed lane only. The encrypted card lane cannot be proxied at all, because the server cannot read the payload, which is why it restricts media to Cloak-hosted origins instead. See Choosing an embed lane.
The force-update gate and -12
The SDK sends a wire/crypto capability version as the trailing slot of its login frame. The backend’s force-update gate, CLOAK_MIN_CLIENT_VERSION, refuses anything below its floor with the shared -12 “update required” code.
A -12 is terminal. The SDK emits disconnect and stops the reconnect loop, because no amount of retrying makes an old build newer. The fix is to update @cloak-software/bot-sdk and redeploy. See Login rejections.
The mirror of this gate is that -2 (a revoked or regenerated token) is also terminal, while -8 (a transient backend error) keeps retrying.
Both sides must be on the HPKE wrap format
Your bot never generates a server’s conversation key. A human member’s client wraps it and uploads it. This SDK produces and accepts only the HPKE (v2) wrap. So the humans in your bot’s servers have to be on a client build that ships HPKE, or every key acquire fails. When the SDK receives a pre-HPKE wrap it names the offending peer in a warning, and it appends that peer’s id to theTimed out acquiring key for server … message so you are not left guessing which member is stale.
This is a fleet property of the servers your bot joins, not something you configure.
Backend deployment level
These are not settings. They are whether the server you are talking to is recent enough.Server member lists: fetchMembers() is broken everywhere today
Server member lists: fetchMembers() is broken everywhere today
fetchMembers() waits out the transport’s 8 second reply timeout and rejects with a plain Error. This affects every bot on every server, and it is blocked on a backend rebuild. There is no version of the server today where it works; searchMembers() works everywhere. See Known limitations.Receipts, replay, routing tails, fetchMessage, resolveContainer: the plan-136 backend
Receipts, replay, routing tails, fetchMessage, resolveContainer: the plan-136 backend
client.capabilities() exposes, and adds: a resume replay for bots (the frames a dropped socket missed arrive before the login reply, and client.resumedLastLogin says so); a bot-only acceptance ack on every send, which is what settles an ack: true receipt before the fan-out echo (needs wire version 4, which 0.5.0 declares); the send nonce echoed on a denial, which makes sendRejected.attribution exact; an authoritative add/remove flag on reactionUpdate; the deleting user on messageDelete; server and channel on the typing event; the container kind and parent on every thread and post frame and hook, so a cold-started bot routes them; fetchMessage() (op 804), resolveContainer() (op 805), and a verdict for reactToStarter(). On an older backend every one of these degrades to the 0.4.0 behavior: receipts settle on the echo, attribution falls back to the outstanding-send queue, the new event fields read null, the two new methods time out, and capabilities() is null. Check the map rather than the version.timeout(): needs a backend that routes op-160
timeout(): needs a backend that routes op-160
timeout() and removeTimeout() require member_timeout and a server carrying the timeout plan. Older servers drop the opcode, so the call times out rather than resolving. A working server acknowledges it.Reaction and pin acks: need the numeric bot ack
Reaction and pin acks: need the numeric bot ack
react(), unreact(), pin() and unpin() await a numeric ack that a backend has to send. Without it the awaited action cannot resolve, because the same opcode also carries the broadcast hook and the SDK will not accept a broadcast as its own acknowledgement.Cross-server events: need membership-routed hooks
Cross-server events: need membership-routed hooks
serverId and channelId on those events fall back to the bot’s current selection cursor, which is a less useful answer.Thread message history: needs the thread-channels plan
Thread message history: needs the thread-channels plan
fetchMessages() with opts.threadId, requires a backend that honors the thread target on the two history opcodes. This is the rare case that fails loudly: on an older server the read falls back to the parent channel’s messages, and rather than hand those back as the thread’s, the SDK rejects with an error naming the cause. Everything else about threads (creating, sending, receiving, enumerating) is unaffected.DM reads, edits, reactions and cards: need a backend that honors wire version 3
DM reads, edits, reactions and cards: need a backend that honors wire version 3
createdAt, repliedTo, and card, and what lets react(), edit(), editCard(), and delete() work on one. On an older server only the legacy notify signal arrives: those fields are null, the bot never sees its own DM sends, and those actions reject with a message saying the frame carried no timestamp. Sending, including sendCardDM(), works either way. See Direct messages.Server voice: two-phase microphone admission
Server voice: two-phase microphone admission
VoiceMediaSession.attach() through VoiceMediaOptions.connectionReporter, which you supply as client.voiceConnectionReporter(), before it publishes, and waits for the admission (polling the engine’s canPublish permission, up to admissionTimeoutMs, default 5 s) before publishing. This is the one voice requirement that runs the other way: the backend needs nothing from you, but a bot that omits the reporter is silent against it, with no error. An older single-phase backend mints the grant into the credential and ignores the report; its canPublish flag never flips, so the wait times out and attach() publishes anyway, which succeeds there. A bot that passes the reporter works on both, at the cost of the timeout on the older one. See Microphone admission.DM history and DM slash commands: need a backend that honors wire version 3
DM history and DM slash commands: need a backend that honors wire version 3
fetchDmMessages() and fetchMessages(null, dmId) read history on the DM cursor, and a /command picked from a DM dispatches only because the DM lane’s message broadcast carries the command sidecar. Both come with wire version 3. On an older backend the DM read resolves empty and nothing dispatches from a DM. See Direct messages.What none of this changes
The wire format is stable and backward-compatible. A backend that is missing one of the features above does not break your bot’s messaging: sending, receiving, history, moderation and the firehose all work. What you lose is the specific feature, and usually the loss is quiet, which is the reason this page exists.Next
Known limitations
When a bot will not start
The REST lane
client.rest is for, once an operator enables it.