Guides / A machine you own
Run your own IVR on a machine you own
Try it on your laptop tonight with no accounts at all. Then, with two keys, a carrier's and a decision model's, answer a real phone call within the hour, through a tunnel that needs no sign-up. When it works, move the same setup to any machine that stays on: a Mac, a Linux mini PC, a spare desktop.
1. Try it with no keys
Everything here runs on your laptop with no key and no sign-up. You need Node 22.19 or later and pnpm (section 7 says how to get them). With no model attached, an app understands through its own labelled examples and a keyword fallback: enough to walk every path, not the real thing.
git clone https://github.com/DialogWright/dialogwright.git
cd dialogwright
pnpm install
pnpm --filter @dialogwright/widget build
pnpm configure --app utility --mode try
pnpm start --app utility
pnpm configure asks which app and how to run it (the flags above answer for the utility example, on this
computer) and writes the app's settings to apps/utility/.env: the server on localhost, the web
chat and its widget on, no model. pnpm start runs the app's server with that file. Then open
http://localhost:3000/chat-demo (a fictional account page with the chat widget on it) and
http://localhost:3000/dashboard (the operator console, showing the chat as it happens). Both answer only
on this machine. The utility's README
says what to try. Ctrl-C stops it.
Or talk to the clinic example in a text console, with no server at all:
pnpm --filter @dialogwright/example-clinic cli --client heuristic. To build your own app, run
pnpm create-app myline and read
Authoring an app.
2. A phone line in an hour
A real call needs three things you do not have yet, and only two of them are accounts:
- A carrier and a number. Telnyx or Twilio: a number with voice, and the account's public key (Telnyx) or auth token (Twilio).
- A decision model key. TypeSafe's, or OpenRouter's or the Vercel AI Gateway's; or none, with a compatible model on your own machine (section 4).
cloudflared, Cloudflare's tunnel client, so the carrier can reach your laptop. Its quick tunnel needs no account:brew install cloudflaredon a Mac; on Linux, Cloudflare's package repository or a release from its downloads page.
-
Configure a phone line.
--forcereplaces the settings the try-out wrote.pnpm configure --app utility --forceThis time choose a phone line. It asks for the carrier and its key, the model and its key, and the number a call goes to when the caller wants a person (your mobile, say). A key is read with the echo off, or taken from the environment variable of its own name; a key is never a flag, which would stay in your shell's history. It is written only to the app's
.env, readable by you alone, and the command prints its length, never the key, and sends it nowhere. It then checks the file (pnpm diagnose --offline). -
Start it with a quick tunnel.
pnpm start --app utility --tunnel quickpnpm startstartscloudflared, waits for thehttps://<words>.trycloudflare.comhostname it is given, starts the server with it, and prints the webhook URL (https://<words>.trycloudflare.com/voice/telnyx) with the steps for your carrier's console.--tunnel quickis the default whilePUBLIC_HOSTis unset, aspnpm configureleaves it. -
Point your number at the webhook it printed (section 5 has each carrier's console steps).
-
Check it end to end.
pnpm startprints the line to run, with the tunnel's hostname, in another terminal:PUBLIC_HOST=<words>.trycloudflare.com pnpm diagnose --app utilityOne line per check, each with its fix (section 6).
-
Call your number.
Ctrl-C stops it: the server finishes the calls under way first (a drain), and only then is the tunnel closed. The
hostname changes every time pnpm start runs, so the webhook does too: fine for an evening, wrong for a
line people rely on. Sections 7 to 9 make it permanent: a machine that stays on, the settings outside the repository,
a named tunnel, a service that starts it at boot, and calls that survive a restart.
Cloudflare's quick tunnel page does not mention WebSockets, which the call's relay runs on. Named tunnels document them; for a quick tunnel, your first call is the check (live check 7). If it fails there, use a named tunnel (section 7) or ngrok.
3. How it fits together
A caller dials your number. The carrier asks your server what to do (an HTTPS webhook), then opens a WebSocket to it and streams the call as text: the carrier recognises speech and speaks the lines, and DialogWright understands and decides. Both reach your machine through a tunnel, so nothing on your router is opened. On each turn the server asks the decision model a few short typed questions.
- The carrier owns the number. DialogWright answers it on
/voice/<carrier>(the webhook),/cr-action/<carrier>(the callback when the relay ends) and the WebSocket/conversation/<carrier>. - The tunnel gives the carrier an HTTPS address for a machine behind a home router.
- The server is
pnpm --filter <your app> serve: the phone line, the operator console and, if you turn it on, the web chat on/chat. - The decision model reads what the caller said and answers typed questions. It never writes what the caller hears.
4. Two keys, and what is optional
pnpm configure asks for both keys and writes them where they go. A phone line needs two accounts: a carrier, for the number, and a decision model, for the understanding (or a
compatible model on your own machine, which needs neither an account nor a key). Everything else is optional or
free of sign-up. The running cost is small: a few dollars a month for a number, and fractions of a cent per call
for the model. The repository's live checks put a handful of test calls at "a few cents of call minutes and
model requests", and each trace records the estimated cost of its turns' model requests
(usage.costUsd), so your own numbers are in your own files.
| What | Needed? | What it is for, and what you take from it | Where it goes |
|---|---|---|---|
| A carrier: Telnyx, or Twilio | Required for a phone line (not for chat) | The number, speech recognition and the voice. Telnyx: a TeXML application whose voice URL is your server, and the account's public key. Twilio: a number whose voice webhook is your server, and the auth token. | VOICE_PROVIDERS=telnyx with TELNYX_PUBLIC_KEY; or VOICE_PROVIDERS=twilio
(the default) with TWILIO_AUTH_TOKEN; or twilio,telnyx with both |
| A decision model key | Required for real understanding, unless you run a compatible model yourself | Jev, TypeSafe's decision model, from TypeSafe, or through OpenRouter or the Vercel AI Gateway. | JEV_CLIENT=jev, and TYPESAFE_API_KEY (the default provider);
or JEV_PROVIDER=openrouter with OPENROUTER_API_KEY;
or JEV_PROVIDER=vercel with AI_GATEWAY_API_KEY |
| A compatible model on your machine | Optional, instead of a key | Any endpoint that serves Jev's API (POST /v1/systemone), such as an open-weight model.
No account, no key. Its probabilities are not Jev's: every confidence threshold was measured on Jev, so the
server warns at startup, and you should record a cassette with it and compare with regress.
The repository does not ship or name one. |
JEV_CLIENT=jev, JEV_PROVIDER=custom, JEV_BASE_URL=http://localhost:8000
(plain http only to this machine), JEV_MODEL; JEV_API_KEY only if it asks for one |
| A tunnel | Required for a phone line; no account to try it | An HTTPS address the carrier can reach. A Cloudflare quick tunnel needs no account; a permanent one needs a domain on Cloudflare, or Tailscale, or ngrok (section 2, section 7). | PUBLIC_HOST: the bare hostname, no https://, no path |
| A number to hand calls to | Required setting; no account | Where a call goes when the caller asks for a person, or the agent cannot help: your mobile, say. | HANDOFF_NUMBER=+15555550123 (E.164) |
| An Anthropic key | Optional | A three-line note (what the caller wanted, what was done, what to do next), shown in the console when a call goes to a person, written by Claude Haiku from the call's audit entries. | ANTHROPIC_API_KEY; HANDOFF_SUMMARY (default on) |
| An identity provider | Optional | Signed-in web chat on your site: its JWKS URL, its issuer, and your application's client id. | CHAT_SIGNIN=jwt, CHAT_JWKS_URL, CHAT_ISSUER, CHAT_AUDIENCE |
Telnyx or Twilio: either works, and the same app answers on both. The Telnyx adapter is built from Telnyx's documentation and checked against its documented frames, but a few of its details (the webhook's encoding, the handoff field, whether a callback is posted when the socket drops) are assumptions until a live call settles them: live check 2 is the call to make first. If anything there fails, Twilio is the fallback.
5. Pointing the carrier at it
pnpm start prints these steps with your webhook URL in them. With a quick tunnel the hostname is
<words>.trycloudflare.com and changes every run; with a named tunnel it is yours for good. Below,
ivr.example.com stands for either.
Telnyx
- In the Mission Control Portal, get a number with voice.
- Go to Real-Time Communications, Voice, Programmable Voice, the TeXML Applications tab, and create an application.
- In Send a TeXML Webhook to the URL, enter
https://ivr.example.com/voice/telnyx(yourPUBLIC_HOST), with the Voice Method set to POST (Telnyx defaults it to GET, and the server takes POST webhooks). - Assign your number to the application, on its Numbers tab or from Real-Time Communications, Numbers, Manage Numbers, where it is the small link icon next to the number (easy to miss).
- Copy the public key from Account settings, Keys & Credentials, Public Key into
TELNYX_PUBLIC_KEY, withVOICE_PROVIDERS=telnyx, (pnpm configuredoes both). - On a line people rely on: the application's Webhook Failover URL is asked when your
server does not answer. Point it at the document
pnpm fallbackwrites (section 9), so a call still reaches you when the machine is off.
Portal labels move. These are the ones Telnyx's TeXML pages use as of this writing; if yours differ, the fields are the application's voice URL, its method and its Webhook Failover URL.
Twilio
- In the Twilio Console, open Phone Numbers, Manage, Active numbers and choose your number.
- Under Voice Configuration, set A call comes in to Webhook,
https://ivr.example.com/voice/twilio, HTTP POST. - On a line people rely on: Primary handler fails takes a fallback URL, asked when your server errs or
does not answer within 15 seconds. Point it at the document
pnpm fallbackwrites (section 9). - Copy the account's auth token into
TWILIO_AUTH_TOKEN, withVOICE_PROVIDERS=twilio(ortwilio,telnyx), (pnpm configuredoes both).
The plain https://ivr.example.com/voice works for Twilio too: it is the path from before carriers were plug-ins.
6. Checking it works
-
The startup line names what is in force, each secret by its length only (
telnyx public key set (44 chars)), thenlistening on 3000; voice webhook https://ivr.example.com/voice/telnyx. The server answers on the machine and through the tunnel:curl -s http://localhost:3000/health curl -s https://ivr.example.com/healthBoth print
{"ok":true,"sessions":0,"retained":0}: the calls live now, and the ended ones still held for a minute./healthsays the process is up;/readysays whether it takes new calls ({"ready":true}, or 503 while it drains before a stop). -
Let
pnpm diagnosecheck all of it, and more, one line each with its fix: the settings load, the hostname resolves and/healthanswers through the tunnel, the console is a 404 there, the carrier's secret has its shape, the model and its key variable, the handoff number is not a 555 number, the folders can be written with space to spare, the settings file is yours alone, and the clock is right against the carrier's (Telnyx refuses a webhook whose signature is more than five minutes off). It sends no key anywhere and calls no carrier or model API: its requests are to your own hostname, and oneHEADto the carrier's API host to read the time it answers with.PUBLIC_HOST=<words>.trycloudflare.com pnpm diagnose --app utility # with a quick tunnel: the line pnpm start prints ENV_FILE=~/ivr/myline.env pnpm diagnose --app myline # on a machine that stays on (section 7) -
Call your number. The server's output shows the webhook as the call arrives (
/voice/telnyx ... from ...) and/cr-action/telnyx ... -> completedwhen it ends, with nosignature rejectedline. -
Watch calls in the console on the machine itself,
http://localhost:3000/dashboard, or from your phone (section 10). -
Each call leaves two files in the traces folder:
<call>.jsonl, its turns (what was heard, what the model answered, what the gate decided, how long each step took), and<call>.frames.jsonl, every frame in and out. The audit folder has one hash-chained file per UTC day; check the chains. Both folders are inside the app's folder untilTRACE_DIRandAUDIT_DIRsay otherwise (section 7):ls -lt apps/utility/traces | head pnpm audit:verify apps/utility/audit
Then go deeper with the live checks: the Telnyx call that settles the adapter's assumptions, a restart mid-call, languages, a gateway for the model, a real identity provider for the chat, a quick tunnel, and the console from a phone.
7. A machine that stays on
Your laptop is fine for trying a real call. For a line that stays up, use anything that stays on: a Mac, a Linux mini PC, a spare desktop. It needs little: one Node process and a tunnel. The same commands run there; what changes is where the settings live, a tunnel whose hostname stays, and a service manager that keeps the server up (section 8).
Keep it on
- macOS: in System Settings, Energy, turn on Prevent automatic sleeping when the display is off, Start up automatically after a power failure and Wake for network access. With FileVault on, a Mac that restarts after a power failure waits for a password before anything starts.
- Linux: stop it suspending (
sudo systemctl mask sleep.target suspend.target hibernate.target hybrid-sleep.targeton a desktop install), and set the firmware to power on when power returns. - Both: let the clock set itself. Telnyx's signature is refused when the server's clock is more
than five minutes off, and
pnpm diagnosechecks it.
Node and pnpm
The repository requires Node 22.19 or later (CI runs Node 22 and 24) and pnpm 9 (it pins pnpm@9.12.0
in package.json, which corepack follows). Install Node 24 from
nodejs.org, your package manager or Homebrew (brew install node@24),
then:
corepack enable pnpm
node --version # v22.19 or later
which node pnpm # pnpm service puts these folders on the service's PATH
Then clone and install as in section 1, and run pnpm --version once inside the
repository: corepack fetches pnpm 9.12.0 then, so the service never has to. If you will put the chat widget on a
site, build it (pnpm --filter @dialogwright/widget build, again after each update).
The settings, outside the repository
This guide calls your app myline (pnpm create-app myline makes apps/myline); to
follow along with a finished app, use utility in its place. Keep what is yours apart from the repository:
the settings, the logs and the call data. Move the file pnpm configure wrote, and name it with
ENV_FILE in front of each command from now on:
mkdir -p ~/ivr/data/traces ~/ivr/data/audit
chmod 700 ~/ivr
mv apps/myline/.env ~/ivr/myline.env
chmod 600 ~/ivr/myline.env
ENV_FILE=~/ivr/myline.env pnpm diagnose --app myline
A variable already in the environment wins over the file. (--env-file <path> works too where pnpm
passes it on, but some pnpm builds read a --env-file themselves, and fail on a relative path before the
command starts, so ENV_FILE= is the form to use.) Then add what a machine that stays up wants to the file,
with your own paths (on Linux, /home/alex):
~/ivr/myline.env# Your named tunnel's hostname (below), so the webhook never changes.
PUBLIC_HOST=ivr.example.com
# Call data in one place to back up, out of the repository.
TRACE_DIR=/Users/alex/ivr/data/traces
AUDIT_DIR=/Users/alex/ivr/data/audit
# Each live call saved after every turn, so a restart resumes it (section 9).
SESSION_STORE=file:/Users/alex/ivr/data/sessions
# Traces and frame logs older than this are removed (section 11). Unset, they are kept.
TRACE_RETENTION_DAYS=90
# The zone "today" is read in. Default: this machine's zone.
TIMEZONE=America/Chicago
The server checks every variable at startup and refuses to start, naming the one that is missing or malformed. The full
list, with each default and when to change it, is the
options table
(and the channels');
the commented .env.example
lists them too. Section 12 has a whole settings file written by hand.
A tunnel whose hostname stays
A tunnel is an outbound connection from your machine to someone's edge; requests for its hostname come back down it, so your router needs no open port. The quick tunnel's hostname changes every run. For one that stays:
A named Cloudflare Tunnel keeps one hostname (ivr.example.com) for good. It needs a free Cloudflare account
and a domain whose DNS is on Cloudflare. These are the steps for a locally managed tunnel, as Cloudflare's docs give them:
-
Log in (it opens a browser; choose your domain) and create the tunnel. It prints the tunnel's id and writes its credentials to
~/.cloudflared/<id>.json.cloudflared tunnel login cloudflared tunnel create myline -
Point your hostname at it (this adds the DNS record):
cloudflared tunnel route dns myline ivr.example.com -
Write
~/.cloudflared/config.ymlwith your tunnel's id. The last rule, a catch-all, is required: anything not for your hostname is a 404 at Cloudflare.tunnel: 0b5e7c2a-5555-4f00-9d1e-123456789abc credentials-file: /Users/alex/.cloudflared/0b5e7c2a-5555-4f00-9d1e-123456789abc.json ingress: - hostname: ivr.example.com service: http://localhost:3000 - service: http_status:404 -
Check the rules and run it once in the foreground:
cloudflared tunnel ingress validate cloudflared tunnel run myline -
Run it as a service.
cloudflared service installstarts it at login from~/.cloudflared/;sudo cloudflared service installstarts it at boot and reads/etc/cloudflared/(copy the config and the credentials file there, and fixcredentials-file). On Linux,sudo cloudflared service installmakes a systemd service.sudo cloudflared service install
The choices
| Cloudflare quick tunnel | Cloudflare named tunnel | Tailscale Funnel | ngrok | |
|---|---|---|---|---|
| Account | None | Cloudflare, and a domain on it | Tailscale | ngrok |
| Hostname | Random, new each run | Your own, for good | <machine>.<tailnet>.ts.net; no custom domain |
A free dev domain on ngrok-free.app, or your own on a paid plan |
| WebSockets | Not stated: check with a call | Passed through | Not stated: check with a call | Supported |
| Worth knowing | No uptime guarantee; 200 requests in flight | Cloudflare closes an idle WebSocket, and sockets when it restarts its servers: a live call reconnects | Public ports 443, 8443 and 10000 only; bandwidth limits you cannot change | The free plan shows a warning page to browsers (not to the carrier's webhooks) |
| Best for | Trying it tonight | A line that stays up | Someone already on Tailscale | Someone already on ngrok |
When a tunnel drops a call's socket, the carrier calls /cr-action and the call reconnects, up to
RECONNECT_LIMIT times (default 2), then goes to a person with an apology. That works because the
server still holds the call. A restart of the server ends it, unless the call was saved
(SESSION_STORE=file:) and the carrier's callback reaches the restarted server. Whichever tunnel you use, the server
tells a tunnelled request from one made on the machine by the headers a proxy adds and by the Host it
names, so the console stays off the internet.
With the named tunnel running as its own service and PUBLIC_HOST set to its hostname, point the carrier at
https://ivr.example.com/voice/<carrier> once (section 5), and check it:
ENV_FILE=~/ivr/myline.env pnpm start --app myline --tunnel named # the server alone, to try it; Ctrl-C before section 8
ENV_FILE=~/ivr/myline.env pnpm diagnose --app myline # in another terminal
8. Keeping it running
Hand the line to the system's service manager: it starts the server at login or boot, restarts it when it exits, and
keeps its output. pnpm service writes the service file for this machine, with the absolute paths of the
repository, pnpm, node and the settings file, and prints the commands that install it; it runs none of them. The service
runs pnpm --filter <your app> serve from the repository with ENV_FILE naming
~/ivr/myline.env, so the server reads the same file pnpm start and pnpm diagnose
read. Nothing from the file goes into the service file but its path, and it warns when others can read the file.
A stop sends SIGTERM to pnpm, which passes it to the server and waits. The server drains: /ready answers
503, a new call is put through to HANDOFF_NUMBER, a new chat is told it is busy, and live calls go on
until they end or DRAIN_MS passes (30 seconds by default). What happens to a call still going then depends
on where sessions are kept (section 9). The service gives a stop DRAIN_MS and ten
seconds more before it kills anything. A second signal stops the server at once. A crash is logged with its stack and
exits 1 after a short close, and the service manager starts it again.
macOS: a launchd agent
cd ~/dialogwright
ENV_FILE=~/ivr/myline.env pnpm service launchd --app myline --label com.example.myline
It writes ~/Library/LaunchAgents/com.example.myline.plist from this template, its values filled in:
packages/dialogwright/templates/service/launchd.plist<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<!-- Written by pnpm service: the DialogWright app {{app}}, run from the repository with its settings file. -->
<plist version="1.0">
<dict>
<key>Label</key>
<string>{{label}}</string>
<key>ProgramArguments</key>
<array>
<string>{{pnpm}}</string>
<string>--filter</string>
<string>{{app}}</string>
<string>serve</string>
</array>
<key>WorkingDirectory</key>
<string>{{root}}</string>
<!-- The server reads the settings file itself (ENV_FILE). launchd starts with a short PATH: these hold node and pnpm.
LOG_FILE: the server writes its own log there and rolls it (LOG_MAX_MB, LOG_KEEP in the settings file). -->
<key>EnvironmentVariables</key>
<dict>
<key>ENV_FILE</key>
<string>{{envFile}}</string>
<key>LOG_FILE</key>
<string>{{logDir}}/server.log</string>
<key>PATH</key>
<string>{{path}}</string>
<key>COREPACK_ENABLE_DOWNLOAD_PROMPT</key>
<string>0</string>
</dict>
<!-- Start when loaded, and again whenever it exits, at most once every 10 seconds. -->
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>ThrottleInterval</key>
<integer>10</integer>
<key>ProcessType</key>
<string>Interactive</string>
<!-- On stop: SIGTERM, which starts the drain, then this many seconds (DRAIN_UNREADY_MS, DRAIN_MS and 10 more) before SIGKILL. -->
<key>ExitTimeOut</key>
<integer>{{stopSeconds}}</integer>
<!-- What the server prints is in LOG_FILE, rolled; launchd's own files would only grow. err.log keeps what
goes wrong before the server reads its settings (pnpm not found, say), which is little. -->
<key>StandardOutPath</key>
<string>/dev/null</string>
<key>StandardErrorPath</key>
<string>{{logDir}}/err.log</string>
</dict>
</plist>
and prints the commands that install, check and stop it:
mkdir -p ~/Library/Logs/com.example.myline
plutil -lint ~/Library/LaunchAgents/com.example.myline.plist
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.example.myline.plist # start now, and at every login
launchctl print gui/$(id -u)/com.example.myline | grep -E 'state|pid' # running or not
tail -f ~/Library/Logs/com.example.myline/server.log ~/Library/Logs/com.example.myline/err.log # its output (server.log rolls at LOG_MAX_MB, LOG_KEEP kept)
launchctl kickstart -k gui/$(id -u)/com.example.myline # restart: live calls get DRAIN_MS to finish
launchctl bootout gui/$(id -u)/com.example.myline # stop, and keep it stopped
An agent runs while you are logged in: turn on automatic login, or run it at boot as a daemon (copy the file to
/Library/LaunchDaemons/, owned by root, add <key>UserName</key><string>alex</string>,
and use sudo launchctl bootstrap system ... and system/com.example.myline). With
KeepAlive on, killing the process only restarts it: bootout stops it.
Linux: a systemd user unit
cd ~/dialogwright
ENV_FILE=~/ivr/myline.env pnpm service systemd --app myline --label myline
It writes ~/.config/systemd/user/myline.service from this template:
packages/dialogwright/templates/service/systemd.service# Written by pnpm service: the DialogWright app {{app}}, run from the repository with its settings file.
[Unit]
Description=DialogWright: {{app}}
After=network-online.target
Wants=network-online.target
[Service]
WorkingDirectory={{root}}
# The server reads the settings file itself (ENV_FILE), as pnpm start and pnpm diagnose do.
Environment="ENV_FILE={{envFile}}"
Environment="PATH={{path}}"
Environment="COREPACK_ENABLE_DOWNLOAD_PROMPT=0"
ExecStart={{pnpm}} --filter {{app}} serve
Restart=always
RestartSec=10
# On stop: SIGTERM to pnpm, which passes it to the server and waits while it drains; the rest of
# the service gets SIGKILL only if it is still there after TimeoutStopSec (DRAIN_UNREADY_MS, DRAIN_MS and 10 more).
KillSignal=SIGTERM
KillMode=mixed
TimeoutStopSec={{stopSeconds}}
[Install]
WantedBy=default.target
and prints the commands below. A systemd user service runs only while you are logged in: systemd starts it at your
first login and stops it when your last session ends, an ssh session too. For a line that starts at boot and stays
up with no one logged in, turn on lingering for your user once, with loginctl enable-linger (some systems
ask for sudo; loginctl show-user "$USER" --property=Linger says yes when it is on).
systemctl --user daemon-reload
systemctl --user enable --now myline # start now, and at every login
loginctl enable-linger "$USER" # and at boot, with no one logged in (once)
systemctl --user status myline # running or not
journalctl --user -u myline -f # its output
systemctl --user restart myline # restart: live calls get DRAIN_MS to finish
systemctl --user disable --now myline # stop, and keep it stopped
A system unit works the same way: copy the file to /etc/systemd/system/, add User=alex under
[Service], make it WantedBy=multi-user.target, and use sudo systemctl without
--user (and without the linger).
On either system, a wrong setting shows as a restart every ten seconds, with the variable named in the output
(missing required environment variable TELNYX_PUBLIC_KEY (VOICE_PROVIDERS includes telnyx)):
run ENV_FILE=~/ivr/myline.env pnpm diagnose --app myline first.
9. Surviving a restart
A line that runs for months restarts: for an update, a reboot, now and then a crash. By default the server keeps calls in
its memory, so a restart ends the calls under way (after the drain, the stopping server puts each one through to
HANDOFF_NUMBER). With SESSION_STORE=file:<folder>, as in the settings above, it saves each
call after every turn, and a restart no longer drops a caller.
- What is saved
- Each live call (what the caller has said so far, where they are in the form), each web chat, and the carrier's one-time
relay tokens, kept only as hashes, in a folder only you can read (mode 700, each file 600, every save written whole and
renamed into place). It holds what callers said, like the traces: keep it on this machine, on a disk that outlives a
reboot (not
/tmp;pnpm diagnosechecks). One server per folder. - What the caller hears
- When the carrier calls back for the call, the restarted server loads it, and the caller hears "Sorry, I lost you for
a moment." (an app can say it its own way: the
resumedline) and the question they were on, with what they had said still there. The trace and the audit chain go on in the same files. A call saved by a version whose session shape differs is not resumed: that caller is put through to a person. - A planned restart
- An update or a
launchctl kickstart -k/systemctl --user restart. A call still going afterDRAIN_MSis handed over: the server closes its socket, answers the carrier's callback with "waitRESTART_PAUSE_Sseconds, then connect again" (5 by default), stops listening and exits; the service starts the server again, and the carrier's connection, a few seconds later, reaches it. The caller hears a few seconds of silence, then the resumed line. SetRESTART_PAUSE_Sa little above how long your server takes to stop and start (the log'slistening online says when it is back). - A crash, a reboot, a power cut
- Nothing is handed over. The carrier calls back the moment the socket drops, usually before a server is listening again,
and then turns to the number's fallback (below), which puts the caller through to you. A callback that comes after the
restarted server is up resumes the call. A save is renamed into place whole, so a crash loses nothing; a power cut may
lose the last few seconds, unless
SESSION_FSYNC=onflushes every save to the disk (for a machine with no battery or UPS). - A write is never done twice
- A call that resumes may run a write again (a report filed just before the crash, confirmed again after it). Each write carries a key that is the same for the same confirmed write, which your systems use to answer a repeat with what they did the first time (how a tool uses it).
When the server is down: the carrier's fallback
A carrier turns to a number's fallback when it cannot reach your server at all: a call that comes in while it is down, or a
live call's callback that comes before a restarted server is listening. pnpm fallback writes the document: an
apology and a dial to HANDOFF_NUMBER. Host the file anywhere that stays up when your machine does not (a static
page on GitHub Pages or Cloudflare Pages, object storage) and paste its URL where the command says. Set it once per number:
with it, a caller reaches a person whenever the server cannot answer; without it, they hear the carrier's own error.
pnpm fallback --provider telnyx --number +15555550123 --out fallback.xml
# Telnyx: the TeXML application's Webhook Failover URL. Twilio (--provider twilio): the number's "Primary handler fails" URL.
That a carrier uses its fallback for a failed callback mid-call (and not only for a call that comes in), and takes the pause before it connects again, is read from the carriers' references and not yet shown on a live call: live check 3.
10. Looking at calls from your phone
The console shows each call as it happens, with what the caller said, so by default it answers only on the machine
(http://localhost:3000/dashboard; from another computer, forward the port:
ssh -L 3000:localhost:3000 alex@homebox.local). To watch from your phone through the tunnel, add
CONSOLE_AUTH=token to the settings file and restart the server. The console then asks for a sign-in: a link
that works once, for ten minutes. The server writes one when it starts, to a file only you can read (its log names the
file, never the link); for a new one, on the machine:
cd ~/dialogwright && ENV_FILE=~/ivr/myline.env pnpm console:link --app myline
It prints https://ivr.example.com/dashboard/login?code=.... Send it to your phone (a note to yourself, say),
open it, and press Sign in (a link preview only fetches the page, so it never uses the link up). The
phone stays signed in for twelve hours (CONSOLE_SESSION_HOURS), or until you press Sign out
in the console's header, and the console fits the phone's screen. Each new link retires the one before it. A restart signs
everyone out unless CONSOLE_SESSION_KEY is set (32 random bytes or more: openssl rand -hex 32);
with it, a sign-out is kept on disk and still holds after a restart. Without a sign-in,
https://ivr.example.com/dashboard shows only the sign-in page, and pnpm diagnose checks that it does.
Every sign-in, every refused attempt, the first look at the live feed and every replay of a stored call go into the audit,
as console_access entries naming the session by a hash and never the link: the record of who looked at
which call. Failed attempts are limited per address and in all. The address is the one your tunnel reports: with
Cloudflare's tunnel, as here, CONSOLE_CLIENT_ADDRESS=cf-connecting-ip reads only Cloudflare's header (the
default, auto, reads it first too); behind ngrok or another proxy, set x-forwarded-for. Your app's
own pages that are local only (its chats) stay local only. Treat a sign-in link like a key: it opens every call for whoever
uses it first.
11. Updating, the disk, backups, security
Updating
DialogWright is pre-release and its APIs change, so update on purpose, and note the commit you last ran well
(git rev-parse --short HEAD) so you can go back. The restart drains and, with the file store, hands each call
still going over to the restarted server (section 9); with the memory store, check
/health says "sessions":0 first.
cd ~/dialogwright
git pull
pnpm install
pnpm check
pnpm --filter @dialogwright/example-myline test
pnpm --filter @dialogwright/widget build # if you serve the widget
ENV_FILE=~/ivr/myline.env pnpm diagnose --app myline
systemctl --user restart myline # or: launchctl kickstart -k gui/$(id -u)/com.example.myline
Update the operating system, Node and cloudflared on the same quiet occasions.
Keeping the disk in check
Unless you say otherwise, nothing deletes old traces: two files per call, kept until you remove them.
TRACE_RETENTION_DAYS=90 removes traces and frame logs last written more than 90 days ago, swept at startup and
once an hour, never a live call's; /health then reports disk (traceBytes,
auditBytes) for a monitor. AUDIT_RETENTION_DAYS does the same for audit days, which ends the
record for those days (never today's): keep them as long as you must keep the record. The traces are what
pnpm kb:gaps reads to find the questions your knowledge base did not answer, so archive them before they go.
The sessions folder needs no sweep: it holds only the calls under way. Check the audit's hash chains with
pnpm audit:verify ~/ivr/data/audit.
A settings file pnpm configure writes now starts with a policy: traces kept 30 days and at most 5 GB
(TRACE_MAX_GB: past it, the calls written least recently go first, whole), a warning on /health and in
the log under 2 GB free (DISK_WARN_FREE_GB), and the server's own log, which pnpm service writes to
server.log on a Mac, rolled at 10 MB with 5 kept (LOG_MAX_MB, LOG_KEEP). The audit is left to
you. A trace that cannot be written (a full disk) is counted on /health and never fails a call; an audit that
cannot be written still does, as the audit is the record.
Backing up
What is yours and not in git: ~/ivr/myline.env (your keys: back it up only somewhere encrypted), the traces
and audit folders, and ~/.cloudflared/ (a named tunnel's credentials). The sessions folder needs no backup.
The audit files are append-only and chained: copy them, never edit them, since an edit breaks every later link.
What to watch
- The output. Errors and restarts show there first.
- Slow turns. Each trace record carries its timing (
timing.askMs,timing.totalMs). A model request slower thanJEV_TIMEOUT_MSplays the slow-turn line and keeps the question open. - The carrier's own logs (Telnyx's webhook debugging, Twilio's call logs) when a call never reached you.
Security
- Keys only in the settings file, mode 600, outside the repository: never in the unit, the plist, a script you commit, or a message. The server and the commands print a secret's length, never its value.
- Signature checks on (
SIGNATURE_CHECK=on, the default). - The console local-only (
CONSOLE_LOCAL_ONLY=on, the default): reach it from elsewhere withCONSOLE_AUTH=token's sign-in or over SSH, never by turning this off. - No port opened on the router. The tunnel connects out. Turn on the machine's firewall: the server
needs no incoming connection from the network, only from
cloudflaredon the same machine. - The traces hold what callers said, and so does the sessions folder while a call is live. Treat the data folder as personal data, kept on this machine and in encrypted backups.
- Keep the machine updated.
- Building a line for a small business (a law office's, say)? Its callers are the business's clients: ask what it must keep, for how long, and who may read it, before the first call.
12. By hand, without the commands
The commands above only write files and run processes you could write and run yourself. When one does not fit (a machine
where you would rather not run a wizard, a tunnel pnpm start does not know), these are the same steps by hand.
A settings file
Write it from this example (the values are made up), mode 600, and name it with ENV_FILE. The variables,
their defaults and their checks are the server's own (packages/dialogwright/src/server/config.ts).
~/ivr/myline.env, written by hand (paths for a Mac user called alex; on Linux, /home/alex)# Where the carrier reaches this server: the tunnel's hostname, bare (no https://, no path).
PUBLIC_HOST=ivr.example.com
# The port the server listens on, and the tunnel points at (default 3000).
PORT=3000
# The carrier: telnyx, twilio, or both (twilio,telnyx). Default twilio.
VOICE_PROVIDERS=telnyx
# Telnyx: the account's public key, base64, from Keys & Credentials.
TELNYX_PUBLIC_KEY=Zm9yLWV4YW1wbGUtb25seS1ub3QtYS1yZWFsLWtleSE=
# Twilio instead (or as well): the account's auth token.
# TWILIO_AUTH_TOKEN=0123456789abcdef0123456789abcdef
# Check every webhook's signature. Default on; leave it on.
SIGNATURE_CHECK=on
# Where a call goes when it needs a person, E.164.
HANDOFF_NUMBER=+15555550123
# The decision model. JEV_CLIENT defaults to stub (no model: the app's own examples);
# set jev for a real one.
JEV_CLIENT=jev
JEV_PROVIDER=typesafe
TYPESAFE_API_KEY=example-key-replace-me
# Or through a gateway:
# JEV_PROVIDER=openrouter
# OPENROUTER_API_KEY=example-key-replace-me
# JEV_PROVIDER=vercel
# AI_GATEWAY_API_KEY=example-key-replace-me
# Or a compatible model on this machine (no key; not calibrated like Jev):
# JEV_PROVIDER=custom
# JEV_BASE_URL=http://localhost:8000
# JEV_MODEL=example-local-model
# How long one model request may take before the turn plays its slow-turn line (default 1500 ms).
# JEV_TIMEOUT_MS=1500
# The operator console: on (the default), and only for this machine (the default).
DASHBOARD=on
CONSOLE_LOCAL_ONLY=on
# Or look at calls from your phone, through the tunnel, behind a one-time sign-in link
# (pnpm console:link prints one). A key of your own keeps you signed in across restarts.
# CONSOLE_AUTH=token
# CONSOLE_SESSION_HOURS=12
# CONSOLE_SESSION_KEY=example-key-replace-me
# CONSOLE_CLIENT_ADDRESS=cf-connecting-ip
# Where calls are written. Defaults: traces and audit, inside the app's folder.
TRACE_DIR=/Users/alex/ivr/data/traces
AUDIT_DIR=/Users/alex/ivr/data/audit
# Where live calls and chats are kept between turns, so a restart resumes them.
# Default memory (a restart ends them).
SESSION_STORE=file:/Users/alex/ivr/data/sessions
# On a planned restart, how long each caller waits in silence for the restarted server. Default 5.
# RESTART_PAUSE_S=5
# Flush every save to the disk, on a machine with no battery or UPS. Default off.
# SESSION_FSYNC=off
# How long a stopping server waits for live calls, in ms. Default 30000.
# DRAIN_MS=30000
# Remove traces and frame logs, and audit days, older than this many days. Unset: kept forever.
# TRACE_RETENTION_DAYS=90
# AUDIT_RETENTION_DAYS=
# The zone "today" is read in. Default: this machine's zone.
TIMEZONE=America/Chicago
# Optional: the handoff note.
# ANTHROPIC_API_KEY=example-key-replace-me
# HANDOFF_SUMMARY=on
# Optional: the voice and the recognizer (each carrier names its own).
# TELNYX_VOICE=Telnyx.NaturalHD.astra
# Telnyx's event streams (speaker-events, tokens-played), space-separated, so quoted: the server's own
# loader takes it with or without quotes, and a shell can `source` the file only with them. With
# tokens-played, a line Telnyx cuts short is said again (RESAY_CUT_LINES, on by default; it does
# nothing without the events). RESAY_MIN_FRACTION (0.05 to 0.95, default 0.35) is how short counts as cut.
# TELNYX_EVENTS="speaker-events tokens-played"
# RESAY_CUT_LINES=on
# RESAY_MIN_FRACTION=0.35
# A goodbye or a transfer's line plays before the call ends: auto (default) holds the end on a carrier
# that drops unsaid lines at the end (Telnyx) until they have played, on for every carrier, off for none.
# With the carrier's report, a held end waits up to END_PLAYBACK_FACTOR (default 2) times the lines'
# estimate plus END_PLAYBACK_MARGIN_MS (default 2000) for it, so a slow voice is not cut off.
# END_AFTER_PLAYBACK=auto
# END_PLAYBACK_MAX_MS=15000
# END_PLAYBACK_FACTOR=2
# END_PLAYBACK_MARGIN_MS=2000
# With speaker-events, a caller who speaks again within RESUME_AFTER_PAUSE_MS (default 2000) of stopping
# and within RESUME_INTO_REPLY_MS (default 1000) of the reply going out is still talking: their next
# prompt continues the one before it.
# RESUME_AFTER_PAUSE_MS=2000
# RESUME_INTO_REPLY_MS=1000
# With speaker-events, an interrupt with no caller heard around it is not taken as a barge-in, and the
# lines it cut are said again once (RESAY_SPURIOUS_INTERRUPTS, on by default; SPURIOUS_INTERRUPT_WINDOW_MS,
# default 700). A reply to words the model reads as unfinished waits up to INCOMPLETE_WAIT_MS (default
# 1000; 0 turns it off) for the caller to go on, and is not said if they do.
# RESAY_SPURIOUS_INTERRUPTS=on
# SPURIOUS_INTERRUPT_WINDOW_MS=700
# INCOMPLETE_WAIT_MS=1000
# Who may talk over a line: any (default), speech, dtmf or none (the relay element's interruptible).
# A carrier's barge-in can stop speech on noise or echo; dtmf or none also rules barge-in out when
# callers report not hearing replies. BARGE_IN_TWILIO and BARGE_IN_TELNYX override it for one carrier.
# BARGE_IN=any
# BARGE_IN_TELNYX=any
# TTS_PROVIDER=Google
# TTS_VOICE=en-US-Neural2-F
# Optional: web chat for your site, and the widget's script served from here.
# CHAT=on
# CHAT_ALLOWED_ORIGINS=https://www.example.com
# WIDGET=on
# WIDGET_FILE=/Users/alex/dialogwright/packages/widget/dist/dialogwright-widget.js
chmod 600 ~/ivr/myline.env
PUBLIC_HOST- Your tunnel's hostname. The server builds every URL it gives the carrier from it, and checks Twilio's signature
against
https://<PUBLIC_HOST><path>, so it must be exactly the hostname the carrier calls. VOICE_PROVIDERSand the carrier's secret- Each carrier listed needs its secret:
TELNYX_PUBLIC_KEY(refused at startup unless it is a base64 Ed25519 key),TWILIO_AUTH_TOKEN. SIGNATURE_CHECK=on- A webhook the carrier did not sign is refused.
offexists only for posting webhooks by hand on a laptop. JEV_CLIENT,JEV_PROVIDERand the key- Without
JEV_CLIENT=jevthe server runs on the stub. Once it isjev, the provider's key variable is required (forcustom,JEV_BASE_URLandJEV_MODELare). WIDGET_FILE- The default is the widget in your app's own
node_modules, which a new app does not have (the utility does); name the built file instead.
A quick tunnel, and the server
pnpm start --tunnel quick is these two, in two terminals: the tunnel, which prints a random
https://<words>.trycloudflare.com address, and the server with that hostname (without
https://) as its PUBLIC_HOST:
cloudflared tunnel --url http://localhost:3000
cd ~/dialogwright
PUBLIC_HOST=<words>.trycloudflare.com ENV_FILE=~/ivr/myline.env pnpm --filter @dialogwright/example-myline serve
Stop the server first and the tunnel after it, so live calls keep their way in while they drain. Any other tunnel works
the same way: it gives you a hostname for PUBLIC_HOST and forwards to the server's port.
Checking by hand
pnpm diagnose runs these and more; by hand, the server answers on the machine and through the tunnel, and
the console is not public:
curl -s http://localhost:3000/health
curl -s https://ivr.example.com/health
curl -s http://localhost:3000/ready
curl -s -o /dev/null -w '%{http_code}\n' https://ivr.example.com/dashboard # 404, or 302 to the sign-in with CONSOLE_AUTH=token
13. Beyond one machine, and several lines
When one machine is no longer enough (a deploy that must never drop a call, more calls than one machine answers), the same app folder runs on many servers: the session stores this page uses sit behind interfaces with one contract suite, and a Postgres store many servers share is a drop-in, beside the audit in Postgres, traces on S3-compatible storage and one console across the servers. That is built (Phase 8b); the scaling guide tells it step by step.
One machine can also answer several lines, each its own app: a host.yaml beside the apps points each number
callers dial at an app (any other number reaches the default), each app keeping its own settings, records and chat, and one
console showing them all. pnpm start --host host.yaml runs them in one server. That is built too.
The roadmap Production architecture, in the design Every option, in the authoring guide Several apps on one server Scaling out
Sources
The repository is the authority for every variable, path and default here. The steps outside it follow these pages, read on 2026-10-04:
- Cloudflare: quick tunnels
- Cloudflare: create a locally-managed tunnel
- Cloudflare: the configuration file and ingress rules
- Cloudflare: run as a service on macOS
- Cloudflare: WebSockets
- launchd.plist(5), the manual page
- Apple: change Energy settings on a Mac desktop
- Telnyx: TeXML setup and TeXML fundamentals
- Telnyx: receiving webhooks (the public key)
- Twilio: configure a number to receive calls and voice failover
- Tailscale Funnel and ngrok's free plan limits