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):
SENDBLUE_API_KEY, SENDBLUE_API_SECRET, SENDBLUE_SIGNING_SECRET
(= the Global Secret), IMESSAGE_FROM_NUMBER (your line's E.164),
optional SENDBLUE_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
.site host).
- 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
endpoint POST {base}/api/send-message with body
{number, content, from_number, media_url?}.
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, 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 — 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, 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_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).
1---2name: imessage-convex-agent3description: Build an iMessage AI agent channel on a Convex backend — Sendblue webhook → Convex brain → agent (OpenRouter via @convex-dev/agent) → replies, typing indicators, account linking, and card-image sends that work around iMessage's sender-side link-preview limitation (bot links showing "Click to Load Preview" / not unfurling). Use when adding "text this number, the agent handles it" to any Convex project.4---56# iMessage agent on Convex (Sendblue)78A production-extracted playbook. The `references/` directory ships the real9code: `sendblue-adapter.ts` (provider adapter), `schema.ts` (the four channel10tables), `webhook-and-brain.ts` (HTTP route + enqueue + the brain action with11its load-bearing ordering), `links.ts` (account linking), `agent-setup.md`12(@convex-dev/agent + OpenRouter wiring), `testing.md` (simulated-webhook13fixtures + checklist). Table/function names throughout are a recommended14shape, not requirements. (Provenance: extracted from hypersocialinc/savethis,15a private repo — this skill is self-contained; don't chase the pointer.)1617## Stack1819- **Sendblue** — the iMessage provider. Chosen because it's fully self-serve20 (dashboard.sendblue.com/company-signup — NOT the marketing site's "request a21 demo" page), has a free API sandbox (shared number, ~10 contacts), and a22 $100/mo flat "AI Agent" plan for a dedicated line. Apple Messages for23 Business is NOT self-serve (MSP + Apple review). All blue-bubble providers24 ride Apple's private protocol (Beeper Mini precedent) — keep the channel25 layer provider-agnostic so you can swap in Twilio SMS as a fallback.26- **Convex** — HTTP action webhook + mutation enqueue + action brain.27- **@convex-dev/agent** (Convex agent component) + **OpenRouter** as the model28 gateway (AI SDK `generateText` under the hood). Full wiring in29 `references/agent-setup.md`. For streaming architectures and client UX,30 install the `convex-streaming-agents` skill alongside this one — this skill31 covers the CHANNEL.3233## Provider setup (do this first)34351. Sign up at dashboard.sendblue.com/company-signup, grab from the dashboard:36 API key + API secret (auth headers on every send), the **Global Secret**37 (this is the webhook signing secret — Sendblue issues it, you don't mint38 it), and your line's number.392. Set Convex env vars — per deployment, via `npx convex env set` or the40 dashboard (they do NOT copy between dev and prod):41 `SENDBLUE_API_KEY`, `SENDBLUE_API_SECRET`, `SENDBLUE_SIGNING_SECRET`42 (= the Global Secret), `IMESSAGE_FROM_NUMBER` (your line's E.164),43 optional `SENDBLUE_API_BASE`.443. Register the webhook in the Sendblue dashboard:45 `https://<deployment>.convex.site/imessage/webhook?secret=<signing-secret>`46 — note **`.convex.site`**, not `.convex.cloud` (HTTP actions live on the47 `.site` host).484. On the sandbox: add your test phones as contacts (capped ~10).4950API base URL: `https://api.sendblue.co` (keep it in `SENDBLUE_API_BASE` so51sandbox/prod can differ).5253## Architecture5455```56Sendblue webhook (POST, sb-signing-secret header)57 → http.ts httpAction: verify secret → parse → enqueue58 → chats.enqueueInbound (mutation): dedupe by (channel, updateId),59 upsert chat row, schedule brain60 → chat.processInbound (action): resolve identity (unlinked → link flow,61 EARLY RETURN) → typing indicator → bind ownership → ensure thread →62 ingest media → agent.generateText → send reply → card sends63```6465Tables (full definitions in `references/schema.ts`): `chats` (channel,66chatKey=E.164, userId?, threadId?), `processedUpdates` (channel+updateId67dedupe), `identities` (channel, channelKey → userId), `linkCodes` (code,68userId, expiresAt).6970## Sendblue specifics (all learned the hard way)7172- **Auth on sends**: headers `sb-api-key-id` / `sb-api-secret-key`. Send73 endpoint `POST {base}/api/send-message` with body74 `{number, content, from_number, media_url?}`.75- **`from_number` is REQUIRED on the shared/free plan** — omitting it 400s.76- **Webhook secret arrives as the `sb-signing-secret` HEADER** (also accept77 `?secret=` on the registered URL as belt-and-braces). Compare per-character78 constant-time; fail closed but log loudly when the env var is unset.79- **Inbound payload fields**: `content`, `from_number`, `is_outbound`,80 `message_handle` (use as updateId), `media_url` (single string). Sendblue81 posts your OWN outbound messages and their status callbacks to the same82 webhook — drop `is_outbound === true` as an "echo", return 200.83- **Parse as a discriminated union** `inbound | echo | unrecognized` and LOG84 unrecognized shapes (keys only, no PII). A silent payload-shape change from85 the provider otherwise kills the channel with zero signal: 200s everywhere,86 no retries, nothing saved.87- **Typing indicator**: `POST {base}/api/send-typing-indicator` with88 `{number, from_number, state: "start", max_duration_ms}`. iMessage-only,89 requires an existing conversation (always true when replying). Cap at90 ~45s so a crashed run can't leave a phantom "…"; the reply clears it.91 Fire best-effort in a try/catch — never block the real work.92- **ONE webhook registration per number.** Registering dev + prod endpoints93 simultaneously double-replies and double-processes. Point the webhook at94 ONE deployment; test the other by curling simulated webhook payloads at it95 (fixtures in `references/testing.md`).9697## The unfurl lesson (why cards are attached images)9899iMessage link previews are generated **sender-side** by the sender's Messages100app. A bot's URL-only message shows a "Click to Load Preview" stub — being in101the recipient's contacts does NOT fix this (empirically verified). So:102103- **Never send a bare URL and expect a card.** Send the card as an attached104 image (`media_url` pointing at your OG/card image route) with the link105 embedded in text (`↳ https://…`). Text+URL messages render as plain106 tappable links with no stub; the attachment supplies the visuals.107- If the link's preview matters when HUMANS paste it (it does), keep OG meta108 server-rendered with `og:image:width/height` declared, image ~1080×1350 or109 1200×630. Make the OG image a **route handler** (`/card?params`) not a110 Next.js `opengraph-image` file convention — conventions can't read query111 params and take precedence over explicit metadata.112113## Account linking (texts act as a real user)114115App-issued short codes, no SMS verification service needed. Full code in116`references/links.ts` — including the ⟨A⟩ adaptation point for projects whose117auth subject IS the user (no separate identities-for-app mapping needed).118119- App calls `createLinkCode` (authed mutation) → 6-char code from a120 confusion-free alphabet (`23456789ABCDEFGHJKMNPQRSTUVWXYZ`), 15-min TTL,121 via `crypto.getRandomValues`, one live code per user (re-issue replaces).122 User texts the code to the line.123- Webhook side: if sender is unlinked and the text looks like a code124 (`looksLikeLinkCode`), `consumeLinkCode` creates the identity row125 (channel+E.164 → userId). Use `.collect()` not `.unique()` on code lookup126 (collision safety); on re-link, delete the old identity.127- The app shows a reactive `textLinkStatus` query so the linking screen flips128 to "Linked" the moment the code lands. Deep-link Messages pre-filled:129 `sms:<number>&body=<code>` (note `&`, not `?` — iOS quirk).130- `adminIssueCode` (internal mutation) issues codes from the CLI/dashboard so131 the whole flow is testable before any client UI exists.132- Offer "Add to Contacts" in-app: `CNContactViewController(forUnknownContact:)`133 with an org-typed `CNMutableContact` + brand image (needs134 `NSContactsUsageDescription`). Bot texts then show your name + icon.135136## The ownership trap (a known silent total-failure mode)137138If your agent's tools resolve data ownership through a chat/session row (e.g.139`chats.userId`) rather than the agent library's thread userId: **bind the140chat row to the linked user BEFORE running the agent** (`bindChatUser`).141Otherwise every tool write fails — it throws or silently no-ops depending on142how the tool is written — while the agent may still reply "Saved!", because143models gloss over tool errors. `bindChatUser` returns the surviving threadId144(null after a cross-account re-link), which feeds thread creation — see the145numbered ordering in `references/webhook-and-brain.ts`.146147- **Verify channel side effects against DB rows, never against reply text.**148- On re-link to a DIFFERENT account, also clear the chat's threadId — the new149 owner must not inherit the previous owner's conversation context.150151## The agent152153- Make a **text-channel variant** of your main agent: same instructions core,154 but strip every tool that renders in-app UI (cards, decks, choice pickers) —155 they emit nothing over Messages and you get replies like "Here are four156 picks —" followed by silence. Recommendations become numbered plain-text157 lists; add a text-channel addendum to the system prompt (plain text, no158 markdown headers/asterisks).159- **Thread per chat**, race-safe: create a thread, then an160 `ensureThreadId` mutation adopts whichever thread won a concurrent race so161 rapid messages don't split memory.162- **Media inbound**: provider URLs are short-lived — fetch to Convex storage163 IMMEDIATELY in the brain (cap ~3), then pass multimodal content:164 `messages: [{role:"user", content:[{type:"text",...}, {type:"image", image:new URL(servedUrl)}]}]`165 plus storage-id hints in the prompt so tools can persist them.166 Name unsupported types (PDFs…) in the prompt so the agent answers honestly167 instead of ignoring the attachment.168- **Split error handling: generation vs delivery.** Generation failure →169 "try again" nudge. Delivery failure AFTER the agent ran → log only; the170 agent's tool work already committed and a retry would duplicate it.171- Extract tool side effects (e.g. saved ids) from the run's `steps[].toolResults`172 defensively (`output` in AI SDK v5, `result` in v4).173174## Message mechanics175176- Chunk long replies (~2000 chars for iMessage) and send sequentially.177- No typing indicator for instant flows (link help/confirm) — fire it only178 before agent runs.179- Cap side-effect card sends (e.g. 3 per turn), each in its own try/catch —180 a failed card must never disturb the reply that already went out.181- Always return 200 to the provider for echoes/unrecognized/dupes so it182 doesn't retry them into double-processing.183184## Verification checklist185186Concrete fixtures and commands in `references/testing.md`.1871881. Curl a simulated webhook payload at the dev deployment (keeps the single189 prod webhook rule intact) — use the sandbox-confirmed payload shape, not190 an invented one.1912. Text the real line → check the **DB rows** for the side effect, not the192 reply bubble.1933. Send a photo; send a PDF (expect the honest "can't ingest" reply).1944. Text from an UNLINKED number → link-help flow, no agent run, no typing195 indicator.1965. Link, unlink, re-link from a second account → old thread dropped.1976. Confirm exactly one webhook registration in the Sendblue dashboard.198199## When NOT to use this skill200201- The agent/streaming core itself (workers, persisted partials, thinking-dots202 client UX) → `convex-streaming-agents`.203- SMS-only or Twilio-based channels — the pipeline shape transfers, but every204 Sendblue specific here doesn't.205- Apple Messages for Business — different beast entirely (MSP + Apple review,206 not self-serve).