Skip to content

Architecture

chatwire runs as two independent background processes managed by macOS launchd, communicating via files on disk.

Process model

launchd
├── dev.chatwire.bridge   → python bridge.py
│     Polls chat.db every 2s → fans out to integrations
└── dev.chatwire.web      → python -m uvicorn web.main:app
      Serves the React SPA, REST API, and SSE stream

No inter-process socket. The two processes share state through files in ~/.chatwire/.

Bridge process (bridge.py)

The bridge is a long-running async process. On each poll iteration (every 2 seconds by default):

  1. Opens a read-only SQLite connection to ~/Library/Messages/chat.db
  2. Queries for message ROWIDs newer than the last-seen cursor (state/state.json)
  3. For each new inbound message: a. Checks relay scope (SELF_HANDLES + whitelist + group allow-list) b. Resolves sender handle → display name via Contacts.app c. Checks echo log to skip messages the bridge itself sent d. Runs inbound transform plugins (content filter, encryption) e. Fans out to all enabled integrations in tier order f. Writes to the debug mirror (mirror.jsonl) if configured
  4. Updates the cursor on disk

The bridge has no HTTP server. It writes to shared files that the web process reads.

Web process (web/main.py)

FastAPI app (uvicorn ASGI server):

Route Purpose
GET / Redirect to /app/
GET /app/* React SPA
GET /setup First-run wizard
GET /healthz Basic health probe
GET /api/heartbeat Bridge liveness + last message
GET /api/v1/* REST API
GET /api/ui/* UI-specific API
GET /api/logs SSE stream from chatwire.jsonl

The web process reads chat.db independently (its own SQLite connection) for conversation history and search. It does not relay messages — the bridge does.

IPC via shared files

File Direction Purpose
~/.chatwire/config.json Both read Shared config
~/.chatwire/state/state.json Bridge writes Last-seen ROWID cursor
~/.chatwire/chatwire.jsonl Both write, web reads Structured log
~/.chatwire/echo_log.db Both write Bridge-echo dedup
~/.chatwire/read_state.db Web writes, bridge reads Conversation read state
~/.chatwire/mirror.jsonl Bridge writes Debug mirror
~/.chatwire/bridge_heartbeat Bridge writes Liveness timestamp
~/Library/Messages/chat.db Both read Apple's source of truth

Integration (plugin) architecture

Discovery

At startup, the bridge discovers integrations via two paths:

  1. Directory walk — scans integrations/ for subpackages with __init__.py that define an Integration-protocol class
  2. Entry points — calls importlib.metadata.entry_points(group="chatwire.integrations") and loads every registered class

Both paths converge to the same Integration protocol check.

Plugin tiers and sandbox

Plugins run inside SandboxedContext, which wraps BridgeContextImpl and blocks any attribute not allowed for that tier:

BridgeContextImpl (full access — bridge core only)
SandboxedContext (official tier)
    send_text(conversation_id, body)   ← opaque UUID, not raw handle
    send_file(conversation_id, data, mime)
    log_info/warn/error(msg)
    plugin_config                       ← isolated config dict
    mirror(event, **fields)

SandboxedContext (notify tier)
    log_info/warn/error(msg)
    plugin_config
    mirror(event, **fields)
    ← no send, no message text

ConversationMap maps opaque UUIDs ↔ real handles. Only the bridge core holds a ConversationMap instance. Plugins never learn raw phone numbers or email addresses.

Integration lifecycle

class Integration(Protocol):
    NAME: str                        # stable snake_case key
    SETTINGS_SCHEMA: dict            # JSON Schema
    TIER: str                        # notify | official | core

    async def start(ctx: BridgeContext) -> None: ...
    async def stop() -> None: ...
    async def on_inbound(msg: InboundMessage) -> None: ...

Config opt-in: a class is only instantiated if integrations.<name>.enabled: true in config.json. Installed but unconfigured plugins are silently skipped.

React frontend architecture

The frontend is a React 18 SPA built with Vite and TypeScript:

web/frontend/src/
  main.tsx              Entry point — React root + slot registrations
  App.tsx               Router (react-router v6) + QueryClient
  pages/
    ChatPage.tsx         Conversation list + message view
    SettingsPage.tsx     All settings sections (accordion)
    PopoutPage.tsx       Standalone conversation window
  components/
    Layout.tsx           Sidebar + main area + slot host
    MessageList.tsx      Virtualized message list (@tanstack/react-virtual)
    MessageBubble.tsx    Tapbacks, quoted replies, attachments, reactions
    ComposeBox.tsx       Text input + send + attachment + fuse banners
    ConversationList.tsx Sidebar conversation list + search
  hooks/
    useTheme.ts          CSS variable management
    useSounds.ts         Notification sounds
    useServerEvents.ts   SSE stream subscription
  plugins/
    registry.ts          Slot registry (registerSlot, getSlotComponents)
    SlotRenderer.tsx     Renders registered slot components
    StatsWidget.tsx      Built-in stats sidebar widget

State management

  • Server state — TanStack Query (react-query) with SSE-triggered invalidation
  • UI state — Zustand stores for theme, sounds, sidebar selection
  • Optimistic updates — messages appear immediately in the list; the SSE stream confirms delivery

Plugin slot system

Plugins register React components into named slots:

// Registration (in main.tsx or a plugin bundle)
import { registerSlot } from './plugins/registry'
import { StatsWidget } from './plugins/StatsWidget'

registerSlot('sidebar.panel', StatsWidget, { key: 'stats-widget' })

Available slots:

Slot Host Extra props
sidebar.panel Layout (sidebar)
message.toolbar MessageBubble msgRowid, fromMe
compose.extension ComposeBox handle
settings.page SettingsPage

Each slot is wrapped in <PluginErrorBoundary> — a crash shows an inline error chip, not a full-page failure.

macOS compatibility

Feature macOS 12 macOS 13+
Read messages
Send iMessage text
Tapback reactions (send)
Edit messages (send)
reply_to_guid in chat.db
date_edited in chat.db
RCS in chat.db ⚠️

macOS 13 Ventura is the dividing line. The bridge handles these differences internally — feature-detection queries the schema at startup.

See the full compatibility matrix in docs/wiki/compatibility.md.

Disk layout

Full disk layout is in docs/wiki/disk-layout.md. Key runtime paths:

Path Purpose
~/.chatwire/config.json Main config (mode 600)
~/.chatwire/state/ Bridge cursor, PID lock
~/.chatwire/chatwire.jsonl Structured log
~/.chatwire/plugins/<name>/ Per-plugin data
~/Library/LaunchAgents/dev.chatwire.*.plist Launchd agent plists
~/Library/Logs/chatwire/ Process stdout/stderr