For developers working on Allternit phone calls. The voice worker never calls the runtime directly: cloud-api queues and relays every call request to the runtime’s allternit-api, waking a sleeping cloud computer if needed. Events and thread rendering are described in Channel events, connect API and phone tools. User docs: Calls and texts in threads.

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 as v1=34edb38cb1c7839397d5993a055972d2f352b42164bcdfd935264cdcae1d1576)
The credential hash is now a signing secret for relays: treat 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
The runtime resolves the thread with the caller’s number (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_events row on the call’s thread with the idempotency key call:<callId>:<type>:<n>. Replays are counted as duplicates.
  • Event types must start with call.. Every payload gets callId.
  • The actor comes from payload.speaker: caller is the caller’s number, human is payload.by (or the owner), anything else is the bot.
  • call.transcript.delta with final: false is ignored (counted in ignored); interim text belongs to the live stream.
  • call.ended marks the call ended and stops any turn still running.
  • Response: { "written": 4, "duplicates": 0, "ignored": 1 }. An unknown callId is 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 env ALLTERNIT_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 same x-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.started becomes the create body above (callId, botId, ownerId, numberId, from, to, direction, room, startedAt). The cloud queues its own copy first with seq 0, so the worker’s own call.started is a duplicate.
  • Every other event becomes {"events":[{type, n, payload, occurredAt}]} with one entry. n counts 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; each text.delta becomes a delta line, tool progress and keep-alives are not spoken. If the worker drops the connection before done, cloud-api calls the runtime’s DELETE …/turn. If the runtime does not answer within 20 seconds, the worker gets a 504.
Cloud-api migration 030 adds the columns behind this (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).