Skip to content

feat: text a Dot over iMessage and SMS through Sendblue - #107

Open
lookevink wants to merge 5 commits into
CopilotKit:mainfrom
lookevink:sendblue-text-messages
Open

lookevink wants to merge 5 commits into
CopilotKit:mainfrom
lookevink:sendblue-text-messages

Conversation

@lookevink

@lookevink lookevink commented Oct 6, 2026 •

Copy link
Copy Markdown

Workflow

Text a Dot from your phone over iMessage or SMS through a Sendblue line. Each allowlisted phone number gets one persistent conversation with the selected Dot, which also appears in the web app. Texting /new starts a fresh one.

No Mac required. Sendblue hosts the iMessage line, so the machine running OpenDots doesn't need to be a Mac or have the Messages app. It works wherever OpenDots runs, including Linux, Docker, and hosted servers.

Setup is a set of SENDBLUE_* variables, a public HTTPS route to the webhook port, and one receive-webhook registration. It is documented in docs/SETUP.md#text-messages-sendblue, including account creation, a two-turn handset check, and removal. Without those variables nothing changes: no listener starts and Settings reads Sendblue: not configured.

How it works

  • Conversations: each text runs through the existing Platform.turn(), the server-side path scheduled tasks use, in a conversation created with Platform.createConversation(). History lives in Intelligence like any other conversation. I did not use a Channels SDK direct adapter: in Intelligence mode, a Channel whose only adapter is direct reports setup_required (no managed provider is declared), and direct adapters keep conversation state in the adapter rather than in Intelligence Threads, so texts would not show up in the web app.
  • Listener: a separate Hono listener on HOST and SENDBLUE_WEBHOOK_PORT (default 4313) serves only POST /sendblue/webhook and returns 404 for everything else. A tunnel or proxy pointed at it cannot reach the app API. That matters because, without OWNER_TOKEN, the app API relies on the Host check.
  • Admission: the listener compares sb-signing-secret with SENDBLUE_WEBHOOK_SECRET in constant time before reading the body, and caps the body at 64 KiB. It then validates field types. Outbound echoes, non-RECEIVED events, group messages, other lines, and senders outside SENDBLUE_ALLOWED_NUMBERS are acknowledged and ignored; an unknown sender is logged without the number. The allowlist has no wildcard. Attachments become a text note for the Dot, and media URLs are never fetched.
  • Delivery semantics: the webhook is acknowledged immediately, because Sendblue waits 45 seconds and a Dot turn can take 90. Turns then run one at a time. A burst of texts from one sender becomes one turn; /new is never merged into the texts around it. Message handles are deduplicated in SQLite for seven days. When the 32-item queue is full, the webhook answers 503 before claiming the handle, so Sendblue's retry is accepted later.
  • Replies: Markdown is converted to plain text and split into parts of at most 1,500 characters, which stays under the 1,600-character SMS limit. Each part goes to POST /api/send-message and needs a message_handle, an accepted status, and no error_code. Sendblue has no idempotency key, so a rejected or unconfirmed part is never resent and later parts are skipped. Logs carry only safeFailure() names and HTTP statuses, never message text, numbers, or provider bodies.
  • Pause and errors: while OpenDots is paused, the bridge replies with the same paused notice as Slack. A failed turn gets a generic reply.
  • Lifecycle: Platform.start() starts the listener only when the settings are valid, the Dot exists, and conversations are configured. Otherwise Settings shows setup required and the log names the missing settings without their values. Platform.stop() closes the listener and aborts any in-flight turn; nothing is sent after shutdown. A port conflict reports activation failed without affecting the app.
  • Storage: two new tables, sendblue_threads (number → conversation) and sendblue_inbound (recent handles), are created next to the existing workspace tables. No existing table changes.

Relationship to #53

#53 bridges the Messages app on a signed-in Mac. This PR uses a hosted Sendblue line instead, so it runs in containers or on a server without Full Disk Access, and it also covers SMS. Both use the same model of one Platform.turn() conversation per sender. They touch the same setup-status and config spots, so I'm happy to rebase onto whichever lands first.

Verification

Base 565bf781d654339ee1ce83b17ee00d76d679608e. The evidence below was collected at dce22939d406d00161eb75c86cb8aebf4705e683; the current head dad6250ca8ed03ccf9f66bd3dd013943e3c78938 only adds the no-Mac wording to the README and setup guide, and npm run check-format passes on it.

  • npm run check-format, lint, typecheck, test and build all pass on Node 26.3.1. Tests: 266 passing, 254 existing plus 12 new. npm test and npm run build also pass on Node 24.10.0, the CI major.
  • tests/sendblue.test.ts uses the real Platform, the Store/WorkspaceStore SQLite (file-backed for the restart case), Platform.start()/stop(), the real HTTP listener, and a local HTTP stand-in for the Sendblue API. Only Intelligence thread creation and the model turn are stubbed. It covers:
    • two turns in one conversation, then a restart that keeps the mapping, ignores a redelivered handle, and starts a new conversation on /new
    • missing or wrong secret, oversized bodies (with and without content-length), malformed JSON, invalid field types, and paths other than the webhook
    • echoes, receipts, groups, other lines, unknown senders, and empty texts
    • duplicate handles, a full queue followed by an accepted retry, burst batching, and /new separation
    • the paused notice, and a failed turn whose log omits the raw error
    • HTTP 500, an ERROR status, a missing handle, a nonzero error_code, and a dropped connection: each is sent once and never resent
    • a three-part reply that stops after a failed second part
    • attachments described in text, never fetched
    • the listener not starting without conversations or with an unknown Dot, a port conflict, and shutdown during a turn that either rejects or returns late
    • Markdown conversion, and splitting on paragraph and surrogate-pair boundaries
  • On base, the same test file fails with Cannot find module '../src/server/sendblue.js'.
  • Mutation check: removing any of these guards fails at least one test:
    • the secret check
    • deduplication
    • checking queue capacity before claiming the handle
    • the line filter
    • the surrogate-safe split
    • the no-resend rule
    • either shutdown guard
    • /new separation
  • Built-server smoke test (node dist/server/server/index.js with fixture keys and DO_NOT_TRACK=1):
    • /api/workspace reports sendblue: "listening".
    • An unauthenticated callback gets 401. An unknown sender gets 200 and a log line without the number. Other paths get 404.
    • SIGTERM exits 0 and closes the listener.
    • With partial settings, status is setup_required, the log names only the missing settings, and no listener starts.
  • Settings & setup reads "Sendblue: listening." and wraps cleanly at desktop width and at 375 px.
  • I ran the documented webhook-registration snippet against a local stand-in. It sends {"type":"receive","webhooks":[{url, secret, sendblue_numbers}]}, reports "Already registered" on a second run, and prints no credentials.

Not verified: a live Sendblue account, delivery to a real handset, SMS fallback, or a live Intelligence and model turn started by a text. The README lists text messages next to Slack as still needing connected-service verification, and the setup guide includes the handset checklist. A QUEUED response means Sendblue accepted the message, not that the phone received it.

Limitations

  • Direct one-to-one text only. Not supported: group chats, reactions, typing indicators, and texts started by scheduled tasks.
  • Deduplication is durable, but processing is at-most-once across restarts. A text accepted just before shutdown may go unanswered and needs to be resent.
  • Every allowlisted number acts as the single OpenDots owner, with the selected Dot's tools and Space access. The allowlist trusts the sender number Sendblue reports, and SMS sender numbers can be spoofed. The docs say so.
  • Which contacts a Sendblue account can reach, and whether webhooks and replies are available, depends on its plan. The guide tells users to check their account.

Rollback

Revert the commit and remove the SENDBLUE_* variables. The two new tables are inert without the feature, and no existing data changes.

AI assistance

The implementation, tests, docs, and this description were generated with Claude Code. Maintainer review is pending.

🤖 Generated with Claude Code

lookevink and others added 2 commits October 6, 2026 11:10
Add an optional Sendblue webhook bridge. Each allowlisted phone number
gets one persistent conversation with the selected Dot, answered through
the same server-side turn path scheduled tasks use, so texts appear in
the web app. `/new` starts a fresh conversation.

The webhook runs on its own port and serves only POST /sendblue/webhook,
so a tunnel never exposes the app. It checks the shared webhook secret
before reading a bounded body, ignores echoes, receipts, groups, other
lines and unknown senders, deduplicates message handles in SQLite, and
answers 503 without claiming a handle when its bounded queue is full.
Replies are plain text in SMS-safe parts and are never resent after an
unconfirmed send.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sendblue hosts the iMessage line, so OpenDots can answer texts from
Linux, Docker, or any server without a Mac or the Messages app.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@jerelvelarde jerelvelarde left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Useful optional text channel and good template fit. Webhook authentication, sender allowlisting, parsing limits, deduplication, listener separation and safe logs were checked against the official Sendblue contract. Hold merge for the shared-queue timeout issue below. All 45 focused tests and full branch checks pass. Synthetic HTTP/lifecycle fixtures were used; no live messages, provider calls or account registration occurred.

Comment thread src/server/sendblue.ts
.some((thread) => thread.id === current && thread.dotId === dotId)
)
return current;
const { id } = await this.host.createConversation(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Include conversation creation in the reply deadline. A first message or /new awaits createConversation without the abort signal; the installed client has no request timeout. A stalled creation therefore keeps the shared inbox queue blocked after the two-minute deadline and prevents later senders from receiving replies. A deferred-creation fixture confirms the deadline aborts while draining remains active and no turn begins. Abort or bound creation as well as the Dot turn, and cover pending creation timing out with the queue continuing.

lookevink and others added 3 commits October 6, 2026 17:23
Conversation creation cannot be cancelled, so a stalled Intelligence
request kept the shared queue blocked past the two-minute deadline and
held up replies to every sender, as well as shutdown. Stop waiting for
the whole reply step at the deadline, and never start a turn once it has
passed. A conversation created late is kept for the sender's next text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts:
#	src/server/platform.ts
#	src/server/workspace.ts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants