Primitive spec
Speaker
The outbound communication primitive. Speaker is the single choke point for every notification, reminder, confirmation, receipt, and marketing message the platform sends. It picks the right channel, routes through the right vendor, and enforces Terminus consent before any dispatch — with no override path.
What it owns
Speaker owns outbound communication and only outbound communication. Every notification path on the platform — booking confirmations from Seldon, receipts from Hober, statements from Mallow, payment-failed alerts from Payments, marketing campaigns — goes through Speaker. There is no other code path that sends a message.
The single choke point matters because consent is enforced in Speaker, not in the callers. Terminus owns consent state; Speaker calls assertConsent before every dispatch. A bug in Daneel or a careless workflow change cannot accidentally route around the consent gate — the gate is in the layer that holds the network connection.
Concepts
- Message
- An outbound communication, rendered from a template against a payload, targeted at a Terminus Person. Carries the resolved channel, vendor route, and a delivery-attempt history.
- Template
- A versioned Radiant asset that renders into the body for a given message class (
reservation.confirmed.v1,receipt.email.v3). Templates carry per-channel variants (email body, SMS body, push body) and are localised at render-time. - Channel
- The transport: email, SMS, push, in-app, webhook. The caller picks the channel. Tenants can store
MessageRoutingRules (CEL predicates, priority-ordered) through/speaker/v1/routing-rules; the evaluator exists but is not yet wired into dispatch. - Provider
- The provider that actually moves the bytes: SES or SendGrid for email, Twilio for SMS, APNs, FCM and Web Push for push, and Slack webhooks. Configured per tenant as a
ProviderConfiguration. - Consent gate
- Before any dispatch, Speaker calls
TerminusDirectory.assertConsent(personId, scope). Scope is derived from the message class (marketing.email,transactional.sms, etc.). A negative consent terminates dispatch and records the suppression. Transactional consent is implicit for fulfillment-critical messages; marketing requires explicit opt-in. - Delivery attempt
- One record per vendor send, retry, bounce, or open. The attempt history is the durable audit trail of what was sent, when, through which vendor, and what came back.
- Per-tenant send caps
- Daily per-channel caps (email / SMS / push; platform defaults with per-tenant staff overrides) protect the shared sender reputation: an over-cap send is recorded-but-refused like a consent denial, a fingerprinted health signal flags the runaway tenant, and user-initiated auth mail is exempt so sign-in keeps working. Fail-open by design — it is an abuse throttle, not a privacy gate (consent stays fail-closed).
- Delivery webhooks
- Providers post delivery and bounce callbacks to
/speaker/v1/webhooks/{providerKey}. Each is signature-verified, deduped, matched back to its Message, recorded as aDeliveryEvent, and advances the Message's deliverability status. Speaker does not take inbound replies. - Push & device registry
- The PUSH channel is real: a
DeviceTokenregistry (Web / iOS / Android) holds each subscription, and a Web Push driver delivers via VAPID (RFC 8291 / 8292). Registering a device atPOST /speaker/v1/devicesis idempotent on the token and reactivates a pruned one. Push is consent-gated like every other channel — the browser-permission grant is the opt-in — and the operator PWA enrolls the subscription on install.
API surface
Endpoints are versioned under /speaker/v1/. Most dispatch is event-driven (other primitives emit a message-worthy event; a subscriber drops a Send into Speaker); the HTTP surface is for admin tooling, template management, and external triggers.
| Method | Path | Purpose |
|---|---|---|
| POST | /speaker/v1/messages | Send a message. Body specifies template, recipient, payload, urgency. |
| GET | /speaker/v1/messages/{id} | Fetch a Message with its delivery-attempt history. |
| GET | /speaker/v1/messages?personId=&templateId=&from= | List sent messages, filtered. |
| POST / GET / PUT / DELETE | /speaker/v1/provider-configurations | Manage per-tenant provider configuration and credentials. |
| POST / GET / DELETE | /speaker/v1/routing-rules | Channel routing rules (CEL predicates, priority-ordered). |
| POST / DELETE | /speaker/v1/devices | Register or revoke a push device token (Web / iOS / Android). GET /speaker/v1/push/vapid-public-key serves the Web Push key. |
| POST | /speaker/v1/receipts/dispatch | Send a receipt. |
| POST | /speaker/v1/webhooks/{providerKey} | Delivery and bounce callbacks from a provider. Signature verified. |
Example: send a booking confirmation
Seldon's outbox subscriber drops this when a Booking confirms:
POST /speaker/v1/messages
Content-Type: application/json
Authorization: Bearer <token>
Idempotency-Key: c8a5e2b1-…
{
"templateAlias": "reservation.confirmed.v1",
"recipientPersonId": "per_01JAB7…",
"urgency": "transactional",
"payload": {
"bookingId": "bk_01JAZB…",
"offerName": "60-minute haircut",
"startsAt": "2026-06-12T09:00:00-04:00",
"partySize": 1
}
}
Speaker resolves the template, calls assertConsent(per_01JAB7…, "transactional.email"), picks the channel (email, in this case), routes through the configured vendor, and records the delivery attempt:
→ 202 Accepted Message msg_01JAZH… status=queued
GET /speaker/v1/messages/msg_01JAZH…
→ {
"id": "msg_01JAZH…",
"status": "delivered",
"channel": "email",
"vendorRoute": "sendgrid",
"consentResolved": { "scope": "transactional.email", "version": 7 },
"attempts": [
{ "vendor": "sendgrid", "at": "2026-06-10T14:31:22Z", "result": "accepted" },
{ "vendor": "sendgrid", "at": "2026-06-10T14:31:24Z", "result": "delivered" }
]
}
How it fits with the rest
flowchart LR
S[Seldon] --> Sp(Speaker)
Ho[Hober] --> Sp
Pa[Payments] --> Sp
D[Daneel] --> Sp
R[Radiant] -. templates .-> Sp
Sp -- assertConsent --> Te[Terminus]
Sp --> V[Email / SMS / Push / Webhook]
Wh[Delivery webhooks] --> Sp
Almost everything calls Speaker. Seldon sends confirmations and reminders driven by emission-pipeline events. Hober sends receipts on Order close. Payments sends payment-failed and dispute-opened notifications. Mallow sends statements. Daneel workflows dispatch through Speaker rather than calling vendors directly. Every send re-enters the same consent gate; there is no privileged path. Radiant stores the templates that Speaker renders.