Bot development
Voice bots
When someone calls a bot from the app, an AI voice model answers. How to turn it on, instructions and voices, call updates, tools, MCP servers, own keys, transcripts, stream mode, the Apple Watch and keeping the conversation โ a guide for bot owners.
A voice bot is a bot that people can call from the app. The call is answered not by the bot owner's code but by Pabal's AI voice model; the bot owner uses the Bot API to set the instructions, greeting, voice, language and limits the model follows. You get a voice_call update each time a call starts and ends, and during a call the model can call the bot's code (tools) to look up an order or check a booking. There is also stream mode, in which your own server receives the raw audio instead of the AI model. If this is your first bot, read Build a bot first.
Current status
You can call a voice bot only on a server where all of the conditions below are met. The methods exist even on servers where voice is off, so you can read the settings. But until voice is turned on for this server and the operator has allowed the bot, no setting is saved (403 VOICE_NOT_AVAILABLE) except ones that turn something off: turning voice off, turning transcripts off, turning stream mode off, deleting your own key, deleting an MCP server. Whether your bot can take calls right now is shown by available and unavailable_reason in getVoiceCallSettings.
| Feature | To turn on (all required) | pabal.me now |
|---|---|---|
| Voice-bot calls | The server has a voice gateway and a call relay (TURN) attached ยท the privacy policy's bot-call section (bot-calls) is published ยท the operator has allowed the bot ยท before public launch, only testers chosen by the operator can call ยท the caller's app is a build with the confirmation sheet (desktop pabal-bc2 โ while the confirmation sheet is only in Korean and English, pabal-bc1 is accepted too; Android 62151; iPhone build 5 or later; Apple Watch app build 1 or later) โ no such build has been released yet | Off |
| Calling from the Apple Watch | Voice-bot calls + the server's watch API + the watch audio edge (pabal-voice-edge) + a gateway that takes direct calls (direct) ยท a watch app build of TELEGRAM_VOICE_MIN_BUILD_WATCHOS or later ยท bots that use tools, MCP servers, an own key, stream mode or the bot owner's transcripts don't work yet | Off โ being tested on a development server |
| Keeping the conversation (the caller's copy) | Voice-bot calls + the policy's bot-calls-keep-text section + a gateway that announces keep_text (0.9.0) + an app build with the checkbox ยท legal review of the confirmation sheet's wording | Off โ awaiting legal review |
| Calls from bots | The privacy policy's bot-calls-outbound section and the terms of service's voice-bot-owners-outbound section are published (and the voice-bot-owners section, for owners to accept terms version 4) ยท the operator's global switch, after the legal review ยท the bot owner turns it on in @BotFather (terms version 4, purposes) ยท the operator's review of the bot ยท the callee's permission (an app build with the permission sheet) ยท a gateway with the approved outbound notice recordings ยท a bot language of Korean or English | Off โ waiting for the legal review |
| Bot languages other than Korean and English (7) | Voice-bot calls + a gateway that takes calls in the bot's language (from 0.7.0 on it takes all nine) ยท the operator has read the bot's instructions and greeting and allowed it (even on a server that takes every bot) | Off โ not yet deployed |
| Confirmation sheets and notices in languages other than Korean and English (8 languages) | Each language ships in the app builds and the gateway image only after its legal review and a native speaker's check. Until then, callers who use the app in that language also see the English confirmation sheet and hear the English notices | Drafts โ not yet reviewed |
| Bot owner terms (version 4) | The bot-owner section of the terms of service (voice-bot-owners) is published. Transcripts, own keys, stream mode, MCP servers and calls from bots all need these terms first | Cannot be accepted |
| Tools | Voice-bot calls + a gateway that supports tools | Off |
| MCP servers | The MCP components ยท a dedicated egress address (an operating condition โ the code does not check it) ยท the policy's bot-calls-mcp section and the terms' bot-owner section ยท acceptance of the bot owner terms ยท an app build that shows the bot's MCP recipients on the confirmation sheet (until then, registering works but no call carries MCP tools) | Off |
| Own model key | The server's master key ยท the operator turns it on after the apps ship the confirmation sheet (version 2) that shows an own-key bot's recipient ยท the bot owner terms ยท a gateway that runs owners' keys (by default, Google keys only) | Off โ keys are not even accepted |
| Transcripts | The operator allows them on the admin page after a legal review (off by default) ยท the bot owner terms ยท the bot owner's switch ยท a gateway with the transcript notice audio ยท a managed call | Off |
| Stream mode | The server's master key ยท the policy's bot-calls-stream section and the terms' bot-owner section ยท a gateway that takes stream calls ยท an app build with the stream confirmation sheet (none yet) | Off |
- The caller (the person making the call) must be 19 or older, sees the "An AI answers this call" confirmation sheet before calling, and hears an AI notice first once connected (What callers see). Apps without the confirmation sheet (including the original Telegram apps and clients such as Telethon) have no call button for the bot.
- The audio of a managed call goes to Google โ the Gemini Live API in Vertex AI, in Iowa, USA (Google Cloud
us-central1). Pabal does not store audio. Google keeps neither audio nor text after producing its answer, but content flagged as possible abuse by automated checks may be logged for up to 90 days (the confirmation sheet says the same). This is the only engine in production, and calls are never quietly passed to another provider (no automatic failover). - Unlike calls between people, bot calls are not end-to-end encrypted: Pabal's voice gateway decrypts the audio and hands it to the AI model. The caller's confirmation sheet says so.
How a call flows
About this diagram
- Three areas: the caller's app โ the Pabal operator's server (the Pabal server, the voice gateway, the relay server) โ outside Pabal (Google's AI model, the bot owner's program). The bot owner's code never sees a single byte of audio.
- โ Call: the app reports the consent given on the confirmation sheet to the server, then places the call. The server checks both the caller (consent โ self-confirmed 19 or older ยท app build ยท tester ยท limits) and the bot (allowed ยท settings ยท limits), and either takes the call or ends it with a busy signal.
- โก Link: the server hands the call to the voice gateway along with the bot owner's instructions, greeting, voice, language and tools. The gateway always puts Pabal's base rules (such as saying it is an AI) first and the instructions after them.
- โข Bot API: the bot owner's program sets the settings, receives
voice_callwhen a call starts and ends, and receives and answersvoice_call_tool_querywhen the model calls a tool. The blue arrow at โข is what the server sends to the bot; the gray arrow below it is what the bot sends to the server. - โฃ Audio (bottom row): the app and the gateway exchange audio only through the relay server (coturn), without knowing each other's address, and the gateway passes the audio to Google's model and gets the replies back. The red box is where audio leaves Pabal.
- In stream mode, the bot owner's server takes Google's place at โฃ (Stream mode).
Calls from the Apple Watch (coming soon)
The watch app doesn't use MTProto. The watch signs in, gets the bot list and the confirmation sheet, and places calls through the server's watch API (HTTPS JSON). The server runs the same admission checks as for an app call, then gives the watch a one-time ticket (15 seconds) usable only for that call, and hands the call to a gateway. The watch takes that ticket to the audio edge (WSS, pabal-voice-edge), and the edge connects the audio to the gateway worker handling that call. There is no key exchange (DH) and no call relay (coturn). The gateway still decodes the audio and hands it to the model, so the call is not end-to-end encrypted, and the audio goes to the same place. The bot owner gets the same voice_call as for an app call (only the moment of started differs, below).
Turning on voice calls
Voice calls are turned on by a person, in @BotFather โ agreeing to the voice-bot rules is the bot owner's promise, not the token's. Everything else can be set either in @BotFather or with the Bot API.
- Ask the operator to allow the bot. On a server that takes only allowed bots (the default), @BotFather has no voice-call button and changing settings gives
403 VOICE_NOT_AVAILABLEuntil the operator allows the bot by its bot ID (the number before the:in the token) in the admin page's voice tab. Before public launch, also ask for the account you'll make test calls from to be added as a tester (your user ID ismessage.from.idingetUpdatesafter you send the bot a message). - Write the instructions. This is the text that says what the bot does on the phone. Set it with
setVoiceCallSettingsbelow, or send it with the Instructions (์ง์๋ฌธ) button on @BotFather's voice-call screen. Without instructions it cannot be turned on (except in stream mode). - In @BotFather:
/mybotsโ the bot โ Voice calls (์์ฑ ํตํ) โ Turn on (์ผ๊ธฐ). The first time, the voice-bot rules appear; press Agree to the rules and turn on (๊ท์น์ ๋์ํ๊ณ ์ผ๊ธฐ) to turn it on. After that it only asks "Turn it on?" (์ผค๊น์?). - Wait to be allowed again. On a server that takes only allowed bots, allowing a bot allows the instructions, greeting and tools as they were at that moment, so writing instructions after being allowed gives
unavailable_reason: "awaiting_review". The bot takes calls again only after the operator reviews and allows it again. The same happens every time you change any of the three (in stream mode, the stream address). If the bot's language is neither Korean nor English, the operator must read the instructions and greeting and allow the bot before it takes calls, even on a server that takes every bot (Voices and languages). - Check that
availableingetVoiceCallSettingsistrue, then try calling with the call button on the bot's profile in an app build with the confirmation sheet.
BotFather replies in Korean. The rules above say: 1. the bot always says it is an AI and cannot be made to pretend to be a person; 2. it cannot impersonate a real organization, company or person; 3. it cannot be made to ask for card numbers, resident registration numbers, passwords or verification codes; 4. Pabal does not store call audio, which is sent to Google (Vertex AI) to produce the replies; 5. only people 19 or older can make voice calls with bots; 6. if the rules are broken, the operator can suspend voice calls or the bot. After you agree, BotFather confirms that voice calls are on and shows the bot's voice-call status (state: on, voice: Kore (firm), language: Korean), and notes that when a call is answered the notice "You're now talking to an AI voice bot." plays first and then the bot greets the caller.
On a server that takes only allowed bots, the status may read "์ผ์ง โ ๋ฐ๋ ์ง์๋ฌธยท์ธ์ฌ๋ง์ ์ด์์๊ฐ ํ์ธํ ๋๊น์ง ์ ํ๋ฅผ ๋ฐ์ง ์์์" ("On โ calls are not taken until the operator reviews the changed instructions and greeting") (step 4 above). If the bot's language is neither Korean nor English, the status reads "์ผ์ง โ ํ๊ตญ์ดยท์์ด๊ฐ ์๋ ๋ด์ ์ด์์๊ฐ ์ง์๋ฌธยท์ธ์ฌ๋ง์ ์ฝ๊ณ ํ์ฉํ ๋๊น์ง ์ ํ๋ฅผ ๋ฐ์ง ์์์ (์๋ ๊ท์น ๊ฒ์ฌ๊ฐ ํ๊ตญ์ด์ ์์ด๋ง ์ฝ์ด์)" ("On โ a bot that is not in Korean or English takes no calls until the operator reads its instructions and greeting and allows it (the automatic rule check reads only Korean and English)") on every server until the operator allows the bot.
The voice-call screen has buttons for turning on and off, instructions, greeting, voice, language, limits, usage, transcripts, own key, stream mode and (on servers with MCP turned on) MCP servers. Turning off and deleting always work, even after voice is turned off or the bot is no longer allowed โ turning voice off, deleting your own key, turning stream mode off, deleting an MCP server.
The bot's name is also checked for impersonation when turning on and on every call. A bot that uses the name of a real organization or company cannot be turned on; you get VOICE_CONTENT_REFUSED: name: impersonation. If the bot really belongs to that organization, contact the server's support.
Settings โ setVoiceCallSettings
Sets the bot's voice settings. It is a partial update: only what you send changes; everything is checked first and then written at once, or all of it is rejected. If nothing changed, nothing is written.
curl -s "https://pabal.me/bot$BOT_TOKEN/setVoiceCallSettings" -H 'Content-Type: application/json' -d '{
"instructions": "You are the phone agent of the Order Helper shop. Answer politely and briefly. Opening hours are 10 am to 8 pm.",
"greeting": "Order Helper here. How can I help you?",
"voice": "kore",
"language": "ko-KR",
"max_call_duration": 180
}'
# {"ok":true,"result":true}
| Parameter | Type | Description |
|---|---|---|
enabled | Boolean | Turn on or off. Turning on requires having agreed to the rules in @BotFather (otherwise 403 VOICE_RULES_NOT_ACCEPTED), and a managed bot must have instructions. Turning off always works โ calls in progress continue to the end; only new calls are refused |
instructions | String | The instructions the model follows. Up to 8,192 bytes of UTF-8 (about 2,700 Korean characters). "" clears them โ a managed bot that is on cannot have them empty. Longer instructions make calls cost more, and callers can find them out, so do not put passwords or keys in them |
greeting | String | The first thing the bot says after the AI notice. Up to 512 bytes (about 170 Korean characters). With "" the model greets briefly on its own |
voice | String | Voice id (list), default kore |
language | String | The language the bot speaks and understands: one of nine ids (default ko-KR; see the list). zh-CN is stored as cmn-CN, and an id that differs only in case (ja-jp) is stored as listed. If it is neither Korean nor English, the bot takes calls only after the operator has read its instructions and greeting and allowed it. The AI notice at the very start is not in this language but in the language of the confirmation sheet the caller saw |
max_call_duration | Integer | The longest a call may last (seconds). At least 60 and at most limits.max_call_duration (the smaller of the operator's and the server's cap, default 600). Bot default 300 |
max_concurrent_calls | Integer | Number of calls taken at the same time. At least 1 and at most limits.max_concurrent_calls (default 2). Bot default 2 |
tools | Array | The bot's tools that the model can call during a call (tools). [] removes them all |
transcript | Boolean | The transcript switch (transcripts) |
mode, stream_url, stream_secret | String | Stream mode (setting up stream mode) |
model, api_key | Not accepted โ VOICE_FIELD_NOT_AVAILABLE. The server chooses the model, and keys are set only with setVoiceCallApiKey |
- Content rules: instructions, greetings and tool descriptions are checked when saved and on every call. Making the bot say it is a person (
claims_human), making it hide that it is an AI (hides_ai), telling it to ignore Pabal's rules (overrides_rules), impersonating a real organization or company (impersonation), presenting it as an official body (official_claim), asking for card numbers, resident registration numbers, passwords or verification codes (asks_secrets), text that looks like a secret key (looks_like_secret) and invisible characters (hidden_text) are refused withVOICE_CONTENT_REFUSED: <field>: <code>. Negative sentences such as "Never ask for passwords" pass. - Pabal's base rules always come first. On every call the model reads Pabal's rules โ the bot owner's instructions โ the closing part of Pabal's rules, in that order, and the instructions are placed inside a block marked as "the bot owner's text". Instructions cannot replace or override Pabal's rules.
- You can change the settings up to 30 times an hour (together with @BotFather; only actual changes count). Beyond that you get
429 Too Many Requests: retry after Nandparameters.retry_after. - When sending as a form (
-d name=value), puttoolsin as a JSON string. - A bot signed in only over MTProto sends the same JSON with
bots.sendCustomRequest(MTProto bots).
Current settings โ getVoiceCallSettings
curl -s "https://pabal.me/bot$BOT_TOKEN/getVoiceCallSettings"
{
"ok": true,
"result": {
"enabled": true,
"available": true,
"suspended": false,
"rules_accepted": true,
"instructions": "You are the phone agent of the Order Helper shop. โฆ",
"greeting": "Order Helper here. How can I help you?",
"voice": "kore",
"language": "ko-KR",
"max_call_duration": 180,
"max_concurrent_calls": 2,
"limits": {"max_call_duration": 600, "max_concurrent_calls": 2, "bot_minutes_per_day": 60, "owner_minutes_per_day": 120},
"voices": [{"id": "zephyr", "name": "Zephyr", "description": "Bright"}, โฆ],
"languages": ["ko-KR", "en-US", "ja-JP", "cmn-CN", "fr-FR", "es-US", "id-ID", "vi-VN", "ar-EG"],
"testers_only": true,
"mode": "managed",
"has_stream_secret": false,
"owner_terms_accepted": false,
"stream_available": false,
"updated_date": 1790380712,
"tools": [],
"tool_limits": {"max_tools": 16, "max_definition_bytes": 8192, "definition_bytes": 0, "mcp_tools": 0},
"tool_delivery": "none",
"transcript": false,
"transcript_available": false,
"transcript_delivery": "get_updates",
"has_api_key": false,
"own_keys_available": false,
"mcp_available": false
}
}
| Field | Meaning |
|---|---|
enabled | Whether the bot owner has turned it on |
available, unavailable_reason | Whether the bot can take a call right now, whoever calls, and if not, why (table below) |
rules_accepted, owner_terms_accepted | Whether the voice-bot rules and the current bot owner terms (version 4) have been accepted |
limits | The largest values you can set on this server and the daily limits (minutes) |
voices, languages | The voices and languages you can choose |
testers_only | True when voice bots are not yet public and only testers chosen by the operator can call |
tools, tool_limits, tool_delivery, tool_delivery_problem | Tools and their limits, where tool queries would go right now (webhookยทget_updatesยทmtprotoยทnone) and why they cannot get there (Receiving tool queries) |
mode, stream_url, stream_secret_fingerprint, has_stream_secret, stream_available, stream_ignores | Stream mode โ the secret is never shown, only an 8-character fingerprint |
transcript, transcript_available, transcript_unavailable_reason, transcript_delivery, transcript_delivery_problem | The transcript switch, and whether transcripts will actually be delivered now |
has_api_key, api_key, own_keys_available | Own model key โ the key is never shown, only the provider, fingerprint, date set and whether it can be used |
mcp_available, mcp_servers, mcp_unavailable_reason | MCP servers |
outbound | The state of calls from the bot (Calls from bots โ settings and usage) |
updated_date | When the settings last changed (Unix seconds) |
unavailable_reason | Meaning |
|---|---|
voice_off | Voice is off on this server, or the privacy policy's bot-call section is not there yet |
kill_switch | The operator has stopped all voice calls |
bot_voice_off | The bot owner has not turned it on |
not_allowed | The operator has not allowed this bot |
awaiting_review | The operator has not yet reviewed the changed instructions, greeting or tools (in stream mode, the stream address). If the bot's language is neither Korean nor English, the operator must read its instructions and greeting and allow it, even on a server that takes every bot |
suspended | The operator has suspended voice for this bot (or its owner's account) |
name_refused, text_refused | The bot's name, or the saved instructions, greeting or tool descriptions, do not meet the current content rules. text_refused also appears when the language the bot chose has been taken off the list and can no longer be used โ choose a language again |
no_relay, no_gateway | The server has no call relay or no voice gateway |
no_gateway_with_tools, no_own_key_gateway | No gateway can run this bot's tools or own key |
no_gateway_with_language | No connected gateway takes calls in this bot's language (Voices and languages) |
own_key_unavailable, stream_unavailable | The own key or stream mode that was set cannot be used right now, so calls are not taken (they are not answered with the platform key instead) |
Voices and languages
Choose from Gemini Live's 30 prebuilt voices (default kore). There is no way to upload or clone a voice. The Voice (๋ชฉ์๋ฆฌ) button in @BotFather lets you choose by name and character.
| id | Character | id | Character | id | Character |
|---|---|---|---|---|---|
zephyr | Bright | puck | Upbeat | charon | Informative |
kore | Firm | fenrir | Excitable | leda | Youthful |
orus | Firm | aoede | Breezy | callirrhoe | Easy-going |
autonoe | Bright | enceladus | Breathy | iapetus | Clear |
umbriel | Easy-going | algieba | Smooth | despina | Smooth |
erinome | Clear | algenib | Gravelly | rasalgethi | Informative |
laomedeia | Upbeat | achernar | Soft | alnilam | Firm |
schedar | Even | gacrux | Mature | pulcherrima | Forward |
achird | Friendly | zubenelgenubi | Casual | vindemiatrix | Gentle |
sadachbia | Lively | sadaltager | Knowledgeable | sulafat | Warm |
The language (language) is one of the nine below. It is the language the bot speaks and understands, and the bot answers in it. In @BotFather, choose it with the Language (์ธ์ด) button on the voice-call screen.
| id | Language | Good to know |
|---|---|---|
ko-KR | Korean | Default |
en-US | English | โ |
ja-JP | Japanese | โ |
cmn-CN | Chinese (Mandarin) | Outside Google's documented list of supported languages, so Google does not guarantee its quality (in our tests, both understanding and answers were accurate). zh-CN is also accepted and stored as cmn-CN |
fr-FR | French | โ |
es-US | Spanish | โ |
id-ID | Indonesian | โ |
vi-VN | Vietnamese | โ |
ar-EG | Arabic (Modern Standard) | The bot speaks Modern Standard Arabic. The code is ar-EG, the only Arabic code Google's real-time model has, but it does not set a dialect โ the bot speaks a dialect only when its instructions ask for one |
- A bot whose language is neither Korean nor English takes calls only after the operator has read its instructions and greeting and allowed it โ even on a server that takes every bot. This is because the automatic rule check at save time (pretending to be a person, impersonation and so on) reads only Korean and English. Until it is allowed, the reason given is
unavailable_reason: "awaiting_review"; if you change the instructions, greeting or tools after it is allowed, it waits to be allowed again. @BotFather says so on the language picker and after you choose: "ํ๊ตญ์ดยท์์ด๊ฐ ์๋ ์ธ์ด๋ ์๋ ๊ท์น ๊ฒ์ฌ๊ฐ ์ฝ์ง ๋ชปํด์, ์ด์์๊ฐ ์ง์๋ฌธยท์ธ์ฌ๋ง์ ์ฝ๊ณ ํ์ฉํ ๋ค์ ์ ํ๋ฅผ ๋ฐ์์." ("The automatic rule check cannot read languages other than Korean and English, so the bot takes calls only after the operator reads its instructions and greeting and allows it"). In the voice tab of the operator's admin page, the bot is shown as "ํ๊ตญ์ดยท์์ด๊ฐ ์๋ ๋ด (์๋ ๊ท์น ๊ฒ์ฌ๊ฐ ์ฝ์ง ๋ชปํจ): ์ง์๋ฌธยท์ธ์ฌ๋ง์ ์ฝ์ด ์ฃผ์ธ์" ("Not a Korean or English bot (the automatic rule check cannot read it): please read the instructions and greeting"). - The bot takes calls only if there is a gateway that takes calls in the bot's language. When a gateway connects, it tells the server which bot languages it can take calls in (from 0.7.0 on it names all nine); a gateway that names none is assumed to run only Korean and English. If no gateway takes calls in the bot's language, the reason is
no_gateway_with_languageand the call button is hidden. The call is never run in another language instead. - Ids are accepted in any case (
ja-jp) and stored as listed. A value that is not on the list is refused withVOICE_LANGUAGE_INVALID: language is one of ko-KR, en-US, ja-JP, cmn-CN, fr-FR, es-US, id-ID, vi-VN, ar-EG, which names the accepted ids. - If the language you chose is later taken off the list, the bot keeps its settings but takes no calls (
text_refused). It does not take calls in another language instead, so choose again under Language (์ธ์ด) in @BotFather. - Khmer cannot be chosen as a bot language yet. The model understands it, but no native Khmer speaker has checked its free-form answers yet.
If the caller keeps speaking another language, the model answers in that language. The AI notice is not in the bot's language but in the language of the confirmation sheet the caller saw (What callers see).
Receiving call updates โ voice_call
When someone calls the bot, a voice_call update arrives. The three voice updates (voice_call, voice_call_tool_query, voice_call_transcript) arrive only if you ask for them by name: put them in allowed_updates of getUpdates or setWebhook. An empty list does not bring them either (for the other kinds, an empty list means all of them).
curl -s "https://pabal.me/bot$BOT_TOKEN/getUpdates" -H 'Content-Type: application/json' \
-d '{"allowed_updates": ["message", "voice_call", "voice_call_tool_query"], "timeout": 30}'
{
"update_id": 7,
"voice_call": {
"id": "4611686018427387905",
"direction": "incoming",
"event": "started",
"from": {"id": 100002, "is_bot": false, "first_name": "Minji", "language_code": "ko"},
"date": 1790380803,
"max_duration": 180
}
}{
"update_id": 9,
"voice_call": {
"id": "4611686018427387905",
"direction": "incoming",
"event": "ended",
"from": {"id": 100002, "is_bot": false, "first_name": "Minji", "language_code": "ko"},
"date": 1790380952,
"start_date": 1790380803,
"duration": 149,
"max_duration": 180,
"end_reason": "caller"
}
}direction:"incoming"for a call to the bot,"outgoing"for a call the bot made (updates of calls from bots). Reading a missing field as a call to the bot, as before, is still right.allowed_updatesis one setting per bot, and the list last given togetUpdatesorsetWebhookis the one kept (if you leave it out, the previous value is kept). So if you give a list as in the example above, default kinds not in the list (edited_message,callback_query) no longer arrive โ list every kind your bot uses. Updates about calls that happened before you asked are not kept, so asking later does not bring them.- For the caller (
from), only the ID, name and app language are sent. The username and phone number are not (the confirmation sheet promises this). A deleted account appears asfirst_name: "Deleted Account". - With a webhook, these arrive by POST in order like other updates. The getUpdates queue keeps up to the latest 1,000 per bot in memory and is lost when the server restarts.
- Nothing is sent to a suspended or deleted bot.
event | When | Extra fields |
|---|---|---|
started | When the caller's app has confirmed the call (key exchange done) and the server has told the gateway the call is on. The server does not see the audio, so this is "on" as far as the server knows. For an Apple Watch call there is no key exchange, so it is when the gateway reports that the watch's audio has connected | max_duration |
ended | When a call that was on has ended. If it waits for the transcript, it arrives up to 10 seconds late. On a server with conversation keeping on, every call that isn't in stream mode arrives up to 10 seconds late whatever the caller chose โ so that the bot owner can't tell what the caller chose | start_date, duration (seconds it was on, by the server's clock), max_duration, end_reason, (if transcripts were on) transcript |
missed | A call that ended before it was on (hung up while ringing, etc.) | end_reason |
refused | Only when the caller passed every check but the call could not be taken because of the bot's side (concurrent calls, daily minutes, gateways); other refusals are not reported to the bot owner | refused_reason: busy (concurrent calls full) ยท budget (the bot's, owner's or server's daily minutes are used up) ยท unavailable (the gateways that could take it are full) |
end_reason: caller (the caller hung up) ยท bot (the bot hung up, endVoiceCall) ยท max_length (maximum length) ยท failed (connection failed) ยท operator (the operator or the kill switch) ยท suspended (the bot was suspended or deleted).
For every call handed to a gateway, a call record (the call's length or "Cancelled call") is left in the chat between the caller and the bot (calls refused before being taken leave none). The bot's copy does not arrive as a message update โ the bot's record is voice_call. If the caller chooses to keep the conversation, the conversation text is left only in the caller's chat, right below the call record, and nothing is left on the bot's side. On a server with conversation keeping on, 30 message numbers after the call record are reserved in advance, so the number of the message after a call record is at least 31 more than the record's (the same for calls whose conversation isn't kept).
During a call
Hanging up โ endVoiceCall
curl -s "https://pabal.me/bot$BOT_TOKEN/endVoiceCall" -d voice_call_id=4611686018427387905
farewell(Boolean, default true): if the call is on, the model says a short goodbye and hangs up. If it has not hung up within 10 seconds, the server ends the call. Withfalseit hangs up at once. Stream-mode calls always hang up at once.- The ended call's
voice_call.end_reasonisbot. If it is not a live call of this bot, you get400 VOICE_CALL_NOT_FOUND.
Giving the model a note โ sendVoiceCallContext
Tells the model something it should know during a call. The model reads it as the bot owner's text, not as the caller's words, and Pabal's rules still come first.
curl -s "https://pabal.me/bot$BOT_TOKEN/sendVoiceCallContext" -d voice_call_id=4611686018427387905 \
--data-urlencode "text=This caller has just cancelled order A-1042. Tell them so if they ask." -d respond=false
text: up to 2,000 characters (6,144 bytes), checked against the content rules. Withrespond=truethe model reacts right away; with the default, false, it uses the note on its next turn.- Notes also go to the model (Google, for a managed call), and the model may say them to the caller โ put in only what the caller may know.
- Before the call is connected:
400 VOICE_CALL_NOT_ACTIVE; for a stream call or a gateway that does not take notes:400 VOICE_CONTEXT_NOT_SUPPORTED; more than 10 per minute per call:429(retry_after60).
Usage โ getVoiceCallUsage
curl -s "https://pabal.me/bot$BOT_TOKEN/getVoiceCallUsage?period=today"
{"ok": true, "result": {"period": "today", "from_date": 1790348400, "to_date": 1790434799,
"calls": 3, "seconds": 412, "missed": 1,
"refused": {"busy": 0, "budget": 0, "unavailable": 0},
"platform_seconds": 412, "own_key_seconds": 0,
"remaining_seconds_today": {"bot": 3188, "owner": 6788}}}
period:today(default) ormonth. Withmonthyou can choose the month, as inmonth=2026-09(a future month givesVOICE_USAGE_PERIOD_INVALID).- Day and month boundaries follow the server's time zone (default
Asia/Seoul).remaining_seconds_todayis the daily limit minus ended calls and the time reserved by calls in progress. - Per-call usage records (without the caller) are kept for 180 days after the month ends; monthly totals stay as long as the bot exists.
Tools โ calling the bot's code during a call
When a caller asks "Where is my order A-1042?", the model calls the bot's check_order tool, the server passes the query to the bot, and the model tells the caller the bot's answer. The bot owner defines the tools, and the bot's code answers them.
About this diagram
- Three lanes: on the left the model and the voice gateway, in the middle the Pabal server, on the right the bot owner's code. The vertical dotted lines are time. The model never talks to the bot directly; every query and answer passes through the server.
- The gray box at โ โ the server checks the arguments first: whether they match the tool's
parameters, their size and depth, undeclared keys (dropped before passing on), and values that look like card numbers or resident registration numbers (these do not reach the bot, and the model hears a refusal). - What the bot receives at โก is the query id, the call id, the caller, the tool name, the arguments (a JSON string) and
budget_msโ the milliseconds left until the answer must reach the server. While waiting, the gateway fills the silence (the box on the left). - The answer at โข is checked too: anything that looks like a secret, words that would make the model break the rules, and card or resident registration numbers are refused, and the model hears an error. The first answer is final, so even a refused answer closes the query.
- At โฃ the solid blue line is the normal case and the red dashed line is a missed deadline. The model receives the tool's result only as data, never as instructions; on failure it hears one of Pabal's short phrases and explains the situation to the caller.
Defining tools
curl -s "https://pabal.me/bot$BOT_TOKEN/setVoiceCallSettings" -H 'Content-Type: application/json' -d '{
"instructions": "You are the phone agent of the Order Helper shop. Answer politely and briefly. When the caller says an order number, look up the delivery status with the check_order tool and tell them.",
"tools": [{
"name": "check_order",
"description": "Looks up the delivery status by order number. Call it when the caller says an order number.",
"parameters": {"type": "object",
"properties": {"order_id": {"type": "string", "description": "Order number, for example A-1042"}},
"required": ["order_id"]},
"timeout_ms": 5000
}]
}'
| Item | Rule |
|---|---|
| Keys | Only these four: name, description, parameters (optional), timeout_ms (optional) |
| Number and size | 16 in total, counting the bot's tools and pinned MCP tools together; the definitions (nameยทdescriptionยทparameters) add up to at most 8,192 bytes of UTF-8. The same for own-key bots |
name | [A-Za-z_][A-Za-z0-9_-]{0,63}. It cannot contain __ (used in MCP tool names), cannot start with pabal, and names must not clash, ignoring case |
description | Required, up to 1,024 bytes, checked against the content rules. The model decides when to call the tool from this, so be specific |
parameters | The top level is {"type": "object"}. Leaving it out means "no arguments" |
timeout_ms | 1,000โ15,000, default 5,000. The longest the model waits for the answer |
parameters uses only the part of JSON Schema that both engines accept.
- Only ten keywords:
type,description,enum,properties,required,items,minimum,maximum,minItems,maxItems.typeis a single one (stringยทnumberยทintegerยทbooleanยทarrayยทobject). propertiesยทrequiredonly on objects,itemsยทminItemsยทmaxItemson arrays (itemsrequired),minimumยทmaximumon numbers,enumonly on strings (1โ32 values, 64 bytes each).- Property names are
[A-Za-z_][A-Za-z0-9_]{0,63}; up to 24 properties per tool; objects up to 3 levels deep and objects and arrays together up to 5. Parameter descriptions up to 512 bytes. - Parameters that ask for secrets are refused: if the name contains a word such as
pinยทcvvยทpasswordยทotpยทssnยทrrnยทcardnumber, or the description mentions card numbers, passwords, verification codes or resident registration numbers, you getasks_secrets. - Errors report the first problem with its path:
VOICE_TOOL_INVALID: tools[0].parameters.properties.tags: items_missing,VOICE_TOOL_INVALID: tools[0].name: reserved,VOICE_CONTENT_REFUSED: tools[0].description: impersonation. - Tools are defined only with the Bot API. @BotFather's voice-call screen only shows the tool names.
- On a server that takes only allowed bots, adding or changing tools means no calls are taken until the operator allows the bot again (
awaiting_review, step 4 of turning on).
Receiving tool queries
{
"update_id": 813,
"voice_call_tool_query": {
"id": "8190448532776044545",
"voice_call_id": "4611686018427387905",
"from": {"id": 100002, "is_bot": false, "first_name": "Minji", "language_code": "ko"},
"name": "check_order",
"arguments": "{\"order_id\":\"A-1042\"}",
"budget_ms": 4700
}
}
argumentsis the text of a JSON object (at most 4,096 bytes and 8 levels deep; the server has already checked it). Even so, these are values the model made from what the caller said, so have the bot check them again. Knowing an order number must not be enough to hear someone else's order โ tell only about orders belonging tofrom.id(the caller as verified by Pabal).budget_msis the number of milliseconds from when the bot receives the query until the answer must reach the server. It is the tool'stimeout_msminus the way back to the model (200 ms) and the way to the bot (100 ms), so it is usually about 300 ms less thantimeout_ms. It does not depend on the two sides' clocks agreeing.
The way a query reaches the bot is decided in this order (getVoiceCallSettings.tool_delivery).
- If there is a webhook, through the webhook. The webhook's
allowed_updatesmust includevoice_call_tool_query; if it does not, the query is not sent any other way and never arrives (tool_delivery_problem: "not_in_allowed_updates"). Queries are separate POSTs, so they are not held up in the ordered queue, and they are not sent again. - If there is no webhook and the bot has called getUpdates within the last 15 seconds, at the front of the next getUpdates. Here too it must be in
allowed_updates. Up to 8 waiting queries per bot. - Otherwise, if the bot is connected over MTProto, as
updateBotWebhookJSONQuery(regardless ofallowed_updates). - If none of these, it does not arrive (
tool_delivery_problem: "not_listening"), and the model hears "The tool could not be reached."
Answering โ answerVoiceCallToolQuery
| Parameter | Description |
|---|---|
tool_query_id | The update's voice_call_tool_query.id |
result | The result. Text, or an object or array if the body is JSON (text that reads as a JSON object or array is also treated as JSON). Up to 16,384 bytes of UTF-8 (the operator may set it lower) |
error | The bot's own failure message, up to 512 bytes. The model passes these words on to the caller |
- Exactly one of
resultanderror(neither givesTOOL_RESULT_EMPTY, both givesTOOL_RESULT_INVALID). Returns True. - The tool's answer goes to the model (Google, for a managed call), and the model tells the caller what it says โ put in only what the caller may know.
- The first answer is final. Late or finished queries get
400 TOOL_QUERY_EXPIRED: <why>โanswered(already answered),timeout,cancelled(the caller barged in and the model withdrew it),call_ended,unreachable. An unknown id or one older than 10 minutes givesTOOL_QUERY_ID_INVALID. - Answers are checked too: invisible characters are removed, and anything that looks like a secret (
looks_like_secret), words that would make the model break the rules (overrides_rulesยทhides_aiยทclaims_humanยทasks_secrets) and card or resident registration numbers (sensitive_data) giveTOOL_RESULT_REFUSED: <code>. The bot owner's own data, such as the shop's address, phone number or URL, passes. Going over the size givesTOOL_RESULT_TOO_LONGยทTOOL_ERROR_TOO_LONG. - With a webhook you can answer in the response body โ put this in the 200 response to the tool-query POST (no other method is run). Always check the
X-Telegram-Bot-Api-Secret-Tokenheader before answering. If you put customer data in the response without checking, anyone who sends a fake query to that address gets the answer.
{"method": "answerVoiceCallToolQuery", "tool_query_id": "8190448532776044545",
"result": {"order_id": "A-1042", "status": "In transit", "eta": "Arrives tomorrow afternoon"}}
Example โ python-telegram-bot
A bot that answers the check_order tool above. It receives with getUpdates and handles queries at the same time as other updates (concurrent_updates) โ if it is slowed down handling earlier updates, budget_ms runs out. Orders are looked up by the Pabal user ID of the person who placed them, and an order is revealed only when that ID matches the query's from.id.
# order_voice_bot.py โ pip install "python-telegram-bot==22.*"
# Run: read -rs BOT_TOKEN && export BOT_TOKEN
# CUSTOMER_ID=<the Pabal user ID of the person who ordered> python3 order_voice_bot.py
# For testing, use your own ID as CUSTOMER_ID โ it is message.from.id in getUpdates after you message the bot
import json
import os
import re
from telegram import Update
from telegram.error import TelegramError
from telegram.ext import Application, ContextTypes, TypeHandler
SERVER = "https://pabal.me"
CUSTOMER = int(os.environ.get("CUSTOMER_ID", "0"))
ORDERS = { # a small table for the example: really, the shop's order system
(CUSTOMER, "A-1042"): {"status": "In transit", "eta": "Arrives tomorrow afternoon"},
}
def check_order(arguments, caller_id):
"""{"result": โฆ} or {"error": โฆ}, to pass straight to answerVoiceCallToolQuery"""
order_id = str(json.loads(arguments).get("order_id", "")).strip().upper()
if not re.fullmatch(r"[A-Z]-\d{4}", order_id): # the model made the arguments โ check them again
return {"error": "I did not catch the order number. For example: A-1042"}
found = ORDERS.get((caller_id, order_id)) # only the caller's own orders
if found is None: # someone else's order gets the same words as a missing one
return {"error": f"I could not find order {order_id}."}
return {"result": json.dumps({"order_id": order_id, **found}, ensure_ascii=False)}
async def on_update(update: Update, context: ContextTypes.DEFAULT_TYPE):
q = update.api_kwargs.get("voice_call_tool_query") # kinds PTB does not know end up in api_kwargs
if q is None:
return
if q["name"] == "check_order":
answer = check_order(q["arguments"], q["from"]["id"])
else:
answer = {"error": "I do not know that tool."}
try:
await context.bot.do_api_request("answerVoiceCallToolQuery",
api_kwargs={"tool_query_id": q["id"], **answer})
except TelegramError as e: # TOOL_QUERY_EXPIRED: too late, or the call ended
print("The answer was not accepted:", e, flush=True)
app = (Application.builder().token(os.environ["BOT_TOKEN"])
.base_url(f"{SERVER}/bot").base_file_url(f"{SERVER}/file/bot")
.concurrent_updates(True).build())
app.add_handler(TypeHandler(Update, on_update))
app.run_polling(allowed_updates=["message", "voice_call", "voice_call_tool_query"], poll_interval=0, timeout=10)
aiogram skips update kinds it does not know, so you have to pick up voice_call_tool_query from update.model_extra first, in the dispatcher's outer middleware (dp.update.outer_middleware). There is an example in the Pabal repository (scripts/bots/voice_tools_aiogram.py); ask the server's operator for it if you need it.
What the model and the caller hear
- Success: the result becomes the model's function response (a JSON value if it is JSON). Text inside the result is read only as data and never becomes instructions.
- Failure: the model hears one of Pabal's short phrases in the call's language and explains the situation to the caller โ for a missed deadline, "The tool did not answer in time."; when the tool could not be reached, "The tool could not be reached."; for arguments such as card numbers, "Pabal does not pass on card numbers or national ID numbers."; and for the bot's
error, its exact words. For wrong arguments the model gets a hint about what was wrong, so it can call again. - While waiting, the gateway fills the silence: after 1.5 seconds of quiet it says "One moment, let me check." once (right away if the tool has recently been slow), and after 5 seconds "Just a moment longer, please." once. These two phrases are in the bot's language, not the caller's, and in English when there is no reviewed recording in that language (today only Korean and English are reviewed).
- If the caller cuts in (barge-in), the gateway may withdraw open queries, and answers after that get
TOOL_QUERY_EXPIRED: cancelled.
Tool limits
At most 2 queries can be open at once per call, and 8 per bot. The model can call tools at most 30 times a minute per call and 120 times a minute per bot. Beyond that, nothing is sent to the bot, and the model immediately hears too_many_open or rate_limited.
Bot owner terms
Transcripts, own model keys, stream mode, MCP servers and calls from bots can be turned on only after agreeing to the bot owner terms (version 4). A person agrees on @BotFather's voice-call screen (from any of the transcripts, own key, stream mode, MCP server or calls from bots screens) โ it cannot be done with the token. Even if you agreed to an earlier version, you are asked again. The agree button does not appear until the bot-owner section is published in the server's terms of service (requests before then get 403 VOICE_OWNER_TERMS_NOT_ACCEPTED).
BotFather shows the terms in Korean; the button reads "Agree to the terms". In short: 1. with transcripts on, you receive the text of the conversation written down by the AI when a call ends, and you are responsible for handling and protecting it; 2. callers to a bot with transcripts on are told first, by a spoken notice when the call connects, that the text goes to the bot owner; 3. keep transcripts only as long as needed, delete them when the caller asks, and do not use them to train models; 4. with your own model key, the call's audio and text go to your key's provider under your contract, you are responsible for that processing and say so in your privacy notice; 5. in stream mode, call audio goes to your server instead of Pabal's AI model, you are the one who receives and processes it, you use the caller's voice and words only as far as the law allows and delete them when no longer needed; 6. if a caller asks where your server is and what it does, you tell them (ideally in the bot's description); 7. the bot always says it is an AI and its replies are made by an AI โ no person answers directly and no human recording is played as if it were an AI reply; in stream mode Pabal plays the notice "This is an AI voice bot. The call's audio goes to the bot developer's server." at the very start of the call; 8. you never ask for card numbers, resident registration numbers, passwords or verification codes, and you keep the voice-bot rules; 9. Pabal does not store transcripts โ once delivered they are deleted from the server within 24 hours at the latest, and keys and the secrets of stream mode and MCP servers are kept encrypted and never logged; 10. if you register an MCP server, values the caller said go to that server when the model calls its tools during a call, you are responsible for the processing by that server and wherever it passes data on, and you declare the recipients truthfully; 11. if the terms are broken, the operator can suspend voice calls or the bot.
Items 12โ24 are what you keep when you turn on calls from bots: use them only for the purposes you declared and the operator allowed, with no advertising; say the bot's name and that it is an AI in its first words; put no sensitive information in context; never try to call anyone under 19; never work around quiet hours, pauses or limits; use what you learn about the people called only for those purposes and delete it when the permission is turned off; keep the token safe. Calls a bot makes bring no transcript (item 24). The wording of the terms may change after the legal review.
MCP servers
They turn on only when the operator has the MCP components (pabal-mcp, the signer, the egress proxy) in place and has published the privacy policy's bot-calls-mcp section and the terms of service's bot-owner section (giving MCP requests a dedicated egress address of their own is an operating condition, and the code does not check it). Even once on, until the apps show the bot's MCP recipients on the confirmation sheet, registering works but no call carries MCP tools (mcp_unavailable_reason: "sheet"). On a server with MCP off, setVoiceCallMcpServer gives 403 VOICE_NOT_AVAILABLE: MCP tools are not available on this server, and @BotFather has no "MCP servers" (MCP ์๋ฒ) button.
If the bot owner registers an MCP server that they run (or have a contract for), the model can call that server's tools during a call. It is Pabal, not the bot's code, that calls them โ Pabal's MCP client sends the request to that server through Pabal's egress proxy and enforces deadlines, sizes and checks. So values the caller said go to that server (bot owner terms, item 10).
What the MCP server must provide
- Streamable HTTP, POST (protocol version 2024-11-05 or later).
https://with a public certificate, no redirects, and only ports the server allows (443 on pabal.me). - A server that uses credentials must honestly refuse with
401without credentials and must not accept wrong credentials. One credential can be used by only one bot. - Operator document: the server's operator puts a JSON document (32 KB or less, no redirect) at
https://<host>/.well-known/pabal-voice-mcpโ who runs it, its country and contact, its purpose, the kinds of data and where they are, recipients and sub-processors, and how long data is kept. This is the recipient information shown on the caller's confirmation sheet; if there is none, the bot owner declares it when registering (below). A public server anyone can call without credentials (auth.type: "none") must state in this document's endpoint entry"accepts": "any", saying it accepts calls from Pabal. - What Pabal adds: the
_metaoftools/callcarries the remaining deadlineme.pabal/budget_ms(milliseconds) and the result-size limitme.pabal/result_budget_bytes, and may carry an assertion signed by Pabal (me.pabal/assertion, a JWT). The assertion's issuer,sub(bot:<bot id>) and verification keys (JWKS) are given bygetVoiceCallMcpBinding. tools/listat most 2 MB and 20 pages. There must be no steps a person has to go through (such as a browser sign-in).- Each tool gets a classification in
_meta["me.pabal/voice"]โdata(noneยทcallerยทthird_party),content(systemยทthird_party_text),effect(noneยทwriteยทnotifyยทfinancial),max_ms(at most 15,000),max_result_bytes(at most 65,536), and optionallyp95_ms. If the server does not give these, the bot owner supplies them intoolswhen registering. - Profile 1.0 grants only L1: only tools with
data: "none"andeffect: "none"(tools that only read public information) are pinned to calls. Tools that handle the caller's data (L2) or carry something out (L3) are recorded but not used. - When you register, Pabal runs conformance checks (address, TLS, version, 401, tool list, tool structure, credential scope, human steps). If it fails, the registration is refused with
MCP_CONFORMANCE_FAILED: CT-xx.
Registering
Secrets are sent only in the request body (in a query string: 400). Keep them in a file and send that, so the secret is not left in your command line.
umask 077
cat > mcp-shop.json <<'EOF'
{"label": "shop",
"url": "https://mcp.example.com/mcp",
"auth": {"type": "bearer", "token": "(the token the MCP server gave you)"}}
EOF
curl -s "https://pabal.me/bot$BOT_TOKEN/setVoiceCallMcpServer" -H 'Content-Type: application/json' -d @mcp-shop.json
label:[a-z][a-z0-9]{0,11}, cannot start withpabal. 2 servers per bot. The tool names the model sees arelabel__toolname.auth.type:none,bearer(token),headers(up to 3 headers),client_credentials(token_url,client_id,client_secret, orprivate_key_jwt: trueโ callcreateVoiceCallMcpClientKeyfirst).authorization_codeis not available yet.none(a public server) is registered only if its operator's public document says it accepts calls from Pabal (otherwiseMCP_OPT_IN_REQUIRED).- Changes that change the recipient (a new server, a different address or token host, a new declaration, a higher level) are answered with
400 MCP_CHANGE_NEEDS_CONFIRMATION, and take effect only when the bot owner, within 5 minutes, presses the Confirm (ํ์ธ โ ์ด ๋ณ๊ฒฝ์ ์ ์ฉ) button that appears in @BotFather. This is so that even if the token leaks, callers' words cannot be quietly sent somewhere else. Rotations that change only the secret take effect at once. - If the server's operator has no public document, the bot owner truthfully declares the recipient (who runs it, country, contact, purpose, kinds of data, location, sub-processors, how long data is kept) in
declaration. This is shown on the caller's confirmation sheet, and when it changes, callers are asked again. - Registrations and checks: 10 an hour per bot, 20 per owner. Other methods:
refreshVoiceCallMcpServer(check again),deleteVoiceCallMcpServer(always works),getVoiceCallMcpBinding(the issuer,suband JWKS your server uses to recognize calls from Pabal),createVoiceCallMcpClientKey. - The state of the registered servers and tools appears in
getVoiceCallSettings.mcp_servers(paths, queries and secrets are not shown). The full profile (Pabal Voice MCP Profile 1.0) is in the Pabal repository atdocs/app/voice-bot-mcp-profile.html; ask the server's operator for it if you need it.
Own model key
For a call answered with an own key, the caller must see a confirmation sheet (version 2) saying that the audio goes to that key's provider, and no app shows that sheet yet. So servers other than test servers do not even accept keys until the operator turns them on once the apps are ready (403 VOICE_OWN_KEY_NOT_AVAILABLE). The server also needs a master key.
Runs the bot's calls on the bot owner's own model account. Charges go to the owner's provider account, and own-key calls do not use the platform's bot, owner or server daily limits (the caller's limits still apply). The bot owner terms come first.
curl -s "https://pabal.me/bot$BOT_TOKEN/setVoiceCallApiKey" -d provider=google \
--data-urlencode "api_key@vertex-sa.json"
provider:googleis a Vertex AI service account JSON key (only the needed fields are kept),openaiis an API key starting withsk-. Anything else givesVOICE_PROVIDER_INVALID. The key goes only in the request body (in the query: 400) โ reading it from a file as above keeps it out of your command line. Do not paste it into the @BotFather chat.- Before accepting the key, the server checks that it really opens, with a test session on the gateway (up to 12 seconds). If it passes, the key is kept and you get True. If the provider refuses it:
API_KEY_INVALID; if it signs in but has no access to the realtime API:API_KEY_NO_REALTIME_ACCESS; if there is no gateway to test with, or it is busy:503 API_KEY_CHECK_UNAVAILABLE: โฆ: the key was not kept. - Tests: 5 an hour per bot, 10 per owner (all bots together). Only tests that reached a gateway count.
- The key is never shown anywhere โ not in answers, errors, logs or screens.
getVoiceCallSettings.api_keyhas only the provider, the date it was set, a fingerprint (8 characters) and whether it can be used. - If the key that was set cannot be used (the terms changed, the server cannot open the key, and so on), the bot does not take calls โ it does not quietly switch to the platform key. Deleting the key with
deleteVoiceCallApiKey(always works) makes the bot take calls with the platform key, and calls in progress on that key end. - Own-key calls have no Pabal daily limit โ only the call length, concurrent calls and the caller's limits apply. The charges go to your account, so set budget alerts in the provider's console.
- OpenAI keys are used only if the gateway's operator turns them on separately (by default gateways take Google keys only). If no gateway has them on, testing an OpenAI key gives
503 โฆ no_gateway. The OpenAI key path has not yet been tested against the real OpenAI (only against fake servers), and calls answered with an OpenAI key use the OpenAI voice chosen by the gateway (defaultmarin), whatever the bot's voice setting. Google keys are used in the gateway's Vertex region (us-central1). - In stream mode, the key is only kept, not used.
Transcripts
The operator must finish a legal review and allow them on the admin page (off by default). On a server that has not allowed them, turning the switch on gives transcript_available: false, transcript_unavailable_reason: "not_allowed", and no transcripts arrive.
When turned on, the bot owner receives the text of the call's conversation as written down by the AI. Turn the switch on after agreeing to the bot owner terms.
Transcripts (this section) are turned on by the bot owner and received by the bot owner, and the caller hears a notice when the call connects. Keeping the conversation is chosen by the caller with a checkbox on the confirmation sheet, and after the call the text is left only in that person's own chat โ it does not go to the bot or the bot owner, and there is no spoken notice. One call can have both. There is no switch for the bot owner to turn conversation keeping on; it is on when the server's privacy policy has the bot-calls-keep-text section (coming soon โ Using the app โ Voice bots).
curl -s "https://pabal.me/bot$BOT_TOKEN/setVoiceCallSettings" -d transcript=true
- Calls a bot makes have no transcript (calls from bots) โ even with the switch on.
- People who call a bot with transcripts on hear "What is said on this call is written down and sent to the bot's owner." right after the AI notice. Only what is said after the notice is written down. Stream-mode calls have no transcripts.
- The
voice_callof an ended call (event: "ended") carriestranscript. This is the only complete copy, so keepvoice_callinallowed_updates. To receive turns one at a time during the call, addvoice_call_transcripttoo (HTTP Bot API only; it does not arrive over MTProto). - Turns containing a card number or resident registration number are delivered with every digit replaced by
*. Numbers spread over two turns or read out as words are not caught, so handle the text you receive with care as well. - Up to 4,096 bytes per turn, 256 KiB and 4,000 turns per call. Anything beyond that is dropped, and you get
truncated: true. - Pabal does not store transcripts: they are in memory during the call, handed to the bot once, and whatever is not picked up is deleted after 24 hours. They are lost if the server restarts. Keeping and processing them after they are received is the bot owner's responsibility.
"transcript": {
"turns": [
{"role": "bot", "text": "Order Helper here. How can I help you?", "offset_ms": 5200},
{"role": "caller", "text": "Where is my order A-1042?", "offset_ms": 9800}
],
"truncated": false
}{"update_id": 21, "voice_call_transcript": {"voice_call_id": "4611686018427387905", "seq": 2,
"role": "caller", "text": "Where is my order A-1042?", "is_final": true, "offset_ms": 9800, "date": 1790380813}}role is caller (the caller) or bot (the model), and offset_ms is the milliseconds since the call's audio started.
Stream mode
People can call a stream-mode bot only if their app shows the stream confirmation sheet, saying that "the call's audio goes to the bot developer's server", and no app build with that sheet exists yet. The server also needs a master key, the privacy policy's bot-calls-stream section, the bot-owner section and a gateway that takes stream calls. Without them, mode=stream gives 403 VOICE_STREAM_NOT_AVAILABLE.
In stream mode, the call's raw audio goes not to Pabal's AI model but to the bot owner's server. Your server listens to the audio and sends its reply back as audio โ you decide which AI to use (another provider's realtime model, your own combination of speech recognition and synthesis). Pabal still plays the AI notice at the very start and keeps enforcing the caller's checks, limits, call length and concurrent-call limits. No instructions are needed, and tools, own keys and transcripts are only kept, not used (stream_ignores).
About this diagram
- Three lanes: on the left the caller, in the middle Pabal (the server and the voice gateway), on the right the bot owner's server. The bot server talks neither to the app nor to the Pabal server directly, but only to the gateway, over a WebSocket.
- โกโโฃ happen while the phone is ringing: the gateway connects over TLS to the public address it has checked, upgrades with the signature header and sends
start. The call is taken only if the bot server sendsreadywithin 3 seconds; otherwise the caller hears a busy signal. - The gray box is a gate: until the AI notice ("This is an AI voice bot. The call's audio goes to the bot developer's server.") has finished playing, the caller's audio does not go to the bot server, and audio the bot sends early is not played. The moment it ends, โค
liveis sent. - โฅ Audio in both directions is PCM16, 24 kHz, mono; on the caller's side it is always 20 ms (960-byte) chunks. Blue arrows are what Pabal sends; gray is what the bot server sends.
- โฆ and โง: the bot can send barge-in (
clear), playback-position notices (mark) and hang-up (hangup); when the call ends, Pabal sends astopwith the reason and closes with 1000.
Setup and the probe
First, run the example server below on a public address (a name its certificate matches). The probe connects to that server the moment you set it up, and failures that could not reach it also count toward the limit of 5 an hour.
After agreeing to the bot owner terms (@BotFather โ Voice calls โ Stream mode โ Agree to the bot owner terms (๋ด ์ฃผ์ธ ์ฝ๊ด์ ๋์)), tell Pabal the server's address and secret. The secret is 32โ128 printable ASCII characters (no spaces), and a new address always goes with a new secret.
umask 077
openssl rand -hex 32 | tr -d '\n' > stream-secret.txt # there must be no newline at the end
curl -s "https://pabal.me/bot$BOT_TOKEN/setVoiceCallSettings" -d mode=stream \
-d stream_url=wss://bot.example.com:8443/voice --data-urlencode "stream_secret@stream-secret.txt"
- Address format:
wss://only, no user name,#or spaces, ASCII only (other characters percent-encoded), at most 2,048 characters. The host is a name with a dot or a public IP (localhostis not allowed), and the port is 443 or 1024โ65535. Otherwise400 STREAM_URL_INVALID: โฆ. - Probe limits are counted the moment you ask: 5 an hour per bot, 10 per owner, and at the same time 1 per bot and 2 per owner. Beyond that,
429. Probes that did not connect anywhere because the gateway was busy do not count. - Address check: if the name points to even one private or reserved address, or to Pabal's own address, you get
400 STREAM_URL_INVALID: the host must resolve only to public addresses. - The probe: the gateway tries connecting once (
"probe": trueinstart). Ifreadycomes back, stream mode turns on, and the secret is kept encrypted under the server's master key. On failure you get400 STREAM_PROBE_FAILED: <code>โunreachable(name, connection, time or TLS),handshake_failed(an answer other than 101 โ usually the signature was rejected),redirect,no_subprotocol,no_ready,bad_ready,address_refused,busy,urland so on โ any other code comes as the gateway gave it. The server waits up to 12 seconds. - When stream mode is turned on or its address is changed through the Bot API (the token), @BotFather sends the bot owner "The stream address was changed to โฆ (secret โฆ)" (์์ ์ฃผ์๊ฐ โฆ ๋ก ๋ฐ๋์์ด์ (๋น๋ฐ โฆ)) with a Back to managed (๊ด๋ฆฌํ์ผ๋ก ๋์๊ฐ๊ธฐ) button; when the bot goes back to managed, it says so, with a Voice calls (์์ฑ ํตํ) button. This is to stop audio from quietly going to another server if the token leaks.
- Putting
stream_secretin the query string gives 400 before anything else. Stream mode cannot be set in a webhook response. - To go back to managed, use
mode=managed(this deletes the address and the secret). If voice is on and the instructions are empty, you getVOICE_INSTRUCTIONS_REQUIRED, so send instructions along with it. - On a server where the operator takes only allowed bots, the bot takes calls only after the operator reviews the stream address and allows it again.
getVoiceCallSettingsshows onlystream_urland the secret's fingerprint.sendVoiceCallContextdoes not work for stream calls (VOICE_CONTEXT_NOT_SUPPORTED), andendVoiceCallhangs up at once, without a farewell.
Wire format
- TLS 1.2 or later, with a public certificate that matches the name in the URL. The gateway connects to the IP it checked, but SNI and the certificate check use the name. Redirects (3xx) are not followed.
- The subprotocol
pabal.voice.v1must be chosen (Sec-WebSocket-Protocol). No compression. Messages up to 65,536 bytes, text messages up to 4,096 bytes (UTF-8). - The gateway sends a ping every 15 seconds. If no pong comes within 15 seconds, it disconnects (most libraries answer on their own).
- Text messages are JSON events; binary messages are audio. There is one audio format: PCM16 little-endian, mono, 24,000 Hz.
call_idandbot_idare decimal strings.
| Direction | Message | When ยท meaning |
|---|---|---|
| Pabal โ bot | {"event":"start","protocol":1,"call_id":"โฆ","bot_id":"โฆ","probe":false,"caller":{"lang":"ko"},"format":{"encoding":"pcm_s16le","sample_rate":24000,"channels":1,"frame_ms":20},"max_seconds":300} | Once, right after the upgrade. There is no caller name, number or ID (only caller.lang โ information about the person comes in the voice_call update) |
| Pabal โ bot | {"event":"live"} | The AI notice has finished. From now on audio flows |
| Pabal โ bot | Binary | The caller's audio, always 960 bytes (20 ms). Only after live |
| Pabal โ bot | {"event":"mark","name":"โฆ"} | The audio before the bot's mark of that name has all been played |
| Pabal โ bot | {"event":"cleared","played_ms":N,"dropped_ms":N,"marks":["โฆ"]} | The answer to clear |
| Pabal โ bot | {"event":"buffer_full","dropped_ms":N} | The bot's audio piled up beyond 30 seconds and the overflow was dropped |
| Pabal โ bot | {"event":"stop","reason":"โฆ","played_ms":N,"duration_ms":N} | The call has ended. Closed with 1000 shortly after |
| Bot โ Pabal | {"event":"ready"} | Within 3 seconds after start, before anything else |
| Bot โ Pabal | Binary | The bot's audio: an even number of bytes, 2โ65,536 bytes. Anything before live is dropped |
| Bot โ Pabal | {"event":"clear"} | Barge-in: stop the queued bot audio now |
| Bot โ Pabal | {"event":"mark","name":"[A-Za-z0-9_.:-]{1,64}"} | "Tell me when playback gets here". Up to 256 waiting per call |
| Bot โ Pabal | {"event":"hangup"} | End the call. Queued audio plays first, for up to 5 seconds, before hanging up |
- Unknown
events are ignored. Text that is not JSON, text over 4 KiB, and audio with an odd number of bytes or over 64 KiB are protocol violations and end the call (the caller hears an apology notice). - Sending limits: text events on average 50 per second (100 in a burst), audio on average 4 times real time (60 seconds' worth in a burst). If the bot socket does not read and the caller's audio piles up beyond 30 seconds (1,500 chunks), the call ends too.
Signature
The gateway adds this header to the upgrade request.
X-Pabal-Voice-Signature: t=<Unix seconds>,nonce=<16 bytes, hex>,call=<call_id>,v1=<hex>
v1 = HMAC-SHA256(stream_secret, "pabal-voice-stream/1\n<t>\n<nonce>\n<call>\n<host>\n<path?query>")
hostis the host of the URL you gave Pabal (lower case, no port). Do not compute it from the request'sHostheader โ the sender writes that value, so it would not bind the signature to the host.path?queryis the URL's path and query as they are (/if there are none). In a probe,callis the probe's number.- The bot server must check:
|now โ t| โค 300 seconds, eachnonceused only once, and the HMAC compared byte by byte with a constant-time function. If anything is wrong, answer with something other than 101, such as 401. - Then it checks that
start.call_idequals the signature'scall. If not, it closes with 1008 โ so a signed upgrade cannot be reused for another call. A server that serves only one bot also checks thatstart.bot_idis its bot. - Test values: secret
example-stream-secret-of-the-bot-developer-0001,t=1790000000, nonce00112233445566778899aabbccddeeff, call4611686018427387905, hostbot.example.com, path/voice?bot=100042โv1=807517ff96ac75223bde35b48c775f49a2eda2fb4832a02c0ea832b914d70d0c.
Audio ยท barge-in ยท mark ยท ending
- Bot โ caller buffer: the gateway plays audio out at playback speed, so you can send it all at once, but anything piled up beyond 30 seconds is dropped and
buffer_fullis sent. When the bot's audio pauses for 300 ms, one "chunk" is considered finished (the unit formarkandclear). clear: drops all bot audio not yet played, and playback goes silent from the next 10 ms frame (measured: under 50 ms). In the answercleared,played_msis the total bot audio actually played in this call so far,dropped_msis how much was just dropped, andmarksare the names of the marks on the dropped chunks โ use them to tell your model "where it was cut off".mark: comes back with the same name once the audio before it has been played. It is best to end the audio before a mark on a multiple of 480 bytes (10 ms); any leftover piece is padded with silence.- Ending: the bot sends
hangupor closes cleanly with 1000 or 1001. Any other close or a dropped TCP connection ends the call like a protocol violation (with an apology notice to the caller). When Pabal ends the call,stop.reasonis a short code โhangup(the caller hung up),max_length,bot_ended,disconnect,drain,kill_switch,operator,link_lost,media_failedโฆ Treat unknown values as "ended". - A probe's
starthas"probe": true, an emptycallerandmax_seconds: 0. After you sendready,{"event":"stop","reason":"probe"}comes and the connection closes. Do not open anything that costs money, such as a model session, for a probe.
Example โ the smallest stream server
A server that checks the whole wire format without any AI: on live it plays a chime, and after that it sends the caller's audio straight back (echo). It checks the signature, time, nonce, call and bot. First use it to see a call work end to end, then put your AI in place of the echo. The Pabal repository also has scripts/bots/stream_echo_bot.py (with a barge-in demo) and scripts/bots/stream_openai_relay.mjs (relays to Realtime with your OpenAI key; an example that has not been run); ask the server's operator for them if you need them.
# stream_bot.py โ pip install "websockets>=13"
# Run: STREAM_SECRET_FILE=stream-secret.txt TLS_CERT=fullchain.pem TLS_KEY=privkey.pem \
# PUBLIC_HOST=bot.example.com BOT_ID=<the bot ID โ the number before the : in the token> python3 stream_bot.py
import asyncio
import hashlib
import hmac
import json
import math
import os
import ssl
import struct
import time
from websockets.asyncio.server import serve
SECRET = open(os.environ["STREAM_SECRET_FILE"], "rb").read().strip()
PUBLIC_HOST = os.environ["PUBLIC_HOST"].lower() # the host of the stream_url you gave Pabal (not the Host header)
BOT_ID = os.environ.get("BOT_ID", "") # empty: take calls for any bot
PATH = os.environ.get("STREAM_PATH", "/voice")
PORT = int(os.environ.get("PORT", "8443"))
CHIME = b"".join(struct.pack("<h", int(8000 * math.sin(2 * math.pi * 880 * i / 24000)))
for i in range(24000 * 6 // 10)) # 880 Hz for 0.6 s, PCM16 24 kHz
seen = {} # nonces already used
def signed_call(header, target):
"""Checks X-Pabal-Voice-Signature and returns the signed call id, or None if it is wrong."""
try:
parts = dict(p.split("=", 1) for p in header.split(","))
t, nonce, call, v1 = int(parts["t"]), parts["nonce"], parts["call"], parts["v1"]
except (KeyError, ValueError):
return None
now = time.time()
if abs(now - t) > 300 or nonce in seen:
return None
text = f"pabal-voice-stream/1\n{t}\n{nonce}\n{call}\n{PUBLIC_HOST}\n{target}"
expected = hmac.new(SECRET, text.encode("utf-8", "surrogateescape"), hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected.encode(), v1.encode("utf-8", "surrogateescape")): # byte by byte
return None
for old in [n for n, at in seen.items() if at < now - 600]:
del seen[old]
seen[nonce] = now
return call
def check(connection, request):
if request.path.split("?", 1)[0] != PATH:
return connection.respond(404, "not here\n")
call = signed_call(request.headers.get("X-Pabal-Voice-Signature", ""), request.path)
if call is None:
return connection.respond(401, "bad signature\n")
connection.signed_call = call # start must name this call
return None
async def handle(ws):
if ws.subprotocol != "pabal.voice.v1":
await ws.close(1002, "pabal.voice.v1 only")
return
start = json.loads(await asyncio.wait_for(ws.recv(), 5))
if (start.get("event") != "start" or start.get("call_id") != ws.signed_call
or (BOT_ID and start.get("bot_id") != BOT_ID)):
await ws.close(1008, "not this call")
return
await ws.send(json.dumps({"event": "ready"})) # within 3 seconds, before anything else
live = False
async for message in ws:
if isinstance(message, bytes):
if live:
await ws.send(message) # send the caller's 20 ms straight back (echo)
continue
event = json.loads(message)
if event.get("event") == "live" and not start.get("probe"): # the AI notice has finished
live = True
await ws.send(CHIME)
await ws.send(json.dumps({"event": "mark", "name": "chime"}))
elif event.get("event") == "stop":
print("Ended:", start["call_id"], event.get("reason"), flush=True)
break
async def main():
tls = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
tls.minimum_version = ssl.TLSVersion.TLSv1_2
tls.load_cert_chain(os.environ["TLS_CERT"], os.environ["TLS_KEY"])
async with serve(handle, "0.0.0.0", PORT, ssl=tls, subprotocols=["pabal.voice.v1"],
process_request=check, max_size=65536, compression=None):
print(f"Listening on wss://{PUBLIC_HOST}:{PORT}{PATH}", flush=True)
await asyncio.get_running_loop().create_future()
asyncio.run(main())
What callers see
- Call button: a call button appears in the voice bot's chat header and profile. It appears only when the server decides it would take a call from this person on this device (before public launch, only for testers). There are no video calls.
- Confirmation sheet (before the microphone permission): the title "An AI answers this call"; that the bot is not a person and the voice is AI-generated; that the call is not end-to-end encrypted; who receives the audio (Google, the Gemini Live API in Vertex AI) and the country and region (United States, Iowa); what is sent (the caller's voice during the call, the conversation text and the AI's replies, notes the bot adds during the call and the results of the bot's functions); retention (Pabal does not store audio and keeps only the call record for 180 days; Google keeps nothing after producing its answer, but content flagged as possible abuse by automated checks may be logged for up to 90 days); what the bot owner receives (name, account ID, language, call time and length, values used by tools โ not the phone number or the audio); and 19 or older. The buttons are "I'm 19 or older ยท Call" and Cancel. The age is self-confirmed, not verified. The call button is the main button (filled with the accent color) and Cancel is the secondary one. On a server with conversation keeping on, the checkbox "Keep this call's conversation as text in my chat" (off by default) follows the explanation.
- How often: desktop and Android ask the first time you call that bot (and again if the recipient or the sheet version changes); iPhone and Apple Watch ask every time. The watch shows the full sheet every time, and the conversation-keeping switch starts at the choice made in the last call to that bot. The app sends the consent to the server with every call, and the server accepts a consent only from the same device, for the same bot, within 2 minutes, and uses it for that one call only.
- Once connected: the AI notice "You're now talking to an AI voice bot." (for a stream bot, "This is an AI voice bot. The call's audio goes to the bot developer's server.") โ (for a transcript bot) the transcript notice โ the bot's greeting. The bot owner cannot turn the notices off, and what the caller says before the notices end does not go to the model. Keeping the conversation has no spoken notice โ the caller chose it on the screen themselves.
- Notice language: the notices are not in the bot's
languagebut in the language of the confirmation sheet the caller read. All the notices of one call are in one language โ the AI notice, the transcript notice and the apology when the connection is lost (for a stream bot, the stream notice and the apology). If any of them has no reviewed recording in that language, they are all in English. Today the only reviewed languages are Korean and English. The confirmation sheets and notices in the other eight languages (Japanese, Chinese, French, Spanish, Indonesian, Vietnamese, Khmer and Arabic) are drafts awaiting legal review and a native speaker's check, so for now callers who use the app in those languages also see the English confirmation sheet and hear the English notices. The Khmer recordings and the Japanese version of "Just a moment longer, please." are still waiting for someone to listen to them and pick a take. The first notice of a call and the transcript notice always start after at least 300 ms of silence, so that the phone line does not swallow the first word. - Call screen: under the name, "@{username} ยท AI ยท Bot" and "AI-generated voice ยท Call audio is processed by Google". There is no emoji key and no encryption text.
- If the connection has a problem, the caller hears "Sorry, we've lost the connection. Ending the call now." and the call ends. Every refusal ends with a busy signal.
The explanation for app users is in Using the app โ Calling a voice bot.
Calls from bots
The server has it, but no bot can call anyone until the privacy policy's bot-calls-outbound section and the terms of service's voice-bot-owners-outbound section have passed the legal review and are published. No app build with the permission sheet has shipped yet either (Current status). What follows is how it works on a server where it is on.
A voice bot can also call a person first: to confirm a booking, to wake someone at a time they chose, to say a delivery is coming. The bot owner's code asks with startVoiceCall, the server judges the request and rings the person's devices, and if the person answers, Pabal's notice plays first and then the AI voice model speaks as the bot's instructions say. How it ended comes as a voice_call update.
- Only people who allowed it. The person must allow that bot to call them in the Pabal app (The callee's permission), and can turn it off at any time.
- Not for advertising. There are four purposes โ reminders, wake-up calls, deliveries and appointments โ and advertising, sales pitches and promotion are not allowed (bot owner terms, items 12โ24).
- Calls from bots are not end-to-end encrypted โ the audio goes through Pabal's server and the AI provider. Once the person answers, the audio goes to Google during the call โ Vertex AI's Gemini Live API, in Iowa, USA (Google Cloud
us-central1). Pabal does not store the audio. - No conversation text goes to the bot owner. Calls a bot makes have no transcript. What the bot owner gets is the five things listed under What the callee sees and hears.
How it flows
About the diagram
- Four lanes: from the left, the bot owner's code, Pabal's server, the voice gateway with the AI model, and the callee's app. The dotted vertical lines are time; the blue solid lines are the call going ahead, and the gray line is the answer to the bot.
- The gray box at โ โ three judgements: before answering, the bot's side (calling turned on, the operator's review, the terms, the limits) and whether there is a permission; after the 200, the person's side (quiet hours, paused, the person's daily limit and gap, a device to ring); and once more right before the ring.
- The 200 at โก only means "taken". Whether it rang and whether it was answered come only in the updates at โง.
- โฃโโฆ, the person's side: the ring shows "(AI)" after the bot's name, and on answering, Pabal's notice (โฅ) plays before the bot. In its first words (โฆ) the bot says its name, that it is an AI, and the purpose of the call. During the green box the audio goes through the gateway and the AI model โ it is not end-to-end encrypted.
- The red dashed line is the point: a request that never rang because the person's side refused it and one that rang with nobody answering end the same way, at the same time, as
not_answered. The bot cannot tell whether the person was asleep or on another call.
Turning it on โ @BotFather
Calls from a bot are turned on by the bot owner in @BotFather only, never with the token (sending calling fields to setVoiceCallSettings gives 400 VOICE_FIELD_NOT_AVAILABLE). Voice calls must be on first. /mybots โ the bot โ Voice calls (์์ฑ ํตํ) โ Calls from the bot (๋ด์ด ๊ฑฐ๋ ์ ํ) โ Turn on (์ผ๊ธฐ).
BotFather speaks Korean. The first message asks whether the bot may call people: only people who allowed it, never in their quiet hours or while they are paused, within a daily cap (50 now) and the server's limits, only after the operator has reviewed and allowed the bot's name, photo, instructions, voice and purposes (and again whenever any of them changes); calls from bots are not end-to-end encrypted. The second shows the bot's screen once it is on: on, waiting for the operator, purpose delivery, cap 50, today's requests.
- Terms, version 4: if you have not agreed yet, the bot owner terms come before turning it on. Items 12โ24 are the promises for calls from bots.
- Purposes: it turns on only with at least one of
reminder(reminders) ยทwake_up(wake-up calls) ยทdelivery(deliveries) ยทappointment(appointments). Every request names one of them; a purpose the bot did not choose is a400. Changing the purposes asks the people who allowed it again, and the operator reviews again. - Daily cap: how many requests the bot may send a day โ 10, 20 or 50 (50 by default).
- The operator's review: the operator looks at the bot's name, @username, photo, instructions, greeting, tools, voice, language, mode, purposes and the owner's name on the admin page and allows it. If any of them changes, the bot cannot call until it is allowed again (
403 VOICE_OUTBOUND_OFF: review). - The token: changing the voice settings with the token (except turning voice off) freezes calls from the bot for 24 hours (
frozen). If it was you, lift it with I changed it (๋ด๊ฐ ๋ฐ๊ฟจ์ด์,vdkยทvdK) in @BotFather. Reissuing the token also freezes it for 24 hours, ends its calls in progress and deletes the webhook of a bot whose calls are on. - Only bots whose language is Korean or English (
ko-KRยทen-US) can call. A bot in stream mode, with its own key, tools or MCP servers cannot (mode).
The callee's permission
A bot cannot get the permission itself. The person opens that bot's permission sheet in the Pabal app and taps "I am 19 or older ยท Allow". The sheet says an AI makes these calls, the purposes, the bot owner's name, where the audio goes, and the quiet hours (Using the app โ Letting a bot call you). There are three ways to open it.
- The bot's menu: "Allow callsโฆ" in the bot's chat or profile.
- A link in the one-to-one chat: the bot sends
https://pabal.me/<bot username>?callme=1to the person's one-to-one chat with it, as a link in the text or an inline URL button. The sheet opens only for a message that bot sent in that chat โ a link forwarded to another chat, a preview or the web only open the chat. A person can open the sheet from a link once a day (all bots together), and not at all if they never wrote to the bot. - The settings list "Bots allowed to call me": see the bots allowed, turn them off, allow them again.
curl -s "https://pabal.me/bot$BOT_TOKEN/sendMessage" -d chat_id=$USER_ID \
--data-urlencode "text=Shall I call you before your order arrives?" \
--data-urlencode 'reply_markup={"inline_keyboard": [[{"text": "Allow calls", "url": "https://pabal.me/order_helper_bot?callme=1"}]]}'
- Self-hosted servers: the apps know only
pabal.melinks. For another server, its host must be added to the link rules of the app build before a link opens the sheet (the menu and the settings list work as they are). - For now: only accounts with a +82 number, with the device's time zone set to Korea (Seoul), on a device signed in for at least 24 hours can turn it on (the operator sets the countries). Someone who answers that they are under 19 cannot have calls with bots for a year.
- When the bot's name, owner or purposes change, or a year after the permission, the person is asked again; until then the bot cannot call.
Calling โ startVoiceCall
curl -s "https://pabal.me/bot$BOT_TOKEN/startVoiceCall" -d user_id=$USER_ID -d purpose=delivery \
--data-urlencode "context=Order A-1042 arrives today around 3 p.m. Ask whether they can take it then." \
-d max_duration=180 -d ring_timeout=30
{"ok": true, "result": {"voice_call_id": "8812345678901234"}}
| Parameter | Meaning |
|---|---|
user_id | The id of the person to call, who must have allowed this bot |
purpose | One of the purposes this bot chose: reminder ยท wake_up ยท delivery ยท appointment |
context | What the model is told about this call, for the model only: 1โ1,000 characters, 3,072 bytes. Checked against the content rules (impersonation, hiding the AI, asking for secrets, advertising). The model uses it after its first words (name, AI, purpose), once the person has answered |
max_duration | The longest the call may last once answered (seconds): 30 up to 300 or the bot's own maximum, whichever is smaller. 180 by default |
ring_timeout | How long it rings (seconds), 30โ45. 40 by default |
| Answer | Meaning | What to do |
|---|---|---|
200 {"voice_call_id": "โฆ"} | Taken. Whether it rang and whether it was answered come in the updates | โ |
400 Bad Request: <what> | A wrong parameter, a purpose the bot did not choose (purpose is not declared), a bot language other than Korean or English | Fix it |
400 VOICE_CONTEXT_REFUSED: <code> | context breaks the content rules | Fix it |
403 VOICE_OUTBOUND_OFF: <reason> | The bot's side: owner_off (not turned on) ยท terms (terms version 4) ยท review (waiting for the operator's review) ยท suspended (automatic stop) ยท frozen (token freeze) ยท owner_name (the owner's name fails the impersonation check) ยท mode, or a bot-side reason of the voice settings (unavailable_reason): bot_voice_off ยท not_allowed ยท suspended ยท name_refused ยท text_refused ยท awaiting_review ยท stream_unavailable ยท own_key_unavailable ยท no_gateway ยท no_gateway_with_tools ยท no_gateway_with_language ยท no_own_key_gateway. The server-side reasons voice_off ยท kill_switch ยท no_relay ยท outbound_off come as 503, not 403 | @BotFather ยท the operator |
403 USER_UNAVAILABLE | This person cannot be called: no permission, turned off or asked again, blocked, not a person, out of scope, under 19, not a tester. The body is always the same, whatever the reason | Ask for the permission |
429 retry after N | A limit on the bot's or owner's side: the request rate, the bot's or owner's daily requests, the bot's concurrent calls, the owner's outbound minutes, the bot's or owner's daily call minutes (shared with calls to the bot) | After N seconds |
503 VOICE_UNAVAILABLE | The server's side: the outbound switch is off, voice is off on the server (also when the privacy policy has no bot-call section), the kill switch, no call relay, the server's limits and outbound share, no gateway that can place calls (or all of them are full), the request could not be recorded | Later |
{"ok": false, "error_code": 403, "description": "Forbidden: USER_UNAVAILABLE"}
The person's side is judged after the 200. Even with a permission, in the person's quiet hours, while the pair is paused, past the daily limit with that person or the person's own daily limit and gap, with no device to ring, or while the person is on another call, nothing rings and not_answered comes about ring_timeout + 12 seconds after the request โ the same as a ring nobody answered. A permission does not change the bot's side (403, 429, 503).
Settings and usage
"outbound": {
"enabled": true,
"purposes": ["delivery"],
"daily_cap": 50,
"reviewed": true,
"frozen_until": 0,
"suspended": false,
"available": true
}"outbound": {
"today": {"requests": 4, "answered": 2, "not_answered": 2, "minutes": 3},
"limits": {"daily": 50, "owner_daily": 100, "owner_minutes": 30}
}outboundingetVoiceCallSettingsis always there: what was set in @BotFather (enabledยทpurposesยทdaily_cap), the operator's review (reviewed), when a token freeze ends (frozen_until, 0 when not frozen), the automatic stop (suspended) and whether the bot can call now (available).unavailable_reasonis there only whenavailableis false (on the bot's side, the same values as the reasons of403 VOICE_OUTBOUND_OFF; on the server's side,voice_offยทkill_switchยทno_relayยทoutbound_offโ these four go with503 VOICE_UNAVAILABLE).outboundingetVoiceCallUsageis there only for a bot that has ever turned calling on: today's requests, answered, not answered and minutes, and the daily limits that apply to this bot. How many rang is not given.callsยทsecondsยทmissedยทrefusednever count calls the bot made. But calls the bot makes spend the same daily call minutes of the bot and its owner as calls to the bot: an answered call's seconds are charged, soremaining_seconds_today'sbotandownergo down โ and while a call rings they are already down by what it holds, which comes back if nobody answers.setVoiceCallSettingscannot change them (400 VOICE_FIELD_NOT_AVAILABLE).
Updates
They come as the same voice_call updates as calls to the bot (name voice_call in allowed_updates). direction is "outgoing", and the person appears only as the id in to (no name, username or phone number).
{
"update_id": 83,
"voice_call": {
"id": "8812345678901234",
"direction": "outgoing",
"event": "not_answered",
"to": {"id": 100005},
"purpose": "delivery",
"date": 1790000123
}
}{
"update_id": 81,
"voice_call": {
"id": "8812345678901234",
"direction": "outgoing",
"event": "started",
"to": {"id": 100005},
"purpose": "delivery",
"date": 1790000130,
"max_duration": 180
}
}{
"update_id": 82,
"voice_call": {
"id": "8812345678901234",
"direction": "outgoing",
"event": "ended",
"to": {"id": 100005},
"purpose": "delivery",
"date": 1790000250,
"start_date": 1790000130,
"duration": 120,
"max_duration": 180,
"end_reason": "callee"
}
}{
"update_id": 84,
"voice_call": {
"id": "8812345678901234",
"direction": "outgoing",
"event": "failed",
"to": {"id": 100005},
"purpose": "delivery",
"date": 1790000110
}
}event | When | Extra fields |
|---|---|---|
started | The person answered and the call connected. On an Apple Watch, when the watch's audio joined (if it never does, a 0-second ended with failed) | max_duration |
ended | An answered call ended. It arrives up to 10 seconds late whatever the server's settings, while the server waits for the gateway's usage | start_date, duration, max_duration, end_reason |
not_answered | A request nobody answered โ whether it rang unanswered, was declined, or never rang because of the person's side. About ring_timeout + 12 seconds after the request | None |
failed | The link to the gateway that took the request was lost before an answer | None |
end_reason: callee (the person hung up) ยท bot (the bot hung up, or its token was reissued) ยท max_length (the longest length) ยท failed (the connection failed after the answer) ยท operator (the operator or the kill switch) ยท suspended (the bot or owner suspended). Calls a bot makes have no missed, refused or ringing โ how fast a person answers would show whether they were by their phone โ and no transcript (off by design).
During the call
endVoiceCall: before the bot is told how it ended, it withdraws the request whether it is ringing or not yet (true, thennot_answered). It ends an answered call (end_reasonbot). Withdrawing within 15 seconds of the request pauses the pair for an hour.sendVoiceCallContext: only after the person answered (before that,VOICE_CALL_NOT_FOUND). Three per call, 1,000 characters and 3,072 bytes, the same content rules ascontext. If the gateway does not take notes,VOICE_CONTEXT_NOT_SUPPORTED.
What the callee sees and hears
- The ring: "(AI)" after the bot's name, and "AI voice bot ยท Pabal never asks for codes".
- On answering, a notice plays before the bot: "This is a Pabal AI voice call. We never ask for passwords, codes or money transfers. To stop these calls, turn off call permission in the bot's chat." โ in the language of the permission sheet the person read (Korean or English); from an app in another language, in English. The bot owner cannot turn it off.
- The bot's first words: the bot's name, that it is an AI, and the purpose of the call.
contextcomes after that. - Ways to turn it off: "Stop calls from this bot" in the menu, blocking the bot and reporting it always turn it off. Saying clearly during a call something like "never call me again" turns it off on the spot (a plain "stop" is up to the model).
- Paused: after a decline, a ring left unanswered, or hanging up within 10 seconds of answering, the bot cannot call that person for 24 hours.
- The missed-call record: when a device really rang, the person has not blocked the bot and the bot's chat has no unread message, it stays as "Missed AI bot call" (read, no notification). With an unread message, or when it never rang because of quiet hours or a pause, there is no record. Every request a bot made is kept in the report evidence (30 days) without exception.
- What the bot owner gets (in the permission sheet's order):
- the person's Pabal id
- whether the permission is on (the bot's call requests show it)
- whether the person answered
- when the call started and how long it lasted
- who hung up
- Shared devices: any signed-in device can answer, and the bot says the purpose and what it was told to whoever answers. On a shared computer, tell people to sign out.
- The lock screen: on Android 14 and later, unless the person allows full-screen notifications, the call comes as a notification instead of a call screen over the lock screen. iPhones and Apple Watches ring like a phone call.
- Keeping the conversation keeps it only in the person's own chat, if they choose it on the permission sheet. With Answer on Watch on in the Pabal Watch app, the Apple Watch rings too.
- Calls from bots are not end-to-end encrypted โ the audio goes through Pabal's server and the AI provider.
Limits ยท quiet hours ยท paused ยท automatic stop
| Limit | Default | On a production server |
|---|---|---|
| One bot to one person (a day) | 3 calls | The operator can only lower it |
| To one person (a day, all bots) | 6 calls | Can only be lowered |
| Gap between calls to one person | 10 minutes | Can only be raised |
| One bot's requests (a day) | 50 calls | The owner picks 10, 20 or 50 in @BotFather |
| One bot owner (a day, all bots) | 100 calls ยท 30 minutes | Calls can only be lowered |
| Until the bot may send its next request | 10 seconds | Can only be raised |
| The server's outbound minutes | 30% of the server's daily minutes | Can only be lowered |
| Quiet hours | 21:00โ08:00 | The operator and the person can only widen them. In the device's time zone |
Wake-up window (wake_up only) | at most 120 minutes | The person picks it inside the quiet hours |
Ring (ring_timeout) | 30โ45 seconds | 40 seconds when the request does not say |
| Paused โ a decline, a ring left unanswered, a hang-up within 10 seconds of answering | 24 hours | Can only be raised |
| Paused โ the bot withdrew within 15 seconds | 1 hour | Can only be raised |
- "A day" is a day in the server's time zone (
TELEGRAM_VOICE_TIME_ZONE). A call never lasts more than 300 seconds. - Calls from bots also spend the same daily call minutes as calls to the bot (the bot's 60 minutes ยท the owner's 120 minutes). Once they are used up,
startVoiceCallgets429, and calls to the bot are refused too (refused,budget). - A pause ends when the time is up, when the person calls the bot, taps "I can take calls now", or allows it again. A message or button from the bot does not end it.
- The automatic stop stops only the bot's calls to people (
403 VOICE_OUTBOUND_OFF: suspended) and lasts until the operator checks and lifts it: an answer rate under 20% over the last 20 calls, 3 reports within 7 days from people it rang, 30% or more of its people turning the permission off (with at least 10), 2 calls caught by the rules during the call, 5 withdrawals within 15 seconds, or 2 or fewer people answering with 60 or more minutes answered.
Limits
| Limit | Default | Who sets it |
|---|---|---|
| Longest call | Bot 300 seconds (range 60 up to the operator's and server's cap, default 600) | The bot owner, within the operator's and server's cap. The @BotFather buttons are 1, 3, 5 and 10 minutes |
| The bot's concurrent calls | 2 (1 up to the operator's and server's cap, default 2) | The bot owner, within the operator's and server's cap |
| Daily call minutes per bot | 60 minutes | Operator |
| Daily minutes per bot owner (all bots together) | 120 minutes | Operator |
| Daily minutes for the whole server | 300 minutes | Operator |
| Per caller | 60 minutes a day, 20 calls an hour | Operator |
| Shortest call taken | 60 seconds โ if less time than this is left, the call is not taken | Operator |
| New accounts | People who signed up less than 7 days ago cannot call โ except testers, and people the bot sent a message to more than a day ago | Operator |
| Changing settings | 30 an hour per bot | โ |
| Notes during a call | 10 per minute per call | โ |
| Tools | 16 tools, 8 KB; open queries: 2 per call, 8 per bot; tool calls: 30 a minute per call, 120 per bot | โ |
| Own-key tests ยท stream probes | 5 an hour per bot, 10 per owner | โ |
| MCP registrations and checks | 10 an hour per bot, 20 per owner, 2 servers per bot | โ |
From the Apple Watch you can't call bots that use tools, MCP servers, an own key, stream mode or the bot owner's transcripts. This refusal goes only to the caller (the watch app's 403 WATCH_NOT_YET); no refused update goes to the bot.
The length of a call is the smallest of the caller's, bot's, owner's and server's time left today and the call cap. When the call is taken, that much is reserved on all four limits, and when it ends, only the actual seconds by the server's clock are charged. Own-key calls do not use the bot, owner or server limits (the caller's limits still apply).
Errors
Errors look the same as for other methods: {"ok": false, "error_code": 400, "description": "Bad Request: VOICE_โฆ"}. 403 reads Forbidden: <code>: <description> (except USER_UNAVAILABLE, which is always exactly Forbidden: USER_UNAVAILABLE, with no description), and 429 comes with parameters.retry_after.
| Code | Meaning |
|---|---|
403 VOICE_RULES_NOT_ACCEPTED | The voice-bot rules must first be accepted in @BotFather |
403 VOICE_NOT_AVAILABLE | This bot cannot use voice on this server (voice off, or not allowed by the operator) โ settings cannot be changed; only turning off and deleting work |
403 VOICE_SUSPENDED | The operator has suspended voice for this bot. Only turning off works |
403 VOICE_OWNER_TERMS_NOT_ACCEPTED | The bot owner terms (version 4) must be accepted first |
403 VOICE_OWN_KEY_NOT_AVAILABLE | This server does not accept own keys |
403 VOICE_STREAM_NOT_AVAILABLE | Stream mode cannot be used on this server right now |
403 VOICE_OUTBOUND_OFF: <reason> | This bot cannot call people now: owner_off ยท terms ยท review ยท suspended ยท frozen ยท owner_name ยท mode, or a bot-side reason of the voice settings (Calling). voice_off ยท kill_switch ยท no_relay ยท outbound_off come as 503 |
403 USER_UNAVAILABLE | This person cannot be called โ always the same body whatever the reason, no permission included |
503 VOICE_UNAVAILABLE | The server cannot place calls to people right now (the outbound switch, voice off on the server, the kill switch, no call relay, the server's limits, gateways, a request that could not be recorded) โ try again later |
Bad Request: purpose is not declared, Bad Request: the bot's voice language must be ko or en for outbound calls | startVoiceCall: a purpose the bot did not choose, a bot language other than Korean or English |
VOICE_INSTRUCTIONS_REQUIRED, VOICE_INSTRUCTIONS_TOO_LONG, VOICE_GREETING_TOO_LONG | Instructions are required ยท instructions over 8,192 bytes ยท greeting over 512 bytes |
VOICE_INVALID, VOICE_LANGUAGE_INVALID, VOICE_LIMIT_INVALID, VOICE_MODE_INVALID | No such voice ยท language ยท a limit out of range ยท a mode other than managed/stream |
VOICE_CONTENT_REFUSED: <field>: <code>, VOICE_CONTEXT_REFUSED: <code> | Content rules (settings, the context of startVoiceCall) |
VOICE_TOOL_INVALID: <path>: <reason> | Tool definitions (defining tools) |
VOICE_FIELD_NOT_AVAILABLE: โฆ | Fields that are not accepted (model, api_key), stream fields on a managed bot, or the fields of calls from bots (outboundยทpurposesยทdaily_cap โ @BotFather only) |
VOICE_CALL_NOT_FOUND, VOICE_CALL_NOT_ACTIVE, VOICE_CONTEXT_NOT_SUPPORTED, VOICE_CONTEXT_TOO_LONG | Methods used during a call (during a call) |
VOICE_USAGE_PERIOD_INVALID | The period for getVoiceCallUsage |
TOOL_QUERY_ID_INVALID, TOOL_QUERY_EXPIRED: โฆ, TOOL_RESULT_โฆ, TOOL_ERROR_TOO_LONG | Tool answers (answering) |
API_KEY_INVALID, API_KEY_NO_REALTIME_ACCESS, 503 API_KEY_CHECK_UNAVAILABLE, VOICE_PROVIDER_INVALID | Own keys (own model key) |
STREAM_URL_INVALID, STREAM_SECRET_REQUIRED, STREAM_SECRET_INVALID, STREAM_PROBE_FAILED: โฆ | Stream mode (setup and the probe) |
MCP_โฆ, MCP_OPT_IN_REQUIRED | MCP servers (registering). For 503 VOICE_NOT_AVAILABLE: mcp: โฆ try again a little later; for 409 MCP_CHECK_IN_PROGRESS try again after the check finishes |
429 Too Many Requests: retry after N | An hourly limit (settings, key tests, probes, MCP), the notes limit, or a limit of calls from bots (limits) |
Bots connected over MTProto
- A bot signed in only over MTProto calls the voice methods with
bots.sendCustomRequest(the method name, and the same JSON). Supported:setVoiceCallSettings,getVoiceCallSettings,answerVoiceCallToolQuery,endVoiceCall,sendVoiceCallContext,getVoiceCallUsage,startVoiceCalland the five MCP methods. Anything else givesMETHOD_INVALID. The answer tostartVoiceCallis{"voice_call_id": "โฆ"}, and refusals come as RPC errors (USER_UNAVAILABLE,VOICE_OUTBOUND_OFF: review,FLOOD_WAIT_n,VOICE_UNAVAILABLE). - Methods a webhook answer cannot call:
setVoiceCallApiKey,startVoiceCalland the MCP registration and key methods. Put in the body of the answer to a webhook POST, they are not run โ so that no message a person sent turns straight into a phone call through a webhook answer. Call them with a request of their own. - Own keys only over HTTPS:
setVoiceCallApiKeyanddeleteVoiceCallApiKeycannot be called withsendCustomRequest. To delete a key, use the HTTP Bot API or @BotFather. - For a bot with no webhook that has not called
getUpdateswithin the last 15 seconds,voice_call(that of a call the bot made too) is pushed once asupdateBotWebhookJSON(regardless ofallowed_updates, and not sent again). Tool queries come asupdateBotWebhookJSONQuery; answer with{"result": โฆ}or{"error": "โฆ"}throughbots.answerWebhookJSONQuery.voice_call_transcriptdoes not arrive over MTProto (the ended update'stranscriptdoes). - For RPC errors,
429becomesFLOOD_WAIT_n, and the rest are the uppercase codes as they are.