Scaling an AI voice-agent platform inside a 120-developer monorepo

Fifteen months embedded in a 120-dev monorepo, building an AI receptionist for home-services contractors without breaking production.

Scaling an AI voice-agent platform inside a 120-developer monorepo
Photo by Petr Macháček on Unsplash
Client
AI voice-agent startup, home-services vertical (anonymized)
Role
Senior full-stack engineer (embedded)
Period
2025–2026
Stack
Next.js · TypeScript · Inngest · PostgreSQL · Twilio · Vercel
Outcomes
  • Embedded ~15 months as a top-15 contributor in a 120-dev production monorepo
  • Took a follow-up calling product v1→GA in ~5 weeks, then hardened it
  • Replaced blind CRM-sync retries with rate-limit-header-aware handling
  • Shipped category-scoped SMS opt-out (A2P 10DLC) compliance end to end
Contents

The product is an AI receptionist for home-services contractors — plumbing, HVAC, electrical, the trades where a missed call is a missed job. It answers inbound calls and texts, books work into the field-service CRMs those contractors already run on, and runs outbound campaigns: speed-to-lead callbacks, post-job follow-ups, appointment reminders. I came in as an embedded senior engineer and stayed about fifteen months. This is what it’s like to ship fast inside a codebase that never stops moving.

Dropping into a monorepo that already had a life

I’ll be honest about the first week, because it’s the part most write-ups skip. The codebase was a pnpm monorepo — 16 apps, 14 shared packages, north of 2,000 API route files in the main web app alone. By the time I left it had passed 14,000 commits from 120 contributors. It’s large enough that plain tsc runs out of memory type-checking it; the team had already moved to a native TS compiler with per-app project references just to keep CI tractable. You don’t onboard to something like that by reading it top to bottom. You find the seam you’re responsible for and learn the blast radius outward from there.

The seam I owned was the outbound and texting side. The rule that shaped everything: this is a live multi-tenant system. Every contractor is a “team,” and a query that forgets its teamId doesn’t throw — it quietly leaks one tenant’s customers to another. So the first thing I internalized wasn’t a framework, it was the convention: one canonical verifyTeamAccess(request, teamId) on every team-scoped route, no exceptions. Over those fifteen months I landed a couple hundred commits and ended up a top-15 contributor by count — not because I wrote the most code, but because I picked one surface and went deep.

Five weeks from self-serve v1 to GA

My first real ownership was a post-job follow-up calling product — the AI calls a customer after a job to check in and, ideally, catch a review or a repeat booking. It existed as a rough self-serve v1 when I picked it up. The mandate was to get it to General Availability without a pause in the rest of the platform’s release train.

It went GA in about five weeks. The speed wasn’t heroics — it was refusing to treat “it works in the happy path” as done. A calling product fails in the boring places: the callback that fires twice because a durable job retried, the completion event that never lands so the campaign thinks the call is still in flight, the tenant whose timezone pushes a call outside its booking window. Background work ran on Inngest durable functions, so “hardening” mostly meant making every step idempotent and failure honest — a failed execution gets marked failed, not silently swallowed, so the dashboard tells the contractor the truth. GA was the easy milestone. The two weeks after it, closing edge cases, were the ones that mattered.

Owning the outbound stack, not just the tickets

The follow-up product was the wedge; the outbound engine was the thing I actually led. Over the year that meant the audience builder and a reusable “filters v1” so ops could slice a tenant’s customer base without an engineer in the loop, recurring-audience exclusions so the same customer doesn’t get two campaigns in a week, the campaign-creation UX, appointment reminders, and migrating the speed-to-lead workflow across tenant rollouts. This is where “embedded” stops meaning “does tickets” and starts meaning “owns the product surface” — I was making the calls about how a campaign gets modeled, not implementing someone else’s spec.

Three of those calls were deliberately against the grain.

Three small decisions that went against the obvious move

An escape hatch, not a rewrite. One feature needed to push a booking feed to a search-partner program over SFTP, and the SFTP client library needs a runtime the serverless platform simply doesn’t provide. The conventional move is to fight it — shim, polyfill, or force the platform to do something it wasn’t built for. I extracted one tiny pure-Node Express service that does nothing but the SFTP upload on a cron, and left everything else serverless. The point wasn’t “microservices are better.” It was the opposite: make the escape hatch as small as possible so the platform decision stays intact and there’s exactly one weird thing to reason about, not ten.

Honor the rate-limit headers; stop guessing. The CRM sync — customers, contacts, appointments, memberships — talks to third-party APIs that rate-limit hard. The default pattern everyone reaches for is blind exponential backoff: fail, wait 1s, 2s, 4s, hope. But the API already tells you exactly how long to wait in its response headers. Guessing either backs off too slowly and burns your quota or too aggressively and stalls the sync. I rewrote it to read the headers, with a hard retry cap so a misbehaving upstream can’t wedge a job forever.

// Respect what the API actually tells us, don't guess.
if (res.status === 429 && attempt < MAX_RETRIES) {
  const retryAfter = Number(res.headers.get("retry-after"));
  const waitMs = Number.isFinite(retryAfter)
    ? retryAfter * 1000
    : backoff(attempt); // fall back to backoff only if the header is missing
  await sleep(waitMs);
  return sync(entity, attempt + 1);
}

Enums live in the app layer, not in Postgres. This one gets pushback, so let me defend it. For enumerated columns on new tables, the textbook answer is a Postgres enum type. But ALTER TYPE ... ADD VALUE can’t always run inside a transaction, and at this team’s migration velocity — hundreds of migrations, re-baselined from a prod dump after years of churn — a migration that can’t roll back cleanly is a liability. So enumerated values are plain text in the database, validated by a TypeScript union in application code, where adding a value is a reviewed code change, not a schema-migration hazard.

const CAMPAIGN_CATEGORY = ["customer_care", "marketing"] as const;
type CampaignCategory = (typeof CAMPAIGN_CATEGORY)[number];

On a slow-moving schema I’d make the opposite call. Here migration velocity was the constraint that mattered, and I optimized for it on purpose.

Category-scoped opt-out, end to end

The last thing I shipped is the one I’m proudest of, because it’s compliance and it’s easy to get quietly wrong. Under A2P 10DLC, a customer-care text and a marketing text are different regulatory categories — and a person can reasonably want the appointment reminders while opting out of the promos. The naive implementation treats STOP as global: one STOP kills all messaging. That’s technically safe and a worse product.

I built opt-out as category-scoped end to end. An inbound STOP/START is scoped to the category it replies to; outbound sends are gated on the recipient’s state for that category; the opt-out state is pushed back to the CRM’s telecom records so it’s consistent with whatever the contractor sees there; and it surfaces as a badge in the UI so an ops person can see at a glance who’s opted out of what. It shipped spec-first — the design doc and implementation plan committed alongside the code, because on a 120-person team the convention is the product, and a compliance feature nobody can find the rules for is a future incident.

What fifteen months there taught me

The lesson wasn’t any one framework. It was that velocity and safety aren’t opposites when the conventions do the load-bearing work — tenant auth in one helper, failure marked honestly, escape hatches kept small, and the reasoning written down where the next person (or the next AI coding agent) will actually read it. You can ship weekly into a live multi-tenant system. You just have to be disciplined about the boring parts, and occasionally brave enough to make the unglamorous call.

In this case study · 4 posts

  1. 01 Retrying Third-Party APIs the Right Way: Honor the Headers
  2. 02 Why We Stopped Putting Enums in Postgres
  3. 03 One Escape Hatch, Not a Rewrite: Extracting a Node SFTP Service
  4. 04 Category-Scoped SMS Opt-Outs (A2P 10DLC) in a Multi-Tenant Platform