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):
- Opens a read-only SQLite connection to
~/Library/Messages/chat.db - Queries for message ROWIDs newer than the last-seen cursor (
state/state.json) - 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 - 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:
- Directory walk — scans
integrations/for subpackages with__init__.pythat define anIntegration-protocol class - 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 |