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. Channel selection considers the recipient's verified contacts, preferences, the message's urgency class, and cost. A confirmation message on a Person with a verified phone and SMS preference may go SMS first and email as fallback.
- Vendor
- The provider that actually moves the bytes (SendGrid, Twilio, Resend, OneSignal, custom SMTP). Vendor selection considers tenant configuration, channel, cost, and provider health.
- 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.
- Inbound
- Speaker handles inbound too — SMS replies, email bounces, webhook callbacks — threading them back to the originating Message and Person. STOP replies update Terminus consent.
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 | /speaker/v1/vendors | Manage per-tenant vendor configuration and credentials. |
| GET | /speaker/v1/preferences?personId= | Resolved channel preferences for a Person (derived from Terminus state). |
| POST | /speaker/v1/webhooks/{vendorType} | Inbound webhook from a vendor (delivery, bounce, open, reply). Signature verified. |
| POST | /speaker/v1/inbound/{channel} | Inbound message ingress (SMS reply, email-to-action). |
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": "18-hole round",
"startsAt": "2026-06-12T09:00:00-04:00",
"partySize": 4
}
}
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]
Inb[Inbound replies] --> 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.