Everything you need to run your own screenless Worker and CLI. Most people never open this file — the hosted service at screenless.sh is the default. Read it if you want to run the backend yourself, or understand how the pieces fit.
CLI + skills ─────► Cloudflare Worker ─────► Telnyx
(your machines) (D1 + R2 + secrets) (verify, calls, SMS)
▲ │
│ screenless watch │ status + conversation webhooks
└───────────────────────┘
- The API key lives in the Worker, never the CLI. The CLI holds only a session token bound to a verified phone number.
- Your phone number is your identity.
screenless setupsends an OTP; entering it mints the session. No password, no OAuth. - It only ever dials verified numbers on your own team. A self-call takes
Tofrom the session token; a teammate call resolves targets to your org's verified members. The number is never a free-form request value — which keeps this structurally incapable of becoming a dialer (see Legal, below).
You need a Telnyx account and a Cloudflare account. Budget about 20 minutes.
Only Telnyx is required — there is no separate Deepgram account. Telnyx hosts
the Deepgram models, selected via transcription.model in the assistant config,
and bills them inside the $0.05/min. You would only need your own Deepgram or
ElevenLabs key to bypass Telnyx's hosting (ElevenLabs voices require one, passed
as api_key_ref).
- Buy a phone number. For a business, a Dutch national number is the easiest legitimate option — it needs a Dutch address but no area-code match and no proof-of-address document. Local (+31 geographic) numbers additionally require an address in the matching area code plus proof dated within 3 months. Mobile numbers have the lightest KYC but are flagged A2P-only, which is a poor fit for a conversational agent.
- Create an API key under API Keys.
- Set your AnchorSite on the outbound voice profile to the region nearest your users. This is the whole reason for choosing Telnyx — their GPUs are co-located with the telephony PoPs, so inference sits next to the SIP edge.
cd worker
npm install
# KV stores call records, briefs, watcher heartbeats, rate-limit counters
npx wrangler kv namespace create CALLS
# D1 stores users, orgs, invites and the money ledger
npx wrangler d1 create screenless
npx wrangler d1 execute screenless --remote --file=schema.sqlEdit wrangler.jsonc: set TELNYX_FROM_NUMBER to the number you bought, the
KV and D1 ids, and — see the warning in that file — an ASSISTANT_VOICE
matching your default language.
npx wrangler secret put TELNYX_API_KEY # from step 1
npx wrangler secret put SESSION_SECRET # openssl rand -base64 32
npx wrangler secret put ADMIN_SECRET # openssl rand -base64 32
npx wrangler deployTelnyx requires a verify_profile_id on every OTP. The Worker has a one-shot
admin endpoint for this:
curl -X POST https://api.screenless.sh/admin/verify-profile \
-H "X-Admin-Secret: <your ADMIN_SECRET>"It returns an id scoped to the countries in ALLOWED_DESTINATIONS. Store it and
redeploy:
npx wrangler secret put TELNYX_VERIFY_PROFILE_ID
npx wrangler deployBilling stays inert while STRIPE_SECRET_KEY is unset, so skip this section
entirely if you are running your own Worker for yourself — every org is then
entitled whatever its balance says.
Pay-as-you-go needs no product or price objects: a topup is a one-time Checkout payment with the amount set per session, credited to the org by the webhook (or by the billing page's own poll, if the webhook never lands).
stripe webhook_endpoints create \
--url="https://screenless.sh/stripe/webhook" \
--enabled-events=checkout.session.completed \
--enabled-events=checkout.session.async_payment_succeedednpx wrangler secret put STRIPE_SECRET_KEY # sk_test_… or sk_live_…
npx wrangler secret put STRIPE_WEBHOOK_SECRET # whsec_… from the command above
npx wrangler deployEvery new org gets a 14-day trial with 2 hours of calls; the team plan is $200 a
month with 40 hours (both in worker/src/plan.ts), and minutes beyond
them cost PRICE_PER_MINUTE_CENTS (default 15¢), invoiced on the plan or taken
from credit. FREE_CREDIT_CENTS (default 0) grants credit to new orgs. Both are
plain vars in wrangler.jsonc. Going live is the two secrets swapped for their live-mode
equivalents — there is no code path that differs.
So a declined morning call can be taken later, the number has to answer. Ask the Worker for its signed voice URL:
curl https://api.screenless.sh/admin/inbound-url -H "X-Admin-Secret: <ADMIN_SECRET>"In the Telnyx portal, set that as the Voice URL (POST) of a TeXML application
and assign TELNYX_FROM_NUMBER to it. A team member who rings in hears no
voice at all — just a beep: say what you need, hang up, and the recording is
transcribed and routed to whichever teammate's terminal is running
screenless watch — the caller's own first, anyone's otherwise, and a queue
that holds up to a week when nobody is. A caller who is not on any team gets
a short robot voice pointing them at screenless.sh.
One org per user, billing per org. screenless team (or screenless.sh/team)
opens the page: invite by email only — the invitee verifies their own phone on
accept, and can re-enter it any time if it was typed wrong — with roles, an
invite list that shows pending and expired, and an admin-only billing tab with
the balance, topups, per-day usage and who-costs-what.
curl -fsSL https://screenless.sh/install | bashThat fetches the CLI into ~/.screenless, puts a launcher on your PATH, and
drops straight into screenless setup. It needs Node 20+ and will not install
it for you.
The call goes out at 08:00 in your machine's timezone, which is read from the machine itself on every settings call — there is no timezone to configure, and moving country corrects the schedule on its own.
Setup asks Self-hosted Worker? y/N first. Answering no — the default — points
at https://screenless.sh (api.screenless.sh still answers for older
configs). Answering yes prompts for your own Worker URL, and --api <url>
skips the question entirely.
Working on the CLI itself:
cd ../cli
npm install && npm run build && npm link
screenless setup --api https://screenless.shEither way you'll get an SMS with a code (--voice gets you a phone call
instead). Enter it and you're done — the session lasts a year and lands in
~/.screenless/config.json at mode 0600.
Publishing anything — a CLI change, a page edit, a change to the loop skills — is one command:
cd site && npm run deploysite/public/ is generated in full by site/build.sh and gitignored. Edit
site/src/ for the pages and installer, skills/ for the branded skills,
cli/src/ for the CLI; never edit site/public/, because the next build
deletes it.
screenless call "<prompt>" # the prompt becomes the agent's instructions
screenless call "..." --lang en # English (default)
screenless call "..." --lang nl # Dutch
screenless call "..." --lang multi # Dutch/English code-switching
screenless call "..." --json # raw result, for piping
screenless call "..." --at # park it for your configured call time
screenless call "..." --at 06:30 # park it for a specific local time
screenless call "..." --hold # park with no time — waits until you ring in
screenless transcript # what was decided on the last call
screenless transcript --wait --json # block until it ends, then emit JSON
screenless settings # call time, timezone, ring-back number
screenless settings --at 08:00 # when the morning call goes out (default 08:00)
screenless settings --pause # stop the scheduled call
screenless billing # credit left, and the price per minute
screenless billing --manage # open the team billing tab (admins top up there)
screenless team # your team: members, credit, the page
screenless watch # block until a team call lands, print it, exit
screenless done <callId> # ack it once the work actually ran — undone
# calls are re-delivered, never lost
screenless whoami
screenless logoutThe loop on your machine writes the brief and parks it; the Worker dials at your
time; the loop reads back what you decided and is the thing that acts on it.
screenless wait is the gate the armed session blocks on; the agent does the
rest in-session, but the primitives are plain commands:
screenless wait # blocks until tonight's run; prints NIGHTLY <repo>
screenless watch # blocks until a call lands; prints WORK <id>
# 03:00, when your agents are done — the agent builds the brief, then:
screenless call "<brief>" --at # parked for your call time
# 08:09, after the call — the watcher hands it over, the agent acts:
screenless done <callId>The assistant on the phone has no tools and takes no action — it collects decisions and hangs up. Everything that merges, comments or closes runs on your machine, with the access you already gave it. Decline the call and ring the line whenever suits to leave your decisions or a request as a voice note.
mail hands the PDF to the Worker, which parks it in KV and sends it on a
five-minute cron sweep once it comes due. The Worker holds it rather than your
laptop because the machine that builds the paper at 03:00 is usually asleep by
06:30. It needs RESEND_API_KEY set as a Worker secret and a verified sender
domain — without them the sweep fails silently, since nobody is watching a cron.
call blocks until the call reaches a terminal state, polling every 2s, and
gives up after 15 minutes.
Until screenless brief exists, you can approximate it by piping your own PR
summary into the prompt:
gh pr list --json number,title,body --limit 20 \
| screenless call "$(cat -) — walk me through these and ask me to decide on each"That is a demo, not the product: there is no triage, no writeback, and a long prompt will blow past the 4000-character limit.
Defaults live in worker/wrangler.jsonc:
| Var | Default | Notes |
|---|---|---|
ASSISTANT_MODEL |
anthropic/claude-haiku-4-5 |
Time-to-first-token is the biggest latency slice |
ASSISTANT_VOICE |
(Dutch — change it) | See the warning in the file |
TELNYX_ANCHORSITE |
Amsterdam, Netherlands |
Pin media to a region near your users |
ALLOWED_DESTINATIONS |
NL |
Doubles as a spend guard; widen for non-NL users |
ALLOWED_DESTINATIONS is the one to revisit first if you take this beyond
yourself — a product for senior engineers that only dials Dutch numbers is not a
product.
cd worker
npm run voices en # Telnyx-hosted English voices
npm run voices en all # every provider
npm run voices nl all # Dutch, all 43Prefer hosted: true voices (Telnyx.Ultra.*, Resemble.Pro.*): they run on
Telnyx's own GPUs, so they need no third-party API key and avoid a network hop
mid-call.
Independent benchmarks measuring what a caller actually experiences — end of your speech to first agent audio, including endpointing and telephony — put every major platform at 1.3–2.4 seconds, not the sub-500ms figures in the marketing. Human conversation gaps average ~200ms; above 800ms callers start talking over the agent.
For this product that matters less than it would for a snappy assistant. A
deliberative review call has natural pauses — you are thinking about whether the
field belongs in its own table. eot_threshold and eager_eot_threshold in
worker/src/telnyx.ts are the knobs if you want them.
For the first fortnight every assistant call was silent — the caller heard
nothing, and the conversation recorded zero assistant turns. The cause was
narrow and upstream: Telnyx-hosted TTS voices render no audio on a PSTN
call. Plain TeXML <Say> was audible, the assistant worked over text, and
standalone TTS worked — but an assistant speaking through a Telnyx voice on a
phone line produced silence.
The fix was to give each language a third-party voice (AWS Polly / Azure
Neural), chosen on measured latency, rather than a Telnyx-hosted one. Calls
have been audible and interruptible since. /texml/say stays in the worker as
the isolation test: point a TeXML application at it and if you hear it,
telephony and TTS are fine and the fault is higher up in the assistant layer.
- The default outbound voice profile only whitelists US and CA. Any Dutch
destination is refused with
D13until you addNL. This is not mentioned during number purchase. StatusCallbackEventmust be a space-separated string, not a JSON array, unlike the TwiML convention of repeating the parameter.- Verify profiles need
whitelisted_destinationson thecallchannel too, not justsms— it is not inherited, and the docs example only showssms. - Deleting an assistant does not delete its auto-provisioned TeXML app. Clean both up or they accumulate one per call.
- Dutch national (+3185) numbers require regulatory approval that a KvK handelsnaam may not satisfy. A US number has no requirements and activates instantly, which is fine while you only call your own verified phone.
This tool only calls the number you verified, so none of the below applies to using it on yourself — which is the entire intended product. It all applies the moment you point it at anyone else.
- AI disclosure is mandatory and already in force. EU AI Act Article 50
applied 2 August 2026 with no grace period; penalties reach €15M or 3% of
worldwide turnover. The agent's greeting is hard-coded in
worker/src/index.tsrather than left to the system prompt, because a prompt instruction is something the model can silently skip. Don't remove it. - You cannot cold-call Dutch consumers. Opt-in has been required since July 2021, and as of 1 July 2026 the existing/former-customer exception is gone too.
- zzp'ers and eenmanszaken count as natural persons — the most-missed trap, and it covers most Dutch self-employed people. Only rechtspersonen (BV, NV, stichting, vereniging) remain cold-callable.
- The Bel-me-niet Register no longer exists. It was abolished and its data deleted. Vendor tools still advertising "automatic register checks" are selling something that isn't there.
- Anonymous caller ID is illegal for telemarketing — your number must be visible and answerable.
- The product itself. See Status.
PR ingestion, triage, agenda, writeback,
screenless brief, and the entire paper surface are unbuilt. - Webhook signature verification. Telnyx signs webhooks with ed25519; the Worker checks an HMAC token in the URL instead. Good enough to stop someone who guesses a call id, not a substitute for signature verification.
- One assistant per call, created and deleted each time. Clean 1:1 mapping to the conversation, but it adds a round-trip to call setup. A long-lived assistant with per-call dynamic variables would be faster — and is close to required for a scheduled daily call.
- The 4000-character prompt limit is well below a real PR agenda. Whatever builds the agenda will need to summarise before it dials, not stuff context into the prompt.
cli/ the `screenless` binary — no dependencies, two files
src/index.ts setup, call, watch, done, team, mail, billing …
src/config.ts ~/.screenless/config.json
worker/ the one Worker — site, team page, and the whole API
src/index.ts routes, calls, inbound IVR, watchers, cron
src/db.ts all state in D1: users, orgs, calls, briefs, ledger …
src/team.ts the /team page and its API
src/billing.ts pay-as-you-go, Stripe topups
src/telnyx.ts Telnyx client — verify, calls, SMS, transcription
src/mail.ts, emailhtml.ts outbox (D1 + R2) and the branded email frame
schema.sql the D1 schema
wrangler.jsonc vars, bindings, tunable defaults
skills/ the branded skills — installable via skills.sh
screenless/ the watcher: takes incoming calls and acts on them
SKILL.md, APPLY.md the watcher, and the return leg after a call
call-when-afk/ phone the user their questions while away
morning-pr-review/ the optional nightly loop: brief + weekly paper
SKILL.md
press/ the PDF toolkit the paper skill calls
site/src/ the landing page and installer (screenless.sh)