Cookbook: connecting Discord with cantus (echo bot + slash command)
This walkthrough takes you from an empty Discord application to a working cantus serve deployment that runs an echo bot (a member types in a server, the bot replies) plus a /ping slash command. Discord carries one extra layer of complexity compared with LINE or Telegram: cantus needs both a Gateway WebSocket (a long-lived connection that streams events in) and an Interactions HTTP endpoint (Ed25519-signature verification for slash-command and button callbacks). Every command below assumes you have already worked through the "Serve via CLI" and "Expose via Cloudflare Tunnel" sections of docs/quickstart-desktop.md.
0. What you'll need
- A Discord Developer Portal account (your regular Discord account can sign in).
- A Discord server where you have administrator rights (a fresh server for testing is fine).
cantus-agent[serve]>=0.5.0andcloudflaredinstalled.- A laptop on any OS. Both
pynaclandwebsocketsship prebuilt wheels for Linux x86_64, macOS arm64 + x86_64, and Windows AMD64.
1. Create an application in the Discord Developer Portal
- Open the Discord Developer Portal, click New Application, and give it a name (for example
cantus-echo-bot). - On the application's detail page, note three values:
- Application ID (the General Information tab). This becomes
CANTUS_SERVE_CHANNEL_DISCORD_APPLICATION_ID. It is a public value that appears in the OAuth invite URL, so it is not a secret. - Public Key (a little further down the same page). A 64-character hex string. This becomes
CANTUS_SERVE_CHANNEL_DISCORD_PUBLIC_KEY; cantus uses it to verify the Ed25519 signature that Discord attaches to requests sent to/channels/discord/interactions.
- Application ID (the General Information tab). This becomes
- Switch to the Bot tab:
- Click Reset Token to obtain the bot token (shown only once; it disappears on refresh). This becomes
CANTUS_SERVE_CHANNEL_DISCORD_BOT_TOKEN. It is the real secret — leaking it lets someone else act as your bot. - Under Privileged Gateway Intents, enable MESSAGE CONTENT INTENT. Without it, the
contentfield arrives empty on everyMESSAGE_CREATEevent cantus receives.GUILDSandGUILD_MESSAGESare non-privileged and enabled by default.
- Click Reset Token to obtain the bot token (shown only once; it disappears on refresh). This becomes
- Switch to OAuth2 → URL Generator:
- Under Scopes, check
botandapplications.commands. - Under Bot Permissions, check
Send Messages,Read Message History, andUse Slash Commands. - Copy the generated URL, paste it into your browser, and use it to add the bot to your test server.
- Under Scopes, check
2. Write myskills/app.py
# myskills/app.py
from cantus.core.registry import Registry
from cantus.protocols.skill import register_skill
from cantus.serve import DiscordRealtimeChannel
registry = Registry()
@register_skill
def echo(text: str) -> str:
"""Echo back whatever the user said."""
return text
registry.register("skill", echo)
# All three secrets come from the CANTUS_SERVE_CHANNEL_DISCORD_* env vars.
# (The constructor also accepts them as arguments, but never put secrets in source.)
discord_channel = DiscordRealtimeChannel()3. Put the secrets in your shell (not in source)
export CANTUS_SERVE_CHANNEL_DISCORD_BOT_TOKEN="<bot token from step 1>"
export CANTUS_SERVE_CHANNEL_DISCORD_PUBLIC_KEY="<public key hex string from step 1>"
export CANTUS_SERVE_CHANNEL_DISCORD_APPLICATION_ID="<application id from step 1>"cantus v0.5.0 still does not read .env automatically. Either use direnv or source your own script.
4. Start cantus serve
cantus serve \
--host 127.0.0.1 \
--port 8765 \
--registry-import myskills.app:registry \
--channels myskills.app:discord_channelOnce it starts, cantus prints the Discord Gateway connection progress (IDENTIFY → READY), and the bot flips from offline to online in your Discord client. The FastAPI lifespan wraps discord_channel.connect() in an asyncio.create_task that runs in the background; Ctrl-C triggers the lifespan shutdown, cantus calls discord_channel.disconnect() to close cleanly (close code 1000), and the bot goes back to offline.
5. Expose the interactions URL with Cloudflare Tunnel
Slash-command and button events are pushed to cantus by Discord over HTTP, so you also need a public URL:
cloudflared tunnel --url http://127.0.0.1:8765Your public interactions entry point is https://<slug>.trycloudflare.com/channels/discord/interactions.
6. Set the Interactions Endpoint URL in the Developer Portal
On the application's General Information tab:
- Paste
https://<slug>.trycloudflare.com/channels/discord/interactionsinto Interactions Endpoint URL. - Click Save Changes. Discord immediately sends a type=1 Ping interaction to validate the URL:
- With a correct
X-Signature-Ed25519andX-Signature-Timestamp, cantus replies{"type":1}(PONG) and the URL passes. - On a signature mismatch, cantus returns
401 {"detail":"Authentication required"}and the Discord Console shows "validation failed". Almost every validation failure here is a pasted public key that doesn't match the application's, not a cantus bug — re-copy the Public Key from General Information before you suspect anything else.
- With a correct
7. Register a slash command by hand (cantus does not register it for you)
cantus deliberately does not call PUT /applications/{id}/commands on your behalf (the same discipline as the "we don't register your webhook URL" note in the LINE and Telegram cookbooks: the secret always stays in your hands). Register a /ping command yourself with curl:
APP_ID="$CANTUS_SERVE_CHANNEL_DISCORD_APPLICATION_ID"
BOT="$CANTUS_SERVE_CHANNEL_DISCORD_BOT_TOKEN"
curl -X POST "https://discord.com/api/v10/applications/$APP_ID/commands" \
-H "Authorization: Bot $BOT" \
-H "Content-Type: application/json" \
-d '{"name":"ping","description":"Reply with pong","type":1}'A few seconds later, type / in your test server and you'll see /ping.
8. Run the echo + ping loop
The Discord events cantus receives are pushed onto an internal queue inside discord_channel. Write a worker loop:
# scripts/worker.py
import asyncio
from myskills.app import discord_channel, registry
async def main():
while True:
try:
event = discord_channel.receive()
except IndexError:
await asyncio.sleep(0.1)
continue
# Two inbound paths: (a) a MESSAGE_CREATE pushed in by the Gateway;
# (b) a slash command pushed in over the interactions HTTP endpoint.
# Tell them apart by dict shape.
if "interaction" in event:
# Slash-command callback. Reply with type=4 (CHANNEL_MESSAGE_WITH_SOURCE).
cmd = event["interaction"].get("data", {}).get("name")
if cmd == "ping":
await discord_channel.send({
"interaction": event["interaction"],
"data": {"type": 4, "data": {"content": "pong"}},
})
elif event.get("t") == "MESSAGE_CREATE":
# Gateway MESSAGE_CREATE. Echo it back (skip the bot's own
# messages to avoid an infinite loop).
msg = event["d"]
if msg.get("author", {}).get("bot"):
continue
await discord_channel.send({
"channel_id": msg["channel_id"],
"data": {"content": f"echo: {msg['content']}"},
})
if __name__ == "__main__":
asyncio.run(main())In a third shell, run:
python scripts/worker.pySay anything in your Discord server and you'll see echo: <what you typed> come back. Type /ping and you'll see pong.
9. Common pitfalls
- The
contentof a MESSAGE_CREATE event is an empty string: you forgot to enable MESSAGE CONTENT INTENT on the Bot tab of the Developer Portal. - The Discord Console shows "validation failed": check the public key first —
CANTUS_SERVE_CHANNEL_DISCORD_PUBLIC_KEYis usually short a character or is actually the client secret. If the key is right, the Cloudflare Tunnel probably isn't up yet. ChannelSendError: discord send failed: HTTP 403 Missing Permissions: the bot lacksSend Messagesin that channel. Re-invite via OAuth, or add the permission in the channel settings.ChannelSendError: discord send failed: HTTP 401 Unauthorized: the bot token is invalid (you reset it but didn't update the env var).- The bot keeps reconnecting: check the
last_errorthat cantus logs. Discord enforces a 1000/24h rate limit on IDENTIFY; after 10 consecutive failures cantus stops reconnecting without crashing, but the bot stays offline. Confirm the token is correct before restarting cantus. - You accidentally committed the token: go to the Bot tab of the Developer Portal and Reset Token right away; the old one is invalidated immediately. The public key can't be reset on its own — you'd have to recreate the application.
Next steps
- Fold the worker loop into your Agent or Workflow so the LLM decides what to reply.
- Add more slash commands: copy the
curlfrom step 7 and changename/description/options. - Add components (buttons, select menus): add a
componentsarray to thedatapayload yousendback. Discord pushes the user's click back as a type=3 interaction, which travels the same path as a slash command. - Deploy for real: replace the Cloudflare Tunnel with a named tunnel that has a fixed hostname, or front cantus with your own reverse proxy. Manage the bot token and public key with your platform's secrets manager.