Connectors — Overview¶
A connector is the single platform-specific component in AMC. Its one job has two directions:
- Inbound — turn raw platform events (a new iMessage row, a Discord gateway message) into the normalized message envelope and hand it to the adapter.
- Outbound — turn an adapter send request into a concrete platform action (an AppleScript send, a Discord REST call).
Everything above the connector — the message sink, SQLite storage, the REST API, and the MCP wrapper — is platform-agnostic. All of the platform knowledge (the Discord SDK, AppleScript, the chat.db schema and mach-time quirks) is encapsulated inside the connector. That is what lets you add a platform by writing one connector and changing nothing else.
Two connectors ship today: iMessage and Discord. This page documents the shared contract they both satisfy; the detailed per-platform mechanics live on their own pages.
Adding a new platform
Write one connector that produces the normalized envelope inbound and accepts a send(...) request outbound. The wrapper, the REST surface, and the storage schema stay unchanged. See the Architecture overview.
The connector contract¶
There is no shared base class. A connector is anything that satisfies a structural (duck-typed) protocol that the adapter's lifespan wiring and the /healthz registry accept:
from typing import Protocol, Sequence, Any
class SendResult(Protocol):
ok: bool # True on success, False on any failure
message_id: str | None # platform-native id on success; None on failure
error: str | None # error code when ok is False; None on success
detail: str | None # human-readable detail for logs; may be None
class Connector(Protocol):
# Flipped to True on a *permanent* platform failure (auth revoked,
# Full Disk Access missing). Read by /healthz; the connector owns it.
degraded: bool
async def start(self, *args: Any) -> None:
"""Begin the inbound loop."""
async def stop(self) -> None: # iMessage; Discord exposes close()
"""Stop cleanly."""
async def send(
self,
channel_id: str,
text: str,
*,
reply_to: str | None = None,
attachments: Sequence[Any] | None = None,
) -> SendResult:
"""Perform a platform send and report the outcome."""
The signatures are real: degraded is a plain bool attribute the connector sets; send(...) returns a frozen SendResult dataclass; iMessage exposes stop() and Discord exposes close() for clean shutdown.
Lifecycle & conditional start¶
Connectors are started inside the adapter's FastAPI lifespan (amc/app.py). Each one starts only when its preconditions are met:
- Discord starts only if
AMC_DISCORD_BOT_TOKENis set. It runs as anasynciotask (discord_connector.start(token)). - iMessage starts only on macOS (
sys.platform == "darwin") when~/Library/Messages/chat.dbexists and is readable. A missing path raisesChatDbPathMissingErrorand the connector is skipped.
Each connector that successfully starts registers itself:
healthz.register_connector("discord", discord_connector)
healthz.register_connector("imessage", imessage_connector)
On shutdown they are stopped in reverse order of start, so in-flight work drains before the engine is disposed.
A skipped connector is not an error
If a connector's preconditions aren't met it simply never registers, and /healthz reports that source as disabled. That is the normal state for a Discord-only deployment, or for the adapter running on a non-macOS host.
Health model¶
/healthz derives a state for each known source (imessage, discord) purely from the registry and the connector's own degraded flag. The route only reads the flag — it never mutates connector state.
| State | Condition |
|---|---|
disabled |
No connector registered for this source (not configured, or preconditions unmet). |
degraded |
Connector registered and connector.degraded == True. |
ok |
Connector registered and not degraded. |
Connectors set degraded = True themselves on a permanent platform failure:
- iMessage — the
chat.dbpath goes missing, or Full Disk Access is denied. - Discord — a
discord.Forbidden/401on send (a revoked or invalid bot token).
The inbound handoff¶
Every connector hands inbound messages to the adapter through the same sink method, inside the adapter's single transaction:
source is the connector's platform-native checkpoint — its resume point:
- iMessage — the
chat.dbROWIDas a string (e.g."4821"). - Discord — a JSON cursor
{"seq": <int>, "resume_url": <str|null>}.
Inside that one transaction the sink does all of the platform-agnostic work: it upserts the sender, channel, and attachments; inserts the message; advances connector_state.cursor to the supplied checkpoint; and — for allowlisted inbound, when a webhook is configured — enqueues a delivery. See the Architecture overview and the Storage Schema.
flowchart LR
P[Platform event]:::warning --> C[Connector<br/>build envelope]:::secondary
C -->|record_inbound| S[sink<br/>single transaction]:::success
S --> DB[(SQLite)]:::neutral
classDef secondary fill:#f3e8ff,stroke:#7c3aed,color:#000
classDef success fill:#dcfce7,stroke:#16a34a,color:#000
classDef warning fill:#fef3c7,stroke:#d97706,color:#000
classDef neutral fill:#f3f4f6,stroke:#6b7280,color:#000
Outbound routing¶
The POST /messages/send route picks the connector by inspecting the channel_id:
- A
discord:prefix routes to the Discord connector. - Anything else routes to the iMessage connector.
Both connectors return a SendResult. The route maps failures onto HTTP errors:
| Connector failure | HTTP response |
|---|---|
Auth rejected (401 / 403, e.g. revoked token) |
502 PLATFORM_AUTH |
| Any other send failure (no connector configured, channel not found, platform error) | 502 PLATFORM_SEND_FAILED |
Per-platform differences at a glance¶
| Aspect | iMessage | Discord |
|---|---|---|
| Inbound transport | Poll chat.db (~1s) |
Gateway WebSocket (push) |
| Outbound transport | AppleScript via osascript |
Discord REST |
channel_id format |
E.164 / email, no prefix | discord:dm:<id> / discord:channel:<id> |
| Inbound cursor | ROWID string |
{"seq": …, "resume_url": …} JSON |
| Inbound attachments | Re-hosted by the adapter | Not yet implemented |
For the full mechanics — polling and attributedBody decoding on iMessage, gateway resume and intents on Discord — see the iMessage and Discord pages.