Developers
Self-hosting the central API
Run the whole platform yourself. Everything is optional except a public URL; missing services fall back to safe practice modes.
For developersOn this page
Running it
The API is one Node.js process with a SQLite database. It also serves the dashboard (/app), the widget (/w.js), these docs (/docs) and the landing page (/). It needs Node 24.
For development, from the repository:
npm install
cd apps/api
PUBLIC_URL=http://localhost:8100 npx tsx src/index.tsFor production, build everything from the repository root with npm run build, then run the API's built server.mjs with your settings in the environment (or in a .env file in its working folder, which is read on start).
On shutdown (SIGTERM) it stops taking new work and waits up to 2 minutes for live phone calls to finish.
Settings
Every setting is an environment variable. Paths are relative to the folder the API runs in.
Core
| Variable | Default | What it does |
|---|---|---|
PUBLIC_URL | http://localhost:8100 | The address people and services reach the API at. Links in messages, Stripe returns and Twilio webhooks all use it, and Twilio signatures are checked against it, so it must match exactly. An https:// address also turns on secure cookies. |
PORT | 8100 | Port to listen on |
HOST | 127.0.0.1 | Address to listen on |
NODE_ENV | development | Set production in production |
DATA_DIR | ./data | Database, uploads, site versions, backups and generated secrets |
DB_FILE | <DATA_DIR>/jagg.db | The SQLite database |
APP_SECRET | generated | Secret for hashing visitor addresses and signing internal tokens. Generated once into <DATA_DIR>/app-secret if not set. Keep it stable: changing it drops phone calls in progress and restarts the day's visitor count. |
ADMIN_EMAIL | none | The account with this email becomes the operator, with access to the admin tools (after setting up two-step sign-in) |
TRUST_PROXY | false | Set true behind a proxy, so client addresses come from CF-Connecting-IP or X-Forwarded-For. Rate limits depend on it. |
DEV_ORIGINS | http://localhost:5173 | Extra origins allowed to make signed-in dashboard requests, comma-separated. For a dashboard dev server. |
Hosted sites and domains
| Variable | Default | What it does |
|---|---|---|
SITES_DOMAIN | localhost | Hosted sites live at <slug>.<SITES_DOMAIN> |
SITES_SCHEME | https if PUBLIC_URL is https, else http | Scheme used in hosted-site links |
SITES_PORT | empty for https, else 8200 | Port used in hosted-site links |
CUSTOM_DOMAIN_TARGET | <slug>.<SITES_DOMAIN> | The CNAME target shown to owners for custom domains |
CLOUDFLARE_API_TOKEN, CLOUDFLARE_ZONE_ID | none | When set, custom domains are registered as Cloudflare custom hostnames |
AI
| Variable | Default | What it does |
|---|---|---|
OPENROUTER_API_KEY | none | Without it, the built-in practice AI answers instead |
OPENROUTER_BASE_URL | https://openrouter.ai/api/v1 | Any OpenAI-compatible chat completions endpoint with tool calling and streaming |
AI_MODEL_CHAT | anthropic/claude-haiku-5.5 | Web chat and texts |
AI_MODEL_VOICE | anthropic/claude-haiku-5.5 | Phone calls |
AI_MODEL_ASSISTANT | anthropic/claude-haiku-5.5 | The owner's assistant in the dashboard |
AI_FAKE | false | Force the practice AI even with a key, for tests and demos |
| Variable | Default | What it does |
|---|---|---|
SENDGRID_API_KEY | none | Send all email through Twilio SendGrid. When set, SMTP_URL isn't used. |
SMTP_URL | none | An SMTP connection URL, like smtps://user:[email protected]:465, used when there's no SendGrid key. Without either, emails are written to <DATA_DIR>/dev-mail instead of sent. |
MAIL_FROM | Just a Good Guy <hello@localhost> | The From address. Customer emails keep this address but show the business's name, with Reply-To set to the business's own email. |
INBOUND_EMAIL_DOMAIN | none | The domain customers email receptionists at: each business gets <slug>@<this domain> |
INBOUND_EMAIL_SECRET | none | A long random string in the Inbound Parse webhook URL. Inbound email is off unless this and the domain are both set. |
SENDGRID_API_BASE | https://api.sendgrid.com | For testing against a fake |
Email through Twilio SendGrid
One-time setup in SendGrid:
- API key. Create one under Settings, API Keys with Mail Send access. Put it in
SENDGRID_API_KEY. - Sender. Under Settings, Sender Authentication, authenticate the domain in
MAIL_FROM(for examplejustagoodguy.us): add the DNS records SendGrid shows, then verify. Until a domain or single sender is verified, SendGrid refuses to send, and the outbox shows that error. - Customers emailing receptionists (optional). Pick a subdomain you don't use for your own mail, like
mail.justagoodguy.us. Add an MX record for it pointing atmx.sendgrid.net(priority 10). Under Settings, Inbound Parse, add that subdomain with the URLPUBLIC_URL/api/webhooks/email/<INBOUND_EMAIL_SECRET>. Leave "POST the raw, full MIME message" off; turning on the spam check is a good idea. Then setINBOUND_EMAIL_DOMAINandINBOUND_EMAIL_SECRET.
Each business's address then shows in its Inbox. Owners forward their business email there, and the receptionist answers. See Email.
Phone and texts (Twilio)
| Variable | Default | What it does |
|---|---|---|
TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN | none | Without them, texts are logged instead of sent, number search returns practice numbers, and signature checks are skipped |
TWILIO_FROM | none | Fallback sender for texts not sent from a business's own number |
TWILIO_MESSAGING_SERVICE_SID | none | Send those texts through a Messaging Service instead (used before TWILIO_FROM) |
TWILIO_API_BASE | https://api.twilio.com | For testing against a fake |
TWILIO_WA_TEMPLATE_CONFIRM | none | Optional approved WhatsApp template (Content SID, HX...) for booking confirmations sent more than 24 hours after the customer's last WhatsApp message |
TWILIO_WA_TEMPLATE_REMINDER | none | The same for reminders |
TWILIO_PRIMARY_CUSTOMER_PROFILE_SID | found automatically | Your approved Primary Business Profile in Trust Hub (BU...), needed to register businesses for texting. See texting registration |
TWILIO_A2P_EMAIL | the support email, then ADMIN_EMAIL | Where Twilio sends notices about each business's texting registration |
TWILIO_A2P_MOCK | false | Register free mock brands that can't send, for a dry run |
TWILIO_TRUSTHUB_BASE, TWILIO_MESSAGING_BASE | Twilio's | For testing against a fake |
Numbers bought through the dashboard are pointed at PUBLIC_URL/api/twilio/voice, /api/twilio/voice/status and /api/twilio/sms automatically. Calls use Twilio ConversationRelay over a WebSocket at PUBLIC_URL/api/voice/relay.
WhatsApp uses each business's own Twilio number and the same keys as texts. One-time setup in the Twilio console:
Register the sender. Under Messaging, Senders, WhatsApp senders, register the business's Twilio number as a WhatsApp sender (this needs a Meta Business account, and the display name should be the business's name).
Point it at us. Set the sender's "a message comes in" webhook to
PUBLIC_URL/api/twilio/sms(POST) and its status callback toPUBLIC_URL/api/twilio/sms/status. Texts and WhatsApp share this webhook.Templates (optional). WhatsApp only allows normal messages within 24 hours of the customer's last message. To send confirmations and reminders after that, create templates in the Content Template Builder using exactly these variables, submit them for WhatsApp approval (category: Utility), and put their Content SIDs in the two settings above:
{{1}}business name,{{2}}service,{{3}}date and time,{{4}}link to change or cancel- For example: "{{1}}: you're booked for {{2}} on {{3}}. Change or cancel: {{4}}"
Without templates, those messages go out as normal texts instead. See WhatsApp.
Billing (Stripe)
Stripe is only used to bill businesses for their plan and add-ons.
| Variable | Default | What it does |
|---|---|---|
STRIPE_SECRET_KEY | none | Without it, billing is off and every account stays fully on |
STRIPE_WEBHOOK_SECRET | none | Signing secret for PUBLIC_URL/api/webhooks/stripe (subscriptions: checkout.session.completed, customer.subscription.*, invoice.paid, invoice.payment_failed) |
Stripe events are deduplicated, and subscriptions are re-checked against Stripe every hour in case a webhook was missed.
Other
| Variable | Default | What it does |
|---|---|---|
VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY | generated | Web push keys. Generated once into <DATA_DIR>/vapid.json if not set. |
VAPID_SUBJECT | mailto:hello@localhost | Contact for push services |
TURNSTILE_SITE_KEY, TURNSTILE_SECRET | none | Cloudflare Turnstile. When both are set, a visitor sending more than 15 chat messages in 10 minutes from one address is asked for a quick person check (once an hour). Normal visitors never see it. |
DASHBOARD_DIR | ../dashboard/dist | Built dashboard |
WIDGET_DIR | ../widget/dist | Built widget |
DOCS_DIR | ../../docs/dist | Built docs |
SITE_DIR | ../../site | Landing page |
Behind a proxy
If you put nginx or similar in front:
- Turn response buffering off and allow long reads. Chat replies stream as Server-Sent Events, the host app long-polls for up to 50 seconds, and dashboard live updates stay open.
- Pass WebSocket upgrades through, for phone calls at
/api/voice/relay. - Allow request bodies of at least 9 MB. Site uploads arrive in 8 MB pieces.
- Set
TRUST_PROXY=true, and make sure the proxy setsX-Forwarded-For(orCF-Connecting-IPbehind Cloudflare).
Data and backups
Everything lives in DATA_DIR. A copy of the database is written to <DATA_DIR>/backups once a day (around 08:00 UTC), and the last 7 are kept. Copy that folder somewhere else as well.
Housekeeping runs on its own: expired sessions and codes are removed, and raw analytics events older than 18 months are deleted.