Web, SMS and intake

The phone is the showpiece; three more channels share its brain. A website chat with the same grounding, a browser that can pair with a live call, an SMS agent that manages bookings by text, and a private intake channel where the owner directs Elle's outreach.

Website chat

POST /api/chat runs the same persona and RAG grounding as the phone through the turn engine in ai.ts. The endpoint is deliberately defensive for a public API: rate-limited per IP (30 requests / 10 minutes), message length capped at 2,000 characters, history validated shape-by-shape and truncated to the last 10 turns. CORS allows the main site's origin so the chat can be embedded at lewiswcampbell.com.

Pairing a browser with a live phone call

Every realtime call gets a short pairing code, spoken digit by digit on request. Entering it on the website upgrades the visit: the browser joins the call's session over the /ws WebSocket and both sides of the conversation stream in as live transcripts.

One page, one experience. Chat and pairing share a single page on the main site (SITE_URL + ASK_PATH). Call transcripts render into the same log as the chat, in the same bubbles, and the one composer changes destination while a call is live: what you type goes into the call and Elle answers out loud. Pairing previously lived on the service's own origin as a separate unbranded page, which split the visitor experience across two domains; those paths now redirect. Because the page and the API are different origins, the pairing socket carries its own Origin allowlist, since CORS never applies to WebSockets.

Phone callsays the pairing code
CallSessionsessions.ts · in-memory,
keyed by call and by code
Browser/ws WebSocket
transcripts stream out · typed messages and choices flow in · show_work / show_choices render on screen

The pairing session is the meeting point: the voice pipeline pushes events to it, the browser subscribes, and input flows back into the call.

It's two-way. The model has show_work and show_choices tools to put project screenshots and tappable options on the paired screen while talking. And the visitor can type into the call: a typed message is injected into the voice pipeline as a first-class turn, and Elle answers it out loud on the phone:

src/realtime/deepgramCascade.ts · sendText()
// A message typed on the paired website. Same pipeline as a spoken utterance
// (queue + drain), so Elle answers it OUT LOUD on the call and the reply's
// transcript streams back to the browser. The framing note tells the model
// where the words came from without changing what the transcript shows.
sendText(text: string): void {
  const clean = text.trim();
  if (!clean) return;
  this.h.onUserTranscript?.(clean);
  this.queue.push(`(Typed on the paired website, not spoken — answer out loud as usual.) ${clean}`);
  void this.drain();
}

The pairing view shows the conversation itself: transcripts and shared visuals, not Elle's internal notes. Pairing codes rate-limit at 10 attempts per minute per IP so codes can't be brute-forced, and sessions sweep away after the call ends.

Caller (phone)CascadeCallSessionBrowser"can I see that on screen?"speaks the pairing code, digit by digitPOST /api/pair {code} · 10/min/IPWS /ws joined to this call's sessionboth sides' transcripts as they happenlive transcript eventsshow_work / show_choices tool outputtyped message (length-capped)injectText → queued as a real turnElle answers OUT LOUD on the callsession dies with the call; codes are unusable after hangup
Two-way pairing: the phone drives the screen, and typed input flows back into the spoken conversation.

SMS: bookings by text

Texting the same number reaches a leaner agent tuned for one job: managing intro-call bookings. Its persona enforces SMS style (1–3 short sentences, plain text, one question at a time) and hard rules: look bookings up before changing anything, read the new time back and get a clear yes before rescheduling or cancelling, always say changes are tentative until the owner confirms, and never state anything the tools didn't return. Texts in Spanish get answered in Spanish.

The reply itself doubles as the confirmation message, restating exactly what changed and when, in the owner's timezone. Carrier-required keywords (STOP, HELP) are handled by Twilio's opt-out machinery upstream of the webhook; the application never needs to see them.

Owner intake: directed outreach on a private channel

The owner has a private line to Elle, a bot channel restricted to a single account, used to direct her rather than talk to her as a visitor would. The core flow: message her a name, a number, and a topic; she confirms the details, sends that person a compliance-complete first-contact SMS from the Twilio number, and records the outreach so that when the person texts back, the SMS agent recognizes the thread and its context.

src/telegram.ts · security model
// Security model: the webhook path is unguessable only by convention, so two
// real gates protect it — Telegram's secret_token header (set at setWebhook
// time, echoed on every update) and TELEGRAM_OWNER_ID (updates from any other
// account are dropped). Until the owner id is configured, the bot answers any
// sender with their numeric id so setup is self-serve, and does nothing else.

The same channel is Elle's control surface. Each capability is a tool on this bot:

AskWhat it reads or does
"has anyone called?"
"who called this week?"
The call log, over a time window the owner can name. Each call comes back with its local time, the caller's number, how long they stayed on, whether that number has called before, what Elle noted during that call, and any booking it produced.
"text Jane at +1 555… about the role"Sends the compliance-complete first-contact SMS and records the outreach, after confirming the details back.
"how's the Jane thread going?"The outreach ledger: who was texted, whether they replied, bookings on their number.
"demo mode on" / "back to cascade"The runtime architecture switch, with its own deadline so a demo cannot be left running.

Two details in the call report are deliberate. Notes are read per call rather than per number, because attributing what a previous caller said to whoever rang this time is the same shared-phone mistake the persona already guards against. And an empty result distinguishes "nobody rang" from "this instance has no record of it": the call log lives in SQLite on the instance, so when the process has been up for less time than the window being asked about, the answer says so instead of reporting a quiet day. npm run verify:intake asserts both against a seeded database.

Conversation state is a short-lived in-memory thread (6-hour TTL, 30-message cap); the durable record is the database, not the chat.

This channel is owner-only by design and never appears in any visitor-facing surface. The gates, and what happens when each fails, are detailed on the security page.

Ownertelegram.tsTwilio SMSContactsms.ts agent"text Jane at +1 555… about the role"gate 1: secret_token header · gate 2: owner id; anyone else is dropped silentlyconfirms name, number, topic"yes, send it"compliance-complete first-contact SMSoutreach row · status = sentdelivered from the public numberreply textoutreach → replied · thread context injectedreply that already knows who they are and why"how’s the Jane thread going?" → status from the ledger
The owner directs; Elle executes and remembers. When the contact replies, the SMS agent already has the context.

One brain, provably

The concrete artifact that makes "one brain across channels" real is the shared context assembly: every channel builds its prompt from persona.ts (identity, conversation template, hard rules) plus per-person facts from db.ts keyed on phone number. A person who called yesterday and texts today is recognized, with a carefully worded rule that notes about a phone number are never treated as the identity of the person currently speaking, because phones get shared. The name a caller goes by must come from the current conversation, never from stored notes.