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.
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.
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
Copy .env.example to .env.local (never commit it).
Set OPENAI_API_KEY so the live path can answer.
Run a tunnel to your port and put the https origin in APP_URL.
Start with npm run dev (not dev:next) so the relay is up.
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
If the value was saved in Admin → Configuration, that wins — including an empty value, which means off.
Otherwise process.env is used (.env.local in development).
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.
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"]
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:
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.
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.