@hoverlover/cc-discord
v0.5.7
Published
Discord <-> Claude Code relay: use your Claude subscription to power per-channel AI bots
Readme
cc-discord
Discord <-> Claude Code relay — power per-channel AI Agents using your existing Claude subscription (no API key needed).
- One autonomous Claude Code agent per Discord channel — plus per-thread agents for threaded conversations
- Messages stored in SQLite, delivered to agents via hooks
- Replies sent back to Discord via
send-discordtool - Automatic catch-up of missed messages on startup — no messages lost during restarts
- Typing indicators, busy notifications, live trace threads, memory context, attachment support, and more
Quick start
Prerequisites
- Bun runtime installed
- Claude Code CLI installed and authenticated (
claude auth login) - A Discord bot (see Create a Discord bot below)
Option A: Run directly with bunx (recommended)
bunx @hoverlover/cc-discordThis installs and runs cc-discord in one step. On first run, config files are created at ~/.config/cc-discord/:
~/.config/cc-discord/
├── .env.relay # Discord bot token, channel ID, relay token
└── .env.worker # Relay API token, worker settingsEdit those files with your credentials, then run again. Override the config location with CC_DISCORD_CONFIG_DIR.
Option B: Clone the repo (contributors)
git clone https://github.com/hoverlover/cc-discord.git
cd cc-discord
bun install
bun startbun start launches both the relay server and the orchestrator in a single process. No second terminal needed.
Create a Discord server
If you don't already have a Discord server to use:
- Open Discord and click the + button in the left sidebar
- Choose Create My Own, then select a template (or skip)
- Name your server and click Create
- Create one or more text channels where you want the bot to respond
Create a Discord bot
- Go to Discord Developer Portal
- Click New Application and give it a name
- Go to the Bot section in the left sidebar
- Click Add Bot, then Reset Token and copy the bot token
- Enable the following Privileged Gateway Intents:
- Message Content Intent (required — without this the bot cannot read message text)
- Server Members Intent (optional)
- Go to OAuth2 > URL Generator:
- Under Scopes, select:
bot,applications.commands - Under Bot Permissions, select:
Send Messages,Read Messages/View Channels,Read Message History,Manage Threads
- Under Scopes, select:
- Copy the generated URL and open it in your browser to invite the bot to your server
- In your Discord server, note the channel ID(s) you want the bot to respond in:
- Enable Developer Mode in Discord settings (User Settings > Advanced > Developer Mode)
- Right-click a channel and select Copy Channel ID
Configure environment
bunx users: env files are auto-created at ~/.config/cc-discord/ on first run. Just edit them there.
Cloned-repo users: copy the example files into the project root:
cp .env.relay.example .env.relay
cp .env.worker.example .env.workerProject-local env files take precedence over ~/.config/cc-discord/, so cloned-repo users are unaffected by the config directory.
.env.relay — required
| Variable | Description |
|---|---|
| DISCORD_BOT_TOKEN | Bot token from the Developer Portal |
| DISCORD_CHANNEL_ID | Default channel ID the bot operates in |
| RELAY_API_TOKEN | Shared secret between relay and worker (any random string) |
.env.relay — optional
| Variable | Default | Description |
|---|---|---|
| DISCORD_ALLOWED_CHANNEL_IDS | (all) | Comma-separated allowlist of channel IDs |
| DISCORD_IGNORED_CHANNEL_IDS | (none) | Comma-separated list of channel IDs to ignore |
| ALLOWED_DISCORD_USER_IDS | (all) | Comma-separated list of user IDs that can interact |
| MESSAGE_ROUTING_MODE | channel | channel (orchestrator/subagent) or agent (single-agent) |
| RELAY_HOST | 127.0.0.1 | Host for the relay HTTP API |
| RELAY_PORT | 3199 | Port for the relay HTTP API |
| RELAY_ALLOW_NO_AUTH | false | Set true for local dev without a token |
| TYPING_INTERVAL_MS | 8000 | Typing indicator heartbeat interval (ms) |
| TYPING_MAX_MS | 120000 | Max typing duration before sending fallback |
| THINKING_FALLBACK_ENABLED | true | Send a fallback message if Claude takes too long |
| THINKING_FALLBACK_TEXT | "Still working on that..." | Fallback message content |
| BUSY_NOTIFY_ON_QUEUE | true | Notify user if Claude is busy when their message arrives |
| BUSY_NOTIFY_COOLDOWN_MS | 30000 | Min time between busy notifications (ms) |
| BUSY_NOTIFY_MIN_DURATION_MS | 30000 | Only send busy notification if current activity has been running this long (ms) |
| TRACE_THREAD_ENABLED | true | Create live trace threads showing agent activity |
| TRACE_THREAD_NAME | ⚙️ Live Trace | Name of the trace thread |
| TRACE_FLUSH_INTERVAL_MS | 3000 | How often trace events are flushed to Discord (ms) |
| MAX_ATTACHMENT_INLINE_BYTES | 100000 | Max bytes for inline attachment content |
| MAX_ATTACHMENT_DOWNLOAD_BYTES | 10000000 | Max bytes for downloaded attachments |
| ATTACHMENT_TTL_MS | 3600000 | TTL for downloaded attachment files (ms) |
| CATCHUP_MESSAGE_LIMIT | 100 | Messages to fetch per channel on startup for catch-up (0 to disable) |
.env.worker — required
| Variable | Description |
|---|---|
| RELAY_API_TOKEN | Must match the token in .env.relay |
.env.worker — optional
| Variable | Default | Description |
|---|---|---|
| RELAY_HOST | 127.0.0.1 | Relay server host |
| RELAY_PORT | 3199 | Relay server port |
| RELAY_URL | (derived) | Full relay URL (overrides host/port) |
| DISCORD_SESSION_ID | default | Session identifier for message routing |
| CLAUDE_AGENT_ID | claude | Agent identifier for message routing |
| AUTO_REPLY_PERMISSION_MODE | skip | skip (fully autonomous) or accept-edits (safer) |
| CLAUDE_RUNTIME_ID | (auto) | Runtime context identifier for memory |
| WAIT_QUIET_TIMEOUT | true | Exit quietly on timeout (no noise in Claude UI) |
| BASH_POLICY_MODE | block | block or allow for background bash operations |
| ALLOW_BASH_RUN_IN_BACKGROUND | true | Allow run_in_background=true in Bash tool |
| ALLOW_BASH_BACKGROUND_OPS | false | Allow & background operator in commands |
| BASH_POLICY_NOTIFY_ON_BLOCK | true | Send Discord notification when bash is blocked |
| BASH_POLICY_NOTIFY_CHANNEL_ID | (none) | Channel to send bash policy notifications to |
| STUCK_AGENT_THRESHOLD | 900 | Seconds without heartbeat + unread messages before agent is considered stuck |
Orchestrator env vars
These are read by the orchestrator shell script:
| Variable | Default | Description |
|---|---|---|
| HEALTH_CHECK_INTERVAL | 30 | Seconds between full health checks |
| UNSERVICED_CHECK_INTERVAL | 5 | Seconds between checks for new threads/channels needing agents |
| AGENT_RESTART_DELAY | 5 | Seconds to wait before restarting a dead agent |
| CC_DISCORD_CONFIG_DIR | ~/.config/cc-discord | Directory for user config env files |
| CC_DISCORD_LOG_DIR | /tmp/cc-discord/logs | Directory for all log files |
Security note: the worker process intentionally does not receive DISCORD_BOT_TOKEN.
Architecture
┌──────────────────────────────────────────────────────────┐
│ bun start (scripts/start.sh) │
│ │
│ ┌──────────────────┐ ┌─────────────────────────────┐ │
│ │ Relay Server │ │ Shell Orchestrator │ │
│ │ (bun + express) │ │ (orchestrator.sh) │ │
│ │ │ │ │ │
│ │ - Discord bot │ │ Discovers channels/threads │ │
│ │ - HTTP API │ │ Spawns 1 Claude agent per │ │
│ │ - SQLite store │ │ channel or thread, monitors│ │
│ │ - Typing mgr │ │ health, restarts if stuck │ │
│ │ - Trace threads │ │ │ │
│ └────────┬─────────┘ │ ┌────────┐ ┌────────┐ │ │
│ │ │ │Agent #1│ │Agent #2│ ... │ │
│ │ │ └────────┘ └────────┘ │ │
│ │ └─────────────────────────────┘ │
└───────────┼──────────────────────────────────────────────┘
│
Discord APIMessage flow:
- A Discord user sends a message
- The relay server stores it in SQLite and starts a typing indicator
- The channel's Claude agent picks up the message via the
check-discord-messageshook - Claude processes the message and calls
send-discordto reply - The
steer-sendhook intercepts the send if new messages arrived while Claude was composing, forcing a revised reply - The relay posts the reply to Discord and stops the typing indicator
Features
Thread support
Threads in allowed channels are automatically supported — no configuration needed. When a user posts in a thread, the relay detects it, and the orchestrator spawns a dedicated agent for that thread within seconds. Each thread gets its own independent conversation context, separate from the parent channel.
- Automatic discovery: The orchestrator polls for unserviced messages every 5s (
UNSERVICED_CHECK_INTERVAL), so new threads get an agent almost immediately - Thread context in messages: Agents see thread messages tagged with the thread name (e.g.
user [thread: Bug Discussion]: message text), so they know they're in a thread - Catch-up: Active threads are included in the startup catch-up alongside channels
- Typing & replies: Typing indicators and replies work inside threads just like in channels
- Trace thread exclusion: Bot-managed trace threads are never treated as user conversation threads
Threads in allowed channels are permitted automatically — you don't need to add thread IDs to DISCORD_ALLOWED_CHANNEL_IDS.
Message catch-up
When the relay starts, it fetches recent message history from each allowed channel (and their active threads) and persists any messages that arrived while it was offline. Duplicates are silently ignored. This ensures no messages are lost during restarts or outages. Controlled by CATCHUP_MESSAGE_LIMIT (default 100, set to 0 to disable).
Typing indicators
When a user sends a message, the relay starts a typing indicator that repeats every TYPING_INTERVAL_MS (default 8s). It stops automatically when Claude replies. After TYPING_MAX_MS (default 120s), a configurable fallback patience message is sent.
Busy notifications
If Claude is already processing a task when a new message arrives, a notification is sent to let the user know their message is queued. Controlled by BUSY_NOTIFY_ON_QUEUE, BUSY_NOTIFY_COOLDOWN_MS, and BUSY_NOTIFY_MIN_DURATION_MS.
Send steering
The steer-send hook intercepts outgoing send-discord calls and checks for unread messages. If new messages arrived while Claude was composing a reply, the send is blocked and Claude is forced to revise its reply to address all messages. This prevents Claude from sending stale responses.
Live trace threads
When enabled, the relay creates a thread in each channel (default name: "⚙️ Live Trace") and streams live agent activity — tool calls, status changes, and more. Users can watch the agent work in real time without cluttering the main channel. Controlled by TRACE_THREAD_ENABLED, TRACE_THREAD_NAME, and TRACE_FLUSH_INTERVAL_MS.
Attachments
Discord attachments are downloaded to a temp directory and delivered to Claude as file paths. The cleanup-attachment hook automatically deletes them after Claude reads them. Size limits and TTL are configurable via MAX_ATTACHMENT_INLINE_BYTES, MAX_ATTACHMENT_DOWNLOAD_BYTES, and ATTACHMENT_TTL_MS.
/model slash command
Use /model in any channel to get or set the Claude model for that channel:
/model— show the current model/model name:claude-opus-4-6— set the model/model name:clear— reset to default
Memory system
A pluggable memory system backed by SQLite provides cross-session context. When a message arrives, the check-discord-messages hook retrieves relevant prior turns (avoiding duplicates from the current session) and includes them as memory context.
Stuck agent detection
The orchestrator periodically checks each agent's health. An agent is considered stuck when all three conditions are met:
- Heartbeat is stale (older than
STUCK_AGENT_THRESHOLD, default 15 min) - Unread messages are waiting
- Log file is also stale (no recent output)
Stuck agents are killed and automatically restarted.
Bash safety guard
The safe-bash hook inspects Bash tool calls for risky background execution patterns (run_in_background=true, standalone & operator). Depending on BASH_POLICY_MODE, these are either blocked or allowed with a Discord notification. Fine-grained control via ALLOW_BASH_RUN_IN_BACKGROUND and ALLOW_BASH_BACKGROUND_OPS.
Interactive mode
To run Claude interactively (with a terminal UI) instead of in autonomous headless mode:
# Start the relay in one terminal
bun run start:relay
# Start the interactive orchestrator in another terminal
bun run start:orchestrator-interactiveIn interactive mode, the orchestrator runs as a Claude Code session with a visible terminal. The relay must be started separately since there is no master process managing both.
Running as a daemon
To keep cc-discord running across reboots and terminal closures, install it as a system service.
Dedicated Mac server? If you're setting up a Mac mini (or similar) as a headless, always-on server, see the macOS Headless Server Setup Guide. It covers disabling SIP, configuring TCC permissions, FileVault tradeoffs, auto-login, and LaunchDaemon setup for fully unattended operation.
macOS (launchd)
- Find the full paths to
bunxandclaude:
which bunx # e.g. /Users/you/.bun/bin/bunx
which claude # e.g. /Users/you/.local/bin/claude- Create the plist file (replace all
/Users/you/...paths with your actual paths from step 1):
cat > ~/Library/LaunchAgents/com.cc-discord.plist << 'EOF'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.cc-discord</string>
<key>ProgramArguments</key>
<array>
<!-- Replace with the output of: which bunx -->
<string>/Users/you/.bun/bin/bunx</string>
<string>@hoverlover/cc-discord</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<!-- Must include directories for both bunx and claude -->
<key>PATH</key>
<string>/Users/you/.bun/bin:/Users/you/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
</dict>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>/tmp/cc-discord/launchd-stdout.log</string>
<key>StandardErrorPath</key>
<string>/tmp/cc-discord/launchd-stderr.log</string>
</dict>
</plist>
EOFImportant: launchd does not source your shell profile, so the
PATHmust explicitly include the directories for bothbunxandclaude. Verify the paths match your system.Load the service:
mkdir -p /tmp/cc-discord
launchctl load ~/Library/LaunchAgents/com.cc-discord.plist- Manage the service:
# Check status
launchctl list | grep cc-discord
# Stop
launchctl stop com.cc-discord
# Start
launchctl start com.cc-discord
# Uninstall
launchctl unload ~/Library/LaunchAgents/com.cc-discord.plist
rm ~/Library/LaunchAgents/com.cc-discord.plistLinux (systemd)
- Find the full paths to
bunxandclaude:
which bunx # e.g. /home/you/.bun/bin/bunx
which claude # e.g. /home/you/.local/bin/claude- Create the service file:
sudo cat > /etc/systemd/system/cc-discord.service << 'EOF'
[Unit]
Description=cc-discord — Discord <-> Claude Code relay
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
# Replace "you" with your username
User=you
# Replace with the output of: which bunx
ExecStart=/home/you/.bun/bin/bunx @hoverlover/cc-discord
# Ensure bun and claude are on PATH
Environment=PATH=/home/you/.bun/bin:/home/you/.local/bin:/usr/local/bin:/usr/bin:/bin
Environment=HOME=/home/you
Restart=on-failure
RestartSec=10
[Install]
WantedBy=multi-user.target
EOFReplace
youwith your actual username and update the paths to match your system.Enable and start the service:
sudo systemctl daemon-reload
sudo systemctl enable cc-discord
sudo systemctl start cc-discord- Manage the service:
# Check status
systemctl status cc-discord
# View logs
journalctl -u cc-discord -f
# Stop
sudo systemctl stop cc-discord
# Restart
sudo systemctl restart cc-discord
# Disable (won't start on boot)
sudo systemctl disable cc-discordNotes
- Both approaches run
bunx @hoverlover/cc-discord, which is the same asbun startin the cloned repo. - Environment files are read from
~/.config/cc-discord/— make sure those are configured before starting the service. - Application logs still go to
CC_DISCORD_LOG_DIR(default/tmp/cc-discord/logs). The launchd/systemd logs are separate and capture startup errors. - Claude CLI must be authenticated (
claude auth login) as the user the service runs under before starting.
Development
Scripts
| Script | Description |
|---|---|
| bun start | Start relay + orchestrator (production) |
| bun run start:relay | Start relay server only |
| bun run start:orchestrator | Start headless orchestrator only |
| bun run start:orchestrator-interactive | Start interactive orchestrator (terminal UI) |
| bun run dev | Alias for start:relay |
| bun run memory:smoke | Run memory system smoke test |
| bun run memory:inspect | Inspect memory database contents |
| bun run lint | Run Biome linter |
| bun run lint:fix | Run Biome linter with auto-fix |
| bun run format | Format code with Biome |
| bun run typecheck | Run TypeScript type checking |
Hook system
Claude Code hooks are configured in .claude/settings.local.json, generated automatically from .claude/settings.template.json when the relay starts. This file is gitignored and only exists while the relay is running — start.sh creates it on startup and removes it on shutdown so hooks don't interfere with normal development. The template uses __ORCHESTRATOR_DIR__ placeholders that are replaced with absolute paths at generation time.
| Hook | Event | Description |
|---|---|---|
| check-discord-messages | PostToolUse, SessionStart, UserPromptSubmit, Stop | Delivers unread Discord messages + memory context into Claude's context |
| steer-send | PreToolUse (Bash) | Blocks send-discord if new messages arrived, forcing a revised reply |
| safe-bash | PreToolUse (Bash) | Guards against risky background execution patterns |
| track-activity | PreToolUse, PostToolUse, Stop, SessionStart | Tracks agent busy/idle status and writes trace events |
| cleanup-attachment | PostToolUse (Read) | Deletes downloaded attachment files after Claude reads them |
Logs
All logs are written to CC_DISCORD_LOG_DIR (default: /tmp/cc-discord/logs):
| File | Contents |
|---|---|
| relay.log | Relay server output |
| orchestrator.log | Orchestrator process management |
| channel-<name>-<id>.log | Per-channel Claude agent output |
Monitor all logs:
tail -f /tmp/cc-discord/logs/*.log