Skip to content

Latest commit

 

History

History
407 lines (315 loc) · 17.3 KB

SELFHOST.md

File metadata and controls

407 lines (315 loc) · 17.3 KB

Self-hosting screenless

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.

How it fits together

  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 setup sends 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 To from 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).

Setup

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).

1. Telnyx: get a number and a key

  1. 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.
  2. Create an API key under API Keys.
  3. 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.

2. Cloudflare: deploy the Worker

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.sql

Edit 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 deploy

3. Create the Verify profile

Telnyx 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 deploy

4. Stripe: topping up

Billing 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_succeeded
npx 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 deploy

Every 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.

5. Inbound: let people ring back

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.

The team

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.

6. Install the CLI

curl -fsSL https://screenless.sh/install | bash

That 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.sh

Either 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 deploy

site/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.

Usage

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 logout

The nightly shape

The 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.

Configuration

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.

Voices

cd worker
npm run voices en         # Telnyx-hosted English voices
npm run voices en all     # every provider
npm run voices nl all     # Dutch, all 43

Prefer 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.

Latency expectations

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.

The silent-call bug (resolved)

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.

Setup gotchas found the hard way

  • The default outbound voice profile only whitelists US and CA. Any Dutch destination is refused with D13 until you add NL. This is not mentioned during number purchase.
  • StatusCallbackEvent must be a space-separated string, not a JSON array, unlike the TwiML convention of repeating the parameter.
  • Verify profiles need whitelisted_destinations on the call channel too, not just sms — it is not inherited, and the docs example only shows sms.
  • 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.

Legal, if you ever point this at someone else

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.ts rather 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.

Known gaps

  • 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.

Layout

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)