Messaging infrastructure for deploying an AI agent over iMessage with Sendblue.
This package is intentionally transport and orchestration focused. The developer brings a chat endpoint; this package handles Sendblue webhook ingestion, outbound delivery, status tracking, deduplication, and conversation intelligence.
The root package is scaffolded as TypeScript ESM with Vitest test infrastructure:
npm test
npm run setup:e2e
npm run verify:e2e
npm run dev:e2e
npm run capture:fixtures
npm run capture:guided
npm run probe:sendblue
npm run test:e2e
npm run typechecknpm test runs hardware-free unit and integration tests. The repo owns the
E2E orchestration scripts, but npm run test:e2e still requires a real Sendblue
line, Messages.app on macOS, read access to ~/Library/Messages/chat.db, and
an ngrok tunnel.
The v0.2 runtime buffers rapid direct-message bursts, calls the chat endpoint
with both an aggregated message string and structured messages[], sends
agent replies through an ordered per-conversation queue, and advances that queue
from Sendblue status callbacks. iMessage/RCS queues advance on DELIVERED;
SMS/downgraded queues advance on SENT.
The rich capability contract keeps the v0.2 message, messages, and
silence response forms, and adds actions[] for hosted media, send effects,
reactions, reply intent, silence, and addressed group replies. Direct reply
intent is preserved for chat endpoints but currently sends as a normal Sendblue
message because Sendblue direct sends do not expose a native reply target
parameter. Read receipts and typing refreshes are best-effort agent-side API
calls around chat processing and ordered delivery. XML-style tags are documented
as a compatibility layer for chat endpoints that produce tagged text instead of
structured JSON.
Direct conversations are keyed as direct:{sendblueLine}:{phoneNumber}. Group
messages are silent unless the message is addressed to AGENT_DISPLAY_NAME or
references a known agent outbound. Unaddressed groups continue to be
acknowledged and deduped without calling the chat endpoint.
When REDIS_URL is configured, state and buffer timers use Redis/BullMQ. Without
Redis, the package uses in-memory state for local development and tests. Redis is
the production path for durable buffering, dedupe, and queue state.
The agent ships with a dependency-free observability layer:
GET /health— liveness probe (uptime, version, node version).GET /ready— readiness probe with Redis ping and buffer-scheduler stats; returns 503 when a dependency is unhealthy.GET /metrics— Prometheus text exposition (counters, gauges, histograms for webhooks, chat dispatch, outbound sends, status callbacks, retries, SMS-limit stalls, rate-limit pacing).GET /admin/limits,/admin/conversations/:key,/admin/status/:messageHandle,/admin/queue,/admin/dedupe?messageHandle=...— operator introspection. PII redacted by default; pass?reveal=trueto unmask.- A request-scoped
traceId(also surfaced via thex-trace-idresponse header) is stamped on every pino log line. The traceId persists on the conversation record and outbound handle mapping, so a status callback that arrives later carries aconversationTraceIdchained to the original webhook.
/metrics and every /admin/* route mount only when ADMIN_API_TOKEN is set; the route table is empty when the token is unset, preventing accidental exposure. See docs/features/operational-visibility.md.
- Architecture
- Configuration and tunables
- Inbound webhooks
- Outbound Sendblue client
- Webhook security
- Message buffering and interruptions
- Ordered delivery
- Status tracking
- Typing indicators
- Identity resolver
- Conversation state and chat contract
- Rich chat actions
- Sendblue contact upsert
- Agent-plan limit tracking
- Operational visibility (metrics, tracing, health, introspection)
- Persistence
- Testing infrastructure
- Observed Sendblue payload structures
Try any of the examples locally — no Sendblue account or device required:
npm run example:chat -- action-catalog # every action type
npm run example:chat -- scripted-flow # pizza pickup state machine
npm run example:chat -- minimal-chat-endpoint # echo bot
npm run example:chat -- showcase-bot # LLM-backed (needs ANTHROPIC_API_KEY)The CLI boots the example in a child process and drops you into a REPL that posts properly-shaped chat requests and pretty-prints the response.
To exercise the same examples with real iMessage to your Sendblue line — agent + ngrok + webhooks all wired up automatically:
# .env has SENDBLUE_*, NGROK_AUTHTOKEN, E2E_TEST_DEVICE_NUMBER
npm run example:dev -- showcase-bot
# message your Sendblue line from your device; the bot respondsSee examples/README.md for the full tour, the REPL
commands (/sms, /reset, /raw), and how the hardware loop is wired.
Examples in examples/:
minimal-chat-endpoint— smallest possibleCHAT_ENDPOINT_URLecho bot.identity-lookup— optionalUSER_LOOKUP_URLresolver.action-catalog— one labeled handler per chat action type (message,media,reply,reaction,silence, send effects, group routing, SMS fallback) plus an XML-tag mode.scripted-flow— deterministic pizza pickup state machine that walks through every action type as a real conversation arc, no LLM.showcase-bot— reference architecture for a real LLM-backed bot. Multi-provider via Vercel AI SDK; tools build theactions[]chat contract directly. Lives in its ownpackage.json.
Copy .env.example to .env and set the values for local development.
Required for the agent:
PUBLIC_BASE_URLCHAT_ENDPOINT_URLSENDBLUE_API_KEY_IDSENDBLUE_API_SECRET_KEYSENDBLUE_FROM_NUMBER(must be valid E.164 — leading+then 10–15 digits, e.g.+15551234567. Invalid values throw atloadConfigstartup so a.envtypo fails fast instead of silently 400ing the first Sendblue call.)
Optional for webhook secret validation:
SENDBLUE_WEBHOOK_SECRETSENDBLUE_WEBHOOK_SECRET_HEADER(defaults tosb-signing-secret)
Optional for conversation intelligence:
REDIS_URLBUFFER_BASE_TIMEOUT_MS(defaults to2000)BUFFER_GROWTH_FACTOR(defaults to1.25)BUFFER_MAX_TIMEOUT_MS(defaults to8000)BUFFER_NOISE_MAX_DEVIATION(defaults to0.3)MAX_REPROCESS_ATTEMPTS(defaults to2)OUTBOUND_DELIVERY_TIMEOUT_MS(defaults to30000)USER_LOOKUP_URLOUTBOUND_TYPING_INDICATORS_ENABLED(defaults totrue)INBOUND_TYPING_STATE_ENABLED(defaults totrue)
Optional for rich Sendblue actions:
CHAT_RESPONSE_PARSE_TAGS(defaults totrue)CHAT_RESPONSE_MESSAGE_TAG(defaults tomessage)CHAT_RESPONSE_NO_RESPONSE_TAG(defaults tono_response)CHAT_RESPONSE_REACTION_TAG(defaults toreaction)CHAT_RESPONSE_REPLY_TAG(defaults toreply)READ_RECEIPTS_ENABLED(defaults tofalse; gates best-effortPOST /api/mark-readcalls. Per Sendblue's docs read receipts work for iMessage and RCS (not SMS or downgraded). The feature is also account- gated by Sendblue — contact support@sendblue.com to enable. There is noREADstatus callback.)READ_RECEIPT_DEBOUNCE_MS(defaults to250)TYPING_REFRESH_INTERVAL_MS(defaults to5000)TYPING_REFRESH_MAX_MS(defaults to120000)AGENT_DISPLAY_NAME(defaults tosb-agent)VALID_USER_REQUIRED(defaults tofalse)
Optional for Sendblue contact upsert (see docs/features/contact-upsert.md):
SENDBLUE_CONTACTS_ENABLED(defaults tofalse; opt in once yourUSER_LOOKUP_URLreturns name fields. Group inbound only fires the upsert hook on invoked & authorized groups.)SENDBLUE_CONTACTS_DEFAULT_TAGS(comma-separated, defaults empty; merge with resolver tags is case-insensitive so'Agent'+'agent'collapse to one entry)SENDBLUE_CONTACTS_DEDUPE_TTL_SECONDS(defaults to2592000— 30 days; must be>= 1, the loader rejects0)
Optional for Agent-plan limit tracking and retries (see docs/features/plan-limits.md):
OUTBOUND_RATE_LIMIT_PER_SECOND(defaults to1;0disables pacing)OUTBOUND_RATE_LIMIT_PER_HOUR(defaults to2000)OUTBOUND_RATE_LIMIT_PER_DAY(defaults to4000)INBOUND_CONTACTS_PER_DAY_WARN_THRESHOLD/INBOUND_CONTACTS_PER_DAY_LIMIT(defaults800/1000)FOLLOW_UP_DAILY_WARN_THRESHOLD/FOLLOW_UP_DAILY_LIMIT(defaults160/200; track-only)REPLY_WINDOW_HOURS(defaults to24)TRANSIENT_RETRY_MAX_ATTEMPTS/TRANSIENT_RETRY_BASE_MS/TRANSIENT_RETRY_MAX_MS(defaults3/1000/60000; covers5509/5003/4001/429/5xx andhttpStatus: 0network failures.BASE_MS <= MAX_MSenforced at boot.)SMS_LIMIT_RETRY_INTERVAL_MS(defaults to3600000— 1h, must be>= 1) /SMS_LIMIT_MAX_ATTEMPTS(defaults to24, must be>= 1; the cap is now actually enforced — the agent tracks attempts and aborts after exhaustion. Stalls are persisted withconversationKeyso a fresh process can resume scheduled retries on boot viarecoverPendingRetries.)ADMIN_API_TOKEN(unset =GET /metricsand every/admin/*introspection route — including/admin/limits— is not mounted; set to enable the read-only counter snapshot, Prometheus scrape, and redacted-by-default introspection endpoints. Token compared viacrypto.timingSafeEqual; eitherAuthorization: Bearer <token>orx-admin-api-token: <token>is accepted.)
Optional for operational visibility (see docs/features/operational-visibility.md):
METRICS_LABEL_CARDINALITY_LIMIT(defaults to1000; per-metric guardrail before excess label combinations fold into a__overflow__sentinel series)READY_REDIS_TIMEOUT_MS(defaults to500; timeout on the Redis ping issued byGET /ready. WhenREDIS_URLis unset,/readyshort-circuits with{ kind: "in_memory" }.)
Required only for E2E:
E2E_AGENT_PORTE2E_PUBLIC_BASE_URL(optional override; normally generated by ngrok)E2E_TEST_DEVICE_NUMBERE2E_MESSAGES_DB_PATH(optional, defaults to~/Library/Messages/chat.db)NGROK_AUTHTOKENNGROK_DOMAIN(optional, for a reserved stable ngrok domain)SHOWCASE_MEDIA_URL(optional public HTTPS asset forshowcase:e2e)SHOWCASE_READ_TYPING_DELAY_MS(optional slow response delay for the read-receipt/typing showcase step; defaults to15000)SHOWCASE_BUFFER_BASE_TIMEOUT_MS,SHOWCASE_BUFFER_GROWTH_FACTOR,SHOWCASE_BUFFER_MAX_TIMEOUT_MS, andSHOWCASE_BUFFER_NOISE_MAX_DEVIATIONtune the live buffering demo without changing normal agent buffer settings.SHOWCASE_TYPING_REFRESH_INTERVAL_MSandSHOWCASE_TYPING_REFRESH_MAX_MStune the dedicated outbound typing demo.
Run npm run setup:e2e to create a local gitignored .env file if one does
not already exist. The repo scripts load .env for both agent and real-device
E2E settings.
npm run sendblue:webhooks configures Sendblue to call:
POST {PUBLIC_BASE_URL}/webhook/receivePOST {PUBLIC_BASE_URL}/webhook/status
Use npm run sendblue:webhooks -- --all for capture sessions that should also
register typing_indicator, call_log, line_blocked, line_assigned, and
contact_created callbacks.
The webhook secret header name is configurable. Sendblue documents
sb-signing-secret, and captured real webhooks should be kept as fixtures before
enforcing a production secret policy.
Use:
npm run setup:e2e
npm run verify:e2e
npm run dev:e2e
npm run capture:fixtures
npm run capture:guided
npm run showcase:e2e
npm run probe:sendbluesetup:e2e creates .env if needed. verify:e2e checks the ngrok auth token,
native macOS Messages prerequisites, and Sendblue API reachability without
sending messages. dev:e2e starts the local agent, deterministic chat endpoint,
ngrok tunnel through the JavaScript SDK, and registers Sendblue webhooks.
capture:fixtures starts a local capture server for message, status, and
operational webhook paths and writes raw request envelopes to
.captures/sendblue/. Redact captures manually before moving stable examples
into tests/fixtures/sendblue/captured/.
capture:guided starts the same capture server, opens an ngrok SDK tunnel,
registers Sendblue webhooks, and sends step-by-step instructions to
E2E_TEST_DEVICE_NUMBER for media, tapbacks, send effects, group messages, and
SMS fallback captures. It advances when the expected webhook arrives; reply
skip to move past a scenario. It also registers operational webhook types so
incidental callbacks are saved. Each captured envelope is annotated with the
active scenario before being written to .captures/sendblue/.
Use npm run capture:guided -- --list to show scenario IDs, or run a focused
capture with npm run capture:guided -- --only tapback-custom-emoji,group-message.
Observed payload structures are summarized in docs/SENDBLUE-PAYLOAD-STRUCTURES.md.
probe:sendblue is a one-shot diagnostic that calls POST /api/mark-read
against E2E_TEST_DEVICE_NUMBER and prints the response (success shape or
SendblueApiError body). Useful for verifying the read-receipt account flag
after support enables it, or for catching from_number typos that survive
requireEnv but fail Sendblue's E.164 check. Sends no outbound message and
mutates no webhook config; a successful call does deliver a UI-visible read
receipt to the recipient's device.
probe:contacts is the create-contact equivalent: it calls
POST /api/v2/contacts (upsert) with E2E_TEST_DEVICE_NUMBER and a
clearly-tagged Probe Test payload, then attempts a follow-up
DELETE /api/v2/contacts/{phone} to clean up. Useful for confirming the
exact body shape Sendblue accepts (the live response keys the nested
contact's phone as phone, not number) and for sanity-checking the
Contacts API on a new account before flipping SENDBLUE_CONTACTS_ENABLED=true.
showcase:e2e starts a live scenario-aware chat endpoint, the agent, ngrok,
and managed Sendblue webhooks, then sends guided prompts to
E2E_TEST_DEVICE_NUMBER. Each step asks for a user action and the agent replies
with what it understood. It writes raw webhook captures and summary.json under
.captures/sendblue-showcase/<session>/. Reply skip to move past optional
group, SMS, custom Tapback, or hosted-media steps. Set SHOWCASE_MEDIA_URL to
make the outbound media step send a real hosted asset. The runner also attempts
to register and verify Sendblue's documented typing_indicator webhook; if the
live account API rejects or drops it, the diagnostic is written to summary.json
and outbound typing indicators are still exercised.
Treat typing_indicator as documented but not universally available until the
account's webhook API accepts and persists it. The package keeps the local route,
parser, and chat request enrichment in place so deployments can use inbound
typing when Sendblue enables or exposes the webhook type for the line.
test:e2e also starts its own ngrok SDK tunnel and registers Sendblue webhooks
unless E2E_PUBLIC_BASE_URL is set to an externally managed URL. Rich action
scenario placeholders live behind npm run test:e2e; real-device promotion
should verify hosted media delivery, send effects, reactions, replies, read
receipts, typing refresh cancellation, and addressed group routing.
The repo cannot grant macOS Full Disk Access, sign into Messages.app, approve Automation prompts, or create an ngrok account/token. Those one-time host/account permissions still need to be completed on the Mac.