← back to all work

Conversation & booking agents

Two SMS agents that hold a real conversation with a lead, answer questions from a knowledge base, and book the sales call straight into the calendar. One works the first call, the other the final call. Together they have booked 2,300+ calls since April 2026.

RoleArchitect and lead engineer, end to end
TimelineApr 2026 to present
StatusIn production
ContextBuilt at Oneday, an online education company selling degree programmes to US founders. One funnel, seven stages, five teams. I own the revenue numbers for the business and write the software that runs it. Before the agents, 53% of inbound lead messages were never answered by a rep.
TypeScript on DenoSupabase Postgres + edge functionspg_cronClaude APIiMessage/SMS gatewayCalendlyBitrix24 CRM
2,332sales calls booked · 17 Apr to 1 Nov 2026
14,926leads handled by the first-call agent
4.4%opt-out rate across those conversations
~40%of leads offered a final-call slot book one · June 39.7%

01 — The problemLeads were texting and nobody was answering.

Paid leads arrived around the clock from Meta and Google. Sales reps were on calls or asleep. A lead who replied at 9pm with a question got an answer, if at all, the next afternoon.

An audit of 369 conversations in April 2026 found the lead had the last word in 53% of them. Each of those was a person who wanted to talk and was left hanging. The pre-existing fix was a drip sequence of canned texts, which produced replies that almost never turned into bookings.

The brief I set myself: answer every lead within a minute, in a way that reads like a person, and get a time in the calendar without a human in the loop.

02 — What I builtTwo agents, one conversation layer, no silent paths.

Agent 1First-call agentTexts a new lead, qualifies, answers questions and books the first sales call with a named rep.
Agent 2Final-call agentPicks up once a lead has passed the expert interview and books the group final call across several sender lines.
SharedInbound routerEvery inbound text is classified, routed and either answered, actioned as an opt-out, or escalated to a named human with the thread. No early return is silent.
SharedSlot pickerPicks offer times in the lead's own timezone, daytime only, from live calendar availability, and re-offers if a slot is taken before the lead replies.
SharedSend guardOpt-out check first, lead-local quiet hours, per-line send budgets, dedupe, retries, and a firewall so the bot never speaks on a human-owned thread.
SharedKnowledge baseProgramme facts the agent is allowed to state, plus a queue of questions it could not answer, reviewed by a human and turned into new entries.

Everything is TypeScript on Deno running as Supabase edge functions, with Postgres as the single store for leads, conversations, pending offers and bookings. Scheduled work runs on pg_cron. The LLM does classification and drafting; the rules around it decide what is allowed to be sent.

03 — How it worksFrom an inbound text to a calendar invite.

  1. Inbound arrives via webhook. Opt-out detection runs before anything else, so compliance never depends on lead state.
  2. Classify intent: question, time constraint, booking intent, decline, deferral, or unclear. A validator checks the draft reply against the knowledge base before it goes out.
  3. Resolve time. "After three", "noon", "at work until 5" and "next week" all parse to a window in the lead's timezone. A named window seven or more days out defers the lead instead of pestering them.
  4. Offer slots from the typed daytime pool. One live offer per lead, ever. An explicit time that matches neither pending slot gets a clarifying question, never a guess.
  5. Book through one audited path that re-checks availability, handles a taken slot as a re-offer, and writes the booking to the CRM and the lead record.
  6. Hand over when the agent should stop: a program-level decline, a cap on re-offers, an escalation, or a morning sweep of cold threads. The human gets the full thread and the agent pauses.

04 — What brokeEvery one of these became a rule.

Night slotsA timezone fallback offered a lead a meeting at 3am their time.
A type the daytime filter alone can produceEvery slot-offer path takes a branded DaytimeSlot type, so an unfiltered pool will not compile. The deploy script also refuses any raw booking call outside the audited path.
Silent drops216 inbound texts, 14.1% of a sample, hit an early return and got nothing back.
No silent early returnsEvery exit from the router ends in a reply, an opt-out action, or a notification to a human. A nightly reconciliation compares inbound rows to routing rows and raises the gap.
Split-brain offersTwo systems held live slot offers for the same lead and booked the wrong one.
One live offer per leadEvery writer that creates an offer deletes the other table's pending row. A saved check fires if both are ever live.
Line swap floodNew sender numbers made every lead a new contact. A backlog flushed in one evening sent 379 texts and hit 400 carrier rejections.
Per-line meteringSends are metered per (line, phone) against the carrier's rolling caps, and backlogs drain inside a warm-up budget per tick.
Re-offer loopA lead who kept naming a day the calendar could not satisfy was re-pitched the same times repeatedly.
Cap and hand overThree re-offers, then one honest text, delete the pending row, pause the agent, ping the owner. Never widen to other days on the lead's behalf.

Each incident is written up with the date, the trigger and a regression query, and the query runs daily. The written rule is what stops the same mistake shipping a second time.

05 — What I'd do differentlyMeasure attendance and wins, not just bookings, from day one.

Bookings were the number I optimised first because they were the number I could see. Attendance on agent-booked first calls sits at 61%, which is high, but the agent books warm repliers, so that is selection as much as quality. Agent-booked leads also convert to paying customers at a lower rate than self-booked leads, which is what you would expect from a cohort that needed persuading. I would wire win-rate reporting by booking route into the first release, so the trade-off is visible from the start rather than discovered later.

I would also build the per-line send metering before the first number change, not after the flood.