Architecture
marsClaw is one Bun process. SQLite is the only persistent state. The agent SDK does the LLM work; we glue channels and tools to it.
Message flow
┌────────────────────────────┐ ┌──────────────────────────────────────┐
│ channel adapter │ text ─▶ │ handleMessage (src/agent.ts) │
│ · telegram │ │ · append to sqlite messages │
│ · slack │ │ · build per-turn context │
│ · whatsapp │ audio?▶ │ (time, timezone, location) │
└────────────────────────────┘ whisper │ · runClaudeSdk OR runGeminiSdk │
▲ :9000 │ · trim reply, append, send │
│ └────────────┬─────────────────────────┘
│ │
│ speak / send_file│ ← MCP (stdio)
│ send_message │
│ gmail/calendar… │
│ ▼
└────────── router.send ◀──── outbox drain (250ms / 5s tick)
Single dispatcher in src/index.ts serializes per thread: two messages from the same chat never run two agent calls in parallel. The outbox is the only path for asynchronous side-channel messages — the agent's MCP tools enqueue rows, the drain loop delivers them.
Two runtimes
The flow above is the in-process runtime (default): the agent SDK loop runs inside the host bot process and is secured by capability removal (path gates, no shell/web, sensitive-path guard).
Setting runtime: container (or MARSCLAW_RUNTIME=container) keeps everything left of handleMessage identical — channels, per-thread serialization, SQLite, the outbox drain — but the host becomes a broker that ships each turn over POST /turn to an isolated agent container, with the real Anthropic credential, Google OAuth, and web egress held by host-side sidecars. The container holds no secrets; security shifts from capability removal to isolation. Full detail, the host/sidecar diagram, and the per-mode security table are in container-runtime.md.
Components
| Layer | Where | Notes |
|---|---|---|
| Process root | src/index.ts | Boots channels, drain loop, health server, backup schedule. |
| Per-turn handler | src/agent.ts | Picks provider, runs it, catches errors, manages typing. |
| Channel router | src/channels/router.ts | Dispatches by thread-id prefix: telegram:, slack:, whatsapp:. |
| Channel adapters | src/channels/ | One file per channel. Implements the Channel interface. |
| Provider registry | src/providers/registry.ts | Selects Claude or Gemini from agent_provider. |
| Claude path | src/providers/claude-sdk.ts | Long-lived query() per thread via @anthropic-ai/claude-agent-sdk. LRU cap. |
| Gemini path | src/providers/gemini-sdk.ts | In-process inference via @google/gemini-cli-core. |
| MCP server | src/mcp/server.ts | stdio MCP exposing channel + Google tools to the agent. |
| Per-call tool gate | src/lib/tool-permissions.ts | allowed_paths enforcement + Bash denylist. |
| SQLite | src/db/ | messages, outbox, sessions tables. |
| Config | src/lib/config.ts | data/config.json + env overlay, read once. |
Thread IDs
Every channel writes thread IDs prefixed with its name. The router dispatches outbound sends back to the right adapter using that prefix.
| Channel | Format example |
|---|---|
| Telegram | telegram:123456789 |
| Slack | slack:C0123456789 |
whatsapp:94701234567@s.whatsapp.net |
SQLite schema
data/marsclaw.db (override with MARSCLAW_DB). Migrations live in migrations/ and run on every boot.
messages
| col | type | notes |
|---|---|---|
| id | INTEGER PK | autoincrement |
| thread_id | TEXT | channel-prefixed |
| role | TEXT | user or assistant |
| text | TEXT | raw (no per-turn context decoration) |
| created_at | INTEGER | unix epoch |
outbox — async messages queued by the agent's MCP tools.
| col | type | notes |
|---|---|---|
| id | INTEGER PK | |
| thread_id | TEXT | |
| text | TEXT | reply text (or caption when file_path is set) |
| audio_path | TEXT? | when set, channel sends as voice note |
| file_path | TEXT? | when set, channel sends as document/image |
| file_name | TEXT? | display name override |
| attempts | INTEGER | retry counter (cap = MAX_ATTEMPTS) |
| delivered_at | INTEGER? | non-null when sent |
| failed_at | INTEGER? | non-null when permanently failed |
| last_error | TEXT? | last delivery error |
| created_at | INTEGER |
sessions — provider session continuity (Claude SDK resume).
| col | type | notes |
|---|---|---|
| thread_id | TEXT PK | |
| provider | TEXT | claude |
| session_id | TEXT | resumed across restarts |
| updated_at | INTEGER |
Per-turn flow
- Adapter receives a message and calls
onMessage(threadId, text). src/index.tschains the call onto an in-memoryinFlightpromise keyed bythreadId— serialized per thread, parallel across threads.handleMessage(src/agent.ts) appends tomessages, builds the per-turn context block (current local time, timezone, location), and calls the selected provider.- Claude path: SDK
query()is long-lived per thread (subprocess + MCP boot once, ~10s); subsequent messages stream into the same iterable. - Gemini path: in-process inference via
@google/gemini-cli-core. Re-sends the last 20 turns as context each call. - The reply is trimmed; empty replies are skipped. Non-empty replies append to
messagesand send through the channel. - While the agent is thinking, a typing-indicator refresher (4s cadence) fires through
channel.setTyping. - The MCP tools (
send_message,send_file,speak, Gmail/Drive/etc.) write rows tooutbox; a tick-based drain (250ms while draining, 5s idle) delivers them.
What you don't see
- Circuit breaker.
enforceStartupBackoff(src/lib/circuit-breaker.ts) sleeps before booting if recent restarts have been suspiciously frequent. Stops a crash loop from burning API quota. - Heartbeat file. The provider touches
data/heartbeatwhile a turn is in flight. The typing refresher and external monitors read it. - Cost tracker. src/lib/cost-tracker.ts sums
SDKResultSuccess.total_cost_usdper day. New turns refuse if today's spend exceedsdaily_usd_budget— but only on a metered Anthropic API key; Claude Pro/Max OAuth bypasses the check (no per-token billing). - Conversation archive. src/lib/conversation-archive.ts keeps a JSONL transcript per thread under
data/conversations/. - Backups. src/lib/backup.ts snapshots
marsclaw.db,MEMORY.md, anddata/whatsapp-auth/on a schedule. - Health server. src/lib/health-server.ts exposes a small HTTP endpoint for liveness probes.
What we deliberately delegate
The agent SDK owns: multi-turn tool use, planning, retries, context compaction, prompt caching (Claude), LLM auth and rate-limit handling, and the built-in tools (Bash, Read, Write, Edit, Glob, Grep, WebFetch, WebSearch). That is roughly the hardest 80% of building an agent. Anthropic's and Google's teams iterate on it daily. Outsourcing it is leverage, not laziness.