Fosniedocsv0.6

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.voice and features.voice_live switched 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_stream and voice_live.tts_stream_url, or the matching runtime settings;
  • message_encryption_key set. 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, default 16000). 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:

SettingEnvironment variableDefault
features.telephonyPAI__FEATURES__TELEPHONYfalse
features.voicePAI__FEATURES__VOICEfalse
features.voice_livePAI__FEATURES__VOICE_LIVEfalse

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

  1. Set what answers a call to a telephone carrier. This writes telephony.provider = "twilio".
  2. Set the public address of this deployment, beginning with https and reachable from the internet, for example https://calls.example.com. This writes telephony.public_base_url; empty falls back to server.public_url.
  3. 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.
  4. In your carrier console, point the number at these addresses, all relative to the public address you just set:
PurposeMethodPath
What to do with an incoming callPOST/api/telephony/twilio/voice
Where the audio connectsGET/api/telephony/twilio/media
Call status callbacksPOST/api/telephony/twilio/status
Anybody to ring once our side finishesPOST/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

  1. Set what answers a call to this practice's own telephone system (telephony.provider = "audiosocket").
  2. Set the listen address, an address and port on your own network, for example 0.0.0.0:9092. This writes telephony.audiosocket_listen; empty opens no port at all.
  3. Store a shared secret and put the same value in your telephone system's call routing.
  4. Restart the deployment. The listen address is taken up when the process starts, so a change here does nothing until it does.
  5. Put the following in your telephone system's dialplan, replacing YOUR-SECRET, this-deployment, the port and your-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 – permission telephony.manage, beside the lines, for whoever registers numbers without configuring the carrier.
idAsksA failure means
featureIs the telephone switched on for this deployment?The whole telephone surface is absent
providerIs something named that can answer?No call can be taken
carrier_credentialIs the carrier's credential stored?Every call will be refused unsigned
public_addressCan the carrier reach this deployment?The carrier is not told where to send audio
listen_addressIs a port bound for your telephone system right now?Set but not listening usually means no restart since
shared_secretCan your telephone system identify itself?Every request will be refused
message_keyCan credentials be stored safely at all?No telephone credential can be stored
recognition_rateCan recognition take what a telephone carries?Only 8000 and 16000 Hz convert
synthesiserDoes the speech engine actually answer?The line picks up, cannot speak, and ends the call
linesIs there a number registered and switched on?Nothing answers
line_bindingsDoes 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/numbers
  • PATCH/DELETE /api/admin/telephony/numbers/{id}
FieldTypeDefaultConstraint
e164string– (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_iduuid– (required)Must exist and not be archived
owner_user_iduuid– (required)Must exist and not be deactivated
providerstringtwiliotwilio or audiosocket
labelstringYour own reference
greetingstringSpoken before the notice
noticestring– (standard wording)At most 600 characters
transcript_daysint00 to 3650; 0 keeps indefinitely
log_daysint00 to 3650; 0 keeps indefinitely
record_callsboolfalseSee Recording
recording_daysint00 to 3650, and must be above 0 when record_calls is on
enabledboolfalseA new line arrives switched off
deliver_group_chat_iduuidMust be a chat the line's own account belongs to
transfer_e164stringSame 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.

ThingValue
Where files livestorage.recordings_dir, default ./data/recordings
File name<call_id>.wav
FormatTwo channels, caller and line, 8 kHz companded, about a megabyte a minute
Retentionrecording_days on the line, compulsory and above zero
PlayGET /api/telephony/calls/{id}/recording, served as ordinary PCM
DeleteDELETE /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.

PeriodRemovesLeaves
transcript_daysThe conversationThe call in the log, marked tidied away
log_daysThe call record itselfNothing
recording_daysThe audio, from diskThe 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.

FieldTypeNotes
timezonestringAn IANA zone, for example Europe/London. Must resolve
slot_minutesintHow long an appointment is
lead_minutesintHow soon from now one may be booked
horizon_daysintHow far ahead
enabledboolOff means the line does not offer times
hourslistOpening 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.

FieldValues
kindslack, teams, webhook
eventsmessage_taken, appointment_booked, appointment_moved, appointment_cancelled
urlStored encrypted and never returned; a saved destination shows only the host it points at
enabledDefaults 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:

SettingDefaultAcceptedWhere
telephony.tool_timeout_secs81 to 60Admin → 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.

DialTelephoneBrowserRange
voice.phone.silence_threshold_ms9001500200 to 2000
voice.phone.min_speech_ms2502000 to 2000
voice.phone.turn_detectiontruefalse
voice.phone.speech_rms0.0120.0120 to 1, clamped on read
voice.phone.barge_rms0.0350.0350 to 1, clamped on read
voice.phone.barge_min_ms240320100 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.

SettingEnvironment variableDefault
telephony.providerPAI__TELEPHONY__PROVIDERnone
telephony.public_base_urlPAI__TELEPHONY__PUBLIC_BASE_URL– (falls back to server.public_url)
voice_live.tts_streamPAI__VOICE_LIVE__TTS_STREAMfalse
voice_live.tts_stream_urlPAI__VOICE_LIVE__TTS_STREAM_URL
voice_live.stt_sample_ratePAI__VOICE_LIVE__STT_SAMPLE_RATE16000
voice_live.stt_stream_kindPAI__VOICE_LIVE__STT_STREAM_KINDnone
storage.recordings_dirPAI__STORAGE__RECORDINGS_DIR./data/recordings
message_encryption_keyPAI__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.

KeyTypeDefaultStored encrypted
telephony.providerstringboot value, else noneno
telephony.public_base_urlstringboot value, else server.public_urlno
telephony.max_concurrent_callsint2no
telephony.audiosocket_listenstring– (bound at start, needs a restart)no
telephony.auth_token_encstringyes
telephony.audiosocket_key_encstringyes
telephony.tool_timeout_secsint8no
features.telephonyboolboot value, else falseno

An encrypted credential that cannot be decrypted is treated as absent, which fails the line closed.

Permissions

PermissionCovers
break-glass super-adminThe settings themselves and the readiness check
telephony.manageRegister numbers, bind each to an agent and an account, read the call log
voice.manageThe live-voice engines a call speaks and listens with
config.managetelephony.tool_timeout_secs, the feature flags, the voice.phone.* dials
none, owner or administratorDiary, screening list, notification targets, enquiries, a call's transcript and recording

Endpoints

MethodPathGate
GET, PUT/api/admin/telephonybreak-glass
GET/api/admin/telephony/preflightbreak-glass
GET, POST/api/admin/telephony/numberstelephony.manage
PATCH, DELETE/api/admin/telephony/numbers/{id}telephony.manage
GET/api/admin/telephony/callstelephony.manage
GET/api/admin/telephony/checktelephony.manage
GET/api/admin/telephony/enquiriestelephony.manage
GET, PATCH/api/enquiries, /api/enquiries/{id}owner or administrator
GET, POST, DELETE/api/conflict-namesowner or administrator
GET, PUT/api/diaryowner or administrator
POST, DELETE/api/diary/closuresowner or administrator
GET, POST/api/diary/appointmentsowner or administrator
DELETE/api/diary/appointments/{id}owner or administrator
GET, POST, PATCH, DELETE/api/notify-targetsowner or administrator
POST/api/notify-targets/{id}/testowner or administrator
GET/api/telephony/complianceowner or administrator
DELETE/api/telephony/calls/{id}/transcriptowner or administrator
GET, DELETE/api/telephony/calls/{id}/recordingowner or administrator, every listen audited
POST/api/telephony/twilio/voice, /status, /continuecarrier signature
GET/api/telephony/twilio/mediasingle-use ticket
GET/api/telephony/audiosocket/answer, /continueshared 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

LimitValue
Calls at oncetelephony.max_concurrent_calls, default 2
Calls from one number5 in 5 minutes
Calls to one line60 a minute
Signature failures from one address30 a minute
Ticket lifetime30 seconds, redeemed once
Connection silent before it is given up on5 seconds
Identifier not presented on connecting5 seconds
Carrier webhook body16 KiB
Media frame and message32 KiB
Notice600 characters
Any retention period3650 days
Screening names per request2000, each 200 characters
Outward notification line300 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 trailWhat happenedFix
unknown_numberNo line is registered on the number that was rungRegister it, minding the E.164 form
line_disabledThe line exists and is switched offSwitch it on
agent_unavailableThe agent answering it has been archivedBind the line to a live agent
owner_unavailableThe account it runs as is gone or deactivatedBind the line to a live account
voice_not_enabledfeatures.voice or features.voice_live is off for that accountSwitch both on
no_streaming_synthesiserNo streaming synthesiser is configuredSet voice_live.tts_stream and its URL, then run the readiness check
unsupported_rateRecognition is set to a rate a telephone cannot reachUse 8000 or 16000 Hz
rate_fromThat number has rung 5 times in 5 minutesExpected under a dialplan loop; check the far end
rate_toThe line took 60 calls in a minuteRaise nothing: this is the guard working
concurrencytelephony.max_concurrent_calls is fullRaise it, mindful that each call costs
lookup_failedThe database could not be askedFails closed by design; check the deployment
public_sourceYour telephone system asked from a public addressThese questions are accepted only from your own network
ticket_unavailableThe identifier could not be mintedRedis is unreachable

And the symptoms that leave no refusal:

SymptomLikely cause
The line rings and never answersThe public address does not match the carrier's byte for byte, so the signature fails
Everything is set but nothing is listeningThe listen address is bound at process start; restart, then check nothing else holds the port
The caller hears the notice then silenceRecognition is unreachable; the notice is synthesised, the reply is not
The call ends immediately after the noticeOutcome notice_failed: synthesis was unavailable and the line will not carry a call it cannot speak on
An agent refuses something aloud that works elsewhereThe server or tool is not marked as usable during a call
A caller is never put throughThe account keeps a screening list and the caller was not checked and found clear
Recordings are missingrecord_calls is off, or the call is marked recording_failed

Next steps

Was this page helpful?

On this page