Skip to main content
Some things a bot does depend on how the Cloak server is configured or how recently it was deployed. When one of those is missing, the failure is often silent: a POST succeeds and the image is gone, a request produces no reply frame at all, an ack never arrives.
Everything on this page is a server-operator setting or a server deployment fact. None of it is SDK configuration, and your bot cannot probe for any of it. If you run against Cloak’s hosted service, treat this page as a lookup table for “why did that not work”. If you self-host, treat it as a checklist.

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 no fetchAuditLog(). 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.)
See The REST lane.

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. If WEBHOOK_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.
Text and click-through links always survive: title, description, url, color, timestamp, fields, author.name, provider.name, footer.text. So an embed that arrives with all its words and none of its pictures is the signature of an unconfigured proxy, not of a malformed payload.
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.
CLIENT_WIRE_VERSION is an SDK constant, not a backend environment variable, and it is not exported from the package. Do not write an import for it. The value today is 4 (0.5.0 and later), the SDK that consumes the bot-only send ack; 3 (0.4.0) was the SDK that consumes the DM lane’s message broadcast. 2 was the HPKE-capable SDK (0.2.0 and later), and older SDKs sent no version at all and read as 1 server-side. The gate compares bot sessions only, so a human client on a lower version is unaffected.
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 the Timed 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.
A backend change made op-30 direct-message-only, so a server-scoped request produces no reply frame of any kind, not even an error code. 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.
A backend carrying the bot consumer-gap work answers the login with a capability map, which 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() 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.
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.
Reaction, channel, group and server lifecycle events reaching a bot for a server it has not selected requires a backend with membership-routed server hooks. On an older server, serverId and channelId on those events fall back to the bot’s current selection cursor, which is a less useful answer.
Reading a thread’s history, 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.
A backend that honors the SDK’s wire version 3 puts the bot on the DM lane’s ordinary message broadcast, its own DM sends echoed back included, and on the DM-lane edit, delete and reaction hooks. That is what gives a DM message its 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.
A current backend mints a server-voice credential with the microphone withheld and admits it only after the bot reports its media connection up, re-applying the bot’s durable grant through the media control plane. The SDK sends that report from 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.
login() publishes your registered command menu so a human’s / picker can list it. This needs a backend running the bot-command-registry work. Against an older backend the frame goes nowhere and the SDK carries on: publication is best-effort and never blocks login, and every command still dispatches over the text lane. See Slash commands.Two 0.4.0 additions ride the same registry. A command declared dm: false publishes a fourth flags element on its canonical declaration; a current backend accepts a three- or four-element declaration and stores the flags verbatim. A menu whose commands all allow DMs keeps the three-element shape, so it is unaffected. A backend from before this release may treat the fourth element as malformed, which refuses the whole menu (best-effort, logged behind debug), so declare dm: false only against a backend that accepts it. And the DM / picker reads one bot’s menu with the bot-id form of opcode 626 ([626, botId]), which a backend needs to serve before DM invocations can be offered at all.
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

The client-side half of the same honesty.

When a bot will not start

Symptom-indexed triage for the failures you can see.

The REST lane

What client.rest is for, once an operator enables it.

Webhook embeds

The lane the image proxy applies to.