Human calling
Programmable calls between people: browser softphones for your team, PSTN legs for everyone else, and Telnyx-style call control over both.
Human calling connects people — an agent (a broker, a rep, a dispatcher) on a browser softphone and a phone number on the PSTN — as legs of one call, with server-side control over every leg. It is a separate product from Speko's AI voice sessions: there is no STT→LLM→TTS pipeline, no agent worker, and no voice_session. Calls get their own resources, their own event stream, and their own webhook events.
The API is modeled on Telnyx Call Control. Every leg of a call has an opaque controlId — the equivalent of Telnyx's call_control_id — and every command is addressed to that handle. If you are porting an integration off Telnyx, the mapping is meant to be mechanical; see Porting from Telnyx.
Human calling is in early access and is enabled per workspace. If calls fail with HTTP 503 and code HUMAN_CALLING_DISABLED, ask us to turn it on for your organization.
Concepts
| Term | What it is |
|---|---|
| Call | One conversation. Holds legs, a status (initiating → ringing → active → ended / declined / missed / cancelled / failed), and an event history. |
| Leg | One participant's connection to the call. Kinds: browser (a person on a softphone), pstn (a phone number), agent (an AI session bridged in). |
controlId | The opaque handle every command is addressed to. One per leg. Returned when the leg is created and in every read. |
| Presence | Whether a specific broker can be rung right now. Inbound routing only rings brokers who are available with a fresh heartbeat. |
API key and broker identity
An API key (sk_…) authenticates your organization. A brokerId identifies one softphone inside the identity namespace your organization owns. Supply both when you instantiate the SDK; broker-scoped methods automatically include the id in their requests:
import { Speko } from '@spekoai/sdk';
const speko = new Speko({
apiKey: process.env.SPEKO_API_KEY!,
brokerId: 'broker-42', // your stable id for this person/device
});brokerId must start with a letter or digit, may contain letters, digits, ., _, and -, and is limited to 128 characters. Direct REST callers send the same brokerId in presence, dial, and join request bodies. Commands and org-wide reads need only the API key.
Come online: presence
A broker who wants to receive calls does two things: registers and holds a presence connection open.
import { PRESENCE_STALE_AFTER_MS } from '@spekoai/sdk';
// 1. Register + mint the presence-room credentials in one round trip.
const presence = await speko.callControl.presenceToken();
// → { token, url, identity, roomName, expiresAt }
// 2. Heartbeat on a timer. Presence goes stale after 90 seconds without one,
// and a stale broker is skipped by routing no matter what their status says.
setInterval(() => speko.callControl.heartbeat(), PRESENCE_STALE_AFTER_MS / 3);Connect to the presence room with livekit-client and listen for data messages on the topic speko.human_call. Every message is JSON — either a ring offer or a live call event:
import { Room, RoomEvent } from 'livekit-client';
const presenceRoom = new Room();
await presenceRoom.connect(presence.url, presence.token);
presenceRoom.on(RoomEvent.DataReceived, (payload, _participant, _kind, topic) => {
if (topic !== 'speko.human_call') return;
const message = JSON.parse(new TextDecoder().decode(payload));
if (message.type === 'incoming_call') {
// A RingOffer: { callId, controlId, roomName, caller: { brokerId, name, phoneNumber } }
showIncomingCallUI(message);
} else if (message.type === 'call_event') {
// A CallEventResource, same shape as GET /v1/voice/calls/{id}/events —
// the far end hung up, a transfer completed, a supervisor bridged in.
// This is also how a ring is cancelled: a caller who gives up produces
// call.leg.hangup / call.hangup here; dismiss the ring UI on those.
applyCallEvent(message.event);
}
});Availability is a first-class state — send busy or away to keep the heartbeat alive while diverting inbound, or offline to leave routing entirely:
await speko.callControl.setStatus('busy');GET /v1/voice/presence returns the org roster ({ brokers: [...] }, each with brokerId, status, lastSeenAt, and a computed reachable) — build your team's availability board from it.
Dial out
POST /v1/voice/calls places an outbound PSTN call. The browser leg belongs to the SDK instance's configured brokerId.
const { call, join } = await speko.callControl.dial({ to: '+12015551234' });
const mine = call.legs.find((leg) => leg.kind === 'browser')!; // your softphone
const theirs = call.legs.find((leg) => leg.kind === 'pstn')!; // the far endTwo things about the response:
- It resolves when the call exists, not when it is answered. The PSTN leg comes back
initiatingorringing; watch the event stream (or your own room connection) forcall.answered. - Your join credentials ship with the call. Connect to
join.urlwithjoin.tokenimmediately — your softphone must already be in the room when the far end answers, or the first moments of the conversation are silence. Tokens are short-lived and minted per join; re-join on reconnect rather than caching one.
from is optional and must be a number your organization owns; without it, the org's first outbound-capable number is used. With neither, dialing fails with 400 VALIDATION_ERROR.
curl -X POST https://api.speko.dev/v1/voice/calls \
-H "Authorization: Bearer $SPEKO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{ "brokerId": "broker-42", "to": "+12015551234", "from": "+12015550199" }'Receive inbound calls
Point a phone number at a person instead of an AI agent:
curl -X PATCH https://api.speko.dev/v1/phone-numbers/$NUMBER_ID \
-H "Authorization: Bearer $SPEKO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{ "routeToBrokerId": "broker-42" }'routeToBrokerId and agentId are mutually exclusive — a number answers to either an agent or a person. Setting one clears the other, so a number can be handed back and forth without a dangling destination.
When that number is called and its broker is reachable, a RingOffer arrives on their presence connection. Answering is a three-step sequence, in this order:
// message is the RingOffer from the presence room
const credentials = await speko.callControl.join(message.controlId); // 1. mint room credentials
await callRoom.connect(credentials.url, credentials.token); // 2. be in the room
await speko.callControl.answer(message.controlId); // 3. connect the audioJoin before answering — answering a leg whose participant is not in the room yet gives the caller a silent opening.
The PSTN side of an inbound call is answered at the network edge before routing happens, so a caller whose target broker is offline or slow to answer hears silence, not ringback. There is no ring-timeout or voicemail fallback yet — drive one from the event stream if you need it, and keep your team's presence heartbeats healthy.
Control a live call
Every command is POST /v1/voice/legs/{controlId}/actions/{command}, addressed to the leg it acts on. Commands are idempotent by intent — muting a muted leg is a no-op that still returns the leg — and every command returns the leg's post-command state, so you never re-read to learn what you just did.
| Command | Body | What it does |
|---|---|---|
answer | — | Answer a ringing leg (yours — join the room first). |
hangup | { "reason"?: string } | Drop this leg. The call ends when it runs out of live legs. |
mute / unmute | — | Stop / resume this leg's outgoing audio. A muted leg still hears the call. |
hold / unhold | — | Park / resume a leg. A held leg neither hears nor is heard; status becomes held. |
dtmf | { "digits": string } | Send tones toward a PSTN leg. 0-9, *, #, ,, and w (pause), max 64. |
bridge | { "bridgeTo": controlId } | Pull this leg into another leg's conversation. |
transfer | { "to": string, "mode": "blind" | "warm" } | Hand the leg to an E.164 number or another broker's leg. |
curl -X POST https://api.speko.dev/v1/voice/legs/$CONTROL_ID/actions/mute \
-H "Authorization: Bearer $SPEKO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{}'Errors carry stable codes: 404 NOT_FOUND (or a leg you cannot see), 409 LEG_NOT_LIVE / 409 CALL_NOT_LIVE for a command on a finished leg, 400 UNSUPPORTED_COMMAND for a command the leg kind cannot take, 400 BRIDGE_TARGET_INVALID for a bridge to a leg that is not live or not yours.
Hold is silent
There is no hold primitive on the underlying transport, so hold is synthesized by isolating subscriptions in both directions. With no music-on-hold source, the held party hears nothing at all. Say "on hold" in your UI and consider announcing the hold verbally first — callers read silence as a dropped call.
Hold and mute are different states: a muted rep still hears the customer; a held customer hears nothing.
DTMF needs a connected softphone
Address dtmf to the pstn leg — the leg the tones are for. Any other leg kind fails with UNSUPPORTED_COMMAND.
Under the hood, only a participant inside the room can emit SIP tones, so the server relays the command through one connected browser leg. Two consequences:
- A call with no connected browser leg cannot send DTMF — the command fails rather than silently doing nothing. Keep the softphone connected while driving an IVR.
- The softphone must implement the relay: listen for data messages on the topic
speko.call_controland press the keys it is asked to.
callRoom.on(RoomEvent.DataReceived, (payload, _p, _k, topic) => {
if (topic !== 'speko.call_control') return;
const message = JSON.parse(new TextDecoder().decode(payload));
if (message.type === 'dtmf.press') {
// message: { controlId (the PSTN leg the tones are for), digits }
pressDigits(callRoom, message.digits); // publish each digit via publishDtmf(); treat 'w' as a ~500 ms pause
}
});Transfers
transfer moves a leg elsewhere. to is an E.164 number, or the controlId of another broker's leg for an internal transfer.
blindhands the call off and drops your leg immediately. Watch forcall.transfer.completed(the carrier owns the leg now) orcall.transfer.failed.warmparks the far end and opens a consultation room: your leg moves there and the destination is dialed into it, so the two of you talk before the customer is handed over. You complete a warm transfer — bridge the consultation leg back into the original call (call.bridgedfires); there is nocall.transfer.completedfor warm transfers.
One caveat worth designing around: during a transfer, the far end is left alone in the original room but is not put on hold — their leg stays active with onHold: false, and they hear silence. If you want your UI (and the caller's experience) to say "on hold", call hold on that leg yourself before transferring.
Escalate an AI call to a human
An agent leg is an AI voice session participating in a human call. Bridging is leg-to-leg, so pulling a person into an AI conversation — or an AI agent into a human one — is an ordinary bridge, not a special escalation API.
Events and webhooks
Every call keeps an ordered history: GET /v1/voice/calls/{callId}/events, oldest first. This is the durable record — the recorded equivalent of a Telnyx webhook stream — and the thing to reconcile from.
The same events can be delivered to your workspace webhooks (call.initiated, call.ringing, call.answered, call.bridged, call.hold/call.unhold, call.mute/call.unmute, call.dtmf.sent, call.transfer.*, call.leg.hangup, call.hangup). Two things to know:
- Webhook deliveries are one-shot — no automatic retries. They are a live projection of the event history, which stays readable over the API, so treat webhooks as a trigger and
eventsas the truth. - Inbound DTMF (
call.dtmf.received) is not webhook-subscribable. A key pressed by the far end arrives as an in-room data packet observable by your connected softphone, never by the webhook receiver.
Human calls have no agent, so webhook endpoints scoped to specific agentIds never receive call-control events — subscribe with allAgents.
Porting from Telnyx
| Telnyx Call Control | Speko |
|---|---|
call_control_id | controlId (per leg) |
POST /calls (Dial) | POST /v1/voice/calls |
actions/answer | actions/answer |
actions/hangup | actions/hangup |
actions/bridge | actions/bridge |
actions/hold / unhold | actions/hold / actions/unhold — silent hold, see above |
actions/send_dtmf | actions/dtmf — relayed via a connected browser leg |
actions/transfer | actions/transfer with mode: 'blind' | 'warm' |
| Webhook event stream | Workspace webhooks (one-shot) + GET /v1/voice/calls/{id}/events (durable) |
| SIP credential / registrar | None — human legs are browser legs |
Deliberate gaps, so you can plan around them rather than discover them:
- No SIP registrar. Desk phones, third-party softphones, and PBXes cannot register and be rung. Anything terminating on a Telnyx SIP connection becomes a PSTN transfer or a browser client.
- No hold music / playback yet.
playback_start-style media into a call has no equivalent; hold is silence. - No early media, and busy is not distinguishable from a generic rejection — the leg fails either way, without a
486-specific signal. - Answering-machine detection, recording, and call queuing are not part of human calling yet. (AMD and recording exist for AI sessions.)
Current limitations
- Bring your own softphone UI. The
@spekoai/clientpackage does not yet ship a human-calling surface; drive the presence and call rooms withlivekit-clientdirectly, as shown above. The REST API and the TypeScript SDK (speko.callControl) are the stable integration surface. - The Python SDK exposes the webhook event types but no call-control methods yet — use the REST API from Python.
- One number routes to one broker; there are no ring groups or round-robin queues yet. Fan out with multiple numbers, or build hunting on top of presence +
transfer. - No ring timeout or voicemail fallback on inbound (see the callout above).