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 sign-ups can be switched off 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, DOCX, CSV or text file, import one page of your website, or paste opening
hours, prices, and the things it must never say. The receptionist only answers from what you give it. Chat, the
widget and the turn-based phone path read your knowledge automatically; live phone calls reach it only through the
Search catalog tool, so switch that on and keep the essentials in the instructions too. 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
What each kind of line can do
Importing a number works for every carrier below. Answering and placing calls does not: each needs
carrier-specific handling, and that exists for some carriers and not others. This is what is built today, and
it is enforced in the product — a call that cannot leave from a line is refused with the reason, not offered
and then failed.
Line
Answers calls
Places calls
Twilio
Yes — live conversation
Yes — live conversation
Plivo
Yes — live conversation
No
SIP trunk
Yes, if your trunk sends calls to OpenAI's SIP endpoint
No
Vonage
No — import and verification only
Limited — plays your greeting as a one-way announcement
Telnyx
No — import and verification only
Limited — placed on your Call Control connection, which then decides what happens
Test number or address
No — practice only
No
Outbound minutes are billed at the length the carrier reports. Twilio is polled for how each call ended, and a
call that rings out unanswered costs nothing. The carriers that cannot be polled are metered at one minute per
call. If a carrier rejects a call or times out, it is recorded as failed — never as a completed call.
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.
Documents the receptionist may answer from. Full-text search on the server.
Voices
/app/voices
Pick a voice from the catalog. Several names share a sound — do not count names as unique voices.
Numbers
/app/phone
Import a carrier number and assign it to a receptionist.
Activity
Screen
Path
What you do there
Live chat
/app/inbox
The team inbox: website chats waiting for a person, your own live chats, your teammates', and the ones the receptionist is having right now. Go Online here to be handed chats.
Visitors
/app/visitors
Who is on your website now: the page, time on page and site, where they came from, device and location. Start a chat with anyone still browsing.
Conversations
/app/conversations
Every chat and call transcript, with tags.
Calls
/app/calls
Phone history and campaign call lists.
Results
/app/results
Bookings, tickets, leads, and other outcomes.
This week
/app/analytics
A weekly read of volume and outcomes.
Outreach and tools
Screen
Path
What you do there
Contacts
/app/contacts
People a campaign can ring.
Campaigns
/app/campaigns
Outbound lists and progress.
Apps
/app/apps
Connectors. Gallery is larger than the set that is actually wired.
/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.
Live chat, team and visitors
The website widget can hand a conversation from the receptionist to a person on your team, and your team can
see who is browsing the site and start a chat with them. It all runs on the same widget script; nothing else
needs installing.
Roles
Owner — the account the workspace belongs to. Everything, including the plan, exporting and deleting the workspace.
Admin — everything except billing, export and deletion. Can invite agents; only the owner invites admins or changes roles.
Agent — the Live chat inbox and the Visitors screen, nothing else. The server refuses every other request from an agent, not only the menu.
Building the team
Settings → Team → Invite a teammate. Enter their email and role, and optionally departments.
The invitation is emailed when SMTP is set; the link is always shown to you as well. It works for 7 days and only for that email address — a forwarded link is useless to anyone else.
They sign in or register with that address and open the link. Registration is allowed for an invited address even when public sign-ups are closed.
Someone on more than one team switches between workspaces from the card at the top of the sidebar.
Each teammate has a chat limit (how many live chats at once), skills (free-text
labels such as billing or spanish) and departments.
How a chat reaches a person
The visitor presses Talk to a person in the widget (with two or more departments, they pick one), or
the receptionist decides it needs a person (its handoff tool) — only when someone is online; otherwise it keeps helping and records a follow-up, or
a teammate joins a chat the receptionist is having (Live chat → AI live), or replies to it, or
a teammate starts one from the Visitors screen. It opens on the visitor's screen within about 20 seconds.
Waiting chats are routed, oldest first, to teammates who are Online in the inbox and under their
chat limit — in the chat's department if it has one. Among those, whoever has the most skills matching the
chat's tags wins, then whoever has the fewest open chats, then whoever was given one longest ago. If nobody in
the department is online after two minutes, anyone free may take it. Online status lapses on its own about a
minute after the inbox stops checking in, so a closed laptop does not keep receiving chats.
While a person has the chat the receptionist stays quiet. Transfer moves it to a teammate or a
department queue, Back to AI returns it to the receptionist, and End closes it — the visitor
is then asked for a 1–5 star rating and a comment. The chat survives page changes on your site and is reopened
when a teammate writes.
The inbox and the widget poll the server every two to three seconds rather than holding a socket open; this
works behind any proxy and on any number of instances. Turn on alerts in the inbox for a sound and a
desktop notification when a chat arrives.
Tags
Add tags to any conversation from the inbox or the Conversations screen. They are lower-cased and shared across
the workspace, so suggestions come from what the team already uses. Tags appear in search, the Insights report
and routing (skills).
Proactive messages
Website widget → Proactive messages, per receptionist. A rule fires when every condition holds: the page
address contains some text, seconds on that page, minutes on the site, pages viewed, returning visitors only,
and desktop or phone. It shows its message beside the chat bubble, or opens the chat. It fires once per visit,
per page or ever, and never while the visitor is already chatting.
Visitor tracking
The widget script reports each page a visitor opens (single-page apps included) and checks in every 20 seconds.
Recorded per browser: pages and time on each, referrer and UTM tags, browser, operating system, device type,
language, and a city/country from the IP address. Visits more than 30 minutes apart count as separate visits.
Who they are. A browser stays anonymous unless your site says who is signed in:
ReceptionAI.push(["identify", {id, name, email, plan}]). Extra plain values show as details in the inbox.
High-value visitors. Settings → Live chat → rules such as "visited /checkout and spent 2+ minutes". Matches are labelled and filterable on the Visitors screen.
Alerts. A Slack incoming webhook and/or a WhatsApp message when any visitor, a returning visitor, a known customer or a high-value visitor arrives, at most once per visitor per cooldown. WhatsApp uses the number connected under Connected apps → WhatsApp & email; business-started WhatsApp messages need a Meta-approved template with one text variable, otherwise WhatsApp delivers them only inside a 24-hour customer window.
Location comes from headers a CDN in front of the app already sets (Cloudflare, Vercel, CloudFront) or from a MaxMind GeoLite2/GeoIP2 City file at GEOIP_DB_PATH. With neither, location is blank — the app never sends visitors' addresses to a third party to look them up.
Privacy. IP addresses are masked by default. History is deleted after the retention period (30 days unless you change it; an hourly task enforces it). Wait for cookie consent records nothing until your banner calls ReceptionAI.push(["consent", true]). data-track="false" on the script tag turns tracking off for a page, and tracking can be switched off for the workspace.
Reports
Insights → Live chat: website chats, how many reached a person, average wait for a person, average first
response, average handling time, satisfaction (share of 4–5 star ratings) and missed chats; a per-teammate table
(exportable as CSV), top tags and visitors' comments. "Positive mood" at the top of Insights is the AI's
reading of the conversation, not a rating anyone gave.
AI models and voices
Typed chat, the website widget, simulations and the turn-based phone path can run on OpenAI, Anthropic
(Claude), Google Gemini, xAI (Grok), Groq, DeepSeek, Mistral, OpenRouter or Together. Live phone calls are
answered by OpenAI Realtime and are not affected by this choice.
Switch a provider on by adding its key under Admin → Configuration (or in the environment).
Admin → AI models shows which providers are on, each one's live model list, the default model and the fallback chain. Test sends one real request to one model.
Fallback. When a model fails — out of credits, rate limited, retired or down — the next in the chain answers. With nothing configured, any provider with a key is used.
Per receptionist. Receptionist → Advanced · AI model. "Platform default" follows the admin's choice; any model from the providers' live lists can be pinned instead.
A workspace's own account. Connecting OpenAI, Anthropic, OpenRouter or Together under Connected apps (and attaching it to a receptionist) makes that receptionist use the workspace's account first.
ElevenLabs voices. With ELEVENLABS_API_KEY set, or ElevenLabs connected by the workspace, that account's voices appear in Find a voice and the receptionist's voice picker. They speak widget replies and the turn-based phone path; live Realtime calls use OpenAI's voices.
Spoken replies and transcription fall back to OpenAI when xAI is unavailable.
Guardrails worth knowing
The receptionist should only answer from knowledge you provided.
Handing off to a person is a first-class tool, not a failure. On a website chat with a teammate online it moves the chat to them live; on a phone call it records a brief for your team to follow up — it does not bridge the call to another phone.
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.
Not in this version yet
Some options appear in the interface but are not acted on by the server in 1.0.0. Plan around them:
Live call transfer on phone calls. Handoff records a brief for your team; the call is not bridged to another phone. (Website chats do hand over live — see Live chat above.)
Phone setup options for after-hours behaviour and message, call recording, answering-machine detection and maximum call length are saved but not used. Calls are capped at 15 minutes (10 on SIP).
Workflow on live calls. The visual workflow runs in chat, the widget and the turn-based phone path. Live Realtime calls use the prompt, guardrails, open slots and tools.
Widget voice is push-to-talk (record, transcribe, reply), not a live streaming conversation.
Webhooks. The call.completed event is not sent yet.
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.
Use that for CREDENTIAL_ENCRYPTION_KEY. Losing it makes stored credentials unreadable.
AI
Variable
Need
Used for
OPENAI_API_KEY
Live calls
Realtime voice. Calls connect and then drop if this is missing.
OPENAI_PROJECT_ID
Optional
Bill usage to a project.
OPENAI_WEBHOOK_SECRET
SIP path
Verify OpenAI inbound call webhooks.
REALTIME_MODEL
Optional
gpt-realtime-2.1 (default when blank) or gpt-realtime-2.1-mini, roughly a third of the price.
ANTHROPIC_API_KEY
Optional
Claude models for chat.
GEMINI_API_KEY
Optional
Google Gemini models for chat (AI Studio key).
GROQ_API_KEY
Optional
Groq-hosted open models for chat.
DEEPSEEK_API_KEY
Optional
DeepSeek models for chat.
MISTRAL_API_KEY
Optional
Mistral models for chat.
OPENROUTER_API_KEY
Optional
Hundreds of models through OpenRouter.
TOGETHER_API_KEY
Optional
Open models hosted by Together.
CHAT_MODEL
Optional
Default chat model as provider:model. Set it in Admin → AI models.
CHAT_FALLBACK_MODELS
Optional
Comma-separated models tried in order when the chosen one fails.
ELEVENLABS_API_KEY
Optional
ElevenLabs voices in every workspace's voice library.
GEOIP_DB_PATH
Optional
Path to a MaxMind GeoLite2 or GeoIP2 City .mmdb file, for visitors' city and country when no CDN location header is present.
XAI_API_KEY
Preview
Browser demo, turn-based fallback, some design helpers.
Auth and Google
Variable
Need
Notes
ADMIN_EMAILS
First admin
Comma-separated emails that can become super-admins — once the address is verified (Google sign-in, or the confirmation link the app emails or logs). This is how the admin panel at /admin gets its first operator. See Platform admin.
BETTER_AUTH_SECRET
Before going live
Signs sessions. Unset, a new key is generated per restart and everyone is signed out on deploy.
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 = the machine itself only, judged by the connection's real address — never by the Host header — and never for a request that came through a proxy.
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
Sends the admin confirmation link, and nothing else. Without it the link is printed in the server log. Port 465 uses implicit TLS; anything else upgrades with STARTTLS.
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.
POST /api/widget/track
None (the receptionist must be live; allowed domains apply)
The widget script's visitor ping. Cross-origin, JSON sent as text/plain, rate limited per IP.
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
There are two ways. Both need the account to exist, so sign up first — with your own address, before you publish the URL.
1. ADMIN_EMAILS
Set ADMIN_EMAILS=you@example.com, sign up with that address, and verify it. It then becomes a super-admin the next time /admin is opened. The address can be verified two ways:
Sign in with Google, if it is configured. Google only reports addresses it has verified.
Open the confirmation link. With SMTP configured it is emailed to you. Without, it is printed in the server's output — your terminal locally, the service log on a host — on lines starting [admin]. Open it in the browser where you are signed in, then open /admin.
Verification exists because sign-ups are open and password sign-in checks nothing, so an address typed into a
form is not proof of who typed it. Without it, anyone who registered a listed address first would be handed the
whole platform. Only addresses in ADMIN_EMAILS are ever sent a link; nobody else is asked
to verify anything. The link works for one hour, and a new one is offered at most every five minutes.
2. The command line
The user must exist — sign in once — then promote the email. This needs a real DATABASE_URL and no verification:
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.
Registering as the operator. The sign-up form is hidden on a demo, but an address listed in
ADMIN_EMAILS may still register — without that, nobody could ever administer the
deployment. Do it once, before you share the URL:
curl -X POST https://your-app/api/auth/sign-up/email \
-H 'content-type: application/json' \
-d '{"email":"you@example.com","password":"a long password","name":"You"}'
Then confirm the address as described under Grant the first admin, and sign in with the
normal form under "or use your own account". Anyone else who tries to register is refused. 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.