Skip to main content

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​

LayerWhereNotes
Process rootsrc/index.tsBoots channels, drain loop, health server, backup schedule.
Per-turn handlersrc/agent.tsPicks provider, runs it, catches errors, manages typing.
Channel routersrc/channels/router.tsDispatches by thread-id prefix: telegram:, slack:, whatsapp:.
Channel adapterssrc/channels/One file per channel. Implements the Channel interface.
Provider registrysrc/providers/registry.tsSelects Claude or Gemini from agent_provider.
Claude pathsrc/providers/claude-sdk.tsLong-lived query() per thread via @anthropic-ai/claude-agent-sdk. LRU cap.
Gemini pathsrc/providers/gemini-sdk.tsIn-process inference via @google/gemini-cli-core.
MCP serversrc/mcp/server.tsstdio MCP exposing channel + Google tools to the agent.
Per-call tool gatesrc/lib/tool-permissions.tsallowed_paths enforcement + Bash denylist.
SQLitesrc/db/messages, outbox, sessions tables.
Configsrc/lib/config.tsdata/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.

ChannelFormat example
Telegramtelegram:123456789
Slackslack:C0123456789
WhatsAppwhatsapp:94701234567@s.whatsapp.net

SQLite schema​

data/marsclaw.db (override with MARSCLAW_DB). Migrations live in migrations/ and run on every boot.

messages

coltypenotes
idINTEGER PKautoincrement
thread_idTEXTchannel-prefixed
roleTEXTuser or assistant
textTEXTraw (no per-turn context decoration)
created_atINTEGERunix epoch

outbox — async messages queued by the agent's MCP tools.

coltypenotes
idINTEGER PK
thread_idTEXT
textTEXTreply text (or caption when file_path is set)
audio_pathTEXT?when set, channel sends as voice note
file_pathTEXT?when set, channel sends as document/image
file_nameTEXT?display name override
attemptsINTEGERretry counter (cap = MAX_ATTEMPTS)
delivered_atINTEGER?non-null when sent
failed_atINTEGER?non-null when permanently failed
last_errorTEXT?last delivery error
created_atINTEGER

sessions — provider session continuity (Claude SDK resume).

coltypenotes
thread_idTEXT PK
providerTEXTclaude
session_idTEXTresumed across restarts
updated_atINTEGER

Per-turn flow​

  1. Adapter receives a message and calls onMessage(threadId, text).
  2. src/index.ts chains the call onto an in-memory inFlight promise keyed by threadId — serialized per thread, parallel across threads.
  3. handleMessage (src/agent.ts) appends to messages, builds the per-turn context block (current local time, timezone, location), and calls the selected provider.
  4. Claude path: SDK query() is long-lived per thread (subprocess + MCP boot once, ~10s); subsequent messages stream into the same iterable.
  5. Gemini path: in-process inference via @google/gemini-cli-core. Re-sends the last 20 turns as context each call.
  6. The reply is trimmed; empty replies are skipped. Non-empty replies append to messages and send through the channel.
  7. While the agent is thinking, a typing-indicator refresher (4s cadence) fires through channel.setTyping.
  8. The MCP tools (send_message, send_file, speak, Gmail/Drive/etc.) write rows to outbox; 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/heartbeat while a turn is in flight. The typing refresher and external monitors read it.
  • Cost tracker. src/lib/cost-tracker.ts sums SDKResultSuccess.total_cost_usd per day. New turns refuse if today's spend exceeds daily_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, and data/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.