Voice AI receptionist for service businesses. A clinic or service provider configures a voice agent, points a phone number at it, and the agent answers calls 24/7: qualifying leads, scheduling appointments, transferring to a human when needed. Operators watch each call happen live in the dashboard with streaming transcript and a per-leg latency meter.
- Live demo: https://relay-five-peach.vercel.app
- API docs: TODO
- Loom walkthrough: TODO
A clinic admin signs up, creates an organization, and configures an agent: persona prompt, voice, business hours, and a knowledge base of FAQs. They point a Twilio number at the SIP trunk and they're done.
A caller dials in. Roughly half a second after they finish speaking, the agent responds in a natural voice. While the call is in progress:
- A live waveform pulses to incoming audio.
- The transcript fills in token-by-token with speaker labels on each turn.
- A latency meter shows STT, LLM TTFT, TTS TTFA, and end-to-end p95 in real time, with each leg colored red if it exceeds its budget.
- Any tool the agent invokes (check availability, look up FAQ, book appointment, transfer) appears in an inline timeline with input, output, and duration.
The receptionist can take over the call from the dashboard at any moment.
When the call hangs up, an Inngest job pulls the recording and asks Claude Sonnet 4.6 to produce a structured summary, classify the outcome (SCHEDULED / QUALIFIED / TRANSFERRED / NOT_QUALIFIED / NO_ANSWER), score sentiment, and extract topics. The detail page shows the recording in a scrub-able player with the transcript highlighting the currently-spoken segment.
The same dashboard ships outbound campaigns (CSV upload, working-hour respect, retries with cooldown), an analytics page (volume, conversion, latency p95, weekday-by-hour heatmap), and a Cal.com integration for booking appointments mid-call.
- Twilio terminates the PSTN call and bridges it into LiveKit Cloud over SIP.
- A long-lived Node worker joins the LiveKit room and runs the conversation loop. The worker is deployed separately from the Next.js app, since Vercel functions cannot keep a websocket open for a 10-minute call.
- Deepgram handles STT, VAD, and turn-detection in one streaming API. End-of-turn events fire the LLM, eliminating the 150-300 ms variance of separate VAD + silence-timer pipelines.
- Claude Haiku 4.5 runs the conversation. Streamed tokens are split sentence-by-sentence and handed to Cartesia Sonic-3 so audio starts playing before the LLM finishes generating.
- Tool use is native to the Anthropic SDK call. Four tools are available during the call:
check_availability,book_appointment,lookup_kb,transfer_to_human. Each tool is zod-validated, recorded with input/output/duration, and the LLM continues the conversation with the tool result as a normal turn. - Adaptive interruption / barge-in cancels in-flight LLM generation and flushes the TTS audio queue the moment the user starts speaking.
- Latency is instrumented per leg (STT finalize, LLM TTFT, LLM total, TTS TTFA, tool total, end-to-end) and written to the database for the live meter and the analytics dashboard.
Three layers of tenant isolation:
| Layer | Mechanism | File |
|---|---|---|
| Branded TypeScript IDs at call sites | OrgId, UserId, CallId, etc. |
lib/db/types.ts |
Prisma $extends middleware |
getDb(orgId) auto-injects orgId on every operation |
lib/db/with-org.ts |
| Postgres RLS | is_member_of(org_id) policy on every tenant table |
prisma/sql/setup.sql |
The $extends middleware is unit-tested in lib/db/with-org.test.ts with explicit cross-tenant negative cases for every operation shape (findMany, create, createMany, upsert).
Plus the rest of the B2B surface:
- Magic-link auth via Supabase, with create-org / accept-invite onboarding flows.
- Admin / member roles with last-admin protection (you can't demote or remove the only admin).
- Invite-by-link onboarding with Resend-delivered email and 7-day token expiry.
- Audit log on every mutating action.
- Service-role path for webhook handlers that need to resolve a tenant from an inbound phone number before any user is logged in.
- The worker triggers an Inngest
call/completedevent on hangup. The function loads the transcript and tool calls, asks Claude Sonnet 4.6 (quality over latency for offline analysis) to fill a structured summary, runs idempotently, and writes back to theCallrow. - The recording is captured by LiveKit Egress, stored in Supabase Storage, and rendered in the detail page via a scrub-able
<audio>element synced to the transcript. Moving the playhead highlights the currently-spoken turn. - An outbound campaign engine runs as a 1-minute Inngest cron. It scans running campaigns for leads that are eligible (working hours, cooldown elapsed, attempts not exhausted) and emits dispatch events. The dispatcher is concurrency-limited per campaign.
- The analytics page aggregates per-call metrics into a volume chart, a latency p95 histogram (the headline metric; buckets above 1000 ms render destructive-red), a weekday-by-hour heatmap, and a per-agent comparison.
Next.js 16, TypeScript strict, Tailwind v4, shadcn/ui (new-york, zinc), Prisma 7 (adapter pattern), Supabase (Auth + Postgres + Realtime + Storage), LiveKit Agents on the worker side with the Node SDK, Twilio for PSTN, Deepgram for STT, Claude Haiku 4.5 for the live LLM and Sonnet 4.6 for offline analysis, Cartesia Sonic-3 for TTS with ElevenLabs Flash v2.5 as a premium SKU, Cal.com API key for calendar, Inngest for background jobs, Resend for transactional email, Sentry, PostHog, Vitest, Geist Sans and Mono, violet accent.
The numbers below are what the architecture is designed for. They will be measured against the live deployment once services are wired up.
- End-to-end response time on a normal turn: 600 ms p50, 900 ms p95 from end-of-user-speech to first-byte-of-agent-audio.
- Time-to-first-audio after Claude starts streaming: under 100 ms (Cartesia Sonic-3 streaming with sentence-chunked input).
- Live transcript surfaces in the dashboard within 300 ms of finalization (Supabase Realtime).
- Post-call analysis on a 3-minute call: 15 to 25 seconds (one Claude Sonnet call with prompt caching warmed).
- Cross-tenant access probes: blocked at the branded-types layer, verified at the Prisma
$extendslayer, verified again at the Postgres RLS layer.
flowchart LR
Caller[Caller] -->|PSTN| Twilio[Twilio Elastic SIP Trunk]
Twilio -->|SIP| LK[LiveKit Cloud]
LK -->|dispatch| Worker[Agent worker, defineAgent, Fly.io]
Worker --> STT[Deepgram STT, turn detection]
Worker --> LLM[Claude Haiku 4.5 via OpenAI-compat]
Worker --> TTS[Cartesia Sonic-3 streaming TTS]
LLM -.tools.-> Tools[lookup_kb / check_availability / book_appointment / transfer]
Tools --> Cal[Cal.com API]
Worker --> DB[(Postgres / Supabase)]
DB --> RT[Supabase Realtime]
RT --> UI[Live call monitor in dashboard]
LK -.egress.-> Storage[Supabase Storage]
Worker -.hangup.-> Inn[Inngest: call/completed]
Inn --> Sonnet[Claude Sonnet 4.6: summary, outcome, sentiment]
Sonnet --> DB
UI -.outbound campaign.-> SIPOut[LiveKit SIP outbound] --> Twilio
Relay reads env vars via Next.js (.env.local for development, the platform's secret store for production). Same variable names in both. The "Sensitive" column tracks whether the value should be marked secret in your hosting provider. NEXT_PUBLIC_* values end up in the client bundle anyway, so marking them sensitive only hides them in the UI.
| Variable | Production | Local (.env.local) |
Sensitive |
|---|---|---|---|
NEXT_PUBLIC_APP_URL |
the public origin of your deployment | http://localhost:3000 |
no |
NEXT_PUBLIC_APP_DOMAIN |
host shown in the landing page screenshot chrome | app.relay.so or unset |
no |
NEXT_PUBLIC_SUPABASE_URL |
https://<project>.supabase.co |
same | no |
NEXT_PUBLIC_SUPABASE_ANON_KEY |
publishable key, sb_publishable_xxx |
same | no |
SUPABASE_SERVICE_ROLE_KEY |
secret key, sb_secret_xxx |
same | yes |
DATABASE_URL |
Supabase transaction pooler URI + ?pgbouncer=true&connection_limit=1 |
Supabase session pooler URI | yes |
DIRECT_URL |
Supabase session pooler URI | same | yes |
ANTHROPIC_API_KEY |
from console.anthropic.com |
same | yes |
ANTHROPIC_MODEL_FAST |
claude-haiku-4-5-20251001 |
same | no |
ANTHROPIC_MODEL_SUMMARY |
claude-sonnet-4-6 |
same | no |
DEEPGRAM_API_KEY |
from console.deepgram.com |
same | yes |
DEEPGRAM_MODEL |
nova-3 |
same | no |
CARTESIA_API_KEY |
from play.cartesia.ai |
same | yes |
CARTESIA_MODEL |
sonic-3 |
same | no |
ELEVENLABS_API_KEY (premium SKU) |
from elevenlabs.io |
same or unset | yes |
LIVEKIT_API_KEY |
from LiveKit Cloud project | same | yes |
LIVEKIT_API_SECRET |
from LiveKit Cloud project | same | yes |
LIVEKIT_URL |
wss://<project>.livekit.cloud |
same | no |
LIVEKIT_SIP_INBOUND_TRUNK_ID |
id of the shared LiveKit inbound trunk (E.164 allow-list managed by app) | same | no |
ENCRYPTION_KEY |
32+ char random for lib/crypto.ts AES-256-GCM (openssl rand -base64 48) |
same value as production | yes |
CALCOM_API_BASE |
https://api.cal.com/v2 |
same | no |
INNGEST_EVENT_KEY |
from app.inngest.com |
from inngest-cli dev |
yes |
INNGEST_SIGNING_KEY |
from app.inngest.com |
from inngest-cli dev |
yes |
RESEND_API_KEY |
sending-access key | same | yes |
RESEND_FROM_EMAIL |
onboarding@resend.dev until you verify a domain |
same | no |
NEXT_PUBLIC_SENTRY_DSN (optional) |
from a Sentry project | same or unset | no |
NEXT_PUBLIC_POSTHOG_KEY (optional) |
from PostHog | same or unset | no |
NEXT_PUBLIC_POSTHOG_HOST (optional) |
https://us.i.posthog.com |
same | no |
Twilio credentials are stored per-org in the database (TwilioConnection), not in env vars. Each org admin pastes an API Key in Settings, Telefonia; the app then provisions the org's Elastic SIP Trunk, registers a per-org LiveKit outbound trunk, and keeps the shared inbound trunk's allow-list in sync. ENCRYPTION_KEY must be identical between local and production — secrets written in one environment can't be decrypted by the other if the keys differ, so rotating it requires re-pasting every Twilio connection.
One value differs between local and production: DATABASE_URL uses the transaction pooler in production (every serverless invocation opens a fresh connection) and the session pooler locally (longer-lived, supports prepared statements, plays nicely on IPv4 home networks).
Free tiers on every platform cover the demo. The order below matches the dependency chain: Supabase first (database), then the model providers, then telephony.
-
New project at supabase.com. Pick a region close to your users.
-
Authentication, URL Configuration:
-
Site URL: production URL of the app (e.g.
https://relay-five-peach.vercel.app) -
Redirect URLs: add four separate entries:
http://localhost:3000/auth/callbackhttp://localhost:3000/auth/callback?**https://relay-five-peach.vercel.app/auth/callbackhttps://relay-five-peach.vercel.app/auth/callback?**
Each environment needs both the bare path (initial magic link with no query) and the
?**glob (login flow appends?next=…). Without the localhost pair, magic links issued fromyarn devfall back to the production Site URL and land you on the live app instead of your local one.
-
-
Settings, API: copy
NEXT_PUBLIC_SUPABASE_URL,NEXT_PUBLIC_SUPABASE_ANON_KEY,SUPABASE_SERVICE_ROLE_KEY. -
Settings, Database, Connect: copy the pooled URI into
DATABASE_URLand the direct URI intoDIRECT_URL.
- New project at livekit.io. Note the WebSocket URL as
LIVEKIT_URL. - Project Settings, Keys: create one and copy into
LIVEKIT_API_KEYandLIVEKIT_API_SECRET. - SIP, Inbound Trunk: create one with number-based auth and an empty initial numbers list. Copy its trunk ID into
LIVEKIT_SIP_INBOUND_TRUNK_ID. The Relay app extends this trunk's allow-list automatically when a tenant attaches a number through Settings, Telefonia. - No outbound trunk to create here. Per-org outbound trunks are provisioned via API when a tenant connects Twilio.
This one is different from the others: you (the Relay operator) don't need a Twilio account at all. Each tenant connects their own Twilio account from inside the app.
Tenant flow (do this once per workspace, in the Relay dashboard):
- Go to Settings, Telefonia in Relay. The screen shows a connection form with three fields.
- In a separate tab, open Twilio Console, Account, API Keys & tokens, Create API key, type Standard, friendly name
Relay. The creation dialog shows two values; copy both, plus your Account SID from the top of any Console page:- Twilio Client SID (starts with
SK) → "Twilio Client SID" field in Relay - Secret (random characters, shown only once at creation) → "Secret" field in Relay
- Account SID (starts with
AC, top of any Twilio Console page) → "Account SID" field in Relay
- Twilio Client SID (starts with
- Submit. Relay validates against
/v2010/accounts/{sid}.json, stores the secret encrypted (lib/crypto.ts, AES-256-GCM withENCRYPTION_KEY), and lists the Twilio numbers in your account. - For each number you want a Relay agent to answer, click Conectar and pick the agent. Relay then:
- Creates a Twilio Elastic SIP Trunk on your account (first attach only)
- Sets the trunk's Origination URL to your LiveKit inbound SIP host
- Attaches the number to the trunk
- Adds the E.164 to the LiveKit inbound trunk's allow-list
- Provisions a per-org LiveKit outbound trunk carrying your Twilio credentials (so outbound calls bill to your Twilio account)
To use the Twilio integration you need Trunking enabled on your Twilio account; it's a free add-on you toggle once in Console, Voice, Manage, SIP Trunking.
- Anthropic (console.anthropic.com), API Keys, copy into
ANTHROPIC_API_KEY. Used both by the live worker (Haiku 4.5) and by the post-call Inngest function (Sonnet 4.6). - Deepgram (deepgram.com), API Keys, copy into
DEEPGRAM_API_KEY. Streaming STT with native turn detection. - Cartesia (cartesia.ai), API Keys, copy into
CARTESIA_API_KEY. Default TTS. - ElevenLabs (optional, premium voice SKU) (elevenlabs.io), Profile, API Key, copy into
ELEVENLABS_API_KEY. Leave unset and the agent UI hides ElevenLabs voices.
Personal API key only (no OAuth). Generate at app.cal.com, Settings, Security, API Keys; the key starts with cal_live_. Don't store in env. Paste it per-tenant in the app at Settings, Calendário. Each org's key is encrypted in the DB.
- Sign up at inngest.com. Create an "App" for Relay.
- Manage, Event Keys: copy into
INNGEST_EVENT_KEY. - Manage, Signing Keys: copy into
INNGEST_SIGNING_KEY. - Apps, Sync new app: URL is
<your-vercel-url>/api/inngest. Inngest auto-discovers the functions.
- resend.com, API Keys, copy into
RESEND_API_KEY. - Verify a sending domain, or use the sandbox
onboarding@resend.devuntil you do.RESEND_FROM_EMAILis whatever address is verified.
Both are no-ops with unset env vars. To enable: create projects, copy the DSN or project key, paste into NEXT_PUBLIC_SENTRY_DSN or NEXT_PUBLIC_POSTHOG_KEY.
- Install
flyctlandfly auth login. fly launch --no-deploy --copy-configto register the app from the includedfly.toml. Pickdfwwhen prompted.grep -vE '^[A-Z_]+=$' .env.local | fly secrets importto push all non-empty secrets in one shot.fly deploy --ha=falsefor the first (and every) deploy.--ha=falsekeeps it to one machine; LiveKit Cloud already distributes inbound rooms across whichever workers are connected.- Add a credit card at https://fly.io/trial. On the trial plan Fly stops machines after 5 minutes with
Trial machine stopping. To run for longer than 5m0s.... The worker needs to stay running to accept inbound LiveKit jobs. Theshared-cpu-1x:1024MBsize used here costs a couple of dollars a month above the free tier.
yarn vercel linkto attach this repo to a Vercel project.- Set
ENABLE_EXPERIMENTAL_COREPACK=1as an env var (Settings, Environment Variables, oryarn vercel env add). Without it Vercel falls back to Yarn 1 and the Yarn 4 lockfile resolution breaks the build. - Bulk-push the rest of
.env.local(see the install loop in the deploy section below). yarn vercel --prodto deploy. Auto-deploy on git push is intentionally disabled invercel.json; promotion is always manual.
Requires Node 22 and Yarn 4 via Corepack.
cp .env.example .env.local
# Fill in every key per the table above.
corepack enable # one-time, installs yarn 4
yarn install # postinstall runs prisma generatePrisma 7 reads connection URLs from prisma.config.ts, which calls Next.js's env loader so .env.local is picked up automatically. The baseline migration is committed at prisma/migrations/0_init/migration.sql — for a fresh Supabase project, just apply it:
yarn prisma migrate deployThen the RLS policies and the auth.users -> public.users mirror trigger:
yarn db:rlsyarn db:rls runs prisma/sql/setup.sql over DIRECT_URL. Idempotent, safe to re-run.
Optional demo data, "Clínica Lumen" with 60 fake calls across the last 30 days:
yarn db:seed # skips if the demo org exists
yarn db:seed --reset # wipes and re-createsThe seed runs every insert inside one transaction, so a failed/interrupted run leaves nothing behind. Re-run with --reset to recover from a partial state.
yarn dev # Next.js dashboard on :3000
yarn worker:dev # Agent worker (separate terminal)
yarn inngest:dev # Inngest dev server (separate terminal)Scripts:
yarn typecheck # tsc --noEmit
yarn lint # ESLint flat config
yarn test # Vitest
yarn build # Next.js production buildAuto-deploys are off by default (vercel.json sets git.deploymentEnabled: false). Every push to main skips both Vercel and any branch-watching integration. Promote a build manually with the steps below.
Import the repo and paste your env vars from .env.local swapping in the production values from the table above. Leave Build / Output / Install commands empty — Vercel auto-detects the packageManager: yarn@4.14.1 field in package.json and uses Corepack for installs. Setting a custom installCommand here causes Vercel to skip that detection and fall back to Yarn 1, which misresolves the Yarn 4 lockfile and crashes the Next 16 TypeScript validator at build time.
Deploy from your machine:
yarn vercel --prodThe worker is outbound-only (joins LiveKit rooms over websocket, no inbound HTTP). The included Dockerfile at the repo root and fly.toml target Fly in dfw.
fly secrets import < .env.local # one-time, see the worker section above
fly deploy --ha=false # single machine; LiveKit dispatch already distributes load--ha=false is important — Fly creates a primary + standby pair by default, but LiveKit Cloud routes inbound rooms across whichever workers are connected, so the standby would be paid-for idle capacity.
The worker uses @livekit/agents job dispatch. When LiveKit Cloud creates a room (SIP call or dashboard test-call), it dispatches a worker process. No HTTP coupling between Vercel and Fly.
Relay treats Twilio (or any other SIP-capable carrier) as a SIP carrier, not as a Voice webhook target.
flowchart LR
subgraph Inbound["Inbound call"]
direction LR
Caller([Caller]) -->|PSTN| TwIn[Twilio Elastic SIP Trunk]
TwIn -->|SIP INVITE| LKIn[LiveKit SIP inbound trunk]
LKIn --> Room1[Room created]
Room1 --> Worker1[Agent worker dispatched]
end
subgraph Outbound["Outbound campaign"]
direction LR
Cron[Inngest cron] --> Place["placeOutboundSipCall()"]
Place --> LKOut[LiveKit SIP outbound trunk]
LKOut -->|SIP INVITE| TwOut[Twilio Elastic SIP Trunk]
TwOut -->|PSTN| Lead([Lead])
end
After the first deploy, configure each provider's dashboard as described below.
Project creation:
- Enable Data API: uncheck. Relay queries through Prisma server-side; PostgREST is unused.
- Automatically expose new tables: uncheck (default).
- Enable automatic RLS: check. Defense in depth on top of
prisma/sql/setup.sql. - Postgres Type: Postgres (default), not OrioleDB.
After project exists, Authentication, URL Configuration: add https://<your-deployment> to Site URL and Redirect URLs.
Connection strings:
DATABASE_URLon Vercel: Transaction pooler URI, append?pgbouncer=true&connection_limit=1.DIRECT_URLon Vercel: Session pooler URI.- Both
DATABASE_URLandDIRECT_URLlocally: Session pooler URI. The Direct connection option is IPv6-only and not needed here.
Create a trunk under Twilio Console, Elastic SIP Trunking, Trunks.
Features tab:
| Setting | Value |
|---|---|
| Call Recording | Disabled (LiveKit Egress handles recording) |
| Secure Trunking | Disabled initially; enable after basic connectivity works, matching LiveKit SRTP |
| Call Transfer (SIP REFER) | Enabled (needed by transfer_to_human) |
| Caller ID for Transfer Target | Set caller ID as Transferee |
| Enable PSTN Transfer | Checked |
| Symmetric RTP | Enabled |
Termination tab:
- Twilio auto-generates a Termination URI like
<trunk>.pstn.twilio.com. Copy it into LiveKit's outbound trunk config. - Assign a Credential List (created under Authentication) so LiveKit can authenticate when dialing out.
Origination tab:
- Add the LiveKit SIP inbound URI as the origination URI. Get it from LiveKit Cloud, SIP.
- Priority 10, Weight 10, Enabled.
Numbers tab:
- Assign every Twilio E.164 you've purchased. Each one must match what you connect in Relay's
Settings, Phone numbers.
Under SIP:
- Inbound trunk: name
twilio-inbound. Direction Inbound. Numbers: paste every Twilio E.164 (comma-separated). Allowed addresses: leave blank. Media encryption SRTP: Disabled (must match Twilio's Secure Trunking). Include headers: No headers. - Outbound trunks: Relay provisions one per-org automatically when an admin connects Twilio in
Settings, Telefonia. The trunk id is stored inTwilioConnection.livekitOutboundTrunkIdand looked up by the campaign-dispatch job; no global env var is required. - Dispatch rule: one room per call, default name
call-<sid>.
Under Agents: register a dispatch rule that runs your worker on every new room.
Cal.com Platform is closed to new signups as of 2026, so OAuth Client provisioning is not an option. Use personal API keys instead. Any Cal.com account works (free, paid, personal, team).
Each org admin generates one key on their own Cal.com account:
- In Cal.com, Settings, Security, API Keys, Add.
- Set a recognizable name like
Relay. Set an expiration (or none for the demo). - Copy the generated key. It starts with
cal_live_. - In Relay, Settings, Calendar, Conectar Cal.com: paste the key and submit. Relay validates it by calling
/meand stores it against the org.
The agent worker uses the stored key for /event-types, /slots/available, and /bookings. There is no OAuth dance, no redirect URI to register, and nothing to configure in env beyond CALCOM_API_BASE (which defaults to https://api.cal.com/v2).
Register the app with the /api/inngest route URL on app.inngest.com. Copy the event key and signing key into the env. The campaign-tick cron will start firing once the app is registered.
The phone number you connect inside Relay (Settings, Phone numbers) must match the E.164 the carrier sends in the SIP To header. The worker reads this from SIP attributes on the joining participant to resolve which org and agent owns the call.
The Vercel build only runs next build. It does not apply pending Prisma migrations. After committing a schema change locally, run from your machine:
DIRECT_URL="<value of prod DIRECT_URL>" yarn prisma migrate deployprisma migrate deploy applies every migration in prisma/migrations/ that hasn't been recorded yet. The command is idempotent and atomic per migration.
app/
(auth)/ login, signup
(onboarding)/ create-org, accept-invite
(app)/ authenticated shell
dashboard/ home stats
calls/ history list + live monitor + detail
agents/ CRUD + persona / voice / hours / knowledge base
campaigns/ outbound CRUD + lead board
calendar/ Cal.com bookings overview
analytics/ volume, latency p95, heatmap, agent comparison
settings/ org, members, phone-numbers, calendar
auth/ supabase callback, signout, check-email
api/
webhooks/livekit/ room / participant events
inngest/ Inngest serve handler
components/
ui/ shadcn primitives
call/ waveform, transcript stream, latency meter, audio player
hooks/
use-realtime.ts Supabase Realtime subscription hooks
lib/
analytics/ aggregations for /analytics
auth/ session helpers (server-only)
calendar/ Cal.com Platform API client
db/ Prisma client + with-org $extends + branded IDs
email/ Resend client + invite template
inngest/ client, post-call function, campaign-dispatch, campaign-tick (cron)
posthog/ client + server analytics
supabase/ server, admin (service-role), browser, middleware
voice/ livekit (room + SIP outbound), persistence, prompts, cost
voice/tools/ lookup-kb
prisma/
schema.prisma 18 models
sql/setup.sql RLS policies + auth.users sync trigger
seed.ts Clínica Lumen demo data
scripts/
apply-rls.ts run setup.sql against DIRECT_URL
worker/ LiveKit Agents worker (deployed separately)
agent.ts defineAgent({ entry }) that resolves tenant from SIP attrs,
wires Deepgram STT + Anthropic LLM + Cartesia TTS + Silero VAD,
runs the conversation loop, persists transcripts and metrics
Dockerfile Worker container, used by Fly.io via fly.toml
fly.toml Fly.io app config (dfw region, outbound-only worker)
Runs on every PR and on push to main. Pipeline order:
prisma generatetsc --noEmit- ESLint flat config
- Vitest
next build
MIT. See LICENSE.