Connectors
Discord
Talk to an agent with a slash command in Discord.
Quickstart
Create a Discord application
In the Discord Developer Portal, create an application. Copy its application ID and public key, and create a bot token.
Register the command
Register an /ask command with a prompt option:
curl -X PUT "https://discord.com/api/v10/applications/$DISCORD_APPLICATION_ID/commands" \
-H "Authorization: Bot $DISCORD_BOT_TOKEN" \
-H "Content-Type: application/json" \
-d '[{"name":"ask","description":"Ask the agent","options":[{"type":3,"name":"prompt","description":"What to ask","required":true}]}]'
Install the application to your server with the applications.commands scope.
Add the route
npm add @rivet-dev/pi rivetkit hono discord-interactions
import { pi } from "@rivet-dev/pi";
import { Hono } from "hono";
import { setup } from "rivetkit";
import { type DiscordCommand, discord, postReply } from "./discord";
const agent = pi({
model: "anthropic/claude-opus-5-5",
state: { pending: [] as DiscordCommand[] },
vars: { replying: false },
actions: {
receive: async (c, command: DiscordCommand) => {
c.state.pending.push(command);
await c.schedule.after(0, "drain");
},
// Answers the oldest message. Messages that arrive mid-run wait their turn.
drain: async (c): Promise<void> => {
const command = c.state.pending[0];
if (!command || c.vars.replying) return;
c.vars.replying = true;
try {
const self = c.client<typeof registry>().agent.getForId(c.actorId);
await self.prompt(command.prompt);
await postReply(command, await self.getLastAssistantText());
} catch (error) {
c.log.error({ msg: "reply failed", error });
} finally {
c.state.pending.shift();
c.vars.replying = false;
}
if (c.state.pending.length > 0) await c.schedule.after(0, "drain");
},
},
});
export const registry = setup({ use: { agent } });
const app = new Hono();
app.all("/api/rivet/*", (c) => registry.handler(c.req.raw));
app.route("/discord", discord);
export default app;
import { InteractionResponseType, InteractionType, verifyKey } from "discord-interactions";
import { Hono } from "hono";
import { createClient } from "rivetkit/client";
import type { registry } from "./server";
export type DiscordCommand = { applicationId: string; token: string; prompt: string };
const client = createClient<typeof registry>();
export const discord = new Hono();
discord.post("/interactions", async (c) => {
const body = await c.req.text();
const signature = c.req.header("x-signature-ed25519") ?? "";
const timestamp = c.req.header("x-signature-timestamp") ?? "";
if (!(await verifyKey(body, signature, timestamp, process.env.DISCORD_PUBLIC_KEY ?? ""))) {
return c.text("invalid signature", 401);
}
const interaction = JSON.parse(body);
if (interaction.type === InteractionType.PING) return c.json({ type: InteractionResponseType.PONG });
if (interaction.type === InteractionType.APPLICATION_COMMAND && interaction.data.name === "ask") {
const prompt = interaction.data.options?.find((option: { name: string }) => option.name === "prompt")?.value;
const agent = client.agent.getOrCreate(["discord", interaction.channel_id ?? interaction.channel?.id]);
await agent.receive({ applicationId: interaction.application_id, token: interaction.token, prompt: String(prompt ?? "") });
return c.json({ type: InteractionResponseType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE });
}
return c.text("unhandled interaction", 400);
});
export async function postReply(command: DiscordCommand, reply: string | undefined) {
const url = `https://discord.com/api/v10/webhooks/${command.applicationId}/${command.token}/messages/@original`;
await fetch(url, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ content: (reply || "I finished without a reply.").slice(0, 2000) }),
});
}
Run the server with any runtime that serves a fetch handler, for example npx srvx --import tsx server.ts. See HTTP Server.
Set the environment
DISCORD_PUBLIC_KEY=...
ANTHROPIC_API_KEY=sk-ant-...
Set the interactions endpoint
Under General Information, set the interactions endpoint URL to https://<your-worker>/discord/interactions. Discord sends a ping, which the route answers. Run /ask in a channel.
How it works
- The route verifies Discord’s Ed25519 signature against the raw body with the application’s public key.
- Each Discord channel gets its own agent. Every
/askin the channel continues the same session. - Discord expects a response within three seconds and an agent run takes longer, so the route hands the command to the agent’s
receiveaction and defers the reply. Thereceiveaction adds the message to the agent’s queue and schedulesdrain, which prompts the agent with the oldest message and edits the deferred message. Interaction tokens stay valid for 15 minutes, and a message holds up to 2,000 characters. A message that arrives while the agent is answering waits in the queue. The queue is saved with the agent, so replies still run if the request that delivered a message goes away.
Configure
| Variable | Purpose |
|---|---|
DISCORD_PUBLIC_KEY | Required. Verifies that requests come from Discord. |
DISCORD_APPLICATION_ID | Registers the command. Not needed by the server. |
DISCORD_BOT_TOKEN | Registers the command. Not needed by the server. |
ANTHROPIC_API_KEY | Required. The agent’s model key. For another provider, change model and set its key instead, such as OPENAI_API_KEY or OPENROUTER_API_KEY. See LLM API Keys. |
Supported events
| Discord interaction | Webhook path | What the route does |
|---|---|---|
PING | /discord/interactions | Answers Discord’s endpoint check. |
Application command /ask | /discord/interactions | Hands the command to the channel’s agent and defers. The agent edits the reply. |