Personal AI assistant platform — multi-bot system supporting Telegram, Slack, and web chat, with pluggable AI connectors, semantic memory, goal tracking, scheduled tasks, and a full-featured web dashboard.
graph LR
Platforms["Telegram · Slack · Web Chat<br/>Chrome Extensions"] --> Bots["Multi-Bot Engine<br/>(Claude CLI / Copilot SDK / Ollama)"]
Bots --> Intelligence["Memory · Goals<br/>Scheduler · Watchers"]
Bots --> DB[(PostgreSQL<br/>+ pgvector)]
Intelligence --> DB
Dashboard["Web Dashboard<br/>10+ pages"] --> DB
- Multi-Bot Architecture — Multiple bots in one process, each with isolated persona, MCP tools, and conversation history
- Multi-Platform — Telegram (Grammy), Slack (Bolt, Socket Mode), and browser-based web chat — all sharing the same core message pipeline
- Multiple AI Connectors — Claude CLI, GitHub Copilot SDK, or any OpenAI-compatible API (Ollama, LM Studio, vLLM) — configurable per bot and switchable per conversation thread
- 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, X/Twitter timeline digest, etc.) with quiet hours, dedup, and configurable prompts
- 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
- Web Dashboard — Hono server with 10+ pages: real-time activity feed, traces waterfall, memory search, MCP debugger, log viewer, YouTube summarizer, knowledge search, Serena code analysis, and more
- Chrome Extensions — Jira issue research (extract issue → AI analysis → chat thread) and YouTube video summarizer (transcript → Claude summary → knowledge base)
- Serena Code Analysis — Managed MCP proxy for Serena code search instances with unified tool catalog
- API Documentation — Auto-generated OpenAPI 3.1.0 spec with Scalar UI at
/docs - Local-first — All data stays on your machine (PostgreSQL via Docker, local embeddings, no cloud dependencies beyond Telegram/Slack and the AI provider)
- Bun runtime
- Docker (for PostgreSQL + pgvector)
- At least one AI backend:
- Claude CLI installed and authenticated, or
- GitHub Copilot SDK access, or
- An OpenAI-compatible API (Ollama, LM Studio, vLLM)
- Huginn (optional but recommended — companion project for knowledge search and X/Twitter):
- Knowledge base MCP tool — bots use huginn's MCP adapter to search indexed documents (Confluence, Jira, Notion, YouTube transcripts)
- X/Twitter fetcher — the X watcher shells out to huginn's
scripts/x/to fetch the timeline via cookie-based GraphQL - Dashboard search page queries huginn's knowledge API
- 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 |
DASHBOARD_HOST | No | 127.0.0.1 | Dashboard/chat bind address. Defaults to loopback because with MUNINN_AUTH=off the dashboard exposes MCP tools, logs, traces, and full CRUD with no authentication. Set 0.0.0.0 to deliberately expose on the LAN (trusted home network) — required when running under Docker, where the container's network is the trust boundary (docker-compose sets it). |
MUNINN_PROFILE | No | (unset ⇒ default) | Which deployment this process is. Unset is today's muninn — every route registered. nais is a pod: thirteen route groups (wiki, wiki-gardener, plans, sync, claude-usage, benchmark, logs, summaries and the five capture verticals) are not registered, so they answer 404 with no handler and the nav omits their links; the one log line carrying message text drops to debug; and the Haiku spawns — spawnHaiku, i.e. the Haiku router's CLI fallback, the watchers and the scheduler — refuse with a typed HaikuCliUnavailableError instead of hanging on a binary the WITH_CLI=false image does not ship. spawnHaiku only: the claude-cli chat connector (src/ai/executor.ts) and the executeOneShot capture family spawn the CLI on their own path and are not covered, so every bot on a nais deployment must be pinned to a non-CLI connector in its config.json. /chat, the DB/huginn-bound operator routes and both health paths are unchanged. An unrecognised value throws at startup rather than degrading to default — the degrade direction here is "serve everything". |
MUNINN_AUTH | No | off | off is the default and is unchanged behaviour. local requires a session for HTTP requests: one pinned identity (MUNINN_LOCAL_USER) behind a shared secret (MUNINN_LOCAL_TOKEN, min 16 chars), presented on X-Muninn-Token / Authorization: Bearer / ?muninn_token= and exchanged for an HttpOnly cookie. Also requires MUNINN_ADMIN_IDENTS and MUNINN_ALLOWED_ORIGINS; the process refuses to start without them. Optional MUNINN_LOCAL_ROLE (user by default, admin to reach the operator surface). A direct loopback request bypasses auth by design, so a wrong secret is not a lockout. Routes with an "own" version derive the id from the session rather than the client; id-addressed routes resolve the owner from the row (404, never 403); the /chat/ws upgrade is authenticated and owner-scoped; MUNINN_ALLOWED_ORIGINS is enforced on side-effecting requests, on the CORS headers and on the socket handshake; and role decides which routes are reachable at all — a user gets /chat and the routes that page calls, everything else answers 403, and GET / redirects them to /chat. Only /api/live and /api/ready answer with no credential. user unless MUNINN_LOCAL_ROLE=admin, so by default the operator's own dashboard is 403 — and that promotion deliberately does not apply to a credential-less loopback request, so a browser on the muninn host stays user (reach the dashboard through the proxy, or with the token on the request). Admin-zone collection routes still return every user's rows to an admin (audited, not filtered); a user still sees operator nav links they get 403 on; and the loopback bypass is blind to an L4 forward (ssh -L, socat, tailscale serve --tcp, a bare nginx proxy_pass) — behind one, every client is granted the pinned identity with no credential, at role user. An unauthenticated browser gets raw 401 JSON, not a login page. The four Chrome extensions in extensions/ do not work on an authenticating instance: allowlisting their chrome-extension://<id> origin in MUNINN_ALLOWED_ORIGINS fixes CORS and the origin check, but their capture/research routes are not in the user zone and answer 403 by role — a documented residual, not something the allowlist lifts. It hardens a single-human instance against casual LAN/tailnet access; it does not make muninn multi-tenant. entra is the third mode: every credential is a Bearer access token introspected against NAIS_TOKEN_INTROSPECTION_ENDPOINT (one introspection per token, cached and single-flighted), the claims are linked to a users row keyed on the token's oid, and role comes from MUNINN_ADMIN_IDENTS matched against the token's own claims. It additionally requires MUNINN_TENANT (written onto the identity row as provenance) and refuses to start without it or the introspection endpoint. No muninn cookie is minted in that mode — an external auth proxy owns the session — and memory/goal extraction is forced OFF for such accounts. Read src/auth/CLAUDE.md before exposing an instance. |
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 |
TRACING_ENABLED | No | true | Enable request tracing |
TRACING_RETENTION_DAYS | No | 7 | Days to keep trace data |
PROMPT_SNAPSHOTS_RETENTION_DAYS | No | 3 | Days to keep prompt snapshots |
HUGINN_TRACE_POINTER | No | — | 1 enables Huginn's out-of-band trace channel (recommended). The Huginn MCP adapter is stdio-spawned by muninn, so this var propagates to it from muninn's env. Adapter emits a huginn-trace-url: line; Muninn fetches the trace via HTTP and attaches it as searchTracePointer. |
HUGINN_TRACE_DEFAULT | No | 1 (forced) | Legacy inline-fence Huginn trace mode. Muninn forces this on for spawned MCP children regardless of .env, so it is always active as a fallback when pointer mode does not engage. |
LOG_DIR | No | ./logs | Log file directory (set none to disable) |
MUNINN_AGENT_CWD | No | ~/.muninn/agent-cwd | Root for the working directories muninn's own claude -p spawns run in (one subdir per caller). Keeps those agent sessions out of the repo's own ~/.claude/projects/ folder and stops them loading the repo's CLAUDE.md. Covers everyspawnHaiku spawn plus the benchmark judge. A call that needs bot tools (today only the email watcher) asks for them by name — --mcp-config + --settings from bots/<name>/ — instead of by running there; a call that needs the bot persona passes it as --system-prompt. Every spawn runs --strict-mcp-config, so it sees exactly the bot's servers or none — never the developer's user-scope ones. Measured cold on an identical prompt: $0.070 → $0.020 per call. Overrides that are relative, or that resolve inside the checkout (symlinks and filesystem case included), are refused with a warning. |
Trace env vars must be in process.envbefore muninn starts — Bun auto-loads .env, but if you edit .env after launch you must restart for the new values to reach spawned MCP adapters. On startup, both connectors log a single line like Trace env: HUGINN_TRACE_POINTER=1 HUGINN_TRACE_DEFAULT=1 YGGDRASIL_TRACE_POINTER=unset YGGDRASIL_TRACE_DEFAULT=unset so you can confirm what the running process actually sees.
YGGDRASIL_TRACE_POINTER / YGGDRASIL_TRACE_DEFAULT are not in this table because yggdrasil runs as an http-mode MCP started separately from muninn — those flags belong in the yggdrasil server's own startup environment, not in muninn's .env. Same applies to any other type: "http" entry in a bot's .mcp.json (e.g. the Serena tool proxy). The startup log line still reports their values from muninn's process env so you can spot-check whether the parent shell is exporting them when you spawn yggdrasil from there.
| Variable | Required | Description |
|---|---|---|
TELEGRAM_BOT_TOKEN_<NAME> | Yes (for Telegram) | Token from @BotFather (e.g. TELEGRAM_BOT_TOKEN_JARVIS) |
TELEGRAM_ALLOWED_USER_IDS_<NAME> | Yes (for Telegram) | Comma-separated Telegram user IDs |
| Variable | Required | Description |
|---|---|---|
SLACK_BOT_TOKEN_<NAME> | Yes (for Slack) | Slack bot token (e.g. SLACK_BOT_TOKEN_JARVIS) |
SLACK_APP_TOKEN_<NAME> | Yes (for Slack) | Slack app-level token for Socket Mode |
SLACK_ALLOWED_USER_IDS_<NAME> | No | Comma-separated Slack user IDs (empty = all allowed) |
A bot can have both Telegram and Slack tokens — it will connect to both platforms simultaneously.
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",
"contextWindow": 200000,
"showWaterfall": true
}| Field | Type | Default | Description |
|---|---|---|---|
connector | string | "claude-cli" | AI backend: "claude-cli", "copilot-sdk", "openai-compat", or "claude-sdk" |
model | string | CLAUDE_MODEL env | Model name (e.g. "claude-sonnet-4-6", "qwen3:32b") |
thinkingMaxTokens | number | CLI default | Max thinking tokens (0 = disable). 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) |
contextWindow | number | — | Context window size in tokens. Shown as usage bar in web chat |
showWaterfall | boolean | true | Show request progress waterfall overlay in web chat |
prompts | object | — | Custom prompts: jiraAnalysis, investigateCode |
graph LR
subgraph Clients
TA[Telegram User A]
TB[Slack User B]
TC[Web Browser C]
end
subgraph "Single Muninn Process"
B1["Bot 1 (Jarvis)<br/>→ Claude CLI"]
B2["Bot 2 (Assistant)<br/>→ Copilot SDK"]
B3["Bot 3 (Local)<br/>→ Ollama"]
SHARED["Shared: DB, Dashboard,<br/>Scheduler, Watchers"]
end
TA --> B1
TB --> B2
TC --> B3
B1 --- SHARED
B2 --- SHARED
B3 --- SHARED
A bot is active when its folder has a CLAUDE.md and at least one platform token (TELEGRAM_BOT_TOKEN_<NAME> or SLACK_BOT_TOKEN_<NAME>). Bots without any platform token still appear in the web chat UI.
bots/
├── jarvis/ ← example bot
│ ├── 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
│ └── ...
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.
All platforms (Telegram, Slack, Web) converge on the same core message processor:
sequenceDiagram
participant P as Platform<br/>(Telegram/Slack/Web)
participant C as Core Processor
participant AI as AI Connector
participant DB as PostgreSQL
participant E as Extractors
P->>C: processMessage(text, callbacks)
C->>DB: Load history, memories, goals
C->>AI: Build prompt → Execute
AI-->>P: Stream deltas (text, intent, tool status)
AI->>C: Final response
C->>DB: Save message + trace
C->>P: formatAndSend()
C-->>E: Fire-and-forget: memory, goals, schedules
Each bot selects its AI backend via connector in bots/<name>/config.json. Four 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) |
| Claude SDK | "claude-sdk" | Anthropic's @anthropic-ai/claude-agent-sdk — direct API transport, no CLI subprocess |
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
}Uses Anthropic's @anthropic-ai/claude-agent-sdkquery() iterable as a direct chat transport — no claude CLI subprocess, no Copilot subscription. Useful for personal bots that want smoother streaming and like-for-like benchmarks vs copilot-sdk.
{
"connector": "claude-sdk",
"model": "claude-sonnet-4-6",
"thinkingMaxTokens": 16000,
"timeoutMs": 180000
}Auth comes from either ANTHROPIC_API_KEY (sent as x-api-key, use for production / shared deployments) or CLAUDE_CODE_OAUTH_TOKEN (sent as Authorization: Bearer, generate via claude setup-token for personal Max-subscription dev). Same env surface as the Haiku router.
MCP servers from .mcp.json are converted to the Agent SDK shape (src/ai/connectors/claude-sdk-mcp.ts). Permission prompts are bypassed (permissionMode: 'bypassPermissions', same trust model as the Copilot connector's approveAll) and settingSources: [] keeps the SDK from auto-loading the user's global ~/.claude/settings.json — muninn's prompt-builder delivers the full system prompt.
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 11434Create
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.
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
Connectors can be switched per conversation thread from the web chat UI. The dropdown in the inspector panel persists the preference to the database. Thread-level connector overrides take priority:
- Thread's stored connector (if set)
- Bot
config.json - Global
.envdefaults
Grammy-based Telegram bot with text, voice, and command support.
| 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, x) |
/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 a named topic |
/topics | List all topics with message counts and last activity |
/deltopic <name> | Delete a topic (cannot delete main) |
Any other text or voice message is forwarded to the AI for a conversational response.
Slack Bolt app running in Socket Mode. Supports four message contexts:
| Context | Trigger | Thinking Indicator |
|---|---|---|
| Assistant sidebar | Slack's built-in Assistant UI | Native setStatus("Thinking...") |
| @mention in channels | @BotName mention | Native thread status bubble |
| Thread follow-ups | Reply in a tracked thread (no @mention needed) | Native thread status bubble |
| Direct messages | DM to the bot | Temporary "Thinking..." message |
Features:
- Automatic thread tracking — once the bot replies in a channel thread, it responds to follow-ups without @mention (in-memory, 24h TTL)
- Channel context — fetches recent messages for context-aware responses
- Channel posting — Claude can post to channels via
<slack-post channel="#name">content</slack-post>directives - Slack formatting — markdown automatically converted to mrkdwn (bold, links, code, tables → bullet lists)
- Topic commands —
/topic,/topics,/deltopicwork in Slack DMs and Assistant threads
Setup: Add SLACK_BOT_TOKEN_<NAME> and SLACK_APP_TOKEN_<NAME> to .env. The bot auto-discovers and connects via Socket Mode.
Browser-based chat interface at /chat. Any bot with a CLAUDE.md appears here, even without platform tokens.
bun run dev # Full app — chat at http://localhost:3010/chat
bun run dev:chat # Chat-focused — scheduler off, port 3011Three-panel layout:
- Left — Thread list with creation controls, message counts, and connector info
- Center — Chat view with real-time streaming (text deltas, tool activity widget, intent bubbles)
- Right — Inspector panel (context window usage, token stats, aggregated tool calls, connector selector)
Real-time features:
- WebSocket for messages, streaming text, tool status, and response metadata
- SSE overlay for request progress waterfall (from dashboard)
- Streaming text rendered with
requestAnimationFramebatching for performance
Conversation types:telegram_dm, slack_dm, slack_channel, slack_assistant, web
| Method | Endpoint | Description |
|---|---|---|
GET | /chat/bots | List available bots |
POST | /chat/conversations | Create a conversation |
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 (response arrives via WebSocket) |
POST | /chat/threads | Create a new thread within a conversation |
GET | /chat/threads | List threads for a conversation |
DELETE | /chat/threads/:id | Delete a thread |
Each user+bot pair can have multiple named conversation threads. Only chat history is isolated per thread — memories, goals, and scheduled tasks are shared across all threads.
- First message auto-creates a
mainthread - Threads support per-thread connector overrides (switch AI model per conversation)
- Thread names are case-insensitive, max 50 characters
- Works on all platforms (Telegram commands, Slack DMs, web UI)
The web dashboard (default port 3010) provides real-time monitoring and management across 10+ pages:
graph TB
subgraph "Dashboard Pages"
HOME["/ — Activity Feed<br/>Stats, goals, tasks, watchers"]
TRACES["/traces — Request Traces<br/>Waterfall visualization"]
CHAT["/chat — Web Chat<br/>Multi-bot conversations"]
MEM["/memsearch — Memory Search<br/>Semantic + FTS hybrid"]
MCP["/mcp-debug — MCP Debugger<br/>Test tool connections"]
SERENA["/serena — Code Analysis<br/>Manage Serena instances"]
YT["/youtube — YouTube<br/>Summarize & index videos"]
RESEARCH["/research — Knowledge<br/>Browse document collections"]
SEARCH["/search — Search<br/>Cross-collection search"]
LOGS["/logs — Log Viewer<br/>Browse JSONL logs by date"]
DOCS["/docs — API Docs<br/>Scalar OpenAPI UI"]
end
| Page | Path | Description |
|---|---|---|
| Activity Feed | / | Real-time SSE activity feed, stats, goals, tasks, watchers, connectors, Slack analytics |
| Traces | /traces | Request tracing with waterfall visualization, span tree, prompt snapshots |
| Web Chat | /chat | Multi-bot chat interface with streaming and inspector panel |
| Memory Search | /memsearch | Semantic + full-text hybrid search over bot memories by scope |
| MCP Debugger | /mcp-debug | Connect to MCP servers, list tools, call tools with arguments |
| Serena | /serena | Manage Serena code analysis instances, start/stop/index, proxy status |
| YouTube | /youtube | Submit videos for summarization, track live progress, browse article library |
| Research | /research | Browse knowledge collections by category, view documents |
| Search | /search | Full-text search across all knowledge collections |
| Logs | /logs | Browse daily JSONL log files with filtering |
| API Docs | /docs | Scalar UI rendering the auto-generated OpenAPI spec |
Core data:
GET /api/stats— Dashboard statisticsGET /api/activity— Recent activity eventsGET /api/events— SSE stream (activity, agent status, request progress)GET /api/bots/config— Bot connector configurations
Per-user data:
GET /api/messages/:userId— Conversation historyGET /api/goals/:userId— Active goalsGET /api/scheduled-tasks/:userId— Scheduled tasksGET /api/memories— Recent memories (with bot filter)
Traces:
GET /api/traces— Recent traces (?bot=,?name=,?limit=,?offset=)GET /api/traces/:traceId— Span tree for a single traceGET /api/trace-stats— 24h counts, avg duration, errorsGET /api/prompts/:traceId— Prompt snapshot
Connectors (CRUD):
GET /api/connectors— List named connectorsPOST /api/connectors— Create connectorPUT /api/connectors/:id— Update connectorDELETE /api/connectors/:id— Delete connector
MCP / Serena:
POST /api/mcp/connect— Connect to MCP serverPOST /api/mcp/call— Call a toolGET /api/serena/instances— Serena + proxy status
YouTube:
POST /api/youtube/summarize— Submit video (returnsjob_id)GET /api/youtube/stream/:jobId— SSE stream of summarization progressGET /api/youtube/documents— Browse summarized articles
Search / Research:
GET /api/search/collections— List knowledge collectionsGET /api/search/search— Cross-collection searchPOST /api/research/chat— Create research thread from Jira issue
Full spec:GET /api/openapi.json or browse at /docs
Two Chrome extensions integrate external tools with Muninn:
Extracts Jira issue data from the DOM and sends it to Muninn for AI-powered analysis.
sequenceDiagram
participant J as Jira Page
participant E as Extension Popup
participant M as Muninn API
participant C as Chat Page
J->>E: Extract issue (DOM scraping)
E->>M: POST /api/research/chat
M->>M: Create thread, queue message
M->>E: Return chatUrl
E->>C: Open chat page
C->>C: Multi-phase analysis<br/>(analysis → investigation → deep analysis)
Features:
- Extracts: key, summary, status, type, priority, assignee, description, comments
- User and connector/model selection synced with web chat preferences
- Thread collision detection ("Reuse existing thread?" dialog)
- Multi-phase research: initial analysis → code investigation → deep analysis
Detects YouTube video navigation and sends videos to Muninn for summarization and knowledge indexing.
Flow: Detect video → popup with "Summarize" button → POST /api/youtube/summarize → opens dashboard YouTube page with live SSE progress → transcript fetch → Claude summary → knowledge base ingest
Dashboard YouTube page shows: live streaming summary, status badges, category tagging, similar articles from knowledge base, and a browsable article library.
Serena provides code search and analysis tools (find_symbol, search_for_pattern, etc.) for large codebases. Instances run as persistent HTTP servers managed from the dashboard.
The Serena tool proxy (src/serena/tool-proxy.ts) sits in front of all running Serena instances and reduces ~40 tool schemas down to 2 — a ~95% context savings:
search_tools— Discover available tools by keyword across all connected serverscall_tool— Execute a specific tool on a specified server
graph LR
BOT[Bot / AI Connector] -->|2 tools| PROXY["Tool Proxy<br/>:9120"]
PROXY -->|~20 tools| S1["Serena Instance 1<br/>:9121"]
PROXY -->|~20 tools| S2["Serena Instance 2<br/>:9122"]
The proxy auto-starts when the first Serena instance starts and auto-stops when all instances are stopped. Tool catalogs refresh automatically.
Serena instances are defined in the bot's config.json:
{
"serena": [
{ "name": "serena-api", "displayName": "Backend API", "projectPath": "/path/to/project", "port": 9121 }
]
}The bot's .mcp.json points to the proxy (not individual instances):
{
"serena-proxy": { "type": "http", "url": "http://127.0.0.1:9120/mcp" }
}- Open the Serena page in the dashboard (
/serena) - Click Start on the instances you need (or Start All)
- The proxy auto-starts and builds a unified tool catalog
- Click Stop when done to free resources
Create the bot folder:
mkdir -p bots/mybot/.claude
Write the persona in
bots/mybot/CLAUDE.mdOptionally add
bots/mybot/config.json,bots/mybot/.mcp.json, andbots/mybot/.claude/settings.local.jsonAdd platform tokens to
.env:# Telegram (optional)TELEGRAM_BOT_TOKEN_MYBOT=<token from @BotFather>TELEGRAM_ALLOWED_USER_IDS_MYBOT=123456# Slack (optional)SLACK_BOT_TOKEN_MYBOT=xoxb-...SLACK_APP_TOKEN_MYBOT=xapp-...
Restart — the bot is auto-discovered and connects to configured platforms. It also appears in web chat immediately.
See
docs/examples/jira-assistant/for a complete team bot example with Serena code search and Copilot SDK connector.
Bot folders (everything except the bundled bots/jarvis/) are gitignored, but they're often the most valuable part of a deployment — personas, MCP wiring, tool permissions. Muninn syncs them to/from external "source-of-truth" repos via a manifest at bots.config.json (repo root). Each entry maps a bot name to either a local path (e.g. a personal config repo) or a git URL (typically a private team repo).
{
"schemaVersion": 1,
"bots": {
"jarvis": { "inline": true },
"capra": { "repo": "https://github.com/capraconsulting/huginn-capra.git", "subpath": "bot" },
"melosys": { "repo": "~/source/private/muninn-config", "subpath": "bots/melosys" }
}
}inline: true means the bot is checked into muninn directly (the case for jarvis). Git-URL repos are sparse-cloned into ~/.muninn/bot-repos/<name>/. Manifest entries you can't reach (path missing, clone fails) are skipped with a warning, so each contributor only needs access to the repos for the bots they actually run.
bun run config:sync # push local bots/<name>/ → each repo
bun run config:sync -- --pull # fetch latest from git remotes first
bun run config:sync -- --commit # commit + push in every touched repo
bun run config:restore # reverse: pull each repo subpath → bots/<name>/.env is per-developer — each contributor maintains their own with the tokens for the bots they actually run. It is not synced by this tool.
Onboarding a new contributor for a single bot (e.g. someone joining a Capra-only team):
git clone https://github.com/RuneLind/muninn.git
cd muninn && bun install
bun run config:restore # pulls bots/capra/ from huginn-capra (the only repo they have access to)
cp .env.example .env # add their own SLACK_BOT_TOKEN_CAPRA etc.
bun run db:up && bun run db:migrate
bun run devThey never touch a personal config repo and never see other teams' bots.
Path conventions inside synced .mcp.json: paths resolve relative to cwd: bots/<name>/. To reference a sibling project (e.g. ~/source/private/huginn when muninn lives at ~/source/private/muninn), use ../../../huginn. Values inside env blocks are read literally — for HOME-relative paths use shell expansion in a bash -c command instead.
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. Memories are scoped as personal (per-user) or shared (visible to all users of a bot) — Haiku auto-classifies during extraction. With MUNINN_AUTH set to an authenticating mode the read side narrows to the reader's own rows, so a shared memory no longer crosses between identities.
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
Supports cron-style (hour/minute/days) and interval-style (every N ms) scheduling, timezone-aware.
Background monitors that check external services at intervals:
- Email — Spawns Haiku with the bot's Gmail MCP to search and evaluate unread emails
- X/Twitter — Fetches home timeline via huginn's cookie-based GraphQL fetcher, Haiku summarizes into a morning digest. Tweet-level dedup via
trackingIds. - Quiet hours support (per-user, timezone-aware, overnight ranges like 22-08)
- Dedup via rolling window of notified IDs + content hashes
- Configurable prompts per watcher (custom or sensible defaults), time-of-day scheduling (hour/minute in config)
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). MCP tool calls (Gmail, Calendar, etc.) are captured from the stream events. Each tool call becomes a child span with its own timing, visible in the traces dashboard waterfall. See docs/tracing-and-tool-tracking.md for details.
Each request assembles a prompt from multiple sources:
graph TB
PERSONA[Persona — CLAUDE.md] --> PROMPT
MEMORIES[Relevant Memories — hybrid search] --> PROMPT
GOALS[Active Goals] --> PROMPT
TASKS[Scheduled Tasks] --> PROMPT
HISTORY[Thread-scoped Chat History] --> PROMPT
PROMPT[Assembled Prompt] --> AI[AI Connector]
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.
Tables:users, messages, activity_log, memories (with vector embeddings + scope), goals, scheduled_tasks, watchers, connectors (named AI connector configurations), threads (per-user+bot with optional connector FK), user_settings, haiku_usage, traces (spans with parent-child hierarchy + JSONB attributes)
Incremental changes go in db/migrations/ as numbered files. Both .sql and .ts migrations are supported.
A Flyway-style migration runner tracks applied migrations in a schema_migrations table:
bun run db:provision -- --yes # Empty database → apply init.sql, then baseline
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)db:provision is the one to reach for on a database that has never been
provisioned — the schema comes from db/init.sql, not from the runner, so
db:migrate against an empty database applies migrations onto nothing.
It requires --yes, and prints the host and database it resolved before
asking for it. That is not ceremony: Bun auto-loads .env, so a bare invocation
in a checkout resolves whatever DATABASE_URL that file names, with nothing
typed and nothing exported — and this command writes a schema. (--dry-run
needs no --yes — it is the form that cannot write, though against a database it
would refuse it exits 1 rather than 0. An unrecognised flag is refused rather
than ignored, so a typo'd --dryrun cannot become a real run.)
The predicate is the whole table set, not one table — and it is shared.db/schema-state.ts parses the tables db/init.sql declares out of the file
itself and compares them with what is actually in public; both db/provision.ts
and db/require-provisioned.ts classify with it. The sharing is the load-bearing
part: the entrypoint's check is what an operator actually reads, and while it
kept a users-only copy of its own it printed the fatal instruction below. That is not fussiness: users is
init.sql's first table and schema_migrations its last, so a
psql -f db/init.sql that died mid-file — psql without -1 is not atomic —
leaves users present and schema_migrations absent in almost every case. Read
through a users-only predicate, that state looks like "provisioned but never
baselined", whose remedy is bun db/migrate.ts --baseline — and that command
succeeds, satisfies db/require-provisioned.ts, boots the pod on a stump of a
schema, and records every migration as applied so nothing can repair it
afterwards.
So five states, and only one of them writes: complete (refused, and told to
baseline only when schema_migrations is genuinely empty), incomplete
(refused by name, listing what is present and what is missing, and explicitly
told not to baseline), lone ledger (only schema_migrations — what
db:migrate or db:migrate:baseline against an empty database leaves, with or
without rows; the remedy is DROP TABLE schema_migrations, never a schema
drop), foreign (tables present, none of them init.sql's — which gets neither
the mid-file diagnosis nor DROP SCHEMA public CASCADE, because nothing there
came from init.sql), and empty, which is the one it provisions. A role that may not run what init.sql asks for is
refused separately, with the Postgres message verbatim.
Applying init.sql is all-or-nothing — sql.unsafe on a parameterless
string uses the simple protocol and Postgres wraps it in one implicit
transaction — and so is the baseline that follows it, separately. On a managed
instance have an elevated role run CREATE EXTENSION vector once first: the app
user may not create an extension, and init.sql's own IF NOT EXISTS then
short-circuits before the privilege check.
Create a numbered file in
db/migrations/:touch db/migrations/021-my-change.sql # SQL (schema changes) touch db/migrations/021-my-change.ts # TypeScript (data transforms)
For TypeScript, export a
migratefunction:importtypepostgresfrom"postgres";exportasyncfunctionmigrate(db: postgres.Sql){awaitdb`UPDATE ...`;}
Run it:
bun run db:migrateUpdate
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/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 report
bun run test:e2e # Playwright end-to-end tests
bun run test:e2e:ui # Playwright with interactive UITests are split into multiple bun invocations because bun:test runs all files in the same process, and mock.module() calls leak between files.
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 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
The image is built from one Dockerfile with three build args. Two are on by default (so the compose image is unchanged): --build-arg WITH_MEDIA=false drops ffmpeg and --build-arg WITH_CLI=false drops the Claude CLI. The third is off by default: --build-arg WITH_EMBEDDINGS=true bakes the ~23 MB embedding model into the image instead of downloading it at first use — worth it wherever runtime egress is restricted, since the download failure is swallowed and shows up only as memory search returning nothing. It moves the requirement rather than removing it: the builder then needs egress to the model host. Anything other than true/false fails the build rather than silently meaning "off".
The container's entrypoint runs, in order: adopt DB_URL as DATABASE_URL if only the former is set, refuse an unready database, apply pending migrations (serialised across replicas by an advisory lock), then exec the CMD. Both scripts it calls resolve the same pair themselves (DATABASE_URL → DB_URL, then — for the migration runner only — the dev default; db/database-url.ts), so the remedies below still find nais's credentials when they replace the entrypoint.
"Unready" is three distinct states, each answered with the command that fixes it rather than a raw SQL error. The predicate is the whole table set (see above), so a partly applied schema is its own answer rather than being mistaken for the second row:
| State | What the entrypoint says |
|---|---|
| No tables at all — never provisioned | Run bun db/provision.ts --yes (bun run db:provision -- --yes) — it applies db/init.sqland records the shipped migrations, from the image itself. No psql, no checkout, no file transport (see the one-off forms below). psql -f db/init.sql still works from a machine that has psql and can reach the database — but a private-IP Cloud SQL instance has no such machine, which is why this is not the leading answer |
Partly applied — some of db/init.sql's tables, not all | Refused by name, listing what is present and what is missing, and told explicitly not to baseline. --baseline here succeeds, satisfies this very check, and boots the pod on a stump of a schema with every migration recorded — so nothing could repair it afterwards. (A lone schema_migrations, with or without rows, gets DROP TABLE schema_migrations instead of a schema drop; tables that never came from init.sql get neither.) |
Complete schema, schema_migrations empty — provisioned but never baselined | Run bun db/migrate.ts --baseline (bun run db:migrate:baseline) — from the image itself, no psql and no checkout needed (see the one-off forms below) |
The baseline command cannot be an exec. The refusal makes the entrypoint exit, so under restart: unless-stopped (or a Deployment) there is no running process to exec into — docker compose exec app … answers service "app" is not running. Run it as a one-off container instead:
docker compose run --rm --entrypoint bun app db/migrate.ts --baselineThe --entrypoint override is required; without it the same refusal runs first. On Kubernetes the equivalent is ad-hoc off the Deployment's own pod spec — the copied pod inherits its env and secrets, and db/migrate.ts resolves DATABASE_URL → DB_URL itself (db/database-url.ts), so it finds nais's credentials even though this command replaces the entrypoint that normally exports one from the other:
kubectl debug deploy/<app> --copy-to=<app>-baseline --container=<app> \
--profile=general -- bun db/migrate.ts --baseline # delete the copied pod afterwardsThe same shape provisions a database that has never been provisioned at all — which is the case on a nais deployment, where the Cloud SQL instance is private-IP and the app user's credentials exist only inside the pod:
kubectl debug deploy/<app> --copy-to=<app>-provision --container=<app> \
--profile=general -- bun db/provision.ts --yes # delete the copied pod afterwards--profile=general is what kubectl wants; without it it warns that the legacy profile is deprecated. A one-off Job from the same image carrying the same env and secrets (nais: a naisjob) works too — but it must set its own command, or the image's default entrypoint re-runs the refusal before it gets to the baseline.
The second state is the one a fresh compose prod stack lands in: db/init.sql creates schema_migrationsempty, so without a baseline the migration runner reads "everything is pending" and dies inside migration 006 with column "bot_name" of relation "messages" already exists — a crash-loop about a database that is in fact fine. Neither state is auto-repaired: baselining a schema laid down by an older init.sql would record migrations it has never seen.
| 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 image pins DASHBOARD_PORT=3000 and DASHBOARD_HOST=0.0.0.0 so a bare docker run -p 3000:3000 works: the app's own defaults (3010, loopback) would otherwise serve on a port nothing maps and bind to an address no probe outside the container can reach.
The app container polls GET /api/live every 30 seconds — the dependency-free open-zone path, which answers with no credential on an authenticating instance (the old /api/stats probe is an admin-zone DB read and reported a healthy process as unhealthy the moment MUNINN_AUTH was on). The probe reads its port from DASHBOARD_PORT at runtime, and --start-period is 120s because the entrypoint's connect budget alone is 30s before migrations run. 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
| Path | Purpose |
|---|---|
bots/<name>/ | Per-bot config: persona, MCP, permissions, CLI history |
src/index.ts | Entrypoint — inits DB, discovers bots, starts all + dashboard + scheduler |
src/bots/config.ts | Bot auto-discovery from bots/ directory |
src/core/ | Unified message processor, response handler, metadata extraction |
src/bot/ | Telegram handlers (text, voice), auth middleware, HTML formatting |
src/slack/ | Slack Bolt app, handler pipeline, thread tracking, mrkdwn formatting |
src/chat/ | Web chat — routes, WebSocket, conversation state, inspector panel, streaming UI |
src/ai/ | Connector abstraction, Claude CLI + Copilot SDK + OpenAI-compat, prompt builder, embeddings |
src/memory/ | Async memory extraction (personal + shared scope) |
src/goals/ | Goal detection (async Claude Haiku) |
src/scheduler/ | Unified scheduler (tasks + goal reminders + watchers), shared Haiku executor |
src/watchers/ | Proactive outreach — email watcher (Haiku + Gmail MCP), X/Twitter digest, quiet hours |
src/db/ | Postgres CRUD — messages, memories, goals, tasks, activity, watchers, threads, traces, connectors |
src/tracing/ | Request tracing with span hierarchy and MCP tool call child spans |
src/dashboard/ | Hono web server — 10+ pages, REST APIs, SSE, OpenAPI spec |
src/serena/ | Serena instance manager + MCP tool proxy |
src/youtube/ | YouTube video summarizer (transcript → Claude → knowledge base) |
src/voice/ | STT (whisper-cli) + TTS (macOS say + ffmpeg) |
src/web/ | Web HTML formatting (markdown → HTML) |
extensions/ | Chrome extensions (Jira research, YouTube summarizer) |
docs/ | Architecture documentation, examples |
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 platform relay only (Telegram long-polling, Slack Socket Mode)
- Per-bot user ID whitelist enforcement (Telegram required, Slack optional)
- 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 - Web chat dashboard on localhost only