A stripped-down Discord agent powered by Claude. Simplified fork of openclaw — keeps only Discord, replaces multi-provider AI with Anthropic SDK, adds a web dashboard.
- 💬 Conversational AI — @mention in channels or DM directly. Full conversation history per session.
- 🧵 Thread-First Replies — In guild channels, every response goes into its own thread for clean session isolation. Monitored channels auto-respond without @mention.
- 🎤 Voice Message Support — Send voice DMs and the bot transcribes them automatically, fully offline via whisper.cpp (falls back to NeMo Parakeet, then the OpenAI Whisper API if configured).
- 🎙️ Voice Assistant — Join voice channels with
/join. Listens via Silero VAD, transcribes locally with whisper.cpp, thinks with Claude, speaks back with ElevenLabs TTS. Supports interruptions, streaming TTS pipelining, and auto-disconnect. Auto-join mode tracks a configured user. - 🚴 Voice Coach — AI cycling coach that lives in a dedicated voice channel. Auto-joins when a tracked rider connects, monitors simulated cycling telemetry (power, heart rate, cadence), listens to rider speech via VAD+STT, and delivers real-time coaching via ElevenLabs TTS — all in the persona of a team sport director on race radio. Requires
ELEVENLABS_API_KEY. - 📎 Artifacts — Persistent file storage for session inputs and outputs. Files uploaded to or generated by the agent are tracked with metadata, stored on disk, and browsable via the dashboard Artifacts page. Supports input/output tagging, Discord URL linking, and per-session browsing.
- 🧠 Persistent Memory — Remembers things across conversations. Markdown files indexed with FTS5 full-text search. Optional mem9 cloud memory for semantic vector search.
- 📜 Conversation History — Messages are archived across sessions. Query past conversations with
get_conversation_historyandget_conversation_statstools. - 🎭 Customizable Personality — Edit
SOUL.mdto change how the bot behaves. Hot-reloads on save. - 🔧 Tool Use — Runs shell commands, reads/writes files, sends messages across channels, reacts to messages, attaches files, creates threads. Real-time tool call progress shown in Discord during agentic loops.
- ↩️ Mid-turn Steering — A message sent while the bot is still working is injected into the running turn instead of queueing behind it; the bot reacts ↩️ and says which tool it interrupted.
/interruptcuts the turn but keeps the session,/stopkills it. - 📦 Skills — Drop a
SKILL.mdfolder intodata/skills/and the bot learns new capabilities instantly. Install from GitHub or upload directly. - ⏰ Scheduled Tasks — Cron jobs that run agent turns on a schedule, delivering results in daily threads. Hot-reloads
jobs.jsonwithout restart. - 🧬 Self-Evolution — The bot can modify its own source code via GitHub PRs. You approve the build plan up front; once CI passes, the PR is merged and deployed automatically (no diff review step). Optional Daytona sandbox CI for isolated validation. Deployment notifications posted automatically.
- 🔍 Autonomous Reflection — Collects signals (errors, tool failures, duplicate loops) and structured logs, then periodically analyzes them to suggest improvements.
- 📊 Web Dashboard — React SPA for managing sessions, channels, soul, memory, cron, skills, artifacts, evolution history, and logs.
- 📝 Structured Logging — Three SQLite-backed log streams (application, error, tool calls) with dashboard viewer and automatic pruning.
- 🔍 Web Search — Install the SearXNG skill for web, news, and package repository search.
You: Hey @Discordclaw, what did we talk about yesterday?
Bot: [searches memory] We discussed setting up the cron job
for daily standups. Want me to finish that?
You: Yeah, set it up for 9am every weekday in #general
Bot: [creates cron job] Done! I'll post a standup prompt
to #general at 9am Mon-Fri. ✅
You: 🎤 [sends voice message]
Bot: [transcribes audio] I heard you say "Can you search
for the latest Node.js release?" Let me check...
Node.js v22.5.0 was released on April 1, 2026.
You: Can you search the web for the latest Node.js release?
Bot: [reads searxng-search skill, runs search] Node.js v22.5.0
was released on April 1, 2026 with...
You: I want you to add a /ping command that shows latency
Bot: Build plan:
1. src/bot/commands.ts — register /ping, reply with gateway latency
2. README.md — document the command
Risk: needs a restart to re-register slash commands. OK to start?
You: yes
Bot: [evolve_start(plan, plan_approved) → evolve_write → evolve_propose]
PR #8 validated, merged, deploying now... ✅
/ping command is now live!
| Command | Description |
|---|---|
/ping |
Show bot health status, latency, and uptime |
/help |
Show all commands and capabilities |
/config |
Toggle bot on/off per channel, set custom instructions |
/clear |
Stop this channel's session and drop its context (channel + the threads under it) |
/soul |
View the bot's personality |
/model |
Show the active model, or switch to another one from the proxy's list (persists across restarts) |
/skills |
List, install (from GitHub or file upload), or remove skills |
/cron |
View, add, enable/disable, force-run, set a per-job model, or show history of cron jobs |
/caveman |
Toggle terse caveman-speak mode (lite/full/ultra/off) for the current channel |
/restart |
Restart the bot process |
/stop |
Stop every running session (kills the SDK child processes) |
/interrupt |
Interrupt the current turn in this channel/thread, keeping the session and its context alive |
/join |
Join your voice channel as a voice assistant |
/leave |
Leave the voice channel |
Each instance of Discordclaw is its own bot with its own personality, memory, and evolution history. To run your own, you'll fork the repo and deploy from your fork. This way the self-evolution feature creates PRs against your repo, not the upstream one.
- Node.js v20+
- Git and GitHub CLI (
gh) — required for the self-evolution engine - A Discord bot token (setup guide below)
- An Anthropic API key (or a proxy endpoint)
- (Optional) An OpenAI API key for voice message transcription
- (Optional) An ElevenLabs API key for voice assistant and voice coach TTS
- (Optional) An ElevenLabs API key for voice coach TTS
- (Optional) A Daytona API key for sandbox CI during evolution validation
- Click Fork on the repo to create your own copy
- Clone your fork:
git clone https://github.com/YOUR_USERNAME/discord-claw.git
cd discord-clawWhy fork? The evolution engine pushes branches and creates PRs via
gh. If you clone without forking, PRs would target the original repo. Your fork gives you full control — the bot evolves your codebase.
- Go to https://discord.com/developers/applications
- Create a new application → Bot tab → copy token
- Enable Message Content Intent and Server Members Intent
- OAuth2 > URL Generator → scopes:
bot,applications.commands - Permissions: Send Messages, Read Message History, Add Reactions, Attach Files, Use Slash Commands, Create Public Threads
- Invite bot to your server with the generated URL
cp .env.example .envEdit .env with your tokens:
DISCORD_BOT_TOKEN=your_discord_bot_token
ANTHROPIC_API_KEY=your_anthropic_api_key
# Optional
OPENAI_API_KEY=your_openai_key # Voice message transcription
ELEVENLABS_API_KEY=your_elevenlabs_key # Voice assistant + coach TTS
ELEVENLABS_API_KEY=your_elevenlabs_key # Voice coach TTS
ELEVENLABS_VOICE_ID=your_voice_id # Voice coach voice
GATEWAY_PORT=3000 # Dashboard port
GATEWAY_TOKEN=your_secret_token # Dashboard auth token
ANTHROPIC_MODEL=bedrock-claude-opus-5-1m # Fallback model (/model overrides it)
DAYTONA_API_KEY=your_daytona_key # Sandbox CI for evolution validationnpm install
npm run build:ui # Build the dashboard
npm run dev # Start in development modeThe bot responds to @mentions in guild channels and all DMs. Dashboard at http://localhost:3000.
For production, use the startup script which handles auto-pull, migrations, health checks, and rollback:
./start.shOr use the watchdog daemon for crash recovery and auto-restart:
npm run daemonYou can set up a systemd service, Docker container, or any process manager to keep it running. Point it at start.sh or the daemon as the entry point.
Tip: Set
DISCORD_WEBHOOK_URLin.envto receive deploy/rollback notifications in a Discord channel.
The evolution engine uses gh to create and merge PRs. Make sure it's authenticated:
gh auth loginWithout this, the bot can still function normally — it just won't be able to create PRs to modify its own code.
- Personality — Edit
data/SOUL.mdto define how your bot talks and behaves. Hot-reloads on save. - Skills — Drop skill folders into
data/skills/or install from GitHub via the dashboard or/skills add-github. - Memory — The bot builds memory over time. You can also seed
data/MEMORY.mdwith initial context.
To pull improvements from the upstream repo into your fork:
git remote add upstream https://github.com/NaichuanZhang/discord-claw.git
git fetch upstream
git merge upstream/main| Variable | Required | Description |
|---|---|---|
DISCORD_BOT_TOKEN |
Yes | Discord bot token |
ANTHROPIC_API_KEY |
Yes* | Anthropic API key |
ANTHROPIC_BASE_URL |
No | Proxy URL (overrides default API endpoint) |
ANTHROPIC_AUTH_TOKEN |
No | Auth token for proxy (used instead of API key) |
ANTHROPIC_MODEL |
No | Fallback model name (default: bedrock-claude-opus-5-1m). A /model selection is stored in the DB and takes precedence. |
OPENAI_API_KEY |
No | OpenAI API key for voice message transcription (Whisper) |
ELEVENLABS_API_KEY |
No | ElevenLabs API key for voice coach TTS |
ELEVENLABS_VOICE_ID |
No | ElevenLabs voice ID for voice coach |
COACH_MODEL |
No | LLM model for voice coach brain (default: bedrock-claude-sonnet-4-1m) |
GATEWAY_PORT |
No | Dashboard port (default: 3000) |
GATEWAY_TOKEN |
No | Auth token for dashboard API access |
GATEWAY_PUBLIC_URL |
No | Public URL for the gateway (used for artifact download links) |
SESSION_TTL_HOURS |
No | Session expiry (default: 24) |
DISCORD_WEBHOOK_URL |
No | Webhook for start.sh notifications (deploy, rollback alerts) |
DAYTONA_API_KEY |
No | Daytona API key for sandbox CI during evolution validation (falls back to local if not set) |
DAYTONA_API_URL |
No | Daytona API endpoint (default: https://app.daytona.io/api) |
REFLECTION_CHANNEL_ID |
No | Discord channel for reflection daemon proposals |
REFLECTION_INTERVAL_HOURS |
No | How often the reflection daemon runs (default: 6) |
REFLECTION_LOOKBACK_HOURS |
No | Signal lookback window (default: 24) |
REFLECTION_MIN_SIGNALS |
No | Minimum signals before reflection triggers (default: 3) |
REFLECTION_MODEL |
No | Claude model for reflection analysis (default: same as ANTHROPIC_MODEL) |
VOICE_MODEL |
No | Claude model for voice responses (default: claude-sonnet-4-20250514) |
VOICE_SILENCE_MS |
No | Silence duration to end utterance (default: 800) |
VOICE_MIN_UTTERANCE_MS |
No | Minimum utterance length, skip noise (default: 500) |
VOICE_MAX_TOKENS |
No | Max tokens for voice responses (default: 512) |
VOICE_DEBUG |
No | Voice debug logging (default: on, set 0 to disable) |
VOICE_TTS_STREAM |
No | Streaming TTS for lower TTFB (default: on, set 0 to disable) |
VOICE_TOOLS_MODE |
No | Voice agent tools: full (all tools except evolution) or minimal (memory + conversation history only). Default: full |
VOICE_TTS_VOICE_ID |
No | ElevenLabs voice for the assistant. Falls back to ELEVENLABS_VOICE_ID, then premade "Sarah" |
VOICE_STT_REMOTE_FALLBACK |
No | 1 = fall back to ElevenLabs Scribe when local whisper.cpp is unavailable (default off) |
SDK_IDLE_MS |
No | Session idle timeout before teardown (default: 1800000 = 30 min) |
SDK_TURN_TIMEOUT_MS |
No | Interrupt a turn that runs longer than this (default: 900000 = 15 min) |
SDK_TURN_MAX_COST_USD |
No | Soft cost cap per turn — over it, the usage footer says so. 0 (default) disables the warning |
SDK_ATTACHMENT_MAX_BYTES |
No | Per-file cap on attachments downloaded into the session inbox (default: 26214400 = 25 MB) |
SDK_INBOX_MAX_AGE_MS |
No | How long downloaded attachments are kept before pruning (default: 86400000 = 24 h) |
SDK_ANTHROPIC_API_KEY |
No | Opt into API-key auth for sessions (overrides inherited model auth) |
SDK_ANTHROPIC_MODEL |
No | Override the model used by sessions |
SDK_ANTHROPIC_BASE_URL |
No | Alternate Anthropic-compatible endpoint for sessions (e.g. a local gateway) |
The SDK_* names above are also read under their pre-rename PILOT_* spellings, so an existing .env keeps working.
*Either ANTHROPIC_API_KEY or ANTHROPIC_BASE_URL + ANTHROPIC_AUTH_TOKEN required.
Soul — Bot personality defined in data/SOUL.md. Hot-reloads on file change. Editable via dashboard.
Memory — Hybrid search system. Local markdown files in data/ indexed with SQLite FTS5 (BM25-ranked), plus optional mem9 cloud memory for semantic vector search. Both sources are queried in parallel on every memory_search call. Queries are sanitized for FTS5 compatibility (special characters like hyphens and colons are handled automatically).
Sessions — Per-thread/DM/channel conversation tracking. The context the model reads lives inside its own SDK child process; our messages rows are the durable transcript, archived so get_conversation_history and get_conversation_stats can query across sessions. Ordering is handled by the session itself rather than a lock: a message arriving mid-turn is injected into the running turn (see Agent Runtime), so nothing has to wait for a mutex.
Cron — Scheduled tasks with three schedule types: one-shot (at), interval (every), cron expression (cron). agentTurn jobs run the agent and deliver results inside threads (the agent handles all delivery via tools — no duplicate top-level messages). Each one gets a fresh session in a thread of its own, so a scheduled run starts with clean context and a per-job /cron set-model override actually applies. systemEvent jobs deliver results directly to the configured channel. Auto-disables after 3 consecutive failures. Hot-reloads jobs.json on each tick cycle (up to every 60s) so externally-added jobs are picked up without a restart.
Skills — Modular capabilities defined as SKILL.md files with YAML frontmatter. Install from GitHub URL or upload directly. Uses SDK progressive loading pattern: only skill metadata (name, description, path) is injected into the system prompt; the agent reads full skill content on demand via read_skill tool. Skills can include companion files (scripts, references). Manageable via dashboard and /skills command.
Dashboard — Single-page React app at http://localhost:3000. Pages: Status, Sessions, Channels, Config, Cron, Skills, Artifacts, Evolution, Logs. Real-time message streaming via WebSocket.
Agent Runtime — Every message and every scheduled agentTurn is handled by a Claude Agent SDK session (src/sdk/) — one child process per Discord thread, DM or channel, keyed by target id. The tool loop, context compaction and duplicate-call handling are the SDK's; what this repo owns is the Discord side of it. See Agent Sessions below.
Thread-First Replies — In guild text channels, every bot response creates a thread on the user's message. Bot-created threads don't require @mentions for follow-up. Monitored channels auto-respond to all messages without @mention. DMs bypass threading entirely.
File Attachments — The agent can send files (PDFs, images, HTML, etc.) to Discord channels via the send_file tool. Files up to 25 MB are supported (Discord bot default tier).
Image Support — When the agent's response contains markdown images (), they are automatically extracted and rendered as Discord embeds (for web URLs) or file attachments (for local files). Image markdown is stripped from the text to avoid showing raw URLs.
Voice Messages — Discord voice DMs and audio attachments are automatically detected and transcribed. Backends are tried in order, so audio only leaves the machine as a last resort:
- whisper.cpp (default) — fully local, a native binary plus a GGML model. Needs no Python, pip, or root, which makes it the only local option on hosts with a broken/absent Python packaging stack. One-time setup:
./scripts/setup-whispercpp.sh(requiresgit,cmake,g++,ffmpeg). Override paths withWHISPER_CPP_BIN/WHISPER_CPP_MODEL, thread count withWHISPER_CPP_THREADS(default 4). - NVIDIA NeMo Parakeet — also fully local, but lazily
pip installsnemo_toolkit[asr]on first use, so it requires a working pip. - OpenAI Whisper API — remote fallback, used only if
OPENAI_API_KEYis set.
The transcribed text is passed to the agent as the message content. If every backend is unavailable the user gets a friendly message, while the logs record a combined per-backend diagnostic (e.g. whisper.cpp: binary missing at ...; parakeet: No usable pip ...; openai: not configured). Supports OGG, MP3, WAV, M4A, WebM, FLAC, and other common audio formats.
Voice Assistant — Real-time voice interaction in Discord voice channels. Pipeline: user speaks → opus decode → downsample to 16kHz mono → Silero VAD (ONNX, ~2MB model) detects speech boundaries → local whisper.cpp transcribes → Claude generates concise spoken response (1-3 sentences, no markdown) → ElevenLabs TTS synthesizes audio → bot speaks back. STT runs on-device (same backend as voice-message transcription), so audio never leaves the machine and there is no per-utterance cost; set VOICE_STT_REMOTE_FALLBACK=1 to fall back to ElevenLabs Scribe when whisper.cpp is unavailable. The LLM is Anthropic Claude (default: claude-sonnet-4-20250514, configurable via VOICE_MODEL) with full tool support. Max tokens default 512 (configurable via VOICE_MAX_TOKENS). Tools configurable via VOICE_TOOLS_MODE: full (memory, Discord, bash, file I/O, skills, conversation history — everything except evolution) or minimal (memory + conversation history only). Supports interruptions (cuts off bot when user starts speaking), streaming TTS pipelining (sentence-level), minimum utterance filtering (skips coughs/noise < 500ms), and auto-disconnect after 10 minutes idle. Auto-join mode tracks a configured user and joins/leaves their voice channel automatically. Requires ELEVENLABS_API_KEY.
Voice Coach — AI-powered cycling coach that operates independently of the voice assistant. Runs in a dedicated voice channel and auto-joins when a tracked rider connects. Every 7 seconds, it polls simulated cycling telemetry (power, heart rate, cadence, speed) from a mock server, feeds it to an LLM coach brain (configurable via COACH_MODEL, default: bedrock-claude-sonnet-4-1m), and if the coach has something to say, synthesizes speech via ElevenLabs TTS and plays it in the voice channel. Also listens to the rider's speech via VAD+STT (reusing the voice assistant's receiver/VAD/STT pipeline) and incorporates rider messages into coaching context. The coach persona is a German-accented team sport director on race radio. Requires ELEVENLABS_API_KEY and ELEVENLABS_VOICE_ID.
Artifacts — Persistent file storage system for tracking session inputs and outputs. When files are uploaded to or generated by the agent, they're registered as artifacts with metadata (direction, MIME type, size, Discord URL). Artifacts are stored on disk under data/artifacts/<sessionId>/ and tracked in SQLite. The dashboard Artifacts page provides per-session browsing with download links. API routes at /api/artifacts and /api/artifacts/:sessionId. Uses GATEWAY_PUBLIC_URL for generating download URLs in production.
Agent Sessions — Every Discord message and every scheduled agentTurn is served by a Claude Agent SDK session (src/sdk/): one child process per target, keyed by thread / DM / channel id. A top-level message spawns a thread via the normal createThreadForReply path and the session keys to that thread, so each thread is an isolated session with its own context. Sessions are resumed by SDK session id after a restart, reaped after SDK_IDLE_MS (default 30 min) idle, killed by /stop, and any child that outlives a hard kill is swept at boot by matching its cwd. If a stored session id can no longer be resumed, the session is cleared, whatever the CLI never answered is replayed, and a fresh one starts rather than every later turn failing.
Mid-conversation injection — the SDK prompt is an async iterable, so a message arriving mid-turn is handed to the turn already running instead of queueing behind it. When that happens the bot reacts ↩️ on the message and posts a marker line naming the tool it interrupted, so the transcript shows where the course changed.
Tools — an in-process MCP server (createSdkMcpServer) bridges send_message, send_file, add_reaction, ask_user, the memory tools, the context tools get_channel_history / create_thread / get_conversation_history / get_conversation_stats, the skill tools read_skill / list_skill_files (which honour /skills disable), and the full evolve_* set — so a session self-evolves through the same worktree → PR → CI → auto-merge path, with the plan-approval gate still enforced in code by evolve_start.
No sandbox, by operator choice — permissionMode: 'bypassPermissions' with allowDangerouslySkipPermissions: true: no permission prompts, no canUseTool gate. cwd is the session's own folder under data/sdk/workspace/sessions/ but nothing confines a session to it — paths and shell commands are unrestricted (a session can edit src/**, read .env, run git push) — and the child inherits the full environment (src/sdk/env.ts), so DISCORD_BOT_TOKEN, GH_TOKEN and every provider key are readable with one Bash call. Only convention holds: the system prompt says to route source changes through evolve_* — and since settingSources: ['project'], the workspace's own CLAUDE.md is loaded too, which is where the artifact-placement conventions live ('user' is still omitted, so the operator's ~/.claude settings stay un-inherited). To restore a boundary, filter the child env in env.ts and set permissionMode: 'default' with a canUseTool gate or a PreToolUse hook.
Identity and configuration — SOUL.md, the memory-recall instructions and /caveman are injected into the system prompt from src/shared/prompt-fragments.ts, and a channel prompt (/config set-prompt) goes in under a ## Channel Instructions heading. Both are read at session start, so a change mid-session applies from the next one (the replies say so with a -# 🧪 note) and both fall back to the parent channel, since the session lives on a thread while those commands write to the channel above it. /model (or a per-job cron model) sets the model a new session starts on; SDK_ANTHROPIC_MODEL still wins and a running child keeps the model it started with. Model auth arrives with the inherited environment — set SDK_ANTHROPIC_API_KEY / SDK_ANTHROPIC_BASE_URL to point sessions at a separate key or endpoint.
Per-session folders. Each session owns data/sdk/workspace/sessions/<channel-id>/, or sessions/<parent-id>/threads/<thread-id>/ when the session keys to a thread — nested rather than concatenated, because a thread's snowflake is already globally unique so the parent id buys hierarchy rather than uniqueness. Created on session start with a seeded CLAUDE.md (session id, parent, status, notes) that is never overwritten, since it outlives the CLI transcript.
That folder is the session's cwd, so artifacts land in it by default instead of by convention, attachments download to <session-folder>/inbox/, and CLAUDE.md discovery walks up — the session's file, the workspace one and the repo one all load. Two consequences were handled with it. The CLI keys transcripts by cwd, so a session id stored before this change points at a directory the CLI no longer reads: migrateTranscript() copies (never moves) the .jsonl and its subagent directory into the new location before a resume, and session.ts cross-checks the derived path against transcript_path from the init handshake so a wrong derivation is loud rather than silent. And the orphan sweep compares cwd by root + separator prefix instead of exactly — every session cwd is now below the workspace root, so the old exact match would have swept nothing, while the separator keeps a sibling like <workspace>-old from matching.
Attachments are handed over as paths, not content blocks: an image or document is downloaded to data/sdk/workspace/inbox/<messageId>/, size-capped (SDK_ATTACHMENT_MAX_BYTES, default 25 MB) with sanitised filenames, pruned after 24 h (SDK_INBOX_MAX_AGE_MS), and its absolute path is appended to the message so the session reads it with its own tools.
Output and limits — relayed output goes through a batching queue (src/sdk/relay-queue.ts): tool-progress lines are merged on a short debounce and a 5-sends/5s token bucket waits rather than dropping. Each turn ends with a -# 📊 usage footer (per-turn tokens, cost as the delta of the SDK's cumulative total, latency), a failed tool posts -# ⚠️ <tool> failed, and an idle reap says so in the channel. A wedged turn is interrupted by a watchdog (SDK_TURN_TIMEOUT_MS, default 15 min) rather than killed, so the session stays resumable and you can just say "continue"; SDK_TURN_MAX_COST_USD adds a -# 💸 line when a single turn passes a soft cost cap. Turns are logged like everything else — the user message and one assistant row per turn go into the normal messages tables, so /history, the archive, get_conversation_history and the log viewer all see them — and a failed turn records an error signal for the reflection daemon. /interrupt is the softer sibling of /stop: scoped to the channel or thread it is used in, it calls the SDK's native Query.interrupt(), aborting the running turn but keeping the child and its context, and its reply names the tool that was in flight. It also clears our pending queue; the shipped CLI does not implement cancel_queued, so anything already handed over may still run and is reported back once the CLI advertises interrupt_receipt_v1. An interrupt is not a rollback — files written and commands run stay. /clear, /stop and /restart act on the whole channel scope (the channel plus live sessions in threads under it), because sessions key to threads while the commands are usually typed in the parent. Known limit: no message-edit streaming.
Evolution Engine — The bot can modify its own source code through GitHub pull requests. All changes are isolated in a git worktree at worktrees/<id>/, validated, and submitted as PRs via gh CLI. Validation runs in two modes: Daytona Sandbox CI (preferred) spins up an ephemeral container via @daytona/sdk, clones the branch, runs npm ci, tsc --noEmit, and vitest run in full isolation — providing clean CI without interfering with the running bot; local fallback runs typecheck and tests in the worktree with symlinked node_modules when DAYTONA_API_KEY is not set. The agent has 9 evolution tools: evolve_start, evolve_read, evolve_write, evolve_bash, evolve_propose, evolve_suggest, evolve_cancel, evolve_review, and evolve_merge. The human gate is the build plan, not the diff: evolve_start requires a plan plus plan_approved: true, so the agent must post its plan and get explicit approval before writing any code. After that, evolve_propose validates, opens the PR, merges it (squash), posts a deployment notification thread, and restarts to deploy — fully automatic. Set EVOLUTION_AUTO_MERGE=false to restore manual evolve_review + evolve_merge. If auto-merge fails (merge conflict, GitHub error) the PR is left open and evolve_merge is the fallback. The bot also records ideas for improvements it can't yet make (evolve_suggest). Evolution history is tracked in SQLite and viewable in the dashboard.
Structured Logging — Three SQLite-backed log streams for observability:
- Application log (
application_log): General operational events with level (debug,info,warn,error), category, and optional metadata. - Error log (
error_log): Errors with stack traces, categorized for breakdown analysis. - Tool call log (
tool_call_log): Every agent tool invocation with tool name, input, output, duration, and success/failure status.
All logging functions (appLog(), errorLog(), toolCallLog()) are non-blocking and never throw — errors during log persistence are silently caught. Use createLogger(category) for scoped module loggers. Logs are viewable in the dashboard Logs page and automatically pruned during reflection cycles.
Reflection System — Autonomous self-improvement discovery. Two components:
- Signal collection (
reflection/signals.ts): Passively records events — errors, tool failures, duplicate loop patterns. Never throws, ensuring it can't crash the main pipeline. Auto-prunes signals older than 7 days. - Reflection daemon (
reflection/daemon.ts): Runs on a configurable interval (default: every 6 hours). Gathers signals and structured logs (tool call statistics, error breakdowns, slowest tool calls), builds a structured analysis prompt, calls Claude, and if an improvement is found, records it as an evolution idea and posts a proposal to a Discord channel. Level 1 trust: never auto-implements — always requires human approval.
Watchdog Daemon — Standalone process (src/daemon/index.ts) that spawns the bot, monitors health, handles crash recovery with evolution rollback, and sends Discord webhook notifications. Exit code 100 from the bot triggers a deploy-restart (git pull + rebuild) rather than a simple respawn. Zero imports from the main bot.
Restart — The bot can restart itself via /restart command or automatically after merging an evolution PR. On restart, stale instances are automatically detected and killed to prevent duplicate bots. An idempotent startup script (start.sh) handles deploy: git pull → run migrations → build → start → health check → auto-rollback on failure.
graph TB
subgraph Discord
DU[Discord Users]
DG[Discord Gateway]
end
subgraph Discordclaw
subgraph Bot["Bot (discord.js v14)"]
CL[Client]
MH[Message Handler]
SC[Slash Commands]
end
subgraph Agent["Agent Runtime (Claude Agent SDK)"]
PM[submitToSdkSession]
SP[System Prompt Builder]
TL[SDK child process<br/>+ MCP tool bridge]
end
subgraph Systems
SOUL[Soul System<br/>SOUL.md]
MEM[Memory System<br/>FTS5 + mem9]
SESS[Session Manager<br/>+ Archiving]
CRON[Cron Service]
EVO[Evolution Engine<br/>Self-Modification]
VOICE[Voice Transcription<br/>Whisper API]
REFL[Reflection Daemon<br/>Signal Analysis]
LOG[Structured Logging<br/>App / Error / Tool Calls]
VCOACH[Voice Coach<br/>Cycling AI Coach]
ART[Artifacts<br/>File Storage]
end
subgraph Storage
DB[(SQLite)]
FS[data/ Files]
end
subgraph Gateway["Gateway (Express)"]
API[REST API]
WS[WebSocket]
UI[React Dashboard]
end
end
CLAUDE[Claude API]
OPENAI[OpenAI Whisper API]
ELEVEN[ElevenLabs TTS API]
DAYTONA[Daytona Sandbox CI]
DU <-->|messages + voice| DG
DG <-->|events| CL
CL --> MH
CL --> SC
MH --> SESS
SESS --> DB
MH -->|voice messages| VOICE
VOICE <-->|transcribe| OPENAI
VOICE -->|text| MH
MH --> PM
MH -->|file attachments| ART
PM --> SP
SP --> SOUL
SP --> MEM
PM --> TL
TL <-->|prompt + tool results| CLAUDE
TL -->|memory_search, memory_get| MEM
TL -->|send_message, send_file, add_reaction, create_thread, ask_user| CL
TL -->|evolve_start, evolve_write, evolve_propose auto-merge| EVO
TL -->|tool call logging| LOG
EVO -->|git worktree, gh pr create| GH[GitHub]
EVO -->|sandbox validation| DAYTONA
EVO --> DB
MH -->|log| DB
MH -->|broadcast| WS
LOG --> DB
MEM --> DB
MEM --> FS
SOUL --> FS
ART --> DB
ART --> FS
CRON -->|agentTurn → thread| PM
CRON -->|deliver| CL
CRON --> FS
VCOACH <-->|telemetry + LLM| CLAUDE
VCOACH <-->|TTS| ELEVEN
VCOACH <-->|voice channel| DG
REFL -->|collect signals + logs| DB
REFL -->|analyze| CLAUDE
REFL -->|propose ideas| EVO
API --> DB
API --> SESS
API --> SOUL
API --> MEM
API --> CRON
API --> ART
UI -->|fetch| API
UI -->|stream| WS
sequenceDiagram
participant U as Discord User
participant B as Bot
participant V as Voice Transcription
participant S as Session Manager
participant K as SDK Session (child process)
participant C as Claude
participant M as Memory
participant DB as SQLite
U->>B: @mention, DM, or voice message
B->>B: Filter (bot? mention? monitored? bot thread?)
opt Voice Message Detected
B->>V: transcribeAudio(attachment URL)
V->>V: Download OGG → local whisper.cpp
V-->>B: transcribed text
end
opt Attachments
B->>B: Download to <workspace>/inbox, append absolute paths
end
B->>U: Create thread (top-level messages only)
B->>S: resolveSession(channel, user)
S->>DB: lookup / create session row
B->>DB: log + archive user message
B->>K: submitToSdkSession(threadId, text, modelOverride?)
alt No live session
K->>K: spawn child (cwd data/sdk/workspace, full env, resume stored id)
K->>K: Build system prompt (soul + memory instructions + skills + channel prompt)
else Turn already running
K->>K: Inject into the running turn
K->>U: ↩️ reaction + "-# steered <tool>" marker
end
K->>C: query() — streaming input, bypassPermissions
loop SDK tool loop
C-->>K: tool_use: mcp__discordclaw__memory_search
K->>U: Tool progress line (debounced, 5/5s bucket)
K->>M: searchMemory(query)
M->>DB: FTS5 MATCH query (sanitized)
M-->>K: ranked results
K->>DB: toolCallLog(tool, input, result, duration)
K->>C: tool_result + continue
end
C-->>K: text response + result (usage, cost)
K->>U: Assistant text, then "-# 📊" usage footer
K->>DB: log + archive assistant response (one row per turn)
K->>WS: broadcastLog(entry)
Note over K: Session stays warm — reaped after SDK_IDLE_MS, or on /stop
sequenceDiagram
participant T as Timer Loop
participant CS as Cron Service
participant ST as Cron Store
participant R as Cron Route
participant K as SDK Session
participant D as Discord
T->>CS: tick() — check due jobs
CS->>ST: getJobs() where nextRunAtMs <= now
alt agentTurn payload
CS->>R: planSdkCronRoute(delivery channel, job)
R-->>CS: session channel + needsThread
Note over R: null route → throw, so the run record shows it
CS->>D: ensureThread(job name) if needed
CS->>K: submitToSdkSession(threadId, prompt, job model?)
K-->>CS: fire-and-forget (session posts via tools)
else systemEvent payload
CS->>CS: log event text
opt delivery configured
CS->>D: channel.send(result)
end
end
CS->>ST: updateJobState(lastRun, status)
CS->>ST: appendRunEntry(history)
CS->>CS: computeNextRun() + armTimer()
graph LR
subgraph Browser
UI[React SPA]
end
subgraph Server
API[REST API]
WS[WebSocket /ws/logs]
end
subgraph Data
DB[(SQLite)]
FS[data/ files]
CRON[Cron Store]
end
UI -->|GET /api/status| API
UI -->|GET/DELETE /api/sessions| API
UI -->|GET/PUT /api/channels| API
UI -->|GET/PUT /api/soul| API
UI -->|GET/PUT /api/memory| API
UI -->|CRUD /api/cron| API
UI -->|GET /api/evolutions| API
UI -->|GET /api/artifacts| API
UI -->|GET /api/logs/*| API
UI <-->|real-time logs| WS
API --> DB
API --> FS
API --> CRON
The voice assistant is the most complex real-time data flow in the system. It processes live audio from Discord voice channels through a multi-stage pipeline: audio capture → speech detection → transcription → AI reasoning → speech synthesis → playback — all while handling interruptions and concurrent user streams.
graph LR
subgraph "🎤 Capture"
A1[Discord Opus Stream]
A2[Opus Decode]
A3[Downsample<br/>48kHz → 16kHz mono]
end
subgraph "🧠 Detect"
B1[Silero VAD<br/>ONNX v4]
B2{Speech<br/>prob > 0.5?}
B3[Silence Timer<br/>800ms]
end
subgraph "📝 Understand"
C1[Concatenate PCM<br/>Chunks]
C2[Encode WAV<br/>16kHz mono]
C3[whisper.cpp<br/>local, on-device]
end
subgraph "🤖 Think"
D1[Voice Agent<br/>Claude]
D2[Tools<br/>memory / discord / bash / skills]
end
subgraph "🔊 Speak"
E1[ElevenLabs<br/>TTS]
E2[AudioPlayer<br/>discord.js]
end
A1 --> A2 --> A3
A3 -->|Float32 frames| B1
A3 -->|Int16 chunks| C1
B1 --> B2
B2 -->|Yes| B3
B2 -->|No speech| B1
B3 -->|Timeout| C1
C1 --> C2 --> C3
C3 -->|text| D1
D1 <-->|tool calls| D2
D1 -->|response text| E1
E1 -->|mp3 stream| E2
sequenceDiagram
participant U as Discord User
participant DC as Discord Gateway
participant CMD as /join Command
participant CONN as connection.ts
participant RECV as receiver.ts
participant VAD as vad.ts<br/>(Silero ONNX)
participant ORCH as index.ts<br/>(Orchestrator)
participant STT as stt.ts<br/>(local whisper.cpp)
participant AGT as agent.ts<br/>(Claude)
participant MEM as Memory System
participant TTS as tts.ts<br/>(ElevenLabs)
participant AP as AudioPlayer
Note over CMD,CONN: ── Initialization ──
U->>CMD: /join
CMD->>CONN: joinChannel(voiceChannel)
CONN->>DC: joinVoiceChannel(channelId, guildId)
DC-->>CONN: VoiceConnectionStatus.Ready
CONN-->>CMD: VoiceConnection
CMD->>ORCH: startVoice(channel)
ORCH->>AP: connection.subscribe(audioPlayer)
ORCH->>ORCH: Start idle timer (10 min)
Note over U,AP: ── User Speaks ──
U->>DC: Speaks into mic
DC->>ORCH: speaking:start(userId)
ORCH->>ORCH: Initialize UserUtteranceState
ORCH->>RECV: subscribeToUser(connection, userId)
RECV->>RECV: receiver.subscribe(userId, AfterSilence 5s)
Note over RECV: Auto-detect mono vs stereo<br/>on first opus packet
loop Every 20ms Opus Packet
DC->>RECV: opus packet (48kHz)
RECV->>RECV: decodeOpus(packet, channels)
RECV->>ORCH: onRawPcm(Int16Array)
RECV->>RECV: downsampleToMono16k(pcm48k)
RECV->>ORCH: onFrame(Float32Array)
ORCH->>ORCH: Accumulate into VAD frame buffer
opt VAD frame buffer full (480 samples = 30ms)
ORCH->>VAD: process(frame)
VAD-->>ORCH: probability [0.0-1.0]
alt prob > 0.5 (speech detected)
Note over ORCH: If first speech frame:<br/>set isSpeaking=true,<br/>start buffering rawChunks
opt Bot is currently playing
ORCH->>AP: stop() — interrupt!
Note over AP: ⚡ Playback cut off
end
ORCH->>ORCH: Reset silence timer
ORCH->>ORCH: Buffer rawChunks += pcm
else prob ≤ 0.5 (silence) AND isSpeaking
opt No active silence timer
ORCH->>ORCH: Start silence timer (800ms)
end
end
end
end
Note over U,AP: ── Utterance Complete ──
ORCH->>ORCH: Silence timer fires → onUtteranceComplete()
alt utterance < 500ms
ORCH->>ORCH: Discard (too short — noise/cough)
else already processing another utterance
ORCH->>ORCH: Skip (no queue yet)
else valid utterance
ORCH->>ORCH: processing = true
Note over ORCH,STT: Step 1/5: Prepare Audio
ORCH->>ORCH: Concatenate rawChunks → Int16Array
ORCH->>RECV: downsampleToMono16kInt16(rawPcm, channels)
RECV-->>ORCH: mono 16kHz Int16Array
ORCH->>VAD: reset() — clear LSTM hidden states
Note over ORCH,STT: Step 2/5: Speech-to-Text
ORCH->>STT: transcribe(mono16kPcm)
STT->>STT: write 16kHz mono WAV to tmp
STT->>STT: spawn whisper-cli<br/>(local ggml model, ~1.65s)
STT-->>ORCH: transcribed text
alt empty transcription
ORCH->>ORCH: Skip — nothing detected
else has text
Note over ORCH,AGT: Step 3/5: AI Response
ORCH->>ORCH: getUserDisplayName(userId)
ORCH->>AGT: processVoiceUtterance(text, userName)
AGT->>AGT: Build system prompt<br/>(voice rules + soul brief + time + speaker)
AGT->>AGT: Append to ephemeral voiceHistory (max 10 turns)
AGT->>AGT: Claude messages.create()<br/>(VOICE_MODEL, 512 max_tokens, tools)
opt Claude requests tool_use (up to 5 rounds)
AGT->>MEM: handleVoiceTool(name, input)
MEM-->>AGT: tool result
AGT->>AGT: Follow-up Claude call
end
AGT-->>ORCH: response text (1-3 sentences, no markdown)
Note over ORCH,AP: Step 4/5: Text-to-Speech
ORCH->>TTS: synthesize(responseText)
TTS->>TTS: POST /v1/text-to-speech/{voiceId}<br/>(model: eleven_turbo_v2_5)
TTS-->>ORCH: mp3 audio buffer
Note over ORCH,AP: Step 5/5: Playback
ORCH->>AP: play(audioResource)
AP-->>ORCH: AudioPlayerStatus.Idle
ORCH->>ORCH: processing = false
end
end
Note over U,AP: ── Cleanup ──
alt Idle timeout (10 min no activity)
ORCH->>ORCH: stopVoice()
ORCH->>AGT: clearVoiceHistory()
ORCH->>CONN: leaveChannel()
CONN->>DC: connection.destroy()
else User runs /leave
CMD->>ORCH: stopVoice()
ORCH->>AGT: clearVoiceHistory()
ORCH->>CONN: leaveChannel()
else Opus stream ends
RECV->>ORCH: onStreamEnd()
ORCH->>ORCH: Complete pending utterance
ORCH->>ORCH: Clean up userState<br/>(re-subscribe on next speaking:start)
end
graph TD
subgraph "Discord Input"
I1["Opus packets<br/>48kHz, mono or stereo<br/>20ms frames"]
end
subgraph "Decode (receiver.ts)"
D1["OpusEncoder.decode()<br/>→ PCM Int16<br/>48kHz, 960 or 1920 samples/frame"]
end
subgraph "Dual Output Path"
direction LR
P1["<b>VAD Path</b><br/>downsampleToMono16k()<br/>→ Float32 [-1.0, 1.0]<br/>16kHz mono, 320 samples/frame"]
P2["<b>Buffer Path</b><br/>Raw Int16 chunks<br/>48kHz, original channels<br/>accumulated while speaking"]
end
subgraph "STT Preparation"
S1["Concatenate raw chunks<br/>→ single Int16Array"]
S2["downsampleToMono16kInt16()<br/>→ Int16, 16kHz mono"]
S3["wavHeader() + PCM<br/>→ temp WAV file<br/>44-byte header + PCM data"]
end
subgraph "STT Backend"
A1["whisper.cpp (local)<br/>spawn whisper-cli -m ggml<br/>→ stdout transcript"]
end
subgraph "TTS Output"
T1["ElevenLabs<br/>JSON POST { text, model_id }<br/>→ mp3 audio"]
end
subgraph "Playback"
PL1["Readable.from(mp3Buffer)<br/>→ createAudioResource()<br/>StreamType.Arbitrary"]
end
I1 --> D1
D1 --> P1
D1 --> P2
P1 -->|"30ms frames"| VAD["Silero VAD<br/>ONNX inference"]
P2 -->|"on utterance complete"| S1
S1 --> S2 --> S3
S3 --> A1
A1 -->|"text"| Claude["Claude<br/>→ spoken response"]
Claude -->|"text"| T1
T1 --> PL1
PL1 --> Speaker["🔊 Discord Voice Channel"]
stateDiagram-v2
[*] --> Listening: speaking:start event
Listening --> SpeechDetected: VAD prob > 0.5
Listening --> Listening: VAD prob ≤ 0.5
SpeechDetected --> SpeechDetected: VAD prob > 0.5<br/>(reset silence timer)
SpeechDetected --> SilenceTimer: VAD prob ≤ 0.5<br/>(start 800ms timer)
SilenceTimer --> SpeechDetected: VAD prob > 0.5<br/>(cancel timer)
SilenceTimer --> UtteranceComplete: Timer expires
UtteranceComplete --> Discarded: duration < 500ms
UtteranceComplete --> Skipped: already processing
UtteranceComplete --> Pipeline: valid utterance
Discarded --> Listening
Skipped --> Listening
Pipeline --> STT: transcribe audio
STT --> AgentThink: text received
STT --> Listening: empty transcription
AgentThink --> TTS: response generated
TTS --> Playing: audio synthesized
Playing --> Listening: playback complete
state "Interruption" as INT {
[*] --> CheckPlaying
CheckPlaying --> StopPlayer: bot is playing
StopPlayer --> [*]: audioPlayer.stop()
}
SpeechDetected --> INT: new speech starts\nwhile bot playing
INT --> SpeechDetected
Listening --> [*]: stream ends / leave / idle timeout
graph TB
subgraph "voice/index.ts — Orchestrator"
INIT[initVoice / startVoice / stopVoice]
STATE["Per-user state management<br/>UserUtteranceState map"]
PIPELINE["5-step pipeline orchestration<br/>Audio → STT → Agent → TTS → Play"]
INTERRUPT["Interruption handling<br/>audioPlayer.stop on new speech"]
IDLE["Idle timeout (10 min)<br/>auto-disconnect"]
end
subgraph "voice/connection.ts"
JOIN["joinChannel(VoiceBasedChannel)<br/>joinVoiceChannel + entersState(Ready)"]
LEAVE["leaveChannel()<br/>connection.destroy()"]
LIFECYCLE["Connection state tracking<br/>Disconnected / Destroyed handlers"]
end
subgraph "voice/receiver.ts"
SUBSCRIBE["subscribeToUser()<br/>opus stream subscription"]
DECODE["decodeOpus(packet, channels)<br/>@discordjs/opus"]
DETECT["Auto-detect mono vs stereo<br/>first-packet heuristic"]
DS_FLOAT["downsampleToMono16k()<br/>48kHz Int16 → 16kHz Float32"]
DS_INT["downsampleToMono16kInt16()<br/>48kHz Int16 → 16kHz Int16"]
end
subgraph "voice/vad.ts"
VAD_INIT["SileroVAD.init()<br/>Load ONNX model (v4, ~2MB)"]
VAD_PROC["process(frame)<br/>480 samples → probability"]
VAD_RESET["reset()<br/>Clear LSTM h/c states"]
end
subgraph "voice/stt.ts"
WAV["encodeWav(samples, sampleRate)<br/>44-byte header + PCM<br/>(remote fallback upload only)"]
TRANSCRIBE["transcribe(pcm16kMono)<br/>→ local whisper.cpp"]
end
subgraph "voice/agent.ts"
VOICE_PROMPT["Voice system prompt<br/>1-3 sentences, no markdown"]
HISTORY["Ephemeral voice history<br/>max 10 turns"]
PROCESS["processVoiceUtterance(text, userName)<br/>Claude + tools (up to 5 rounds)"]
CLEAR["clearVoiceHistory()<br/>reset on disconnect"]
end
subgraph "voice/autoJoin.ts"
AUTOJOIN["Auto-join/leave voice channels<br/>tracks a configured user"]
end
subgraph "voice/tts.ts"
SYNTH["synthesize(text)<br/>POST to ElevenLabs<br/>→ mp3 buffer"]
end
INIT --> JOIN
INIT --> SUBSCRIBE
INIT --> VAD_INIT
STATE --> PIPELINE
PIPELINE --> DS_INT
PIPELINE --> TRANSCRIBE
PIPELINE --> PROCESS
PIPELINE --> SYNTH
SUBSCRIBE --> DECODE
DECODE --> DETECT
DECODE --> DS_FLOAT
PROCESS --> VOICE_PROMPT
PROCESS --> HISTORY
IDLE --> LEAVE
IDLE --> CLEAR
INTERRUPT -.->|"audioPlayer.stop()"| SYNTH
AUTOJOIN --> INIT
sequenceDiagram
participant U as Discord User
participant A as Agent
participant E as Evolution Engine
participant W as Worktree
participant S as Daytona Sandbox
participant GH as GitHub
participant DC as Deploy Channel
U->>A: "Add feature X"
A->>U: Build plan (files, changes, risks)
U->>A: "yes" (plan approved)
A->>E: evolve_start(reason, plan, plan_approved)
E->>W: git worktree add worktrees/<id>/
E->>W: symlink node_modules
E-->>A: evolution ID + branch
loop Make Changes
A->>W: evolve_write(path, content)
A->>W: evolve_read(path)
A->>W: evolve_bash(command)
end
A->>E: evolve_propose(summary)
alt DAYTONA_API_KEY set
E->>S: Create ephemeral sandbox
S->>S: git clone branch
S->>S: npm ci (clean install)
S->>S: tsc --noEmit
S->>S: vitest run
S-->>E: pass/fail results
E->>S: Destroy sandbox
else Local fallback
E->>W: npm run typecheck
E->>W: vitest run (120s timeout)
end
E->>W: git add + commit
E->>GH: git push + gh pr create
E->>E: git worktree remove
E-->>A: PR URL
A->>U: "PR created: github.com/.../pull/N"
U->>A: "Show me the PR"
A->>E: evolve_review(id)
E->>GH: gh pr diff
E-->>A: summary + diff
A->>U: Shows changes for review
U->>A: "Merge it"
A->>E: evolve_merge(id)
E->>GH: gh pr merge --squash
E->>DC: Create deployment thread
E->>E: triggerRestart()
E->>E: start.sh: git pull → migrate → build → start
E->>E: Health check ✓ (or auto-rollback)
discordclaw/
├── src/
│ ├── index.ts # Entry point: start all systems, kill stale instances on restart
│ ├── restart.ts # Shared restart trigger — avoids circular deps
│ ├── bot/ # Discord bot (discord.js v14)
│ │ ├── client.ts # Client setup, intents, event routing, DM raw fallback
│ │ ├── messages.ts # Message pipeline: filter → voice transcribe → thread create → session dispatch
│ │ └── commands.ts # Slash commands: /ping /help /config /clear /soul /skills /cron /caveman /restart /stop /interrupt /join /leave
│ ├── agent/ # Tool definitions + session bookkeeping (bridged into SDK sessions)
│ │ ├── tools.ts # Discord tools (send_message, send_file, add_reaction, get_channel_history, create_thread, ask_user)
│ │ ├── questions.ts # Pending ask_user questions (embed + buttons, answer routing)
│ │ ├── dangerous-tools.ts # Powerful tools: bash, read_file, write_file
│ │ ├── tool-paths.ts # Workspace path resolution for the file tools
│ │ └── sessions.ts # Per-thread/DM session tracking + TTL + message archiving
│ ├── audio/ # Voice message handling
│ │ ├── transcribe.ts # Backend chain: whisper.cpp → Parakeet → OpenAI, with per-backend diagnostics
│ │ └── whispercpp-transcribe.ts # Local whisper.cpp backend (native binary, no Python)
│ ├── voice/ # Voice assistant (real-time voice channel)
│ │ ├── connection.ts # Join/leave voice channels, VoiceConnection lifecycle
│ │ ├── receiver.ts # Opus decode, auto-detect mono/stereo, downsample 48kHz → 16kHz mono
│ │ ├── vad.ts # Silero VAD wrapper (ONNX runtime, v4 model)
│ │ ├── stt.ts # STT — local whisper.cpp, optional ElevenLabs Scribe fallback
│ │ ├── tts.ts # ElevenLabs TTS client (full synthesis + HTTP chunked streaming)
│ │ ├── agent.ts # Voice-optimized LLM (default: Sonnet, configurable via VOICE_MODEL, 512 tokens, spoken style)
│ │ ├── autoJoin.ts # Auto-join/leave voice channels when tracked user joins/leaves
│ │ └── index.ts # Orchestrator: wires receive → VAD → STT → agent → TTS → play
│ ├── voice-coach/ # AI cycling coach (independent voice pipeline)
│ │ ├── index.ts # Orchestrator: auto-join on rider connect, 7s poll loop, session lifecycle
│ │ ├── coach-brain.ts # LLM coach decision engine (COACH_MODEL, team radio persona)
│ │ ├── elevenlabs-tts.ts # ElevenLabs TTS synthesis client
│ │ ├── player.ts # Voice channel connection + audio playback for coach
│ │ ├── listener.ts # Rider speech capture via VAD+STT, queued for coach brain
│ │ └── mock-server.ts # Simulated cycling telemetry (power, HR, cadence, speed)
│ ├── sdk/ # The agent runtime: one Claude Agent SDK session per thread/DM/channel
│ │ ├── session.ts # Per-channel SDK session, streaming-input queue, relay, usage footer, resume self-heal, idle reap, orphan sweep
│ │ ├── relay-queue.ts # Batching send queue: debounced merge + 5-per-5s token bucket (no dropped lines)
│ │ ├── attachments.ts # Downloads Discord attachments into <session>/inbox and hands over paths
│ │ ├── session-dirs.ts # Per-session folders + cwd, CLAUDE.md scaffolding, transcript relocation
│ │ ├── bridge.ts # In-process MCP server re-exporting Discord/memory/skill/history/evolve_* tools
│ │ ├── cron-route.ts # Routes a cron agentTurn to a session: fresh thread, the thread itself, or the DM
│ │ ├── env.ts # Child-process env: full inheritance, secrets included (no allowlist)
│ │ └── index.ts # Public surface
│ ├── artifacts/ # Persistent file storage for session I/O
│ │ └── index.ts # Register, query, and serve artifacts (disk + SQLite metadata)
│ ├── logging/ # Structured logging system
│ │ ├── logger.ts # Core: appLog(), errorLog(), toolCallLog(), createLogger(category) factory
│ │ └── queries.ts # Read-side: getAppLogs(), getErrorLogs(), getToolCallLogs(), stats, pruning
│ ├── skills/ # Skills management (SDK pattern)
│ │ ├── types.ts # Skill, SkillMeta, SkillSource types
│ │ ├── store.ts # Filesystem-based discovery + per-skill .meta.json
│ │ ├── service.ts # CRUD, GitHub install, prompt generation, file watcher
│ │ └── tools.ts # read_skill + list_skill_files tool definitions
│ ├── soul/
│ │ └── soul.ts # Load SOUL.md, file watcher, hot-reload
│ ├── memory/
│ │ ├── memory.ts # File discovery, FTS5 indexing, BM25 search, query sanitization
│ │ ├── mem9.ts # mem9 cloud memory API client (semantic vector search)
│ │ └── tools.ts # memory_search + memory_get + mem9_store/update/delete tool definitions
│ ├── shared/ # Utilities shared across the runtime, the voice agents and the slash commands
│ │ ├── paths.ts # Project root resolution
│ │ ├── format.ts # Token/duration formatting for the -# usage footer
│ │ ├── prompt-fragments.ts # Memory-recall + caveman prompt text (system prompt + slash commands)
│ │ ├── anthropic.ts # Anthropic SDK client factory
│ │ ├── discord-utils.ts # Channel/guild helpers
│ │ └── conversation-history.ts # Cross-session message loading + conversation history tools
│ ├── cron/
│ │ ├── types.ts # Job, schedule, payload, delivery types
│ │ ├── store.ts # JSON persistence + JSONL run history + hot-reload
│ │ └── service.ts # Timer loop, execution, retry, auto-disable, thread-only delivery for agentTurn
│ ├── reflection/ # Autonomous self-improvement
│ │ ├── signals.ts # Passive signal collection (errors, tool failures, duplicate loops)
│ │ └── daemon.ts # Periodic analysis, idea generation, channel proposals
│ ├── evolution/ # Self-evolution system
│ │ ├── engine.ts # Git worktree lifecycle, PR creation via gh CLI, deployment notifications
│ │ ├── sandbox.ts # Daytona sandbox CI — ephemeral container validation for evolution branches
│ │ ├── log.ts # Evolution SQLite table + CRUD
│ │ ├── tools.ts # Agent tools: evolve_start/read/write/bash/propose/suggest/cancel/review/merge
│ │ └── health.ts # /api/health endpoint for start.sh
│ ├── daemon/ # Watchdog daemon (standalone process)
│ │ └── index.ts # Spawns bot, health monitoring, crash recovery, evolution rollback
│ ├── db/
│ │ └── index.ts # SQLite schema, migrations, query helpers
│ └── gateway/
│ ├── server.ts # Express + WebSocket server
│ ├── api.ts # REST API (status, sessions, channels, config, soul, memory, cron, skills, evolutions, logs)
│ ├── artifacts.ts # Artifact API routes (/api/artifacts, /api/artifacts/:sessionId, file serving)
│ └── ui/ # React SPA (Vite)
│ ├── App.tsx # Layout, routing, shared styles
│ ├── shared.ts # Shared utilities (colors, apiFetch, formatters) — breaks circular deps
│ └── pages/ # Status, Sessions, Channels, Config, Cron, Skills, Artifacts, Evolution, Logs
├── tests/
│ └── integration/ # Integration tests (vitest)
│ └── boot.test.ts # Critical boot path: DB, Soul, Memory, Skills, Images, Tools
├── data/ # Runtime data (gitignored)
│ ├── discordclaw.db # SQLite database
│ ├── SOUL.md # Bot personality
│ ├── MEMORY.md # Long-term memory
│ ├── memory/ # Daily memory notes
│ ├── artifacts/ # Stored artifacts organized by session ID
│ ├── cron/ # Job store + run history (jobs.json gitignored, seed file tracked)
│ ├── skills/ # Installed skills (SKILL.md + companion files)
│ ├── models/ # ML models (Silero VAD ONNX, gitignored)
│ └── .migrations/ # Marker files for completed migrations
├── migrations/ # Idempotent migration scripts (run by start.sh)
├── start.sh # Production startup: pull → migrate → build → start → health check
├── .env # DISCORD_BOT_TOKEN, ANTHROPIC_* config, OPENAI_API_KEY
├── package.json
├── tsconfig.json
└── vite.config.ts