Back to the demo

Production blueprint

How this MVP is built for real: modules with one interface each, a PostgreSQL schema you own, a worker for the send queue and the webhooks, and the compliance rules in the send path rather than in a document. Source code, schema and docs are delivered with the build.

Modules

Admin console + agent appNext.js / ReactAPI + auth (RBAC)route handlersPostgreSQLschema below, RLSWorker + queueRedis / BullMQSMS providerTelnyx or TwilioLLM providerOpenAI or AnthropicGoogle CalendarfreeBusy + eventswebhooks in (signed, idempotent)send, ask, book
  • Web app (Next.js + React). Admin console and agent views. Role-based routing: admins see every tenant object, agents only what is assigned to them.
  • API (Next route handlers or a NestJS service). REST endpoints per module plus two webhook receivers. Every handler is tenant-scoped through row-level security.
  • PostgreSQL. The schema on this page. Leads, suppressions, campaigns, conversations, messages, appointments, webhook_events, audit_log.
  • Queue and worker (Redis + BullMQ). Drains the send queue at the campaign throttle, never inside quiet hours for the lead's time zone. Retries with backoff. Processes webhooks off the request path.
  • SMS adapter (Telnyx, Twilio behind the same interface). send(), parseInbound(), parseStatus(), verifySignature(). Provider is a tenant setting.
  • LLM adapter (OpenAI or Anthropic). One prompt, one JSON schema: reply, stage, answers, book_option_id. Swapping providers is one file.
  • Calendar adapter (Google Calendar API). freeBusy for availability, events.insert for the booking, per-agent OAuth refresh tokens encrypted at rest.
  • Auth and RBAC. Email + password or SSO, sessions, two roles (admin, agent), audit log on every write that matters.

One inbound text, end to end

  1. Carrier hands the inbound text to the provider; the provider POSTs message.received to /api/webhooks/telnyx (Twilio: the inbound webhook).
  2. The receiver verifies the signature (Telnyx: Ed25519 over timestamp|payload), stores the raw event in webhook_events with the provider's event id as the idempotency key, returns 200 in under 100 ms, and enqueues a job.
  3. The worker loads the lead by phone. STOP, HELP, START and the hand-off keyword are matched in code (whole message, case-insensitive). STOP writes the suppression row, cancels queued follow-ups for that number and sends the confirmation; nothing else runs.
  4. Otherwise the conversation is loaded. If a person has taken it over, the message is stored, the thread is marked unread for that rep, and the worker stops.
  5. Availability is computed: working hours, minus calendar_busy (refreshed from freeBusy if older than a few minutes), minus existing appointments, plus a two-hour lead time. Up to six options get stable ids for this turn.
  6. The LLM adapter sends the transcript, the qualification questions, the answers so far and the options, and receives a schema-checked JSON object. Any book_option_id that is not one of this turn's ids is discarded.
  7. If a booking came back: round robin picks the free agent with the fewest upcoming appointments (ties by rotation, weight 0 never), the appointment row is inserted (an exclusion constraint prevents double booking), and a calendar job runs events.insert. sync_status flips to synced with the event id, or to failed for the admin to see.
  8. The reply is queued as an outbound message. The provider's status callbacks (message.sent, message.finalized; Twilio: StatusCallback with queued, sent, delivered, undelivered, failed) update the message row and the campaign counters.

Data model

PostgreSQL DDL with enums, indexes, the double-booking exclusion constraint and a row-level-security policy sketch. Every name matches the demo.

schema.sql
tenants, users, working_hours
who sends, who takes bookings, when
calendar_connections, calendar_busy
per-agent Google OAuth and the cached freeBusy
lead_lists, leads, import_rejections
consent source and date on every lead; rejected rows kept for audit
suppressions
STOP, manual, DNC; checked at import and at send
campaigns, campaign_lists, send_queue
template, window, throttle, questions, pool; the queue the worker drains
conversations, messages
stage, mode, answers, offered slots; every SMS with provider id and status
appointments
rep, times, status, calendar sync; no overlaps per rep
webhook_events
raw provider payloads, unique per provider event id
audit_log
imports, launches, take-overs, bookings, setting changes

Compliance in the send path

  • Consent first. A lead without a consent source and date cannot be imported, so it cannot be texted. Purchased lists are rejected at the door.
  • Suppression twice. At import and again when the worker picks a message off the queue. STOP suppresses within the same second and cancels queued follow-ups.
  • Quiet hours per lead. Not before 8 a.m. or after 9 p.m. at the lead's location (47 CFR 64.1200(c)(1)), tightened per state where needed. The campaign window is a second, narrower gate.
  • Keywords in code. STOP, UNSUBSCRIBE, END, QUIT, STOPALL, REVOKE, OPTOUT, CANCEL, START, UNSTOP, HELP and the hand-off word never reach the model.
  • 10DLC gate. Brand and campaign status are read from the registration record and gate the launch button. Sample messages, opt-in description and the footer are kept with the campaign.
  • Disclosure. The assistant can be set to say it is automated in its first reply; the hand-off keyword always reaches a person.

Real in this demo vs production

PieceIn this demoIn production
Assistant repliesLive: Claude, structured output, rate limited per IP and per day, scripted fallback if the cap is hitOpenAI or Anthropic through the adapter; per-tenant key
Import validationReal: E.164, duplicates, consent, suppressionSame rules server-side plus the DNC scrub provider's API
Segment math, quiet hours, send windowReal (GSM-7 160/153, UCS-2 70/67; lead-local hours)Same, enforced in the worker, not the browser
Availability and round robinReal, from seeded working hours and busy blocksfreeBusy against each agent's Google Calendar
Delivery receipts and lead replies after a launchSimulated, deterministicCarrier callbacks through the provider
Google Calendar writesSimulated (event id generated)events.insert with the lead as attendee, invite by SMS link
A2P 10DLC statusSeededRead from the registration API; gates the send button
StorageThis browser (localStorage), resets dailyPostgreSQL schema below, per-tenant RLS

Delivery

  • Full source in your repository from day one, MIT-free of vendor lock: no proprietary framework, no hosted low-code layer.
  • README per module: purpose, interface, environment variables, how to swap the provider. An architecture doc mirroring this page. The schema with migrations.
  • Environments: local (Docker Compose with Postgres and Redis), staging, production. One command deploys each.
  • Tests where money or compliance is involved: keyword handling, quiet-hour math, slot computation, round robin, webhook idempotency, import validation.

References read while building this page