Azentoo
Sign in My home

Documentation

The front desk, written down.

Azentoo 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 Azentoo is

Azentoo 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

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.

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 data file

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, so the app ships with its own small server.

Stack

Layer Choice
App Next.js 16 (App Router), React 19, started by server.mjs
UI Tailwind v4, Radix, TanStack Query, Inter + IBM Plex Mono
Auth Built in at /api/auth/*: email and password (scrypt), optional Google sign-in, a 7-day session cookie
Data Postgres when DATABASE_URL is set (one JSONB row per collection); otherwise data/db.json. Held in memory, written on change
Product API Typed server functions at POST /api/fn/:group/:name, one session check for all of them
Live calls OpenAI Realtime over a μ-law pass-through relay in server.mjs
Preview / fallback Turn-based path and browser chat (xAI key, or OpenAI when xAI is absent)
Jobs A queue in the same store, 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 in both directions — the model is asked for audio/pcmu, so nothing is transcoded. The relay does not touch the data file itself: it asks the app for the call's prompt, voice, and tools, forwards each tool call back to the app, and hands over the transcript when the caller hangs up. Those internal routes are guarded by a secret the server generates at start.

If the relay is not running (plain next dev), there is no OpenAI key, or APP_URL is still localhost, Twilio falls 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. Plivo has no fallback and politely declines the call.

Auth

Accounts live in the same store as the rest of the product. Passwords are hashed with scrypt; a session is an opaque token in the ra_session cookie and lasts seven days. “Continue with Google” appears when a Google sign-in client is configured. Without ADMIN_EMAILS, the first account created becomes the platform's super-admin; with it, only listed and confirmed addresses do.

Every server function declares its access — public, signed-in, admin, or super-admin — and scopes rows to the signed-in user. Admin functions answer “Not found” to everyone else.

Jobs

Campaign ticks, webhook deliveries, sheet appends, and call reconcile work sit in a jobs collection. The process drains it every minute, and right after something is queued. POST /api/jobs/run is the same drain, protected by JOBS_SECRET in production.

Handlers must be idempotent. The queue is at-least-once, with exponential back-off and five attempts.

Settings

Most keys can live in the environment or be set in Admin → Configuration. A value saved there wins — including an empty one, which means “off” and does not fall back to the environment. DATABASE_URL and CREDENTIAL_ENCRYPTION_KEY are environment-only, because both are needed to read the settings stored next to them.

Why these trade-offs

  • One process — one deploy, one log stream, one warm cache, and sockets that survive the call.
  • A file, not a database server — nothing to provision for one business or a showroom. Back the folder up; it is the whole state.
  • 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: missing model key is still 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 sign in, build a receptionist, and talk to it with no cloud services at all. Real phone calls need a public URL and provider keys. Node 20.9 or newer is required; Node 22 is recommended.

What you need

  • Node.js 22 and npm
  • Optional: an xAI or OpenAI key, so the receptionist answers with a model instead of the offline preview
  • Optional: cloudflared (or ngrok) when a carrier must reach your laptop

First run

npm install
cp .env.example .env.local   # optional
npm run dev

The server listens on PORT (3000 by default). On first start it creates data/db.json with the plans and sample content, and a data/.credential-key for sealing secrets. Delete the folder to start over.

Create an account at /login — the first one becomes the platform admin — then open /app.

Useful scripts

Command What it does
npm run dev Development server with the voice relay (node server.mjs)
npm run dev:next Plain next dev, no relay — phone calls use the turn-based fallback
npm run build Production build
npm run start Production server with the voice relay (node server.mjs --prod)
npm run admin:grant -- you@example.com super Promote an existing account (stop the server first)
npm run typecheck tsc --noEmit

Taking a real call on a laptop

  1. Copy .env.example to .env.local (never commit it).
  2. Set OPENAI_API_KEY so the live path can answer.
  3. Run a tunnel to your port and put the https origin in APP_URL.
  4. Start with npm run dev (not dev:next) so the relay is up.
  5. Import your Twilio or Plivo number under Phone numbers and point the carrier at that origin.
cloudflared tunnel --url http://localhost:3000

Carrier signatures are checked against APP_URL. If your tunnel rewrites the URL and every call is refused, set VOICE_SKIP_SIGNATURES=true while developing — never in production.

Do not write secrets into the repo

.env* files and data/ are gitignored. Generate the encryption and job secrets with Node, keep them in a secret manager on deploy, and never rotate the encryption key while credentials are stored — the old rows become unreadable.

Reference

Environment and settings

Nothing here is required for npm run dev. Everything below is required before this is a deployment that can keep data safe, take a card, or stay on a live call.

How a value is read

  1. If the value was saved in Admin → Configuration, that wins — including an empty value, which means off.
  2. Otherwise process.env is used (.env.local in development).
  3. CREDENTIAL_ENCRYPTION_KEY and DATA_DIR are environment-only.

Must exist for a real deployment

Variable Need What happens without it
DATABASE_URL Prod Postgres connection string. The app creates its one table (reception_store) on first start. Without it, data goes to the file below — fine on a persistent volume, lost on hosts that wipe the disk on redeploy (Railway, Render).
DATA_DIR File store Where db.json lives when DATABASE_URL is not set. Point it at a persistent volume.
CREDENTIAL_ENCRYPTION_KEY Prod A key is generated into DATA_DIR/.credential-key. Fine locally; in production keep it outside the data folder.
APP_URL Calls Carriers have nowhere honest to stream, OAuth redirects and Stripe return links point at localhost.
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, the landing-page live demo, text-to-speech and transcription. Also answers chat when no xAI key is set.
REALTIME_MODEL Optional The realtime model for calls. Defaults to gpt-realtime.
XAI_API_KEY Preview Browser chat, the widget, the turn-based phone fallback, and the agent designer.

Sign-in, admins, and app OAuth

Variable Need Notes
ADMIN_EMAILS First admin Comma-separated addresses that become super-admins once the address is confirmed — by Google sign-in, or by opening the link the app emails (or prints to the log on lines starting [admin]). Listed addresses may register even when sign-ups are closed. See Platform admin.
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET Google button Shows “Continue with Google” on the sign-in page. Redirect: <origin>/api/auth/google/callback. Runtime settings — no rebuild needed.
GOOGLE_CONNECTOR_CLIENT_ID / SECRET Gmail / Calendar / Sheets Redirect: <origin>/api/connect/google/callback. Without it, users paste an access token instead.
GOOGLE_ENABLE_GMAIL_READ Restricted Adds gmail.readonly. Leave unset unless Google verification is budgeted.
MICROSOFT_CLIENT_ID / SECRET Outlook Entra app registration; redirect <origin>/api/connect/microsoft/callback. 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.

Jobs, money, phone, demo

Variable Need Notes
JOBS_SECRET External cron Bearer for /api/jobs/run. Without it the route only answers in development. The server drains the queue itself every minute either way.
QSTASH_TOKEN Sleeping hosts Asks QStash to call /api/jobs/run when queued work is due (forwarding JOBS_SECRET). An always-on server drains itself and does not need it.
SMTP_HOST, PORT, USER, PASS, FROM Mail Sends the admin confirmation link. Without it the link is printed in the server log. Port 465 uses implicit TLS; anything else upgrades with STARTTLS.
OPENAI_WEBHOOK_SECRET SIP path Verifies OpenAI's realtime.call.incoming webhooks for calls that arrive over SIP. See carrier webhooks.
PLIVO_AUTH_ID / PLIVO_AUTH_TOKEN Plivo Fallback for webhook verification. Each imported number also stores its own credentials, sealed.
VOICE_SKIP_SIGNATURES Dev only Accept carrier webhooks without checking their signature. Never set in production.
DEMO_MODE Showroom true turns the deployment into a public demo: two one-click accounts on the sign-in page, 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.
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.paid, invoice.payment_failed). Without it, cancellations and card failures never reach the app.

Brand, site, and flags

These are easiest to edit in Admin → Configuration after the first start: BRAND_NAME, BRAND_TAGLINE, SUPPORT_EMAIL, SITE_TITLE, SITE_DESCRIPTION, SITE_OG_IMAGE, SITE_FAVICON, SITE_NOINDEX, ANALYTICS_GA_ID, TERMS_URL, PRIVACY_URL, COMPANY_NAME.

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/…
FEATURE_LIVE_DEMO On Live voice demo on the home page (needs the OpenAI key).
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

Run one long-lived Node process that serves the site, the admin panel, and the call relay, with a persistent disk for DATA_DIR. Do not put it 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. Run exactly one instance: the data store and every call's WebSockets live in that one process.

Docker

FROM node:22-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
ENV PORT=8080 DATA_DIR=/data
VOLUME /data
EXPOSE 8080
CMD ["npm", "run", "start"]
docker build -t azentoo .
docker run --rm -p 8080:8080 -v azentoo-data:/data --env-file .env azentoo

Fly.io

fly launch --no-deploy --name azentoo
fly volumes create data --size 1
fly secrets set \
  CREDENTIAL_ENCRYPTION_KEY='…' \
  APP_URL='https://azentoo.fly.dev' \
  OPENAI_API_KEY='sk-…' \
  JOBS_SECRET='…'
fly deploy
  • Mount the volume at /data and set DATA_DIR=/data.
  • Leave auto_stop_machines off and keep one machine.
  • Health check GET /api/health.

Railway

railway init --name azentoo
railway add --database postgres
railway up
railway domain --port 8080

Railway wipes the disk on every deploy, so use its Postgres. Point DATABASE_URL at the database service by reference rather than pasting a string, so it survives the database being moved:

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

Add CREDENTIAL_ENCRYPTION_KEY and OPENAI_API_KEY the same way. Build command npm run build, start command npm run start, one replica (a call's WebSockets live in a single process), health check /api/health.

Render, and other container hosts

  • A web service that never sleeps, not a static site and not a free instance that spins down.
  • Build: npm ci && npm run build. Start: npm run start.
  • Bind 0.0.0.0 on the platform port: HOST=0.0.0.0 (the default) and PORT.
  • Set DATABASE_URL to a Postgres database, or attach a persistent disk and point DATA_DIR at it.
  • Vercel and other serverless hosts cannot run the relay or keep the data file. Use them for nothing here.

Health

curl https://your-app/api/health

A real database round trip is part of the check; an unreachable Postgres returns 503 so a bad release cannot replace a good one. store says which store is in use. A missing OpenAI key is reported as degraded and stays 200 so you can still open admin and paste it. voiceRelay tells you whether the process was started through server.mjs.

Carrier webhooks after deploy

Carrier URL
Twilio “A call comes in” POST https://<app>/api/voice/inbound
Twilio status callback POST https://<app>/api/voice/status (set automatically on outbound calls)
Plivo answer URL POST https://<app>/api/voice/plivo
Media relay wss://<app>/api/voice/stream
OpenAI SIP (webhook in the OpenAI dashboard) POST https://<app>/api/voice/realtime — point your SIP trunk at sip:<project-id>@sip.api.openai.com;transport=tls and import the number as “BYO SIP trunk”

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 typed server functions at POST /api/fn/:group/:name after a session check. These routes are the ones carriers, schedulers, and browsers hit directly.

Routes

Path Auth Purpose
/api/auth/session, sign-in, sign-up, sign-out, change-password Session cookie Email and password accounts.
GET /api/auth/google/start, callback OAuth state “Continue with Google”, when configured.
GET /api/admin/confirm?token=… Session + one-hour token Confirms an ADMIN_EMAILS address.
POST /api/fn/:group/:name Per function Every product read and write. Admin functions answer 404 to non-admins.
GET /api/health None Store + config probe.
GET|POST /api/jobs/run Bearer JOBS_SECRET (open in development) Drain up to 10 jobs, 20s budget.
POST /api/voice/inbound Twilio signature Twilio inbound. Stream or turn fallback.
POST /api/voice/answer Twilio signature Outbound callee picked up (or voicemail detected).
POST /api/voice/status Twilio signature Call progress; completes and bills the call.
POST /api/voice/turn Twilio signature + call binding One fallback conversational turn.
GET /api/voice/audio/:id Unguessable id Short-lived clip in the receptionist's voice for a fallback call's <Play>.
POST /api/voice/realtime OpenAI webhook signature SIP calls via OpenAI: accept, then follow the call on a sideband connection.
GET|POST /api/voice/plivo Plivo V3 signature Plivo inbound to the relay.
wss://…/api/voice/stream Per-call token μ-law relay for the length of the call.
POST /api/voice/session, tool, finish Relay secret Internal: the relay's view of a call. 404 to everyone else.
POST /api/billing/stripe Stripe signature Subscription changes, payments, failures.
GET /api/connect/:provider/start Session Begin connector OAuth (google, microsoft, salesforce).
GET /api/connect/:provider/callback Session + OAuth state Finish connector OAuth.
GET /w/:agentId, /f/:formId, /widget.js Public Talk page, public forms, embed script.

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,
  "store": "postgres",
  "openaiKey": true,
  "voiceRelay": true,
  "uptimeSec": 840,
  "checkedInMs": 1
}

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

There are three ways. All need the account to exist, so sign up first — with your own address, before you publish the URL.

1. ADMIN_EMAILS (recommended for public installs)

Set ADMIN_EMAILS=you@example.com, sign up with that address, and confirm it. It becomes a super-admin the next time /admin is opened. The address can be confirmed 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 on lines starting [admin]. Open it in the browser where you are signed in.

Confirmation exists because sign-ups are open: an address typed into a form is not proof of who typed it. Only listed addresses are ever sent a link. It works for one hour, and a new one is offered at most every five minutes. In production the link is always built from APP_URL. While ADMIN_EMAILS is set, the “first account becomes admin” rule below is switched off.

2. Be the first account

Without ADMIN_EMAILS, the very first real account created on a fresh install becomes a super-admin automatically. From Admins and audit you can then grant access to anyone else who has signed in at least once.

3. The command line

For an install where you lost access. With the file store, stop the server first; with Postgres, restart it afterwards:

npm run admin:grant -- you@example.com super

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 while empty: OPENAI_API_KEY, APP_URL, DATABASE_URL, and 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 — exist from the first start. Their passwords are hashed like everyone else's.
  • Nothing can be changed. Every write is refused server-side in the one route 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.
  • Talking to a receptionist still works, because a demo that cannot answer is not a demo: chat, transcription, voice preview, and the agent designer stay open.

Cap your model spend before you publish the URL. Every demo visitor's conversation is billed to your OpenAI and xAI accounts. Plan limits cap minutes per workspace, not the number of people who walk in.

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 chat, the widget, and rehearsals need XAI_API_KEY or OPENAI_API_KEY. Without either, replies come from the offline preview.
  • The landing page's live voice needs OPENAI_API_KEY and FEATURE_LIVE_DEMO on.
  • Check GET /api/health — openaiKey should be true.

The call connects, then dies

  • The relay never started: the process was launched with next start/next dev instead of server.mjs — voiceRelay in health is false.
  • The stream URL was built from the wrong origin — APP_URL must match the certificate the carrier sees.
  • Webhook signature failed (403 in the log). Check APP_URL and the number's credentials.

Saved credentials stopped working

The encryption key changed. Secrets are sealed with CREDENTIAL_ENCRYPTION_KEY (or the generated .credential-key); a different key cannot read them. Restore the old key or reconnect the numbers and apps.

Cannot sign in

  • Passwords are at least eight characters.
  • Sign-ups closed: admin flag FEATURE_SIGNUPS, or DEMO_MODE is on.

Campaigns sit in the queue

  • The server drains the queue every minute — but only while the process is awake.
  • On a host that sleeps, set JOBS_SECRET and schedule POST /api/jobs/run.
  • Outbound needs a Twilio number and FEATURE_OUTBOUND on.

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.