iMessage agent on Convex (Sendblue)
A production-extracted playbook. The references/ directory ships the real code: sendblue-adapter.ts (provider adapter), schema.ts (the four channel tables), webhook-and-brain.ts (HTTP route + enqueue + the brain action with its load-bearing ordering), links.ts (account linking), agent-setup.md (@convex-dev/agent + OpenRouter wiring), testing.md (simulated-webhook fixtures + checklist). Table/function names throughout are a recommended shape, not requirements. (Provenance: extracted from hypersocialinc/savethis, a private repo — this skill is self-contained; don't chase the pointer.)
Stack
- Sendblue — the iMessage provider. Chosen because it's fully self-serve
(dashboard.sendblue.com/company-signup — NOT the marketing site's "request a demo" page), has a free API sandbox (shared number, ~10 contacts), and a $100/mo flat "AI Agent" plan for a dedicated line. Apple Messages for Business is NOT self-serve (MSP + Apple review). All blue-bubble providers ride Apple's private protocol (Beeper Mini precedent) — keep the channel layer provider-agnostic so you can swap in Twilio SMS as a fallback.
- Convex — HTTP action webhook + mutation enqueue + action brain.
- @convex-dev/agent (Convex agent component) + OpenRouter as the model
gateway (AI SDK generateText under the hood). Full wiring in references/agent-setup.md. For streaming architectures and client UX, install the convex-streaming-agents skill alongside this one — this skill covers the CHANNEL.
Provider setup (do this first)
- Sign up at dashboard.sendblue.com/company-signup, grab from the dashboard:
API key + API secret (auth headers on every send), the Global Secret (this is the webhook signing secret — Sendblue issues it, you don't mint it), and your line's number.
- Set Convex env vars — per deployment, via
npx convex env set or the
dashboard (they do NOT copy between dev and prod): SENDBLUEAPIKEY, SENDBLUEAPISECRET, SENDBLUESIGNINGSECRET (= the Global Secret), IMESSAGEFROMNUMBER (your line's E.164), optional SENDBLUEAPIBASE.
- Register the webhook in the Sendblue dashboard:
https://<deployment>.convex.site/imessage/webhook?secret=<signing-secret> — note .convex.site, not .convex.cloud (HTTP actions live on the .site host).
- On the sandbox: add your test phones as contacts (capped ~10).
API base URL: https://api.sendblue.co (keep it in SENDBLUEAPIBASE so sandbox/prod can differ).
Architecture
Sendblue webhook (POST, sb-signing-secret header)
→ http.ts httpAction: verify secret → parse → enqueue
→ chats.enqueueInbound (mutation): dedupe by (channel, updateId),
upsert chat row, schedule brain
→ chat.processInbound (action): resolve identity (unlinked → link flow,
EARLY RETURN) → typing indicator → bind ownership → ensure thread →
ingest media → agent.generateText → send reply → card sends
Tables (full definitions in references/schema.ts): chats (channel, chatKey=E.164, userId?, threadId?), processedUpdates (channel+updateId dedupe), identities (channel, channelKey → userId), linkCodes (code, userId, expiresAt).
Sendblue specifics (all learned the hard way)
- Auth on sends: headers
sb-api-key-id / sb-api-secret-key. Send
endpoint POST {base}/api/send-message with body {number, content, fromnumber, mediaurl?}.
from_number is REQUIRED on the shared/free plan — omitting it 400s.
- Webhook secret arrives as the
sb-signing-secret HEADER (also accept
?secret= on the registered URL as belt-and-braces). Compare per-character constant-time; fail closed but log loudly when the env var is unset.
- Inbound payload fields:
content, fromnumber, isoutbound,
messagehandle (use as updateId), mediaurl (single string). Sendblue posts your OWN outbound messages and their status callbacks to the same webhook — drop is_outbound === true as an "echo", return 200.
- Parse as a discriminated union
inbound | echo | unrecognized and LOG
unrecognized shapes (keys only, no PII). A silent payload-shape change from the provider otherwise kills the channel with zero signal: 200s everywhere, no retries, nothing saved.
- Typing indicator:
POST {base}/api/send-typing-indicator with
{number, fromnumber, state: "start", maxduration_ms}. iMessage-only, requires an existing conversation (always true when replying). Cap at ~45s so a crashed run can't leave a phantom "…"; the reply clears it. Fire best-effort in a try/catch — never block the real work.
- ONE webhook registration per number. Registering dev + prod endpoints
simultaneously double-replies and double-processes. Point the webhook at ONE deployment; test the other by curling simulated webhook payloads at it (fixtures in references/testing.md).
The unfurl lesson (why cards are attached images)
iMessage link previews are generated sender-side by the sender's Messages app. A bot's URL-only message shows a "Click to Load Preview" stub — being in the recipient's contacts does NOT fix this (empirically verified). So:
- Never send a bare URL and expect a card. Send the card as an attached
image (media_url pointing at your OG/card image route) with the link embedded in text (↳ https://…). Text+URL messages render as plain tappable links with no stub; the attachment supplies the visuals.
- If the link's preview matters when HUMANS paste it (it does), keep OG meta
server-rendered with og:image:width/height declared, image ~1080×1350 or 1200×630. Make the OG image a route handler (/card?params) not a Next.js opengraph-image file convention — conventions can't read query params and take precedence over explicit metadata.
Account linking (texts act as a real user)
App-issued short codes, no SMS verification service needed. Full code in references/links.ts — including the ⟨A⟩ adaptation point for projects whose auth subject IS the user (no separate identities-for-app mapping needed).
- App calls
createLinkCode (authed mutation) → 6-char code from a
confusion-free alphabet (23456789ABCDEFGHJKMNPQRSTUVWXYZ), 15-min TTL, via crypto.getRandomValues, one live code per user (re-issue replaces). User texts the code to the line.
- Webhook side: if sender is unlinked and the text looks like a code
(looksLikeLinkCode), consumeLinkCode creates the identity row (channel+E.164 → userId). Use .collect() not .unique() on code lookup (collision safety); on re-link, delete the old identity.
- The app shows a reactive
textLinkStatus query so the linking screen flips
to "Linked" the moment the code lands. Deep-link Messages pre-filled: sms:<number>&body=<code> (note &, not ? — iOS quirk).
adminIssueCode (internal mutation) issues codes from the CLI/dashboard so
the whole flow is testable before any client UI exists.
- Offer "Add to Contacts" in-app:
CNContactViewController(forUnknownContact:)
with an org-typed CNMutableContact + brand image (needs NSContactsUsageDescription). Bot texts then show your name + icon.
The ownership trap (a known silent total-failure mode)
If your agent's tools resolve data ownership through a chat/session row (e.g. chats.userId) rather than the agent library's thread userId: bind the chat row to the linked user BEFORE running the agent (bindChatUser). Otherwise every tool write fails — it throws or silently no-ops depending on how the tool is written — while the agent may still reply "Saved!", because models gloss over tool errors. bindChatUser returns the surviving threadId (null after a cross-account re-link), which feeds thread creation — see the numbered ordering in references/webhook-and-brain.ts.
- Verify channel side effects against DB rows, never against reply text.
- On re-link to a DIFFERENT account, also clear the chat's threadId — the new
owner must not inherit the previous owner's conversation context.
The agent
- Make a text-channel variant of your main agent: same instructions core,
but strip every tool that renders in-app UI (cards, decks, choice pickers) — they emit nothing over Messages and you get replies like "Here are four picks —" followed by silence. Recommendations become numbered plain-text lists; add a text-channel addendum to the system prompt (plain text, no markdown headers/asterisks).
- Thread per chat, race-safe: create a thread, then an
ensureThreadId mutation adopts whichever thread won a concurrent race so rapid messages don't split memory.
- Media inbound: provider URLs are short-lived — fetch to Convex storage
IMMEDIATELY in the brain (cap ~3), then pass multimodal content: messages: [{role:"user", content:[{type:"text",...}, {type:"image", image:new URL(servedUrl)}]}] plus storage-id hints in the prompt so tools can persist them. Name unsupported types (PDFs…) in the prompt so the agent answers honestly instead of ignoring the attachment.
- Split error handling: generation vs delivery. Generation failure →
"try again" nudge. Delivery failure AFTER the agent ran → log only; the agent's tool work already committed and a retry would duplicate it.
- Extract tool side effects (e.g. saved ids) from the run's
steps[].toolResults
defensively (output in AI SDK v5, result in v4).
Message mechanics
- Chunk long replies (~2000 chars for iMessage) and send sequentially.
- No typing indicator for instant flows (link help/confirm) — fire it only
before agent runs.
- Cap side-effect card sends (e.g. 3 per turn), each in its own try/catch —
a failed card must never disturb the reply that already went out.
- Always return 200 to the provider for echoes/unrecognized/dupes so it
doesn't retry them into double-processing.
Verification checklist
Concrete fixtures and commands in references/testing.md.
- Curl a simulated webhook payload at the dev deployment (keeps the single
prod webhook rule intact) — use the sandbox-confirmed payload shape, not an invented one.
- Text the real line → check the DB rows for the side effect, not the
reply bubble.
- Send a photo; send a PDF (expect the honest "can't ingest" reply).
- Text from an UNLINKED number → link-help flow, no agent run, no typing
indicator.
- Link, unlink, re-link from a second account → old thread dropped.
- Confirm exactly one webhook registration in the Sendblue dashboard.
When NOT to use this skill
- The agent/streaming core itself (workers, persisted partials, thinking-dots
client UX) → convex-streaming-agents.
- SMS-only or Twilio-based channels — the pipeline shape transfers, but every
Sendblue specific here doesn't.
- Apple Messages for Business — different beast entirely (MSP + Apple review,
not self-serve).