eve-channel-sendblue
v0.4.0
Published
Sendblue (iMessage/SMS) channel for the eve agent framework
Maintainers
Readme
eve-channel-sendblue
Sendblue (iMessage/SMS) channel for the eve agent framework. Inbound texts arrive as a webhook and start or resume an eve session; the agent's reply is sent back over the same line.
Install
bun add eve-channel-sendblue
# peer deps: eve, aiUsage
Add a channel file to your eve app:
import { sendblueChannel } from "eve-channel-sendblue";
export default sendblueChannel();Credentials fall back to environment variables, so the call can stay empty once they are set (a webhook secret is required by default, see Webhook security):
| Variable | Purpose |
| ------------------------------ | ------------------------------------------------------------------- |
| SENDBLUE_API_KEY | Sendblue API key id |
| SENDBLUE_API_SECRET | Sendblue API secret |
| SENDBLUE_FROM_NUMBER | Default sender, E.164 (e.g. +14155551234) |
| SENDBLUE_WEBHOOK_SECRET | Required by default; checked against the sb-signing-secret header |
| SENDBLUE_STATUS_CALLBACK_URL | Optional; per-message delivery-status callback URL |
Or pass them explicitly. Each credential also accepts a lazy resolver function that is called once and cached:
export default sendblueChannel({
credentials: {
apiKey: process.env.SENDBLUE_API_KEY,
apiSecret: () => secrets.get("sendblue-api-secret"), // sync or async resolver
webhookSecret: process.env.SENDBLUE_WEBHOOK_SECRET,
},
fromNumber: "+14155551234",
allowFrom: ["+15557654321"], // "*", a list, or an async resolver
allowedServices: ["iMessage", "SMS"], // defaults to ["iMessage"]
});With no credentials resolvable, the channel runs in dry-run mode: inbound webhooks drive a real eve session, but outbound sends are logged instead of delivered.
Webhooks
The channel mounts a single route:
POST /eve/v1/sendblue/webhookPoint your Sendblue message, status, and typing webhooks at that URL. The handler distinguishes the payload types internally.
Webhook security (fail closed by default)
The webhook is unauthenticated unless you verify it, so the channel requires a
secret by default and throws on construction if none is configured. Set
SENDBLUE_WEBHOOK_SECRET (or credentials.webhookSecret) and the same secret
in Sendblue, so it sends the sb-signing-secret header. The header is compared in
constant time, and requests without a valid signature get a 401.
To run the webhook open on purpose (local dev, a trusted network), set
requireWebhookSecret: false. You then must not also provide a secret; that
contradiction throws, so there's no ambiguity about what you meant.
sendblueChannel({ requireWebhookSecret: false }); // open, no secretFeatures
- Inbound + outbound iMessage and SMS, 1:1 and group threads.
- Markdown flattening so replies read natively on iMessage.
- Inbound media surfaced to the model by URL (see the note below).
- Tapback reactions, typing indicators, and read receipts via the thread handle exposed to event handlers.
- Proactive sends through the channel's
receive()hook (schedules or cross-channel hand-off). - Sender allow-list, service filtering, and shared-secret webhook verification.
Continuation tokens
The channel-local raw token addresses a session by line and contact:
<fromNumber>:<contactNumber> // 1:1
<fromNumber>:g:<groupId> // groupsendblueContinuationToken(from, { contactNumber }) and decodeContinuationToken
are exported for building tokens (for example, to start a proactive session).
Inbound media
Inbound attachments are passed to the model provider by URL, not staged into
the eve sandbox. Staging wrote a durable eve-sandbox: file reference into
session history whose bytes are gone on the next (fresh-sandbox) invocation, so a
later turn failed the staging invariant and terminated the whole session. Passing
the URL keeps a durable reference the provider fetches at model-call time.
Consequence: the model can only "see" an image while its URL is still fetchable,
and eve re-references that URL on every later turn. Sendblue's inbound media_urls
expire after ~30 days, so images drop out of long-lived conversations.
To make media durable, pass a persistMedia hook. On receipt the channel calls
it with the attachment and uses the URL it returns (a permanent one from your own
store) instead of Sendblue's. If the hook throws or returns nothing, the channel
falls back to the Sendblue URL, so media can never fail a turn.
import { put } from "@vercel/blob";
sendblueChannel({
async persistMedia({ url, mediaType, messageHandle }) {
const res = await fetch(url);
if (!res.ok) return url; // keep the Sendblue URL on failure
const blob = await put(`sendblue/${messageHandle}`, await res.arrayBuffer(), {
access: "public", // Sendblue media is already public/unguessable
contentType: mediaType,
addRandomSuffix: true,
});
return blob.url; // permanent, no ~30-day expiry
},
});Public Blob URLs never expire, so this extends media lifetime from ~30 days to permanent. For a private store, keep a stable id in history and mint short-lived signed URLs on demand from a tool, since a signed URL placed directly in history would expire while eve still references it.
Platform limitations
Inherited from Sendblue and iMessage: no message editing, no true unsend,
tapbacks can be added but not removed via API, inbound media_urls expire after
~30 days, and typing indicators work for 1:1 chats only.
License
MIT
