callControl
Programmable human calling: dial, answer, and control browser + PSTN call legs through /v1/voice.
speko.callControl drives calls between people — your team on browser softphones, everyone else on the PSTN. It is modeled on Telnyx Call Control: every leg has an opaque controlId, and every verb is addressed to that handle. This page is the method reference; read the human calling guide for the full flows, the presence wire protocol, and the divergences from Telnyx worth knowing.
Human calling is in early access, enabled per workspace. Calls fail with HUMAN_CALLING_DISABLED (HTTP 503) until it is enabled for your organization.
speko.voice.dial() is a different product and is unchanged — it dials an AI agent out over the same telephony gateway.
Authentication
Use your organization API key and configure the broker identity once when constructing the SDK. dial, join, register, heartbeat, setStatus, and presenceToken automatically send that brokerId.
const speko = new Speko({
apiKey: process.env.SPEKO_API_KEY!,
brokerId: 'broker-42',
});The command surface and org-wide reads use the API key but do not send a broker id.
Calls
dial(params)
Place an outbound PSTN call. Resolves as soon as the call exists — the PSTN leg is still initiating/ringing; watch events() for call.answered. The browser leg belongs to the configured broker.
const { call, join } = await speko.callControl.dial({ to: '+12015551234' });
const mine = call.legs.find((leg) => leg.kind === 'browser')!;
// Connect your softphone with `join` immediately — you must be in the room
// before the far end answers, or the first moments are silence.| Field | Type | Description |
|---|---|---|
to | string | Destination, E.164. |
from | string? | Caller ID. Must be a number your org owns; defaults to the org's first outbound-capable number. |
metadata | Record<string, unknown>? | Free-form, surfaced on the call resource. |
Returns { call: CallResource, join: CallJoinCredentials } — the call with both legs attached, plus room credentials for your own browser leg.
get(callId) / list(params?) / events(callId)
const call = await speko.callControl.get('call_123');
const { calls } = await speko.callControl.list({ status: 'active', limit: 50 });
const { events } = await speko.callControl.events('call_123'); // oldest firstlist filters (status, direction, brokerId, limit ≤ 200) are AND-ed and validated — an unknown status is a VALIDATION_ERROR, not an ignored filter. events is the durable record of the call; webhook deliveries for the same events are one-shot.
join(controlId)
Mint room credentials for one of the configured broker's browser legs. Tokens are short-lived and minted per join; re-join on reconnect instead of caching. A leg belonging to another broker returns NOT_FOUND, deliberately indistinguishable from a leg that does not exist.
Commands
Each command returns CallCommandResult — { leg, noop? }, the leg's post-command state, so you never re-read to learn what you just did. Errors carry stable codes on SpekoApiError.code, exported as VOICE_ERROR_CODES.
| Method | Notes |
|---|---|
answer(controlId) | Answer your ringing leg. Join the room first. |
hangup(controlId, { reason? }) | Drops this leg; the call ends when no live legs remain. reason becomes the leg's endReason. |
mute(controlId) / unmute(controlId) | Stops/resumes this leg's outgoing audio. A muted leg still hears the call. |
hold(controlId) / unhold(controlId) | Parks/resumes the leg. Held parties hear silence — there is no hold-music source yet. |
dtmf(controlId, { digits }) | Send tones toward a pstn leg. 0-9 * # , w, ≤ 64 chars. Fails unless a browser leg is connected to relay the tones — see the guide. |
bridge(controlId, { bridgeTo }) | Pulls this leg into another live leg's conversation. bridgeTo is a controlId, not a call id. |
transfer(controlId, { to, mode }) | to: E.164 or another leg's controlId. blind hands off and drops your leg (call.transfer.completed/failed); warm opens a consultation room that you complete with bridge (call.bridged). The far end is left un-held — hold them first if the UI should say so. |
Presence
All presence methods act as the brokerId configured on the SDK instance.
import { PRESENCE_STALE_AFTER_MS } from '@spekoai/sdk';
await speko.callControl.register(); // = setStatus('available')
const presence = await speko.callControl.presenceToken(); // { token, url, identity, roomName, expiresAt }
setInterval(() => speko.callControl.heartbeat(), PRESENCE_STALE_AFTER_MS / 3);| Method | Notes |
|---|---|
presenceToken() | Registers and mints presence-room credentials in one round trip. Connect with livekit-client; ring offers and live call events arrive as JSON data messages on topic speko.human_call. |
heartbeat() | Keep-alive. Presence goes stale after PRESENCE_STALE_AFTER_MS (90 s) and a stale broker is skipped by inbound routing regardless of status. |
register() | Mark yourself available. Not a SIP registration — reachability comes from the open presence connection + heartbeat. |
setStatus(status) | 'available' | 'busy' | 'away' | 'offline'. busy/away divert inbound while staying online. |
The org-wide roster has no SDK method yet — read GET /v1/voice/presence directly (works with an API key): { brokers: BrokerPresenceResource[] }, each with a computed reachable.
Types and constants
The contract ships with the SDK: CallResource, CallLegResource, CallEventResource, CallJoinCredentials, CallCommandResult, BrokerPresenceResource, PresenceMessage (RingOffer | CallEventNotice), plus CALL_COMMANDS, CALL_EVENTS, DTMF_PATTERN, PRESENCE_STALE_AFTER_MS, and VOICE_ERROR_CODES for branching on error codes without string-matching prose.