Signature scheme
These four routes skip the Clerk session check. cloud-api signs every request it relays to them (runtime_relay::relay_signed_request_to_runtime_with, keyed with the runtime’s stored credential_hash). Each request carries:
path is the request path without the query string, for example /api/v1/voice/calls/call_1/events. The body is hashed exactly as sent; a DELETE hashes the empty string. The runtime compares signatures in constant time.
The HMAC key is relay_key = sha256_hex(device_token): the ASCII bytes of the lowercase hex digest, not the raw token. cloud-api only stores that digest as credential_hash, so it can sign without the raw token. A signature made with the raw token is rejected.
Known-answer vector (device token tok-123, ts 1700000000, POST, path /api/v1/voice/calls, body {}):
relay_key=c8963414bf6c4c869eeac5f8a057c3dc574d422f1b108397b66f67bab3d2f981- signature =
34edb38cb1c7839397d5993a055972d2f352b42164bcdfd935264cdcae1d1576(send asv1=34edb38cb1c7839397d5993a055972d2f352b42164bcdfd935264cdcae1d1576)
runtime_devices.credential_hash as secret. Anyone with database access can forge relayed requests; runtimes are only reachable through the cloud relay.
Routes
Start a call
POST /api/v1/voice/calls
from for inbound, to for outbound), so calls and texts from one person share a thread, stores a voice_calls row and returns { "threadId": "…", "sessionId": "…" }. Repeating a callId returns the same ids. ownerId must match the signed owner, and the number must belong to that owner and bot (403 otherwise; 404 for an unknown number).
Write call events
POST /api/v1/voice/calls/{callId}/events with { "events": [{ "type", "n", "payload", "occurredAt" }] }.
- Each event becomes a
bot_eventsrow on the call’s thread with the idempotency keycall:<callId>:<type>:<n>. Replays are counted asduplicates. - Event types must start with
call.. Every payload getscallId. - The actor comes from
payload.speaker:calleris the caller’s number,humanispayload.by(or the owner), anything else is the bot. call.transcript.deltawithfinal: falseis ignored (counted inignored); interim text belongs to the live stream.call.endedmarks the call ended and stops any turn still running.- Response:
{ "written": 4, "duplicates": 0, "ignored": 1 }. An unknowncallIdis a 404.
Run a turn
POST /api/v1/voice/calls/{callId}/turn with { "text": "…", "segmentId": "…" } answers text/event-stream. Each data: line is JSON:
A native bot session is streamed: the runtime subscribes to the Gizzi session’s event stream and forwards
text.delta as words arrive (cut at word boundaries) and a tool event when a tool starts and when it finishes (done or error). Each tool step is also written to the thread as agent.tool.started, agent.tool.completed or agent.tool.failed. The turn is sent exactly as channel messages are (same model, bot instructions, memory and project permissions). The runtime adds a short spoken-style instruction server-side so replies stay brief. A call that has ended answers 409.
A caller cannot click an approval, so a tool that asks for one stops the turn and the stream carries a spoken-friendly error telling the caller to approve it in the app. A turn that runs longer than 120 seconds is aborted with an error.
Only one turn runs per call. A new turn aborts the running one first, and closing the stream aborts it too.
A vendor-bound session or one placed on another Allternit has no local event stream. Those turns run the channel path and answer once, so the reply arrives as sentence chunks of the finished text and no
tool events are sent. The runtime logs that fallback. The stream shape is the same either way.Abort (barge-in)
DELETE /api/v1/voice/calls/{callId}/turn aborts the running turn the way the Stop button does (the Gizzi turn is stopped and the stream closes within 100 ms) and always returns 204, even when nothing was running.
Where the device token comes from
The runtime reads its own device token from envALLTERNIT_RUNTIME_DEVICE_TOKEN plus ALLTERNIT_RUNTIME_OWNER_ID, or else from the shared runtime-identity.json ($ALLTERNIT_RUNTIME_IDENTITY_PATH, default ~/.config/allternit/runtime-identity.json), re-reading it when allternit-node rotates the token. An expired identity counts as absent. With no source, signed requests answer 503. Provisioned cloud computers get both env vars at pairing time. The verifier is the shared relay_auth module, also used by other relayed envelopes. See cmd/allternit-api/docs/VOICE_CALLS.md.
Call controls from the app
The app sends hangup, mute, hold, DTMF, transfer, listen and takeover to cloud-api, not to a local gateway:POST /api/v1/voice/calls/{callId}/control with the signed-in user’s Clerk token (sendCallControl in allternit-ai src/lib/gateway/call.ts). listen and takeover answer { "token", "url", "room" }, which the app passes to joinCallAudio. Desktop, ai.allternit.com and the m.allternit.com PWA use this one path.
Other signed relays
The voice routes are not the only runtime paths that trust cloud-api instead of a platform signature. cloud-api has already checked Discord’s, Slack’s, Teams’ or the mail provider’s own signature, so the runtime would otherwise accept any caller who could reach the path. Those paths now require the samex-allternit-runtime-sig headers, verified with RelayedAuth, and the owner comes from the verified signature:
Public webhooks signed by the platform itself (Telegram, WhatsApp, raw Discord interactions) are unchanged. Responses are the same as for the voice routes: 401 for a missing or bad signature, 503 when the runtime has no device token yet.
Cloud-api routes the call worker uses
The worker authenticates to cloud-api with a bearer token (ALLTERNIT_VOICE_WORKER_TOKEN). The wire shapes are defined by the worker’s cloud_client.rs.
How cloud-api translates for the runtime:
call.startedbecomes the create body above (callId,botId,ownerId,numberId,from,to,direction,room,startedAt). The cloud queues its own copy first withseq0, so the worker’s owncall.startedis a duplicate.- Every other event becomes
{"events":[{type, n, payload, occurredAt}]}with one entry.ncounts that event type within the call. Delivery retries with backoff for 24 hours and wakes a sleeping cloud computer. - A turn is relayed to the runtime’s SSE
POST …/turn; eachtext.deltabecomes adeltaline, tool progress and keep-alives are not spoken. If the worker drops the connection beforedone, cloud-api calls the runtime’sDELETE …/turn. If the runtime does not answer within 20 seconds, the worker gets a 504.
voice_bot_config.name, voice_calls.sip_call_id and consent_ref, voice_call_events.seq and at_ms). The runtime saves the spoken name with PUT /api/v1/voice/bot-config/{botId} (name; recording accepts "off", "consented" or a boolean; transferTargets is the owner-approved warm-transfer list, see Phone numbers).