Build a phone agent
Inbound and outbound PSTN calls, phone-number routing, lifecycle webhooks, reports, and transfers.
Speko can run the same voice agent over browser sessions and PSTN calls. Phone calls use the platform-hosted LiveKit SIP path: Speko creates or receives the call leg, dispatches the agent worker, records the room, persists transcripts and events, and finalizes a call report after hangup.
Use this guide when you want a receptionist, appointment setter, callback workflow, or support line that can place calls, answer calls, and transfer callers to a human.
Phone surfaces
| Need | API | SDK |
|---|---|---|
| Place an outbound phone call | POST /v1/sessions/phone | speko.voice.dial() |
| Buy or import numbers | /v1/phone-numbers/* | speko.phoneNumbers |
| Route inbound calls | agentId or dispatchMetadataTemplate on a phone number | speko.phoneNumbers.update() |
| Inspect calls and reports | /v1/calls/{id} | speko.calls.get() |
| Read lifecycle events | /v1/calls/{id}/events | speko.calls.events() |
| Transfer live calls | /v1/calls/{id}/transfers/* | speko.calls.blindTransfer() / speko.calls.warmTransfer() |
1. Create an agent
Phone calls work best with a persisted agent because the phone-number row can hydrate its prompt, routing intent, provider preferences, speech settings, and tools every time a call starts. Lifecycle webhooks are configured once at the workspace level and scoped to this agent when needed.
curl -X POST https://api.speko.dev/v1/agents \
-H "Authorization: Bearer $SPEKO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{
"name": "Front desk",
"systemPrompt": "You are the front-desk receptionist. Greet callers, collect their name and reason for calling, answer common questions, and transfer urgent calls.",
"intent": { "language": "en-US", "optimizeFor": "latency" }
}'Create lifecycle endpoints in Settings → Webhooks or through speko.webhooks. See Workspace webhooks for event selection, agent scopes, tagged routing, signing, retries, and logs. Agent-level webhooks fields are deprecated compatibility fields through the current SDK major version.
2. Add a phone number
You can use a platform-managed number or register a customer-owned SIP trunk number.
Managed number search and purchase is currently the US-number path. It requires phone-number business verification and sufficient credits:
const options = await speko.phoneNumbers.searchAvailable({ areaCode: '415' });
const number = await speko.phoneNumbers.create({
e164: options[0]!.e164,
direction: 'both',
agentId: 'agent_123',
label: 'Main line',
});For numbers you already own, import the SIP trunk instead:
await speko.phoneNumbers.importSipTrunk({
e164: '+442071234567',
sipConnectionInstallationId: '00000000-0000-4000-8000-000000000010',
direction: 'both',
agentId: 'agent_123',
label: 'London front desk',
});direction controls whether the number can be used for inbound calls, outbound calls, or both. Inbound calls require either an agentId or a dispatchMetadataTemplate.
3. Place outbound calls
Use POST /v1/sessions/phone or speko.voice.dial() for outbound PSTN calls. The response returns the Speko session id, room name, resolved caller ID, and SIP participant handle.
const call = await speko.voice.dial({
to: '+12015551234',
from: '+12015550199',
agentId: 'agent_123',
firstMessage: 'Hi, this is Ava from Acme. Is now still a good time?',
telephony: {
region: 'us-east',
amd: { mode: 'agent', timeoutSeconds: 8 },
},
webhookTags: {
environment: 'production',
},
metadata: {
campaignId: 'renewal-q2',
leadId: 'lead_456',
},
});
console.log(call.sessionId, call.status);agentId is required unless you pass an ad hoc intent. Per-call fields such as systemPrompt, firstMessage, voice, llm, ttsOptions, sttOptions, constraints, and metadata override or extend the agent defaults for that call.
4. Receive inbound calls
When a call arrives on a registered number, Speko matches the dialed E.164 number, checks direction, creates a voice session, hydrates the linked agent, dispatches the worker, and bridges the caller into the LiveKit room.
For forwarded calls, Speko attempts to preserve the original forwarding source. It looks at Telnyx payload fields such as forwarded_from, original_to, and redirecting_number, plus SIP headers such as Diversion and History-Info. The normalized value is exposed as:
forwardedFromNumberin session metadata.forwarded_from_numberin pre-call webhook payloads.- call events and reports through the session metadata.
Use dispatchMetadataTemplate when you need static metadata or a legacy template alongside the linked agent:
await speko.phoneNumbers.update('pn_123', {
agentId: 'agent_123',
dispatchMetadataTemplate: {
tenant: 'acme',
line: 'front_desk',
caller: '{{callerNumber}}',
dialedNumber: '{{dialedNumber}}',
forwardedFromNumber: '{{forwardedFromNumber}}',
},
});The agent fields are canonical. If agentId and dispatchMetadataTemplate set the same pipeline key, the persisted agent wins and the template fills the gaps.
5. Customize calls before answer
Configure a workspace endpoint subscribed to call.pre_call and scoped to the agent when your application needs to look up the caller, choose a greeting, inject account context, or override the call pipeline before the worker starts speaking. Only one effective pre-call endpoint may match an agent/tag route.
Speko sends:
{
"type": "call.pre_call",
"call_id": "session_123",
"session_id": "session_123",
"organization_id": "org_123",
"direction": "inbound",
"to": "+12015550199",
"from": "+12015551234",
"dialed_number": "+12015550199",
"forwarded_from_number": null,
"phone_number_id": "pn_123",
"call_control_id": "sip_participant_123",
"pipeline_config": {},
"metadata": {}
}Return any supported pipeline overrides at the top level or under pipelineConfig / pipeline_config:
{
"firstMessage": "Hi Maya, thanks for calling Acme.",
"systemPrompt": "The caller is Maya Chen, a priority customer. Keep responses brief.",
"metadata": {
"customerId": "cus_123",
"plan": "enterprise"
}
}Supported pre-call override keys include intent, constraints, voice, systemPrompt, firstMessage, llm, ttsOptions, sttOptions, backgroundAudio, tools, pronunciation and text replacement rules, and idle reprompts.
6. Track lifecycle and reports
Speko records durable lifecycle events for LiveKit, Telnyx, Speko status updates, SIP cause codes, and transfer attempts.
const call = await speko.calls.get('session_123');
const { events } = await speko.calls.events('session_123');
const report = await speko.calls.report('session_123');Endpoints subscribed to call.status receive call progress and failure information:
{
"type": "call.status",
"call_id": "session_123",
"event_type": "call.hangup",
"provider": "telnyx",
"status": "ended",
"failure_cause": null,
"sip_status_code": 200,
"sip_status": "OK",
"metadata": {}
}After hangup, Speko finalizes a report with transcript entries, summary, outcome, structured data, cost breakdown, artifacts, metadata, and any scheduled callback created by analysis. Endpoints subscribed to call.report receive the same report payload. Report, analysis, and recording deliveries retry independently; inspect or manually redeliver them through speko.webhooks.deliveries.
Recording link
recording_url is a presigned download URL you can fetch directly with no auth headers (a GET on the URL streams the audio). It's a signed link valid for up to 7 days from delivery, so download or copy the file to your own storage rather than persisting the URL long-term. To fetch a recording on demand later, call the authenticated GET /v1/calls/{id}/recording endpoint with your API key instead.
A recording usually finishes uploading after the call ends, so the first call.report delivery often arrives with recording_url: null (and artifacts.recording.status: "uploading"). Once the recording finalizes, Speko re-fires call.report for the same call_id with recording_url populated and artifacts.recording.status: "ready". Deduplicate on call_id and treat the later delivery as authoritative for the recording. recording_url stays null when recording is disabled for the org (artifacts.recording.status: "suppressed") or the upload failed.
Dedicated analysis and recording webhooks
If handling the call.report re-fire is awkward for your consumer (for example, your handler dedupes on call_id and drops the delivery that carries the recording), subscribe to the dedicated single-purpose webhooks instead. Each fires once per call — and unlike the call.report re-fire, any webhook-standard duplicate (e.g. a redelivery after a timeout) carries identical content, so deduplicating on type + call_id never loses data:
call.analysisfires when the LLM analysis completes:summary,outcome,structured_data,custom_data, and ananalysisobject (status,provider,model,completed_at). No transcript, cost, or recording — and no recording re-fire to dedupe.call.recordingfires when the recording reaches a terminal state.recording_urlcarries the presigned link (same 7-day-TTL semantics as above) whenrecording.statusis"ready", andnullwhen it's"failed"— so your consumer can stop waiting either way. Suppressed recordings (recording disabled for the org) never fire this event.
{
"type": "call.recording",
"call_id": "b2f7…",
"session_id": "b2f7…",
"organization_id": "org_…",
"agent_id": "agent_…",
"recording_url": "https://storage.googleapis.com/…",
"recording": { "status": "ready", "duration_ms": 21625 },
"created_at": "2026-07-09T15:15:19.131Z"
}Subscribe one endpoint to any combination of these events:
await speko.webhooks.create({
name: 'Call processing',
url: 'https://example.com/speko/calls',
events: ['call.report', 'call.analysis', 'call.recording'],
allAgents: true,
});Both events are fully additive: call.report keeps its combined payload and its recording re-fire, so existing postCall consumers are unaffected. Failed deliveries retry with the same backoff as call.report.
Custom data extraction
Add extractionFields to a workspace endpoint subscribed to call.report or call.analysis to have Speko pull structured values out of every call. Each field has a name, a type (string, number, boolean, or enum), and a description that tells the analysis model what to extract — enum fields also take an options list. After the call, the model fills each field from the transcript, coerced to its declared type (or null when the call gives no basis for it), and the values arrive as a top-level custom_data object keyed by field name:
{
"type": "call.report",
"summary": "Jane booked a table for four.",
"outcome": "scheduled",
"structured_data": { "...": "..." },
"custom_data": {
"caller_intent": "booking",
"party_size": 4,
"wants_callback": true
}
}custom_data always carries one key per configured field, so the payload schema stays stable. Manage these fields from Settings → Webhooks in the dashboard.
Limits: up to 30 fields per webhook, each description up to 10,000 characters, with at most 40,000 characters of descriptions combined across all fields. Detailed rubric-style descriptions are encouraged for classification fields — the analysis model follows them closely.
7. Transfer callers
Use the Calls API for operator-driven transfers, or add the built-in transfer_call tool to let the agent transfer based on conversation context.
await speko.calls.blindTransfer('session_123', {
to: '+12015554321',
ringingTimeout: 25,
metadata: { reason: 'billing escalation' },
});
const transfer = await speko.calls.warmTransfer('session_123', {
from: '+12015550199',
destinations: [
{ to: '+12015551234', label: 'Front desk' },
{ to: '+12015554321', label: 'Overflow' },
],
screeningPrompt: 'Confirm the recipient can help before bridging.',
fallback: { strategy: 'take_message' },
voicemailDetection: { mode: 'agent', timeoutSeconds: 10 },
});Warm transfer starts a consultation leg first. Complete the transfer when the screened recipient accepts, or cancel with tryNext: true to continue through the destination list.