Reception AI
Sign in My home

Documentation

The front desk, written down.

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.

24/7Answers when you cannot
18Trade templates
15 minTalk time on the free plan
2,000Minutes on Scale

What Reception AI is

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.

Build

Screen Path What you do there
Dashboard /app Next step, and whether the line is already live.
Agents /app/agents Create, edit, publish. Prompt, tools, workflow, voice.
Knowledge /app/knowledge 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
Inbox /app/conversations Chat and widget threads.
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.
Tools /app/tools/… Widget, webhooks, forms, CRM, appointments, messaging, sheets.
Settings /app/settings 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

  1. Copy .env.example to .env (never commit it).
  2. Set OPENAI_API_KEY so the live path can answer.
  3. Set XAI_API_KEY so the browser preview and fallback path can talk.
  4. Run a tunnel to port 8080 and put the https origin in APP_URL.
  5. 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

  1. If a row exists in platform_settings, that row wins — including an empty row, which means off.
  2. If there is no row, process.env is used.
  3. DATABASE_URL and CREDENTIAL_ENCRYPTION_KEY are environment-only.
  4. 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.
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"

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-mini (default) or gpt-realtime-2.1.
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 become super-admins on first sign-in. This is how the admin panel at /admin gets its first operator.
BETTER_AUTH_SECRET Before going live Signs sessions. Unset, a new key is generated per restart and everyone is signed out on deploy.
VITE_GOOGLE_CLIENT_ID Google button Public. Redirect: <origin>/api/auth/callback/google. Build-time.
GOOGLE_CLIENT_SECRET With client id Server only. Never prefix with VITE_.
MICROSOFT_CLIENT_ID / SECRET Outlook Entra app registration; redirect <origin>/api/connect/microsoft/callback. Tokens refresh automatically. Optional MICROSOFT_TENANT for single-tenant apps.
SALESFORCE_CLIENT_ID / SECRET Salesforce 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.

docker build --build-arg VITE_GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com -t receptionai .
docker run --rm -p 8080:8080 --env-file .env receptionai

VITE_GOOGLE_CLIENT_ID is a build argument because Vite inlines it. The matching secret is a runtime env var only.

Fly.io

fly launch --no-deploy --name receptionai --region iad
fly secrets set \
  DATABASE_URL='postgres://…' \
  CREDENTIAL_ENCRYPTION_KEY='…' \
  BETTER_AUTH_SECRET='…' \
  BETTER_AUTH_URL='https://receptionai.fly.dev' \
  APP_URL='https://receptionai.fly.dev' \
  OPENAI_API_KEY='sk-…' \
  JOBS_SECRET='…'
fly deploy
  • 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.

railway init --name reception-ai
railway add --database postgres
railway add --service reception-ai
railway domain --service reception-ai --port 8080

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:

railway variable set --service reception-ai \
  'DATABASE_URL=${{Postgres.DATABASE_URL}}' \
  APP_URL='https://<your-app>.up.railway.app' \
  PORT=8080 \
  ADMIN_EMAILS='you@example.com'

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"

Health payload

{
  "status": "ok",
  "database": "ok",
  "databaseMs": 12,
  "openaiKey": true,
  "uptimeSec": 840,
  "checkedInMs": 14
}

How-to

Platform admin

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.