Products/Voice calling
Voice calling
AI voice calling for outreach, with the guardrails in front of the dialler.
AI voice calling for outreach places an outbound call to a lead, runs a live speech-to-text, model and speech loop, and writes the transcript back to that lead’s timeline. FounderReply gates every call on consent, an accepted acceptable-use policy, suppression lists and the recipient’s own local business hours.
Outbound only · Telnyx telephony · EU speech loop · Not dialling yet — what that means
Status
Everything is built except the phone line.
Voice is the one part of FounderReply that is code-complete and not yet callable. Both services are deployed and every guardrail below runs today, but no outbound SIP trunk has been registered, so a call fails closed instead of ringing. Here is the exact line between the two.
Deployed and running
- Both services are deployed and health-checked on Fly: the dispatcher (founderreply-voice, US-East) and the LiveKit media worker (founderreply-voice-worker, Frankfurt).
- The full gate ladder runs: acceptable-use acceptance, card on file, complaint kill switch, consent and do-not-contact, suppression, business-only targeting, line-type checks and calling hours.
- Calls requested outside a recipient’s window are scheduled into outreach_scheduled_calls and re-evaluated by the five-minute cron before they dial.
- The signed transcript webhook is live: turns land on the lead’s timeline, minutes meter idempotently, and any follow-up the agent drafts goes to the approval queue.
- Telephony is Telnyx (TELEPHONY_PROVIDER), with number search, purchase, per-tenant subaccounts and per-workspace caller ID implemented in lib/telephony.ts.
Not connected yet
- No outbound SIP trunk is registered. LIVEKIT_SIP_TRUNK_ID is unset, so services/voice/src/agent.ts fails closed and no phone rings.
- No number has been bought: services/voice/outbound-trunk.json still carries its REPLACE_WITH_ placeholders.
- Until the trunk exists, the pipeline is exercised end to end in simulation — the round trip runs, the transcript posts back, and the telephony leg is skipped.
A Telnyx number plus a registered LiveKit outbound trunk is the whole remaining step. Nothing above it is waiting on code.
Before it dials
Five gates, in this order, every time.
A dialler that asks permission after it rings is not a compliance feature. Requesting a call runs the checks below in sequence and any one of them can refuse; the calling window is the only one that defers rather than refuses, because a good call at the wrong hour is a scheduling problem.
- 1
Gate 1
The workspace may call at all
The acceptable-use policy must be accepted, a card must be on file while billing is live, and the workspace must not be paused by the complaint-rate kill switch. This runs before anything about the lead is read.
lib/outreach-gate.ts
- 2
Gate 2
This lead may be called
Do-not-contact and unsubscribe block outright. Explicit call consent is required unless a workspace turns that default off. The number must not be suppressed, the lead must have business context, and mobile lines are blocked.
lib/eligibility.ts · lib/consent.ts
- 3
Gate 3
It is a reasonable hour where they are
The country comes from the E.164 number, which resolves a timezone, customary hours and the local weekend. Open now means dial now. Closed means the call is scheduled to the next open instant, not dropped.
lib/calling-hours.ts
- 4
Gate 4
The balance covers a minute
A call spends telephony and inference money, so a workspace below one voice minute is refused rather than dialled. Minutes are metered when the transcript comes back, keyed on the call id so a retry cannot double-charge.
lib/voice.ts · lib/billing.ts
- 5
Gate 5
Re-checked again at dial time
A scheduled call is not trusted to still be legal when its turn arrives. Eligibility and the window are evaluated again; a suppression added in the meantime cancels it, and a shifted window defers it rather than firing early.
lib/voice.ts runDueScheduledCalls
Where a call runs
The audio is in the EU. The dispatcher is not.
The transcription, model and speech steps route through phi-cloud’s EU region, and the fallback branches to US providers were deleted so a missing environment variable cannot quietly move call audio. The service that creates the room and places the SIP dial is a separate app in US-East. Both facts belong on this page.
| Leg | Runs in | What it holds |
|---|---|---|
| Speech loop — transcription, model, speech | phi-cloud, EU | The live call audio and everything said on the call.services/voice/worker.py |
| Media / agent session | Fly fra — Frankfurt | The WebRTC and SIP media session for the duration of the call.services/voice/fly.worker.toml |
| Call dispatcher | Fly iad — US-East | Room creation and the SIP dial. Call metadata only: the number, the goal, the persona key. No audio.services/voice/fly.toml |
| App, timeline, approvals | Cloudflare Workers + Supabase | The stored transcript, consent state and the suppression list.lib/voice.ts |
| Carrier leg to the phone network | Wherever the trunk terminates | The call itself. Not provisioned yet — see the status above.services/voice/outbound-trunk.json |
So the precise claim is this: the speech loop and the media session are EU-resident with no US fallback path, and orchestration is not. Call metadata — the number dialled, the goal, the persona key — passes through the US-East dispatcher, which never receives audio. That is a primary_region value in services/voice/fly.toml, not an architectural constraint.
On the call
The parts that only matter after someone picks up.
Most of the difficulty in outbound calling is not the conversation. It is knowing you reached a voicemail greeting, honouring a removal request the second it is spoken, speaking the right language, and never billing for a phone that rang into an empty room.
It knows when it reached a machine
Answering-machine and IVR detection listens from the moment the line goes live, and stops the agent pitching over a voicemail greeting.
Answered-ness is taken from the telephony layer’s sip.callStatus, not inferred from whether any text appeared — the earlier guess billed calls nobody took.
“Remove me” ends it there
The agent has an opt-out tool it is instructed to call immediately, apologise once, and hang up. The request is then applied on our side, not just noted.
An in-call opt-out suppresses the number on every channel and marks the lead do-not-contact. The callback retries four times and dead-letters the signed payload rather than losing it.
It speaks the callee’s language
The likely language is derived from the number’s country and used twice: to select the speech voice, and to tell the model which language to actually write in.
Thirty languages are mapped. Routing only the voice is what once produced a German voice reading English words, so both halves are set on every call.
Nothing it drafts gets sent
When a human actually talked to us, the agent drafts a follow-up referencing what was discussed. That draft goes to the approval queue like every other outbound message.
Same queue as the email channel — one place where a person releases or discards work. Corrected email addresses and dispositions are recorded for a human to apply.
Billed from pick-up
Ring time is never billable and an unanswered call is free. A single call is hard-capped so a hold-music loop cannot run up a bill against a machine.
Duration starts at sip.callStatus=active; metering is keyed on the call id through the processed-events ledger, so a re-delivered webhook cannot charge twice.
Signed in both directions
The dial request and the transcript callback are both HMAC-signed with separate secrets, and the caller ID presented is the workspace’s own number where one is provisioned.
A mismatched signature fails the call closed rather than degrading to an unauthenticated dial. Caller ID resolves per tenant, falling back to the trunk default.
FAQ
What people ask before letting software dial.
Whether it can call at all today, when it is allowed to ring, which numbers it refuses, where the audio goes, and what a minute costs you.
Can FounderReply place AI voice calls today?
Not yet. The dispatcher, the media worker, every safety gate, the transcript webhook and the metering are built and deployed. What is missing is a phone number and a registered outbound SIP trunk, so calls currently fail closed and no phone rings. Email outreach, on the same guardrails, is live.
When will the agent call a lead?
Only inside the recipient’s own local business hours. The country is derived from their E.164 number, which resolves a timezone, customary working hours and the local weekend — Friday and Saturday in Israel, Saudi Arabia, Egypt and Bangladesh. A call requested outside the window is scheduled forward, not dropped.
Does it call mobile numbers?
No, not by default. The eligibility policy blocks known-mobile lines, and the line type is looked up through the telephony provider and cached on the lead before the first dial. A workspace can turn that block off, and can also require a verified business line before any call is placed.
Where is call audio processed?
The transcription, model and speech steps all route through phi-cloud in its EU region, and the media session runs on a worker in Frankfurt. That is deliberately the only path — the US provider branches were deleted so the guarantee cannot break by losing an environment variable.
Is the whole call stack in the EU?
No, and it is worth being exact. The audio and the AI loop are EU-resident. The dispatcher that creates the room and places the SIP dial runs in US-East and sees call metadata — the number, the goal, the persona key — but never audio. That is one region setting, not an architecture.
What happens if someone asks to be taken off the list mid-call?
The agent calls its opt-out tool immediately, apologises once and ends the call. On hangup the number is suppressed across every channel and the lead is marked do-not-contact. That callback retries with backoff and dead-letters its signed payload rather than dropping a compliance signal.
What exactly gets billed?
Minutes, rounded up, measured from the moment the callee picks up. A call nobody answers costs nothing because its duration is zero. Metering is keyed on the call id, so a re-delivered transcript webhook cannot charge you twice, and a single call is capped by default at five minutes. See pricing for what a minute costs.
Get started
Start on the channel that’s already live.
Email outreach runs today on the same consent, suppression and calling-policy stack described above. Set your leads up there, and voice joins the same timeline the day the trunk is connected.
Free to start · No credit card required · Human approval on by default