Sendblue iMessage / SMS / RCS platform plugin for Hermes Agent. It gives non-Mac Hermes users native iMessage access through the Sendblue API as a standalone platform plugin: one register(ctx) call, zero hermes-agent core changes.
The plugin runs a self-hosted webhook server that Sendblue posts inbound messages to, relays them to the Hermes agent, and sends the agent's replies back out through the Sendblue API.
- iMessage, SMS, and RCS send and receive
- Media both directions: image, voice, video, document
- Group chats
- iMessage send styles (invisible, gentle, loud, slam, and the rest)
- Typing indicators and read receipts (iMessage-only, gated on the inbound
servicefield so SMS/RCS don't error) - Tapback reactions via the
sendblue_reacttool (love / like / dislike / laugh / emphasize / question) /quotain-chat command that reports Sendblue usage against your plan- Polling fallback that catches messages a missed webhook would have dropped
- Per-sender allowlist authorization and a configurable daily send cap
flowchart LR
phone["iMessage / SMS<br/>sender"] -->|message| sb[Sendblue cloud]
sb -->|"webhook POST<br/>(sb-signing-secret)"| tunnel["public HTTPS URL<br/>(Tailscale Funnel :443)"]
tunnel -->|proxy| wh["plugin webhook server<br/>127.0.0.1:8665<br/>sig verify · 1 MiB cap · dedup"]
wh -->|MessageEvent| agent[Hermes agent]
agent -->|reply| api[Sendblue API]
api --> sb --> phone
poll["polling fallback<br/>(every 60s)"] -.->|catches missed webhooks| agent
The plugin registers itself with Hermes through a single ctx.register_platform(...) call in adapter.py::register. That one call supplies the adapter factory, the auto-generated hermes-sendblue toolset (core tools plus sendblue_react), CLI listing, Platform enum membership (via the registry, no hardcoded enum), authorization env vars, cron delivery target, the setup wizard, and the standalone sender used by cron. No edits to hermes-agent are required.
- A running Hermes Agent gateway
- A Sendblue account with an API key ID and secret
- A public HTTPS URL that reaches this machine for the inbound webhook (a Tailscale Funnel URL works well; see below)
aiohttpandhttpx(both ship with hermes-agent)
git clone <repo-url> ~/.hermes/plugins/sendblue
hermes plugins enable sendblue-platformThe enable key is the manifest name (sendblue-platform from plugin.yaml), not the clone directory name. Enabling adds it to plugins.enabled in ~/.hermes/config.yaml.
Run the guided wizard and pick "iMessage via Sendblue":
hermes gateway setupThe wizard (the plugin's setup_fn, so it looks and behaves like every built-in platform's setup) walks through:
- the public webhook URL and local port, with tunnel guidance and a reverse-proxy snippet if you need one
- your Sendblue API key ID and secret, validated live against the Sendblue API before it continues (up to three attempts)
- your Sendblue phone number in E.164 form
- a webhook signing secret (auto-generated if you leave it blank)
- the allowlist of phone numbers permitted to talk to the agent, or an access-mode menu for how to handle unauthorized senders
- the home channel used for cron and notification delivery
Re-running hermes gateway setup detects an existing config and offers to reconfigure.
The wizard writes these for you; you can also set them by hand. Configuration resolves from config.yaml's platforms.sendblue.extra first, falling back to the matching SENDBLUE_* environment variable.
| Variable | Required | Purpose |
|---|---|---|
SENDBLUE_API_KEY_ID |
yes | Sendblue API key ID (GET endpoints authenticate on this alone, so treat it as secret) |
SENDBLUE_API_SECRET |
yes | Sendblue API secret |
SENDBLUE_NUMBER |
recommended | Your Sendblue-provisioned sending number, E.164 (e.g. +15551234567) |
SENDBLUE_WEBHOOK_PUBLIC_URL |
recommended | Public HTTPS URL Sendblue posts inbound to, ending in the webhook path |
SENDBLUE_WEBHOOK_SECRET |
recommended | Shared signing secret checked against the sb-signing-secret header. Leave unset only if you accept unsigned webhooks (the verifier fails open when empty) |
SENDBLUE_ALLOWED_USERS |
recommended | Comma-separated E.164 numbers allowed to message the agent |
SENDBLUE_ALLOW_ALL_USERS |
no | Allow any sender (dev only; disables the allowlist) |
SENDBLUE_HOME_CHANNEL |
no | Default chat (number or group ID) for cron / notification delivery |
SENDBLUE_WEBHOOK_HOST / SENDBLUE_WEBHOOK_PORT / SENDBLUE_WEBHOOK_PATH |
no | Webhook bind host / port / path (defaults 127.0.0.1 / 8665 / /sendblue-gateway/receive) |
SENDBLUE_POLLING_ENABLED |
no | Enable the polling fallback (default off) |
plugin.yaml carries the exhaustive list with defaults (send style, daily cap, polling interval/lookback, home-channel name/thread, read-receipt toggle, status callback).
Sendblue delivers inbound messages by POSTing to your public webhook URL, so it has to be reachable from the internet over HTTPS. The plugin's own listener binds locally (default 127.0.0.1:8665); put a tunnel or reverse proxy in front of it.
With Tailscale Funnel, expose the local listener and use the resulting https://<host>.ts.net/sendblue-gateway/receive as SENDBLUE_WEBHOOK_PUBLIC_URL:
tailscale funnel --bg 8665That serves 127.0.0.1:8665 on your tailnet's public HTTPS name, so a POST to https://<host>.ts.net/sendblue-gateway/receive reaches the plugin's webhook path. Run tailscale funnel status to see the public URL. (Funnel supports ports 443, 8443, and 10000 only; if you also run a tailnet-only service such as a WebUI, keep it on a different port, since enabling Funnel on any path of a port makes the whole port public.)
Every request is verified against SENDBLUE_WEBHOOK_SECRET (the sb-signing-secret header); a bad signature returns 401. Keep a signing secret set: the verifier fails open if the secret is empty.
/quota- text/quotafrom an allowlisted number to get your Sendblue usage against your plan, in-chat. It never reaches the agent (it is intercepted in the adapter) and, like every outbound path, is gated on sender authorization.- Reactions - the agent can send an iMessage tapback with the
sendblue_reacttool (love,like,dislike,laugh,emphasize,question), targeting the most recent message in the current chat. It is meant for occasional use, not a reply to every turn.
Hermetic and offline. They import gateway.*, so they need a hermes-agent checkout on the path:
HERMES_AGENT_ROOT=~/path/to/hermes-agent python -m pytest tests/ -vHERMES_AGENT_ROOT defaults to ~/.hermes/hermes-agent. The suite registers the platform in conftest.py the same way discover_plugins does at gateway startup, so Platform("sendblue") resolves without an in-tree enum member.