A new interface to building — run coding agents over Discord/Slack.
- Spawn and manage parallel agents from chat
- tmux in to manage from terminal
┌─────────────┐ ┌─────────────┐
│ Discord │ │ Slack │
│ Gateway │ │ Gateway │
│ (discord.js)│ │(@slack/bolt)│
└──────┬──────┘ └──────┬──────┘
│ │
└────────┬──────────┘
│
┌────────▼────────┐
│ Daemon │ Single process per platform.
│ (daemon.ts) │ Holds gateway connection,
│ │ routes messages, manages
│ unix socket │ sessions and access control.
└────────┬────────┘
│ newline-delimited JSON
┌────────▼────────┐
│ Bridge │ Thin MCP relay. One per
│ (bridge.ts) │ Claude session. Platform-
│ │ agnostic — doesn't import
│ stdio ↔ socket │ any chat SDK.
└────────┬────────┘
│ MCP (stdio)
┌────────▼────────┐
│ Claude Code │ Full Claude with tools,
│ │ memory, file access, etc.
└─────────────────┘
Key design decisions:
- One gateway connection per platform. Prevents token race conditions (Discord) and simplifies state.
- Daemon ↔ Bridge separation. The daemon is long-lived; Claude sessions come and go. The bridge reconnects automatically.
- Platform selection via env var. Set
CHAT_PLATFORM=discordorCHAT_PLATFORM=slack. Default:discord. - Simultaneous platforms. Run two daemons on different state dirs for Discord + Slack at the same time.
- Bun —
curl -fsSL https://bun.sh/install | bash - tmux —
brew install tmux - Claude Code —
npm install -g @anthropic-ai/claude-code
# Install dependencies
bun install
# Create .env with your bot token
mkdir -p ~/.claude/channels/discord
cat > ~/.claude/channels/discord/.env << 'EOF'
DISCORD_BOT_TOKEN=your-token-here
EOF
# Install watchdog + verify setup
bun cli/hydra.ts install discord --cwd ~/your/project
# Start
bun cli/hydra.ts up discord- Discord Setup — bot creation, token, permissions, pairing
- Slack Setup — app manifest, Socket Mode, tokens
All operations go through the hydra CLI (bun cli/hydra.ts or alias to hydra).
hydra install <platform> # Generate launchd watchdog, run preflight
hydra uninstall <platform> # Remove launchd watchdog
hydra preflight <platform> # Verify deployment is readyhydra up <platform> # Start daemon + byte
hydra down <platform> # Stop byte + daemon
hydra restart <platform> # Restart daemon (picks up code changes)hydra spawn <prompt> # Spawn a new session
hydra list # List active sessions
hydra status <name> # Session details
hydra kill <name> # Kill a session
hydra peek [name] # View live sessions (chooser or direct attach)
hydra health # Daemon diagnostics
hydra clear-key <key> # Clear a stuck idempotency key--daemon <name> Target a specific daemon (when multiple running)
--json Output raw JSON
Set in ~/.claude/channels/<platform>/.env:
# Discord
DISCORD_BOT_TOKEN=MTIz...
# Slack
SLACK_BOT_TOKEN=xoxb-...
SLACK_APP_TOKEN=xapp-...access.json controls who can message the bot. Lives in the state dir (~/.claude/channels/discord/ by default).
See ACCESS.md for full reference.
# Install both
hydra install discord
hydra install slack
# Start both
hydra up discord
hydra up slackEach platform gets its own daemon, state dir, and watchdog. Use different CLAUDE_CONFIG_DIR values for separate logins.
Hydra can transcribe inbound audio attachments (Discord voice notes, Slack audio clips) to text, so you can dictate prompts to Claude alongside text and images.
Transcription runs in a self-hosted sidecar (transcribe-server/) so audio never
leaves your machine. Claude doesn't accept audio natively, so the daemon
transcribes first and merges the text into the message as [voice transcript] ...;
the original audio file stays available in downloaded_files. Backend by platform:
- macOS (Apple Silicon) → Parakeet-MLX — NVIDIA Parakeet TDT on Apple's MLX runtime. Native, fast (~50× realtime), no GPU/CUDA. Default on macOS.
- Linux + NVIDIA GPU → Canary-Qwen 2.5B via NeMo (top of the Open ASR leaderboard for English accuracy).
It's on by default on the daemon side — whenever a sidecar is reachable, voice notes are transcribed; when it isn't, audio just passes through. So the only thing to set up is the sidecar.
Try it right now (no model install):
./start-transcribe.sh mock # GPU-free stub, returns a canned transcriptSend a voice note → Claude receives [voice transcript] This is a mock transcription....
(The mock is manual-only: the daemon never auto-starts it, so a leftover mock
setting can't silently feed canned text into real messages. hydra down or
tmux kill-session -t hydra-transcribe stops it.)
Real transcription (one-time; needs ffmpeg — brew install ffmpeg):
./transcribe-server/setup.sh # venv + the right backend for your platformThat's it. Once set up, the sidecar starts and stays up with the daemon —
hydra up, start-daemon.sh, and the watchdog all bring it along; hydra down
stops it. One shared tmux session (hydra-transcribe) serves every platform
daemon. Run ./start-transcribe.sh to start it by hand. Set
HYDRA_TRANSCRIBE_AUTOSTART=0 to keep the daemon from managing it, or =1 to
force autostart even before setup (loud failure instead of a quiet skip).
If the sidecar is unreachable, the daemon logs it and delivers the message without a
transcript — dictation never blocks normal messages (a down sidecar fails fast; a
live-but-slow one delays only the voice message itself, up to
HYDRA_TRANSCRIBE_TIMEOUT_MS, 60s default). Disable entirely with
HYDRA_TRANSCRIBE_ENABLED=0. Full setup, env vars, and tuning:
transcribe-server/README.md.
| Tool | Description |
|---|---|
reply |
Send a message. Takes chat_id + text, optionally reply_to for threading and files for attachments (max 10, 25MB each). Auto-chunks long messages. |
react |
Add emoji reaction to a message. |
edit_message |
Edit a previously sent message. |
fetch_messages |
Pull recent history (up to 100). |
download_attachment |
Download attachments from a message to local inbox. |
create_thread |
Create a thread on a message or standalone. |
spawn_session |
Spawn a new Claude session for a topic (main session only). |
list_sessions |
List active spawned sessions (main session only). |
kill_session |
Kill a spawned session (main session only). |
Spawn isolated Claude sessions from chat:
| Command | Action |
|---|---|
spawn: <topic> |
Create a new session with a thread |
kill: <name> |
Kill a session by name |
/sessions |
List active sessions |
listen / pause |
Toggle auto-routing in a session thread |
help / commands |
Show all available commands |
Sessions get cute names (spark, pixel, nova...) and run in their own tmux sessions. State persists across daemon restarts.
Symptoms first — each maps to one root cause. See docs/ONBOARDING_TIPS.md for a full first-machine checklist.
Bot is online but a spawned thread stays empty, or spawn: does nothing.
Command interception (spawn:, kill:, /sessions, /health) fires only for senders in the top-level access.json allowFrom — separate from a channel group's allowFrom. A group lets replies through; commands need you in the global allowlist. → /discord:access allow <your-snowflake>. (The daemon logs command-shaped message from non-allowlisted sender … when this happens.)
Byte or spawned session hangs on a theme picker, login, or "trust this folder" screen.
The byte is a second, headless Claude in tmux using CLAUDE_CONFIG_DIR (default ~/.claude), so it reads $CLAUDE_CONFIG_DIR/.claude.json — not ~/.claude.json. A fresh config dir triggers first-run gates that block a detached session. → Complete them once via tmux attach -t <platform>-byte, or pre-seed theme, hasCompletedOnboarding, bypassPermissionsModeAccepted, and per-project hasTrustDialogAccepted. hydra preflight now flags this.
Bot connects but never sees inbound (looks healthy, ignores everyone). → Enable Message Content Intent (Developer Portal → Bot → Privileged Gateway Intents), or the bot receives empty message content.
Byte dies instantly, or spawns fail to launch.
→ Check SPAWN_CWD points at a directory that exists. Inspect ~/hydra-<platform>-byte.log and ~/hydra-<platform>-daemon.log.
usage shows ? instead of context percentage.
Claude Code doesn't display context % in the status bar by default. Add a statusLine hook to $CLAUDE_CONFIG_DIR/settings.json:
{
"statusLine": {
"type": "command",
"command": "~/.claude/statusline.sh",
"refreshInterval": 5
}
}Where ~/.claude/statusline.sh extracts the percentage from the JSON passed on stdin:
#!/bin/bash
input=$(cat)
pct=$(echo "$input" | jq -r '.context_window.used_percentage // 0' | cut -d. -f1)
echo "ctx: ${pct}%"The hook receives context_window.used_percentage, model.id, cost.total_cost_usd, and more. Restart the byte to pick up the change.
Verify inbound end-to-end: grep -E "main bridge connected|running tmux new-session" ~/hydra-<platform>-daemon.log
| File | Purpose |
|---|---|
gateway.ts |
ChatGateway interface and shared types |
discord-gateway.ts |
Discord implementation (discord.js) |
slack-gateway.ts |
Slack implementation (@slack/bolt Socket Mode) |
daemon.ts |
Platform-agnostic message router and session manager |
bridge.ts |
MCP relay between Claude and daemon (unix socket ↔ stdio) |
cli/hydra.ts |
CLI entry point — routes commands |
cli/helpers.ts |
Config resolution, tmux wrappers, socket comms, compile check |
cli/lifecycle.ts |
Lifecycle commands: up/down/restart/watchdog/preflight/install |
cli/peek.ts |
View live sessions via tmux linked windows with filtered chooser |
Logs land at ~/hydra-<platform>-daemon.log and ~/hydra-<platform>-byte.log.
{ "dmPolicy": "pairing", // pairing | allowlist | disabled "allowFrom": ["user-id-here"], // platform user IDs "groups": { // channel-level policies "channel-id": { "requireMention": true, "allowFrom": [], "threadReply": true } }, "ackReaction": "👀", "replyToMode": "first", // first | all | off "textChunkLimit": 2000, "chunkMode": "newline" // newline | length }