Telephone
Set up a telephone line answered by an agent: both transports, every setting, and what to do when a call does not work.
A telephone number can be answered by an agent of your choosing, on behalf of an account of your choosing. The caller speaks to it the way you already speak to live voice, and the recognition, the model and the voice are the ones your deployment is already configured with, so an answered call costs the telephone network's charge and nothing else.
Two things can carry the audio, and each line says which one answers it. A telephone carrier brings the call over their media stream: simplest to set up, and the caller's audio passes through them. Your own telephone system, of the kind a practice with a switchboard already runs, hands the audio straight to the deployment over your own network, so nothing about the call reaches anybody else. Both can run at once.
Before you start
The telephone ships dormant. Until a provider is named there is no telephone surface at all: the
addresses a provider would call are absent rather than refused, so an unconfigured deployment
answers 404.
You need all of:
features.telephony,features.voiceandfeatures.voice_liveswitched on;- a streaming speech synthesiser. A telephone line carries raw audio rather than an audio file,
and a line that cannot speak refuses calls rather than answering them silently. Set
voice_live.tts_streamandvoice_live.tts_stream_url, or the matching runtime settings; message_encryption_keyset. No telephone credential can be stored without it, and the settings endpoint refuses to accept one;- recognition running at 8000 or 16000 Hz (
voice_live.stt_sample_rate, default16000). A telephone line carries 8 kHz and nothing else converts cleanly; - then either a number and a credential from a carrier, or an address to listen on and a shared secret for your own telephone system.
Switch the telephone on
All three flags default to false. Boot form on the left, environment variable on the right:
| Setting | Environment variable | Default |
|---|---|---|
features.telephony | PAI__FEATURES__TELEPHONY | false |
features.voice | PAI__FEATURES__VOICE | false |
features.voice_live | PAI__FEATURES__VOICE_LIVE | false |
Each also has a runtime override in config_settings under the same name, editable at
Admin → Config, which wins over the boot flag and takes effect with no restart. That is
deliberate: a line under abuse can be shut without a deployment, and a call costs money.
Option A – a telephone carrier
Configured by whoever operates the deployment, under Telephone in the super-admin panel
(GET/PUT /api/admin/telephony, break-glass).
- Set what answers a call to a telephone carrier. This writes
telephony.provider = "twilio". - Set the public address of this deployment, beginning with
httpsand reachable from the internet, for examplehttps://calls.example.com. This writestelephony.public_base_url; empty falls back toserver.public_url. - Store the carrier credential, the authentication token from your carrier account. It is write-only: what comes back is whether one is stored, never a character of it.
- In your carrier console, point the number at these addresses, all relative to the public address you just set:
| Purpose | Method | Path |
|---|---|---|
| What to do with an incoming call | POST | /api/telephony/twilio/voice |
| Where the audio connects | GET | /api/telephony/twilio/media |
| Call status callbacks | POST | /api/telephony/twilio/status |
| Anybody to ring once our side finishes | POST | /api/telephony/twilio/continue |
Every request from the carrier must carry their X-Twilio-Signature header and is checked against
your stored credential before anything reads it. A request that fails is refused with 403, audited
as telephony.signature.rejected, and rate-limited by source address.
The public address must match what the carrier holds exactly, because it is part of what their
signature covers. A trailing slash or http where the carrier has https produces a line that
rings and never answers, with the reason in the audit trail and nothing on the caller's end.
Option B – your own telephone system
- Set what answers a call to this practice's own telephone system
(
telephony.provider = "audiosocket"). - Set the listen address, an address and port on your own network, for example
0.0.0.0:9092. This writestelephony.audiosocket_listen; empty opens no port at all. - Store a shared secret and put the same value in your telephone system's call routing.
- Restart the deployment. The listen address is taken up when the process starts, so a change here does nothing until it does.
- Put the following in your telephone system's dialplan, replacing
YOUR-SECRET,this-deployment, the port andyour-trunk. Your system asks this deployment what to do with a call, is given a one-off identifier good for thirty seconds, and opens a connection with it. The last lines are what lets the agent put a caller through to a person.
exten => _X.,1,Set(CURLOPT(httpheader)=x-fosnie-telephony-key: YOUR-SECRET)
same => n,Set(ID=${CURL(https://this-deployment/api/telephony/audiosocket/answer?from=${CALLERID(num)}&to=${EXTEN})})
same => n,GotoIf($["${ID}" = ""]?hangup)
same => n,Answer()
same => n,Dial(AudioSocket/this-deployment:9092/${ID})
same => n,Set(TO=${CURL(https://this-deployment/api/telephony/audiosocket/continue?call=${ID})})
same => n,GotoIf($["${TO}" = ""]?hangup)
same => n,Dial(PJSIP/${TO}@your-trunk)
same => n(hangup),Hangup()The two HTTP questions carry the secret in the x-fosnie-telephony-key header and are accepted
only from a private source address. Both are required: the secret alone would be enough if it could
never leak, and the network position alone would be enough if nothing else ran on that network.
The identifier is the ticket. It is minted only by an answer that passed every check, is good for thirty seconds, and is redeemed exactly once, so a connection presenting anything else is closed without a word. That is why an open port here is not an open door.
Check it before anybody rings
Every way a telephone line can be misconfigured looks the same from outside: it does not work, and the person who finds out is a caller. The readiness check asks the questions a call asks, in the order a call asks them.
GET /api/admin/telephony/preflight– break-glass, beside the settings.GET /api/admin/telephony/check– permissiontelephony.manage, beside the lines, for whoever registers numbers without configuring the carrier.
id | Asks | A failure means |
|---|---|---|
feature | Is the telephone switched on for this deployment? | The whole telephone surface is absent |
provider | Is something named that can answer? | No call can be taken |
carrier_credential | Is the carrier's credential stored? | Every call will be refused unsigned |
public_address | Can the carrier reach this deployment? | The carrier is not told where to send audio |
listen_address | Is a port bound for your telephone system right now? | Set but not listening usually means no restart since |
shared_secret | Can your telephone system identify itself? | Every request will be refused |
message_key | Can credentials be stored safely at all? | No telephone credential can be stored |
recognition_rate | Can recognition take what a telephone carries? | Only 8000 and 16000 Hz convert |
synthesiser | Does the speech engine actually answer? | The line picks up, cannot speak, and ends the call |
lines | Is there a number registered and switched on? | Nothing answers |
line_bindings | Does every switched-on line have an agent and an account? | That line will refuse |
synthesiser is the one that is a real request rather than a setting read: it sends a short test
phrase to your configured engine and reports what came back, because configured and working are
different facts and only the second one takes a call. That is why the check runs when you ask for
it and never on its own.
Register a line
Ordinary administration rather than deployment configuration, gated by a permission of its own, so answering the telephone can be delegated without handing anybody the rest of the settings.
GET/POST /api/admin/telephony/numbersPATCH/DELETE /api/admin/telephony/numbers/{id}
| Field | Type | Default | Constraint |
|---|---|---|---|
e164 | string | – (required) | Spaces, dashes, brackets and dots are stripped and a leading 00 becomes +; the result must be + then 7 to 15 digits, not starting 0. Must be unique |
agent_id | uuid | – (required) | Must exist and not be archived |
owner_user_id | uuid | – (required) | Must exist and not be deactivated |
provider | string | twilio | twilio or audiosocket |
label | string | – | Your own reference |
greeting | string | – | Spoken before the notice |
notice | string | – (standard wording) | At most 600 characters |
transcript_days | int | 0 | 0 to 3650; 0 keeps indefinitely |
log_days | int | 0 | 0 to 3650; 0 keeps indefinitely |
record_calls | bool | false | See Recording |
recording_days | int | 0 | 0 to 3650, and must be above 0 when record_calls is on |
enabled | bool | false | A new line arrives switched off |
deliver_group_chat_id | uuid | – | Must be a chat the line's own account belongs to |
transfer_e164 | string | – | Same normalisation as e164 |
PATCH takes the same fields and keeps anything absent. Three of them distinguish absent from an
explicit null, which is how you clear rather than change: notice (back to the standard wording),
deliver_group_chat_id (stop announcing) and transfer_e164 (stop transferring).
A caller has no account and is not signed in, so what they can reach is exactly what the answering agent can reach: its tools and its Libraries, and nothing else. Register a line to an ordinary account rather than an administrator's, because an administrator can read every Library and the line would inherit that. Each line is listed with the number of tools its agent holds, for exactly this reason.
Switching a line off is the reversible way to stop it answering. Releasing it removes it for good, and the calls it took stay in the log. A call to a number you have not registered and a call to one you have switched off are turned away identically, so nobody outside can learn which of your numbers are live.
What every caller hears
The opening is composed, not stored: the line's greeting, then its notice, then the recording sentence if the line records. The interface shows the exact sentence a caller will hear as you type it, and a line that records has no way to be configured without saying so.
The standard notice, used unless a line has its own:
You are speaking to an automated assistant. What you say is written down so that your enquiry can be dealt with, and a member of staff may read it. If you would rather speak to a person, please say so. How can I help you today?
A recording line adds This call is recorded. as its own sentence, after the first sentence of whatever notice is in force.
Two properties worth knowing. The notice is uninterruptible: a caller who talks across it is
neither cut off nor answered underneath it, and their words go nowhere until it has finished. And it
is fail-closed: if synthesis is unavailable the call ends rather than continuing in silence, and
is recorded with the outcome notice_failed.
Recording
Off until you switch it on, per line, and the whole conversation from the moment the call is answered.
| Thing | Value |
|---|---|
| Where files live | storage.recordings_dir, default ./data/recordings |
| File name | <call_id>.wav |
| Format | Two channels, caller and line, 8 kHz companded, about a megabyte a minute |
| Retention | recording_days on the line, compulsory and above zero |
| Play | GET /api/telephony/calls/{id}/recording, served as ordinary PCM |
| Delete | DELETE /api/telephony/calls/{id}/recording |
Only the account whose line took the call may listen, or an administrator of the deployment; anybody
else is refused as 404 rather than 403. Every listen is written to the audit trail as
telephony.recording.played: hearing a member of the public speak is an act rather than a page view.
A recording that fails to be written does not end the call. The notice is consent to being recorded
rather than a promise of a file, so the call is marked recording_failed and carries on. And a
recording ends where your side of the call does: once a caller has been put through to a person, the
conversation is between them and the telephone network.
Retention and erasure
Three independent periods, all 0 by default, and 0 means keep indefinitely.
| Period | Removes | Leaves |
|---|---|---|
transcript_days | The conversation | The call in the log, marked tidied away |
log_days | The call record itself | Nothing |
recording_days | The audio, from disk | The call and its conversation |
The sweep runs daily at 04:15 (0 15 4 * * *) and is a no-op unless a line has been given a
period. It audits telephony.retention.swept with counts only: naming each row would keep in the
trail exactly what the sweep exists to remove. It also removes orphaned recordings, files whose
call row has gone through an erasure, which would otherwise be audio nothing in the product could
see or delete.
By hand, for the moment somebody asks and will not wait for the nightly sweep:
DELETE /api/telephony/calls/{id}/transcript– takes the recording with it.DELETE /api/telephony/calls/{id}/recording– the sound alone.
Messages and enquiries, appointments, and your screening list are never deleted by any of these. They are the practice's own records rather than a by-product of a call, and each simply lets go of its reference to the call when the call goes.
The diary
Per account, at GET/PUT /api/diary, readable and writable by that account or an administrator.
| Field | Type | Notes |
|---|---|---|
timezone | string | An IANA zone, for example Europe/London. Must resolve |
slot_minutes | int | How long an appointment is |
lead_minutes | int | How soon from now one may be booked |
horizon_days | int | How far ahead |
enabled | bool | Off means the line does not offer times |
hours | list | Opening periods, replaced wholesale on each write |
Each opening period is weekday (0 is Monday), opens_minute and closes_minute, both minutes
from midnight local. Two periods on a day is how a lunch break is written; a day with none is a day
you are shut. Periods on one day may not overlap, and closes_minute must be above opens_minute
and at most 1440.
Single days close on top of that at POST/DELETE /api/diary/closures, taking closed_on as
YYYY-MM-DD and an optional note.
Appointments live at GET/POST /api/diary/appointments and cancel at
DELETE /api/diary/appointments/{id}. A booking takes starts_at as an RFC 3339 instant that must
fall in an open slot, plus caller_name, subject and an optional contact; ends_at is derived
from slot_minutes and the reference is generated. Two callers cannot take one slot: the second is
refused outright and told the time has just gone.
Opening hours are kept in your own local time and turned into actual moments with the time-zone database, which is what makes the two awkward hours behave: the hour that does not exist when the clocks go forward is never offered, and the hour that happens twice when they go back is offered once. Appointments are shown in the diary's own zone everywhere they appear, so an administrator in another country is never quietly shown a different time from the one the caller was told.
Changing an appointment by telephone needs two independent things: the reference, and either the number being rung from or the name it was booked under. Three attempts to a call, and a refusal never says which half was wrong.
Screening callers
A list of names an account checks callers against before offering them anything, at
GET/POST/DELETE /api/conflict-names.
Paste it as it comes out of your own system, one name per line, with an optional note applied to every name in that request. At most 2000 names a request, each truncated at 200 characters, already-present names skipped. Spelling, punctuation, titles, company endings and word order are all ignored when a caller is checked.
Three verdicts, and only one lets a call go further. Clear means nothing on your list resembles the caller. A match means a person has to handle it. A caller who will give only a surname is neither: one word identifies nobody, so it counts as not checked.
While an account keeps a list, the check is enforced: a call on its lines cannot be put through to anybody until it has been checked and found clear, and never having been checked counts as not clear. An account with no list is unaffected. The list is never read out and the caller is never told a check happened, because telling somebody they are on your records tells them the practice is involved in a matter concerning them, and they may be the other side of it.
Somebody who may register telephone numbers sees that a list exists and how many names are on it, and cannot read them.
Telling somebody outside
Destinations at GET/POST/PATCH/DELETE /api/notify-targets, plus
POST /api/notify-targets/{id}/test for a live send.
| Field | Values |
|---|---|
kind | slack, teams, webhook |
events | message_taken, appointment_booked, appointment_moved, appointment_cancelled |
url | Stored encrypted and never returned; a saved destination shows only the host it points at |
enabled | Defaults to on |
What leaves is who rang and what it is about, capped at 300 characters, and never a word of what was said: the people in a channel are not necessarily the people entitled to read what a caller dictated. Nothing is sent until an administrator switches outward notifications on, and every attempt passes the same egress gate as any other connector and is recorded.
Deciding to tell a channel and actually posting to it are separate, so the call never waits on a chat service and a service that is down is retried rather than forgotten.
Tools on a call
Tools that reach an outside system already reached a call. What made them unusable was approval: any such tool is held for a person, which is right in front of a screen and wrong on a telephone, where nobody is watching an approval in the middle of somebody else's call.
So on a call nothing waits for a person. The decision is made beforehand: a connected server or a tool definition is marked as usable during a call or it is not. An unmarked side-effecting tool is refused with something the agent can say aloud, and is told to offer to take a message instead. Reading is unaffected and runs as it does anywhere else.
Every tool used on a call has a ceiling, past which the caller is told the same thing as a refusal and the conversation carries on:
| Setting | Default | Accepted | Where |
|---|---|---|---|
telephony.tool_timeout_secs | 8 | 1 to 60 | Admin → Config only |
That one is not surfaced in the telephone panel; it is reachable through the generic configuration
editor, permission config.manage.
Tuning how it listens
A telephone is not a quieter browser tab: it carries a narrow band of frequencies and a constant hiss, its echo cancellation belongs to the network, and it shows the caller nothing, so a pause with no reply reads as a dropped call. Calls therefore have their own layer of the speech dials.
A voice.phone.* setting that is not set follows the shared voice.* one, so a change made for
the browser still reaches a line unless the line has been given its own.
| Dial | Telephone | Browser | Range |
|---|---|---|---|
voice.phone.silence_threshold_ms | 900 | 1500 | 200 to 2000 |
voice.phone.min_speech_ms | 250 | 200 | 0 to 2000 |
voice.phone.turn_detection | true | false | – |
voice.phone.speech_rms | 0.012 | 0.012 | 0 to 1, clamped on read |
voice.phone.barge_rms | 0.035 | 0.035 | 0 to 1, clamped on read |
voice.phone.barge_min_ms | 240 | 320 | 100 to 2000 |
Semantic turn detection is on for calls and off for the browser, because without a screen there is nothing to tell a caller that a mid-thought pause is being waited out. It needs the turn-detection service reachable; a service that stops answering is not consulted again for the rest of that call, and no detector may hold a single turn beyond four times the configured pause.
The two loudness gates keep their browser values on a telephone deliberately. They were chosen for a microphone, and where speech sits relative to a telephone line's background noise has not been measured, so the deployment publishes the distributions to set them from rather than shipping an invented number.
Settings reference
Boot configuration
Read from the config file or the environment. Every TOML path a.b maps to PAI__A__B.
| Setting | Environment variable | Default |
|---|---|---|
telephony.provider | PAI__TELEPHONY__PROVIDER | none |
telephony.public_base_url | PAI__TELEPHONY__PUBLIC_BASE_URL | – (falls back to server.public_url) |
voice_live.tts_stream | PAI__VOICE_LIVE__TTS_STREAM | false |
voice_live.tts_stream_url | PAI__VOICE_LIVE__TTS_STREAM_URL | – |
voice_live.stt_sample_rate | PAI__VOICE_LIVE__STT_SAMPLE_RATE | 16000 |
voice_live.stt_stream_kind | PAI__VOICE_LIVE__STT_STREAM_KIND | none |
storage.recordings_dir | PAI__STORAGE__RECORDINGS_DIR | ./data/recordings |
message_encryption_key | PAI__MESSAGE_ENCRYPTION_KEY | – (required before any credential) |
Runtime settings
In the database, changed with no restart except where noted. A runtime value wins over the boot one.
| Key | Type | Default | Stored encrypted |
|---|---|---|---|
telephony.provider | string | boot value, else none | no |
telephony.public_base_url | string | boot value, else server.public_url | no |
telephony.max_concurrent_calls | int | 2 | no |
telephony.audiosocket_listen | string | – (bound at start, needs a restart) | no |
telephony.auth_token_enc | string | – | yes |
telephony.audiosocket_key_enc | string | – | yes |
telephony.tool_timeout_secs | int | 8 | no |
features.telephony | bool | boot value, else false | no |
An encrypted credential that cannot be decrypted is treated as absent, which fails the line closed.
Permissions
| Permission | Covers |
|---|---|
| break-glass super-admin | The settings themselves and the readiness check |
telephony.manage | Register numbers, bind each to an agent and an account, read the call log |
voice.manage | The live-voice engines a call speaks and listens with |
config.manage | telephony.tool_timeout_secs, the feature flags, the voice.phone.* dials |
| none, owner or administrator | Diary, screening list, notification targets, enquiries, a call's transcript and recording |
Endpoints
| Method | Path | Gate |
|---|---|---|
| GET, PUT | /api/admin/telephony | break-glass |
| GET | /api/admin/telephony/preflight | break-glass |
| GET, POST | /api/admin/telephony/numbers | telephony.manage |
| PATCH, DELETE | /api/admin/telephony/numbers/{id} | telephony.manage |
| GET | /api/admin/telephony/calls | telephony.manage |
| GET | /api/admin/telephony/check | telephony.manage |
| GET | /api/admin/telephony/enquiries | telephony.manage |
| GET, PATCH | /api/enquiries, /api/enquiries/{id} | owner or administrator |
| GET, POST, DELETE | /api/conflict-names | owner or administrator |
| GET, PUT | /api/diary | owner or administrator |
| POST, DELETE | /api/diary/closures | owner or administrator |
| GET, POST | /api/diary/appointments | owner or administrator |
| DELETE | /api/diary/appointments/{id} | owner or administrator |
| GET, POST, PATCH, DELETE | /api/notify-targets | owner or administrator |
| POST | /api/notify-targets/{id}/test | owner or administrator |
| GET | /api/telephony/compliance | owner or administrator |
| DELETE | /api/telephony/calls/{id}/transcript | owner or administrator |
| GET, DELETE | /api/telephony/calls/{id}/recording | owner or administrator, every listen audited |
| POST | /api/telephony/twilio/voice, /status, /continue | carrier signature |
| GET | /api/telephony/twilio/media | single-use ticket |
| GET | /api/telephony/audiosocket/answer, /continue | shared secret and a private source address |
The last five are absent entirely, answering 404, unless the feature is on and a provider is named.
The call log filters on number_id and outcome, and pages with before and limit (50 by
default, 200 at most). An outcome is one of completed, carrier_ended, dropped, no_media,
line_full, transferred, notice_failed, or in_progress.
Limits
| Limit | Value |
|---|---|
| Calls at once | telephony.max_concurrent_calls, default 2 |
| Calls from one number | 5 in 5 minutes |
| Calls to one line | 60 a minute |
| Signature failures from one address | 30 a minute |
| Ticket lifetime | 30 seconds, redeemed once |
| Connection silent before it is given up on | 5 seconds |
| Identifier not presented on connecting | 5 seconds |
| Carrier webhook body | 16 KiB |
| Media frame and message | 32 KiB |
| Notice | 600 characters |
| Any retention period | 3650 days |
| Screening names per request | 2000, each 200 characters |
| Outward notification line | 300 characters |
When a call does not work
Every refusal is written to the audit trail as telephony.refused with a reason. Find the reason
first; it names the cause exactly.
| Reason in the audit trail | What happened | Fix |
|---|---|---|
unknown_number | No line is registered on the number that was rung | Register it, minding the E.164 form |
line_disabled | The line exists and is switched off | Switch it on |
agent_unavailable | The agent answering it has been archived | Bind the line to a live agent |
owner_unavailable | The account it runs as is gone or deactivated | Bind the line to a live account |
voice_not_enabled | features.voice or features.voice_live is off for that account | Switch both on |
no_streaming_synthesiser | No streaming synthesiser is configured | Set voice_live.tts_stream and its URL, then run the readiness check |
unsupported_rate | Recognition is set to a rate a telephone cannot reach | Use 8000 or 16000 Hz |
rate_from | That number has rung 5 times in 5 minutes | Expected under a dialplan loop; check the far end |
rate_to | The line took 60 calls in a minute | Raise nothing: this is the guard working |
concurrency | telephony.max_concurrent_calls is full | Raise it, mindful that each call costs |
lookup_failed | The database could not be asked | Fails closed by design; check the deployment |
public_source | Your telephone system asked from a public address | These questions are accepted only from your own network |
ticket_unavailable | The identifier could not be minted | Redis is unreachable |
And the symptoms that leave no refusal:
| Symptom | Likely cause |
|---|---|
| The line rings and never answers | The public address does not match the carrier's byte for byte, so the signature fails |
| Everything is set but nothing is listening | The listen address is bound at process start; restart, then check nothing else holds the port |
| The caller hears the notice then silence | Recognition is unreachable; the notice is synthesised, the reply is not |
| The call ends immediately after the notice | Outcome notice_failed: synthesis was unavailable and the line will not carry a call it cannot speak on |
| An agent refuses something aloud that works elsewhere | The server or tool is not marked as usable during a call |
| A caller is never put through | The account keeps a screening list and the caller was not checked and found clear |
| Recordings are missing | record_calls is off, or the call is marked recording_failed |
Next steps
- Voice for the engines a call speaks and listens with.
- Configuration and env vars for the settings split.
- Agents and workflows for bounding what a caller can reach.
- Super-admin for minting the break-glass grant the settings need.