Multi-bot Telegram platform backed by Claude CLI — each bot gets its own persona, MCP tools, and permissions, all running in a single process with shared DB, dashboard, and scheduler.
- Multi-Bot Architecture — Multiple Telegram bots in one process, each with isolated persona, MCP tools, and Claude CLI history
- Multiple AI Connectors — Claude CLI, GitHub Copilot SDK, or any OpenAI-compatible API (Ollama, LM Studio, vLLM) — configurable per bot
- Semantic Memory — Automatically extracts and recalls facts from conversations using local embeddings (Transformers.js) and hybrid search (FTS + pgvector)
- Goal Tracking — Detects goals/commitments/deadlines from conversations, injects them into prompt context, and proactively sends reminders and check-ins
- Scheduled Tasks — Cron-style or interval-based recurring tasks detected from conversation ("remind me every morning at 8") — supports reminders, AI-generated briefings, and custom prompts
- Proactive Watchers — Background monitors (email via Gmail MCP) with quiet hours and dedup
- Voice — Speech-to-text (whisper-cli) and text-to-speech (macOS say + ffmpeg) with mirror mode (voice in → voice + text out)
- Request Tracing — Full request lifecycle tracing with MCP tool call tracking (which tools, how long each took), waterfall visualization in the dashboard
- Live Dashboard — Hono web server with SSE real-time activity feed, stats, goals, tasks, memories, and traces panels
- Local-first — All data stays on your machine (PostgreSQL via Docker, local embeddings, no cloud dependencies beyond Telegram and Claude API)
- Bun runtime
- Docker (for PostgreSQL)
- Claude CLI installed and authenticated
- whisper-cpp (optional, for voice:
brew install whisper-cpp) - ffmpeg (optional, for voice:
brew install ffmpeg)
-
Install dependencies:
bun install
-
Start the database and apply schema:
bun run db:up # Start Postgres via Docker bun run db:migrate:baseline # Mark existing migrations as applied
On first start, Docker automatically applies
db/init.sql(the full consolidated schema). The baseline command records all migrations as applied so future migrations run cleanly. -
Configure environment:
cp .env.example .env
Edit
.envwith your values (see Configuration below). -
Set up your first bot:
mkdir -p bots/jarvis/.claude
- Create
bots/jarvis/CLAUDE.mdwith the bot's persona - Optionally add
bots/jarvis/.mcp.json(MCP tools) andbots/jarvis/.claude/settings.local.json(permissions)
- Create
-
Start:
bun run dev # Development with file watching bun run start # Production
| Variable | Required | Default | Description |
|---|---|---|---|
DATABASE_URL |
Yes | — | Postgres connection string |
DASHBOARD_PORT |
No | 3010 |
Web dashboard port |
CLAUDE_TIMEOUT_MS |
No | 120000 |
Claude response timeout in ms |
CLAUDE_MODEL |
No | sonnet |
Claude model for main responses |
WHISPER_MODEL_PATH |
No | ./models/ggml-base.en.bin |
Path to whisper-cpp model file |
SCHEDULER_INTERVAL_MS |
No | 60000 |
Unified scheduler tick interval in ms |
SCHEDULER_ENABLED |
No | true |
Enable/disable unified scheduler |
| Variable | Required | Description |
|---|---|---|
TELEGRAM_BOT_TOKEN_<NAME> |
Yes | Bot token from @BotFather (e.g. TELEGRAM_BOT_TOKEN_JARVIS) |
TELEGRAM_ALLOWED_USER_IDS_<NAME> |
Yes | Comma-separated Telegram user IDs (e.g. TELEGRAM_ALLOWED_USER_IDS_JARVIS) |
┌──────────────────────────────────────┐
│ Single muninn process │
│ │
Telegram user A ───►│ Bot 1 (Jarvis) │
│ → Claude CLI (cwd: bots/jarvis) │
│ │
Telegram user B ───►│ Bot 2 (Team Assistant) │
│ → Copilot SDK │
│ │
Slack user C ──────►│ Bot 3 (Local) │
│ → Ollama / LM Studio (openai API) │
│ │
│ Shared: DB, Dashboard, Scheduler │
└──────────────────────────────────────┘
bots/
├── jarvis/ ← example bot (included)
│ ├── CLAUDE.md ← persona + rules
│ ├── config.json ← connector, model, timeout overrides
│ ├── .mcp.json ← Gmail, Calendar MCPs
│ └── .claude/
│ └── settings.local.json ← tool permissions
├── your-bot/ ← add your own here
│ └── ...
Each bot folder is set as cwd when spawning Claude CLI. This means Claude CLI automatically:
- Reads
CLAUDE.mdas project instructions (persona) - Discovers
.mcp.json(MCP tool servers) - Discovers
.claude/settings.local.json(tool permissions) - Stores conversation history in
.claude/within the bot folder
This keeps bot sessions completely isolated from each other and from interactive dev sessions in the project root.
Each bot selects its AI backend via connector in bots/<name>/config.json. Three connectors are available:
| Connector | Value | Description |
|---|---|---|
| Claude CLI | "claude-cli" |
Spawns claude -p as subprocess (default) |
| Copilot SDK | "copilot-sdk" |
GitHub Copilot SDK with shared JSON-RPC client |
| OpenAI-compat | "openai-compat" |
Any OpenAI-compatible API (Ollama, LM Studio, vLLM) |
All fields are optional — falls back to global .env values:
{
"connector": "claude-cli",
"model": "claude-sonnet-4-6",
"thinkingMaxTokens": 16000,
"timeoutMs": 180000,
"baseUrl": "http://localhost:11434/v1"
}| Field | Type | Default | Description |
|---|---|---|---|
connector |
string | "claude-cli" |
AI backend: "claude-cli", "copilot-sdk", or "openai-compat" |
model |
string | CLAUDE_MODEL env |
Model name (e.g. "claude-sonnet-4-6", "qwen3:32b", "llama3.1") |
thinkingMaxTokens |
number | CLI default | Max thinking tokens. For openai-compat: used as max_tokens. |
timeoutMs |
number | CLAUDE_TIMEOUT_MS env |
Response timeout in ms |
baseUrl |
string | — | Base URL for OpenAI-compatible API (required for openai-compat) |
showWaterfall |
boolean | true |
Show request progress waterfall overlay in web chat |
The default connector. Spawns Claude Code in headless mode with --output-format stream-json --verbose. Each bot's folder is used as cwd, so Claude auto-discovers persona, MCP tools, and settings.
{
"model": "claude-opus-4-6",
"thinkingMaxTokens": 40000,
"timeoutMs": 300000
}No connector field needed — defaults to "claude-cli".
Uses GitHub Copilot SDK with a shared singleton client. MCP tools from .mcp.json are converted to SDK format.
{
"connector": "copilot-sdk",
"model": "claude-sonnet-4-6",
"thinkingMaxTokens": 16000,
"timeoutMs": 180000
}Calls any OpenAI-compatible API endpoint. Includes a built-in agent loop with MCP tool execution — loads tools from .mcp.json, sends them as OpenAI tools parameter, and executes tool calls against MCP servers in a multi-turn loop.
Supports thinking/reasoning tokens from Qwen3 and other models (both reasoning field and <think> tag stripping).
-
Start Ollama with a model:
ollama pull qwen3:32b ollama serve # default port 11434 -
Create
bots/local/config.json:{ "connector": "openai-compat", "model": "qwen3:32b", "baseUrl": "http://localhost:11434/v1", "thinkingMaxTokens": 8192, "timeoutMs": 300000 } -
Create
bots/local/CLAUDE.mdwith the bot persona. -
Add env vars:
TELEGRAM_BOT_TOKEN_LOCAL=<token> TELEGRAM_ALLOWED_USER_IDS_LOCAL=123456
-
Restart — the bot auto-discovers and connects via Ollama.
{
"connector": "openai-compat",
"model": "meta-llama/llama-3.1-8b-instruct",
"baseUrl": "http://localhost:1234/v1",
"thinkingMaxTokens": 4096,
"timeoutMs": 120000
}baseUrlis required foropenai-compat— the connector has no default endpoint- MCP tools from
.mcp.jsonare automatically loaded and sent as OpenAI-format tools - Agent loop supports up to 10 tool-call turns per request
- Empty responses are retried up to 3 times (handles LM Studio cold starts)
- Set
OPENAI_API_KEYenv var if the endpoint requires authentication
The dashboard API exposes bot connector configs:
curl http://localhost:3010/api/bots/configReturns connector type, model, timeout, and base URL for each discovered bot.
See
docs/examples/jira-assistant/for a complete team bot example with Serena code search and Copilot SDK connector.
| Path | Purpose |
|---|---|
bots/<name>/ |
Per-bot config: persona, MCP, permissions, CLI history |
src/bots/config.ts |
Bot auto-discovery from bots/ directory |
src/index.ts |
Entrypoint — inits DB, discovers bots, starts all + dashboard + scheduler |
src/bot/ |
Telegram handlers (text, voice), auth middleware, HTML formatting |
src/ai/ |
Claude executor (stream-json + tool tracking), prompt builder, local embeddings |
src/memory/ |
Async memory extraction from conversations |
src/goals/ |
Goal detection (async Claude Haiku) |
src/scheduler/ |
Unified scheduler (scheduled tasks + goal reminders + watchers), shared Haiku executor |
src/watchers/ |
Proactive outreach — email watcher (Haiku + Gmail MCP), quiet hours |
src/db/ |
Postgres CRUD — messages, memories, goals, scheduled tasks, activity, watchers, traces |
src/tracing/ |
Request tracing with span hierarchy and MCP tool call child spans |
src/dashboard/ |
Hono web server with SSE activity feed, traces waterfall + REST APIs |
src/voice/ |
STT (whisper-cli) + TTS (macOS say + ffmpeg) |
-
Create the bot folder:
mkdir -p bots/mybot/.claude
-
Write the persona in
bots/mybot/CLAUDE.md -
Optionally add MCP tools in
bots/mybot/.mcp.jsonand permissions inbots/mybot/.claude/settings.local.json -
Add env vars to
.env:TELEGRAM_BOT_TOKEN_MYBOT=<token from @BotFather> TELEGRAM_ALLOWED_USER_IDS_MYBOT=123456
-
Restart — the bot is auto-discovered and connects to Telegram
| Command | Description |
|---|---|
/start |
Confirms the bot is online |
/watchers |
List all active watchers with status, interval, last run, and filter |
/watch <type> [filter] |
Create a new watcher. Types: email, calendar, github, news, goal. Example: /watch email from:github.com |
/unwatch <name|id> |
Remove a watcher by name or short ID |
/quiet [start-end|off] |
View, set, or disable quiet hours (e.g. /quiet 22-08) |
/topic [name] |
Show current topic, or switch to (and create) a named topic. Example: /topic work |
/topics |
List all topics with message counts and last activity |
/deltopic <name> |
Delete a topic (cannot delete main). Messages are preserved. |
Any other text or voice message is forwarded to Claude for a conversational response.
Each user+bot pair can have multiple named conversation threads (topics). Only chat history is isolated per thread — memories, goals, and scheduled tasks are shared across all threads.
- First message auto-creates a
mainthread (backward compatible with existing conversations) /topic workswitches to the "work" thread, creating it if needed/topicwith no argument shows the current thread and lists all threads/deltopic workdeletes a thread and switches back tomain- Thread names are case-insensitive, max 50 characters
- Pre-migration messages (before threads existed) are visible only in the
mainthread
GET /— Live activity dashboard (HTML)GET /api/activity— Recent activity events + statsGET /api/messages/:userId— Conversation history for a userGET /api/goals/:userId— Active goals for a userGET /api/scheduled-tasks/:userId— Scheduled tasks for a userGET /api/events— SSE stream for real-time activity updatesGET /traces— Traces dashboard with waterfall view (HTML)GET /api/traces— Recent traces (supports?bot=,?name=,?limit=,?offset=)GET /api/traces/:traceId— Span tree for a single traceGET /api/trace-stats— Trace statistics (24h counts, avg duration, errors)GET /api/trace-filters— Available filter options (bot names, trace types)GET /api/prompts/:traceId— Prompt snapshot for a trace
Browser-based chat interface for testing bots without Telegram/Slack tokens. Always available at /chat — no special mode needed. Any bot with a CLAUDE.md appears in the chat UI, even without platform tokens.
bun run dev # Full app — chat at http://localhost:3010/chat
bun run dev:chat # Chat-focused — scheduler off, port 3011Open /chat on the dashboard (e.g. http://localhost:3010/chat). The UI has a three-panel layout:
- Left — Conversation list and creation controls
- Center — Chat view with message history
- Right — Conversation details and status
Real-time updates are delivered via WebSocket.
| Method | Endpoint | Description |
|---|---|---|
GET |
/chat/bots |
List available bots |
POST |
/chat/conversations |
Create a conversation ({ type, botName, userId?, username?, channelName? }) |
GET |
/chat/conversations |
List all conversations |
GET |
/chat/conversations/:id |
Get conversation with messages |
DELETE |
/chat/conversations/:id |
Delete a conversation |
POST |
/chat/conversations/:id/messages |
Send a message ({ text }) — response arrives via WebSocket |
Supported conversation types: telegram_dm, slack_dm, slack_channel, slack_assistant.
The prod profile in docker-compose.yml runs the full stack (Postgres + app) in Docker.
docker compose --profile prod up -dThis starts:
- postgres — pgvector/pg17 with the schema from
db/init.sql - app — Bun + ffmpeg + Claude CLI, running as non-root
muninnuser
| Mount | Container Path | Description |
|---|---|---|
~/.claude |
/home/muninn/.claude (read-only) |
Claude CLI authentication credentials |
./bots |
/app/bots (read-only) |
Bot persona, MCP config, and permissions |
Bot configuration is mounted (not baked in) so you can change personas and MCP tools without rebuilding the image.
The app container reads .env via env_file, with DATABASE_URL overridden to point at the Postgres container:
DATABASE_URL=postgresql://muninn:muninn@postgres:5432/muninn
The dashboard port maps DASHBOARD_PORT (default 3010) on the host to port 3000 inside the container.
The app container has a health check that polls GET /api/stats every 30 seconds. Use docker compose ps to verify the app is healthy.
- TTS on Linux: macOS
sayis not available — TTS gracefully degrades (text replies only, no voice output) - whisper-cli: Not installed in the Docker image — voice input requires adding whisper-cpp to the Dockerfile
After each conversation exchange, the bot asynchronously asks Claude Haiku whether the exchange contains facts worth remembering (preferences, decisions, project details). If so, it stores a summary with tags and a vector embedding for later semantic retrieval.
Similarly, goals, commitments, and deadlines are detected from conversations. Active goals are injected into the prompt context. A unified background scheduler sends:
- Deadline reminders — 24 hours before a deadline (max once per 12h)
- Check-ins — When a goal hasn't been discussed in 3+ days (max 1 per scheduler tick)
Recurring task requests are detected from conversation (e.g. "remind me every morning at 8 to review my goals"). Three task types:
- reminder — Simple recurring messages
- briefing — AI-generated summaries with goals and context
- custom — Arbitrary prompts run through Claude Haiku
Background monitors that check external services at intervals:
- Email — Spawns Haiku with the bot's Gmail MCP to search and evaluate unread emails
- Quiet hours support (per-user, timezone-aware)
- Dedup via rolling window of notified IDs
Send a voice message and the bot will transcribe it (whisper-cli), process it through Claude, and reply with both text and a voice message (mirror mode).
Every request creates a trace — a tree of timed spans (prompt build, Claude execution, DB saves, send). The Claude executor uses --output-format stream-json --verbose to capture MCP tool calls (Gmail, Calendar, etc.) from the NDJSON event stream. Each tool call becomes a child span with its own timing, visible in the traces dashboard waterfall as orange bars. See docs/tracing-and-tool-tracking.md for details.
PostgreSQL with pgvector, running in Docker.
db/init.sql is the full consolidated schema — it creates all tables, indexes, triggers, and extensions. Docker applies it automatically on first container creation via docker-entrypoint-initdb.d.
Incremental changes go in db/migrations/ as numbered files (e.g. 021-add-feature.sql). Both .sql and .ts migrations are supported. TS migrations must export a migrate(sql: postgres.Sql): Promise<void> function.
A Flyway-style migration runner tracks applied migrations in a schema_migrations table:
bun run db:migrate # Apply pending migrations
bun run db:migrate:status # Show which migrations are applied/pending
bun run db:migrate:baseline # Mark all migrations as applied (for fresh DBs from init.sql)-
Create a numbered file in
db/migrations/:# SQL migration (schema changes) touch db/migrations/021-my-change.sql # TS migration (data transforms) touch db/migrations/021-my-change.ts
-
For SQL: write your DDL/DML statements directly.
-
For TypeScript: export a
migratefunction:import type postgres from "postgres"; export async function migrate(db: postgres.Sql) { await db`UPDATE ...`; }
-
Run it:
bun run db:migrate
-
Update
db/init.sqlto include the change (so fresh installs get the full schema).
bun run db:backup # Saves to backups/muninn_backup_<timestamp>.sql
bun run db:restore # Restores from latest backup in backups/Backups are full pg_dump exports stored in the backups/ directory.
Tests require the local Postgres container (bun run db:up). A separate muninn_test database is used for isolation.
bun run db:up # Start Postgres (if not already running)
bun run db:setup:test # Create muninn_test DB and apply schemabun run test # All tests
bun run test:unit # Unit tests only (pure functions, no DB)
bun run test:db # DB integration tests only
bun run test:handlers # Handler/integration tests (with mocks)
bun run test:coverage # Run with coverage reportTests are split into two bun invocations because bun:test runs all files in the same process, and mock.module() calls leak between files. Group 1 (unit + DB) runs first, then group 2 (mock-based handler tests).
If the schema changes, re-run bun run db:setup:test to rebuild the test database.
src/test/setup-db.ts— Shared DB setup (connects tomuninn_test, truncates tables between tests)src/test/fixtures.ts— Test data factories (makeMessage(),makeMemory(),makeGoal(), etc.)src/test/mock-grammy.ts— Grammy test helpers (fake bot with API transformer, fake updates)*.test.ts— Test files co-located with their source files
The Gmail MCP server (@gongrzhe/server-gmail-autoauth-mcp) uses OAuth tokens that expire periodically. When you see invalid_grant errors, re-authenticate:
GOOGLE_OAUTH_CREDENTIALS=/path/to/gcp-oauth.keys.json \
npx -y @gongrzhe/server-gmail-autoauth-mcp authThis opens a browser for Google OAuth login. Requires port 3000 to be free (used for the OAuth callback).
After re-auth, restart Claude Code so the MCP server picks up the new token.
- No public ports — local Telegram relay only
- Per-bot Telegram user ID whitelist enforcement
- All API keys via environment variables
- Database runs locally via Docker
- Embeddings computed locally via Transformers.js
- Bot sessions isolated from dev sessions via separate
cwd