Reception AI answers the phone, books the appointment, and writes down what happened — in your words, on your
number. These pages cover the product for operators and the system for the people who run it.
Reception AI is an AI receptionist for clinics, salons, restaurants, and trades. A caller rings the number they already
know. The receptionist answers in a chosen voice, stays inside the facts you uploaded, books or takes a message, and
hands over when a person should take it.
The workspace at /app is where you build that receptionist. The public site sells
it. The admin panel at /admin is for the people who operate the
platform — plans, tenants, keys, and health.
How to read these docs
User guide — the path a new workspace follows to a live line.
Workspace — every screen after you sign in, and what it is for.
Architecture — why calls, jobs, and auth are shaped this way.
Install, environment, deploy — from npm run dev to a public origin that carriers can reach.
HTTP API, admin, troubleshooting — webhooks, health, and the failures that actually happen.
Plans the product enforces
Marketing numbers and metering come from the same plan table. Studio is free. Scale is $89 per seat per
month. Enterprise is custom and unlimited.
Plan
Minutes / month
Live receptionists
Numbers
Price
Studio
15
1
1
Free
Scale
2,000
25
25
$89 / seat / month
Enterprise
Unlimited
Unlimited
Unlimited
Custom
Usage is counted in one currency: voice-equivalent minutes. A live minute costs 1. A turn on the fallback
phone path costs 0.25. A text exchange costs 0.025. Admins can change those rates without a deploy.
Tutorial
From empty workspace to a live line
You do not compose or deploy anything. You pick the kind of business you run, tell the receptionist the facts, and
put it on the phone. The checklist on the dashboard is derived from what actually exists — it cannot tell
you to do a step you already finished.
1. Create an account
Open /login. Sign in or create an account with email and password
(eight characters minimum). New accounts can be closed from the admin panel without taking the product down.
After sign-in you land in the workspace. If you are already signed in, /login sends you to /app.
2. Pick your trade
Go to Agents and start from a template: clinic, salon, restaurant, workshop, support, and
the rest of the catalog. The first version already knows what those callers usually ask. You can change
every word.
Why a template
A blank receptionist answers with nothing to stand on. A trade template gives you a working first draft you can hear the same day.
3. Tell it what it needs to know
Open Knowledge. Upload a PDF, a sheet, or paste opening hours, prices, and the things it
must never say. The receptionist only answers from what you give it. Sample documents that ship with a template do
not count as “you taught it” — the checklist waits for a real upload.
4. Set opening hours
Settings → Hours, in your timezone. Booking offers come from this calendar. Without hours
and a timezone, the receptionist cannot honestly offer a slot.
5. Hear it yourself
Open the receptionist and talk to it in the workspace before anyone else does. Change the prompt, the voice, or the
tools if a sentence sounds wrong. The landing page demo (“Maya” at Northstar Support) is the same idea for
visitors who are not signed in.
6. Connect a phone number
Numbers is where you import a number you already own (Twilio, Plivo, and other carriers the
catalog lists) and point it at this receptionist. Simulator numbers in the product cannot receive a real call —
they exist so you can click around without a carrier.
Point the carrier at your public origin:
Carrier
Inbound URL
Twilio
https://your-app/api/voice/inbound
Plivo
https://your-app/api/voice/plivo
7. Take it live
Publish the receptionist and leave the number assigned. You can pause it again at any time. The answering half of
the checklist is now done. Contacts, apps, and campaigns are optional — a clinic that only answers does not
need them.
If you also want it to call out
A
Import contacts
A CSV of names and numbers is enough. Sample contacts that ship with a workspace do not tick this step.
B
Connect tools
Calendar, CRM, sheets, webhooks — only the apps that are actually wired change runtime behavior.
C
Run a campaign
Give it a list and a reason to call. Background jobs walk the list. Outbound can be switched off for the whole platform.
How-to
The workspace, screen by screen
After sign-in, everything a workspace needs lives under /app. The sidebar is grouped
the way the work is grouped: build the receptionist, watch what happened, then optionally call out.
Workspace, hours, and a read-only billing view. Plan changes are admin-side.
Public surfaces
/w/<agentId> — embeddable web widget. The demo receptionist is /w/demo.
/f/<formId> — a public form you can send to a caller or put on a site.
The widget can be switched off for the whole platform with the widget feature flag.
Guardrails worth knowing
The receptionist should only answer from knowledge you provided.
Transfer to a person is a first-class tool, not a failure.
Plan limits are checked before model work. Metering errors fail open so a billing bug cannot drop a live line.
Simulator numbers never become a real inbound path.
Explanation
One process, two sockets, a database
The product looks like a website. The hard part is a phone call: the process must hold a WebSocket to the
carrier and another to the realtime model for as long as the caller is talking. A function that dies after
a request cannot do that.
Stack
Layer
Choice
App
TanStack Start, React 19, Vite 8, Nitro node-server
UI
Tailwind v4, Radix, Satoshi + Newsreader
Auth
Better Auth at /api/auth/*, email/password on by default
Data
Postgres when DATABASE_URL is set; otherwise in-memory PGLite
Live calls
OpenAI Realtime over a μ-law pass-through relay
Preview / fallback
Turn-based path and browser chat (xAI key)
Jobs
Postgres queue, drained every minute in-process
What happens on a live call
Caller→Twilio / Plivo→POST webhook
Stream XML→wss://APP_URL/api/voice/stream
Load agent→OpenAI Realtime→Tools / outcomes
Transcript→Usage row→Call record
Audio is copied as μ-law. There is no transcode in the middle. The relay sits next to the model and the
database, not next to the caller — a hop beside the long audio path is cheaper than a hop beside the phone.
If APP_URL is missing, Twilio can fall back to a turn-based loop: speak, gather,
think, speak again. That path is slower and not interruptible. It is a safety net, not the product.
Auth
The app runs its own Better Auth. Email and password persist in the same database as the rest of the
product. Google sign-in appears only when a first-party Google client is configured. A legacy broker path
still exists for hosted previews; it is not shown on the sign-in page.
Server functions take authMiddleware and scope every row to context.userId.
With a real DATABASE_URL, a disabled-auth “dev user” is refused — fail closed.
Jobs
Campaign ticks, webhook deliveries, sheet appends, and call reconcile work sit in a jobs
table. The process drains that table every minute. POST /api/jobs/run is the same
drain, protected by JOBS_SECRET off loopback. QStash can poke that URL on serverless
hosts; an always-on machine does not need it.
Handlers must be idempotent. The queue is at-least-once.
Settings
Most keys can live in the environment or in platform_settings. An empty row in the
table means “off” — it does not fall back to env. Two values can never move into the table:
DATABASE_URL and CREDENTIAL_ENCRYPTION_KEY, because both are
needed to read the table.
Why these trade-offs
One process — one deploy, one log stream, one warm cache, and sockets that survive the call.
Metering separate from Stripe — usage has to be true even when nobody is charged.
Admin 404, not 403 — do not confirm the panel exists to a stranger.
Health: DB down is 503, missing model key is 200 — keep admin reachable so you can paste the key.
Derived onboarding — a stored step pointer lies the moment someone finishes the work on another screen.
Tutorial
Install and run it on your machine
You can hear the receptionist and sign in with no cloud services. Real phone calls need a public URL, a database,
and provider keys. Node 22 is what the container uses.
What you need
Node.js 22 and npm
Git
Optional: a Neon (or any Postgres) URL when you want data to survive a restart
Optional: cloudflared when a carrier must reach your laptop
First run
git clone <this-repo>
cd receptionai
npm install
npm run dev
The dev server binds 0.0.0.0:8080. Open the site on that port. With no
DATABASE_URL the app boots an in-memory Postgres (PGLite), applies migrations, and
invents an encryption key for the process. Restarting wipes that data.
Create an account at /login, then open /app.
Useful scripts
Command
What it does
npm run dev
Vite on 0.0.0.0:8080
npm run build
Production bundle, then migrate
npm run build:app
Bundle only — used inside Docker so image build does not touch a database
npm run start
node .output/server/index.mjs
npm run db:migrate
Apply migrations/*.sql to DATABASE_URL
npm run tunnel
Public HTTPS in front of local 8080, then set APP_URL to that origin
npm run admin:grant
Promote an existing user email to platform admin
npm run typecheck
tsc --noEmit
npm test
Unit tests
Taking a real call on a laptop
Copy .env.example to .env (never commit it).
Set OPENAI_API_KEY so the live path can answer.
Set XAI_API_KEY so the browser preview and fallback path can talk.
Run a tunnel to port 8080 and put the https origin in APP_URL.
Point the carrier webhook at that origin (see the user guide).
cloudflared tunnel --url http://localhost:8080
Vite allow-lists the hostname in APP_URL so the tunnel is not rejected as an unknown
host.
Do not write secrets into the repo
.env is gitignored. Generate encryption and job secrets with Node, store them in a
secret manager on deploy, and rotate only with a re-encrypt if credentials already live in the database.
Reference
Environment and settings
Nothing in .env.example is required for npm run dev. Everything
below is required before this is a deployment that can keep data, take a card, or stay on a live call.
How a value is read
If a row exists in platform_settings, that row wins — including an empty row, which means off.
If there is no row, process.env is used.
DATABASE_URL and CREDENTIAL_ENCRYPTION_KEY are environment-only.
VITE_* values are compiled into the client at build time. Changing them later needs a rebuild.
Must exist for a real database
Variable
Need
What happens without it
DATABASE_URL
Prod
In-memory PGLite. Lost on restart.
CREDENTIAL_ENCRYPTION_KEY
With DB
The process refuses to write carrier and API secrets into Postgres.
BETTER_AUTH_SECRET
Prod
An ephemeral secret is minted for preview. Sessions will not survive deploys.
BETTER_AUTH_URL
Prod
Must be the public origin Auth will advertise.
APP_URL
Calls
Carriers have nowhere honest to stream. Twilio may fall back to the slow path.
Connected app; callback <origin>/api/connect/salesforce/callback with api + refresh_token scopes. SALESFORCE_LOGIN_URL for sandboxes.
GOOGLE_CONNECTOR_CLIENT_ID
Gmail / Calendar
Separate client on purpose. Sensitive scopes must not sit on the sign-in client.
GOOGLE_CONNECTOR_CLIENT_SECRET
With connector id
Redirect: <origin>/api/connect/google/callback.
GOOGLE_ENABLE_GMAIL_READ
Restricted
Adds gmail.readonly. Leave unset unless verification is budgeted.
VITE_AUTH_ENABLED
Dev only
Set false to force the local dev user. Refused when DATABASE_URL is set.
Jobs, mail, money, phone
Variable
Need
Notes
JOBS_SECRET
Remote drain
Bearer for /api/jobs/run. Unset = loopback only.
QSTASH_TOKEN
Serverless
Pushes a drain after enqueue. Always-on Fly drains itself.
PLIVO_AUTH_ID / PLIVO_AUTH_TOKEN
Plivo
Import and webhook verify. Twilio credentials live on the number, not in env. If a proxy rewrites URLs and Plivo calls are rejected as a signature mismatch, set PLIVO_DEBUG_SIG=1 to log which URL form was signed.
DEMO_MODE
Showroom
true turns the deployment into a public demo: two
one-click accounts on the sign-in page (business owner and platform admin),
seeded with a live receptionist, and every write refused server-side.
Conversations still work, so each visitor spends your model budget — cap it
with your provider first. Leave unset on a real install.
SMTP_HOST, PORT, USER, PASS, FROM
Mail
Transactional email.
STRIPE_SECRET_KEY
Cards
Metering works without Stripe. With it, the Plan tab sells upgrades through Stripe Checkout: create a recurring Price per paid plan and paste its id into that plan at /admin/plans.
STRIPE_WEBHOOK_SECRET
With Stripe
Signing secret of a webhook pointed at POST <APP_URL>/api/billing/stripe (events: checkout.session.completed, customer.subscription.updated / deleted, invoice.payment_failed). Without it, cancellations and card failures never reach the app.
PAYPAL_CLIENT_ID / SECRET / MODE
Alt pay
Sandbox until you take real money.
Brand, site, and flags
These are safer to edit in Admin → Configuration after the first deploy:
BRAND_NAME, BRAND_TAGLINE, SUPPORT_EMAIL,
SITE_TITLE, SITE_DESCRIPTION, SITE_OG_IMAGE,
SITE_FAVICON, SITE_NOINDEX, ANALYTICS_GA_ID.
Flag
Default reading
Effect
FEATURE_SIGNUPS
On
Close registration without taking the site down.
FEATURE_OUTBOUND
On
Stop every outbound dial at once.
FEATURE_WIDGET
On
Embeddable chat on /w/…
RATE_VOICE_MINUTE
1
Minutes charged per live minute.
RATE_PHONE_TURN
0.25
Fallback phone turn.
RATE_TEXT_MESSAGE
0.025
Chat or SMS exchange.
How-to
Deploy an always-on process
Ship one container that serves the site, the admin panel, and the call relay. Do not put the relay on a
platform that sleeps the process or splits each request onto a fresh function.
Why always-on
A twenty-minute call is one long socket, not twenty cheap requests. Cold start on a voice platform is
silence in someone’s ear. The documented host is Fly, region iad, next to the
database and the model. Hosting nearer the caller and farther from Postgres has already cost seconds of dead
air.
Docker
The Dockerfile is two stages on node:22-slim. Build runs npm run build:app
so migrations never touch a database from the image build. Runtime runs as user node,
under tini, on port 8080.
Run migrations as the release command, before new machines take traffic.
Leave auto_stop_machines off.
Measure concurrency in connections, not HTTP requests.
Scale in the same region as Postgres: fly scale count 2 --region iad.
Railway
The repo carries .railway/railway.ts, which describes the whole project: a Postgres
service, the app, migrations as a pre-deploy command, a health check on
/api/health, and one replica — one, for the same reason as Fly, because a call's
WebSockets live in a single process.
Then the variables. Point DATABASE_URL at the database service by reference rather
than pasting a string, so it survives the database being moved or rotated:
Generate the two secrets with
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))" and pipe
each into railway variable set --service reception-ai --stdin BETTER_AUTH_SECRET (and
the same for CREDENTIAL_ENCRYPTION_KEY). Keep the encryption key somewhere safe:
connector credentials are sealed with it and a new one cannot read the old rows. Add
OPENAI_API_KEY and XAI_API_KEY the same way, then:
npm install # the railway SDK is a devDependency
railway config plan # read this before applying
railway config apply
railway up --service reception-ai
Use the Infrastructure-as-Code file, not railway.json.
Config-as-code is deprecated on Railway and is silently partial: a
railway.json carrying preDeployCommand,
startCommand and healthcheckPath applied none of them, and
the first deploy came up on an unmigrated database where every query failed with
relation "jobs" does not exist.
.railwayignore keeps .env, node_modules
and build output out of the upload. Connecting the GitHub repo in Railway's dashboard instead gives
deploy-on-push; the CLI upload above needs no repository access. Note that
VITE_GOOGLE_CLIENT_ID is a build argument — Vite inlines it into the client
bundle — so it belongs in the service's build settings, not its runtime variables.
Render, and other container hosts
There is no render.yaml in the repo. If you deploy there, treat it as a
web service that never sleeps, not a static site and not a free instance that spins down.
Build: npm run build:app then migrate against the attached Postgres.
Start: node .output/server/index.mjs
Bind 0.0.0.0 on the platform port: set HOST=0.0.0.0 and PORT (or NITRO_HOST / NITRO_PORT).
Filesystem is ephemeral — knowledge files and secrets belong in Postgres or object storage, not disk.
Set the same secrets as Fly. Health check GET /api/health.
Health
curl https://your-app/api/health
A real database round trip is part of the check. Unreachable Postgres returns 503 so a bad release cannot
replace a good one. A missing OpenAI key is reported as degraded and stays 200 so you can still open admin
and paste it.
Carrier webhooks after deploy
Carrier
URL
Twilio inbound
https://<app>/api/voice/inbound
Plivo answer
https://<app>/api/voice/plivo
Media relay
wss://<app>/api/voice/stream
APP_URL must be that public origin. Signatures and stream XML are built from it.
Reference
HTTP and WebSocket surfaces
Almost all product work goes through TanStack server functions after a session check. These routes are the
ones carriers, schedulers, and the auth client hit directly.
Routes
Path
Auth
Purpose
/api/auth/*
Better Auth
Session, email, optional Google.
GET /api/health
None
Database + config probe.
GET|POST /api/jobs/run
Bearer JOBS_SECRET or loopback
Drain up to 10 jobs, 20s budget.
/api/voice/inbound
Carrier signature
Twilio inbound. Stream or turn fallback.
/api/voice/answer
Carrier signature
Outbound callee picked up.
/api/voice/turn
Carrier + call binding
One fallback conversational turn.
/api/voice/plivo
Plivo V3 signature
Plivo inbound to the relay.
/api/voice/realtime
OpenAI webhook signature
SIP incoming via OpenAI.
GET /api/voice/audio/:id
Unguessable id
Short-lived TTS clip for <Play>.
wss://…/api/voice/stream
Query + session setup
μ-law relay for the length of the call.
/api/connect/google/start
Session
Begin connector OAuth.
/api/connect/google/callback
OAuth state
Finish connector OAuth.
Draining jobs by hand
curl -X POST https://your-app/api/jobs/run \
-H "Authorization: Bearer $JOBS_SECRET"
The workspace is for one business. Admin is for the people who run every workspace. Non-admins who guess the URL
see “Not found”, not “forbidden”.
Grant the first admin
The user must exist — sign in once — then promote the email:
npm run admin:grant
Levels:
support — read accounts, no secrets, no writes.
super — secrets, plans, billing, and the right to grant others.
Screens
Tab
Path
Who
Overview
/admin
admin
Accounts
/admin/tenants
admin
Finance
/admin/finance
super
Plans
/admin/plans
super
Service health
/admin/health
admin
Configuration
/admin/settings
super
Audit
/admin/audit
admin
Required settings the overview nags about if empty: OPENAI_API_KEY,
APP_URL, DATABASE_URL,
CREDENTIAL_ENCRYPTION_KEY.
Demo mode
DEMO_MODE=true turns a deployment into a public showroom — the thing a marketplace
listing wants, where buyers try the product before paying and nobody has to be handed a password.
The sign-in page offers two one-click doors, business owner and
platform admin, instead of a form.
Both accounts — and a live receptionist with opening hours and a diary — are created the first time
someone uses a door. Accounts are made through the normal sign-up path, so their passwords are hashed
like everyone else's.
Nothing can be changed. Every write is refused server-side in the one middleware that
every server function passes through, so a visitor calling the API directly gets the same answer the
buttons do. The refusal reads
"Demo mode: changes are disabled here. Your own install saves everything normally."
Registration closes. Without that, a visitor could sign up on the public URL and get a real, writable
workspace — around the lock, which binds only the demo accounts.
Talking to a receptionist still works, because a demo that cannot answer is not a demo.
Four functions stay open: sendChat, transcribeAudio,
previewVoice and designAgentTurn.
Cap your model spend before you publish the URL. Every demo visitor's conversation is
billed to your OpenAI and xAI accounts, and nothing limits how many visitors arrive. The plan limits cap
minutes per workspace, not the number of people who walk in.
Your own accounts are unaffected: an operator listed in ADMIN_EMAILS signs in and
administers a demo deployment normally. Leave DEMO_MODE unset on a real install.
How-to
When it does not ring true
Most failures are a missing public URL, a missing key, or a process that went to sleep.
The site loads, the receptionist does not talk
Browser preview and landing demo need XAI_API_KEY.
A live phone call needs OPENAI_API_KEY and a reachable APP_URL.
Check GET /api/health — openaiKey should be true.
The call connects, then dies
The relay never started: process slept, or the host is serverless.
Stream URL was built from the wrong origin — APP_URL must match the certificate the carrier sees.
Webhook signature failed. The app answers 200 with hang-up TwiML so Twilio does not retry forever.
Cannot save a Twilio token
DATABASE_URL is set and CREDENTIAL_ENCRYPTION_KEY is not. The
app will not write plaintext secrets into a real database.
Cannot sign in
Email/password is the path on this product. Google appears only with a first-party client id at build time.
“Invalid origin” means you opened a host that is not in Better Auth’s trusted list. Use the public origin or loopback on 8080.
Sign-ups closed: admin flag FEATURE_SIGNUPS.
Campaigns sit in the queue
On Fly the process drains itself every minute.
On a host without the in-process cron, set JOBS_SECRET and schedule POST /api/jobs/run.
Without the secret, that route only answers loopback.
Agent will not publish
The plan’s live-receptionist cap is full. Studio allows one. Scale allows 25. Enterprise does not cap. Usage
errors fail open; publish caps do not.