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.