Replace complex Docker commands with simple Make targets for building, running, and managing the Discord bot container. This makes it easier for developers to get started without memorizing lengthy Docker flags. Also removes outdated CLAUDE.md and adds AGENTS.md files to guide AI agents working on conversation, database, actors, and sandbox modules.
8 KiB
8 KiB
OpenCord
Discord bot that provisions Daytona sandboxes running OpenCode sessions. Each Discord thread gets its own isolated sandbox with full conversational context.
How It Works
- Mention the bot in an allowed channel
- Bot creates a Discord thread and provisions a Daytona sandbox
- OpenCode runs inside the sandbox, responding to messages in the thread
- Inactive threads pause their sandbox automatically; activity resumes them
- Conversational context is preserved across bot restarts
Setup
Prerequisites
- Bun installed
- A Discord bot application (see below)
- A Daytona account with API access
- An OpenCode API key
1. Create a Discord Bot
- Go to the Discord Developer Portal
- Create a new application
- Go to Bot and click Reset Token — save this as
DISCORD_TOKEN - Enable Message Content Intent under Privileged Gateway Intents
- Go to OAuth2 > URL Generator, select scopes
botandapplications.commandswith permissions: Send Messages, Create Public Threads, Send Messages in Threads, Read Message History - Use the generated URL to invite the bot to your server
2. Get Your API Keys
- Daytona: Sign up at daytona.io and generate an API key from your dashboard
- OpenCode: Get an API key from opencode.ai
- GitHub Token (optional): A personal access token — enables authenticated
ghCLI inside sandboxes
3. Configure and Run
bun install
cp .env.example .env
# Fill in required values (see below)
bun run db:init
bun run dev
4. Run with Docker
From the repo root, use the built-in Make targets:
cp packages/discord/.env.example packages/discord/.env
# Fill in required values in packages/discord/.env
make -C packages/discord docker-build
make -C packages/discord docker-run
make -C packages/discord docker-status
SQLite data is persisted locally in packages/discord/data.
Useful commands:
make -C packages/discord docker-logs
make -C packages/discord docker-stop
If you prefer plain Docker commands instead of Make, run:
docker build -t opencode-discord packages/discord
docker run --name opencode-discord-local \
--env-file packages/discord/.env \
-e DATABASE_PATH=/data/discord.sqlite \
-p 8787:8787 \
-v $(pwd)/packages/discord/data:/data \
opencode-discord
This image does not require Docker Compose or special network wiring; only outbound access to Discord, Daytona, and OpenCode APIs.
Environment Variables
Required
| Variable | Description |
|---|---|
DISCORD_TOKEN |
Bot token from the Discord Developer Portal |
DAYTONA_API_KEY |
API key from your Daytona dashboard |
OPENCODE_ZEN_API_KEY |
API key from OpenCode |
Optional — Discord
| Variable | Default | Description |
|---|---|---|
ALLOWED_CHANNEL_IDS |
(empty) | Comma-separated channel IDs where the bot listens. Empty = all channels |
DISCORD_CATEGORY_ID |
(empty) | Restrict the bot to a specific channel category |
DISCORD_ROLE_ID |
(empty) | Role ID that triggers the bot via @role mentions |
DISCORD_REQUIRED_ROLE_ID |
(empty) | Role users must have to interact with the bot |
DISCORD_COMMAND_GUILD_ID |
(empty) | Register slash commands in one guild for instant updates (dev-friendly) |
Optional — Storage & Runtime
| Variable | Default | Description |
|---|---|---|
DATABASE_PATH |
discord.sqlite |
Path to the local SQLite file |
GITHUB_TOKEN |
(empty) | Injected into sandboxes for authenticated gh CLI |
DAYTONA_SNAPSHOT |
(empty) | Prebuilt Daytona snapshot name for faster startup |
OPENCODE_MODEL |
opencode/claude-sonnet-4-5 |
Model used inside OpenCode sessions |
Optional — Bot Behavior
| Variable | Default | Description |
|---|---|---|
SANDBOX_REUSE_POLICY |
resume_preferred |
resume_preferred or recreate |
SANDBOX_TIMEOUT_MINUTES |
30 |
Minutes of inactivity before pausing a sandbox |
PAUSED_TTL_MINUTES |
180 |
Minutes a paused sandbox lives before being destroyed |
RESUME_HEALTH_TIMEOUT_MS |
120000 |
Timeout (ms) when waiting for a sandbox to resume |
SANDBOX_CREATION_TIMEOUT |
180 |
Timeout (s) for sandbox creation |
TURN_ROUTING_MODE |
ai |
How the bot decides if a message needs a response: off, heuristic, or ai |
TURN_ROUTING_MODEL |
claude-haiku-4-5 |
Model used for AI turn routing |
Optional — Observability
| Variable | Default | Description |
|---|---|---|
LOG_LEVEL |
info |
debug, info, warn, or error |
LOG_PRETTY |
false |
Pretty-print JSON logs |
HEALTH_HOST |
0.0.0.0 |
Host for the health HTTP server |
HEALTH_PORT |
8787 |
Port for the health HTTP server |
Commands
| Command | Description |
|---|---|
bun run dev |
Watch mode |
bun run start |
Production run |
bun run db:init |
Initialize/migrate database |
bun run snapshot:create |
Build/activate a Daytona snapshot |
bun run typecheck |
TypeScript checks |
bun run build |
Bundle for deployment |
bun run check |
Typecheck + build |
Faster Sandbox Startup (Snapshot)
Build and activate a reusable Daytona snapshot once:
bun run snapshot:create opencode-discord-v1
Then set this in .env:
DAYTONA_SNAPSHOT=opencode-discord-v1
Discord Slash Commands
/status— show current sandbox session for the thread/reset— destroy session so next message provisions a fresh sandbox
These map to the existing !status / !reset behavior.
Health Endpoints
GET /healthz— Liveness check (uptime, Discord status, active sessions)GET /readyz— Readiness check (200 when Discord connected, 503 otherwise)
Architecture
Discord / CLI
└─ Conversation service (Inbox → turn logic → Outbox)
├─ IngressDedup (message-id dedup in conversation path)
├─ OffsetStore (durable Discord catch-up offsets)
└─ ThreadChatCluster.send(threadId)
└─ ThreadEntity (cluster actor per thread)
├─ active? → health check → reuse
├─ paused? → SandboxProvisioner.resume() → reattach session
└─ missing? → SandboxProvisioner.provision() → new sandbox + session
Sessions are persisted in a local SQLite file. Sandbox filesystem (including OpenCode session state) survives pause/resume cycles via Daytona stop/start.