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.

Status: Controllers, entities, services, and event subscribers exist. Channel routing logic, vendor adapters, and inbound webhook handling are being implemented in slices alongside production-driver integrations.

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.

MethodPathPurpose
POST/speaker/v1/messagesSend 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/vendorsManage 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.