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
generateTextunder the hood). Full wiring inreferences/agent-setup.md. For streaming architectures and client UX, install theconvex-streaming-agentsskill 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 setor the dashboard (they do NOT copy between dev and prod):SENDBLUE_API_KEY,SENDBLUE_API_SECRET,SENDBLUE_SIGNING_SECRET(= the Global Secret),IMESSAGE_FROM_NUMBER(your line's E.164), optionalSENDBLUE_API_BASE. - 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.sitehost). - On the sandbox: add your test phones as contacts (capped ~10).
API base URL: https://api.sendblue.co (keep it in SENDBLUE_API_BASE 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 endpointPOST {base}/api/send-messagewith body{number, content, from_number, media_url?}. from_numberis REQUIRED on the shared/free plan — omitting it 400s.- Webhook secret arrives as the
sb-signing-secretHEADER (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,from_number,is_outbound,message_handle(use as updateId),media_url(single string). Sendblue posts your OWN outbound messages and their status callbacks to the same webhook — dropis_outbound === trueas an "echo", return 200. - Parse as a discriminated union
inbound | echo | unrecognizedand 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-indicatorwith{number, from_number, state: "start", max_duration_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_urlpointing 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/heightdeclared, image ~1080×1350 or 1200×630. Make the OG image a route handler (/card?params) not a Next.jsopengraph-imagefile 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, viacrypto.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),consumeLinkCodecreates 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
textLinkStatusquery 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-typedCNMutableContact+ brand image (needsNSContactsUsageDescription). 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
ensureThreadIdmutation 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[].toolResultsdefensively (outputin AI SDK v5,resultin 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).