Outbound calls
placeCallphones a number from a workflow and puts one of your voice agents on the call. It returns when the callee answers or the phone stops ringing. When an answered call ends, the box starts the onEnd workflow you named, exactly once, with the transcript and thecontextyou passed.
Before you start
You need three things on the box:
- A deep agent that declares
voice: true. See Voice. - The LiveKit credentials, saved in Settings › Voice settings in Studio.
- A LiveKit outbound SIP trunk: the link from your LiveKit project to your
telephony provider. Create it in your LiveKit project, then save its id as
the secret
LIVEKIT_SIP_OUTBOUND_TRUNK_IDin Settings › Secrets.
To show the callee a specific number, also save it in E.164 as the secret
LIVEKIT_SIP_CALLER_ID. Without it, the trunk's own number is used.
stackbone secrets set LIVEKIT_SIP_OUTBOUND_TRUNK_ID --value ST_xxxxxxxxxxxx
stackbone secrets set LIVEKIT_SIP_CALLER_ID --value +34910000000The box reads both secrets on every call, so a change applies to the next call with no restart.
Place a call
Import placeCall from @stackbone/sdk/workflow. It is a step, so you can call
it from the workflow body or from inside another step.
import { acquireSlot, placeCall, releaseSlot } from '@stackbone/sdk/workflow';
export default async function leadIntake(input: { leadId: string; phone: string }) {
'use workflow';
const slot = await acquireSlot('voice-calls', { label: input.leadId });
const result = await placeCall({
agent: 'sales-agent',
to: input.phone,
context: { leadId: input.leadId, slotId: slot.id },
onEnd: 'call-ended',
ringTimeout: '45s',
});
if (result.status !== 'answered') {
// No call, so no onEnd: this workflow handles the outcome itself.
await releaseSlot(slot.id);
return { called: false, reason: result.reason };
}
return { called: true, sessionKey: result.sessionKey };
}| Option | Required | What it sets |
|---|---|---|
agent |
Yes | A deep agent of this workspace that declares voice: true. |
to |
Yes | The number to dial, in E.164: + and the country code, no spaces (+34600111222). |
context |
Yes | A JSON object of at most 4 KB, handed back untouched in the onEnd event. |
onEnd |
Yes | A workflow of this workspace. The box starts it once when an answered call ends. |
ringTimeout |
No | How long the phone rings before the call counts as no_answer: '45s' by default, at most '5m'. |
The box freezes context when it places the call and never logs it. Put ids in
it, not personal data: the onEnd workflow can look the record up again.
Read the result
placeCall resolves with one of these shapes:
type PlaceCallResult =
| { status: 'answered'; sessionKey: string; room: string }
| { status: 'no_answer' | 'busy' | 'failed'; reason: string };status |
When |
|---|---|
answered |
The callee picked up. The agent is on the call. onEnd runs when it ends. |
no_answer |
The phone rang out, or the callee could not be reached. |
busy |
The callee's line was busy, or the callee declined the call. |
failed |
The call did not connect for another reason. reason names the SIP status when there is one. |
A call that did not connect never throws, and it never starts onEnd. Release a slot or schedule a retry in the same workflow.
Handle errors
placeCall throws in two cases:
- A mistake in the call itself throws a
FatalError, and the run fails at once with no retry: an unknown agent, an agent withoutvoice: true, an unknown onEnd workflow, atothat is not E.164, acontextthat is not an object or is over 4 KB, a badringTimeout, or a box with noLIVEKIT_SIP_OUTBOUND_TRUNK_ID. The message says which. - The box cannot place a call right now (no LiveKit credentials, no public URL for the voice worker) throws a plain error, and the workflow engine retries the step.
Run a workflow when the call ends
The onEnd workflow receives a CallEndedEvent:
import { releaseSlot, type CallEndedEvent } from '@stackbone/sdk/workflow';
export default async function callEnded(event: CallEndedEvent) {
'use workflow';
await releaseSlot(String(event.context.slotId));
if (event.outcome === 'failed') {
return { saved: false };
}
return { saved: true, turns: event.transcript.length, seconds: event.durationSec };
}| Field | What it holds |
|---|---|
sessionKey |
The call's session, the same value placeCall returned. |
room |
The LiveKit room of the call. |
agent |
The agent that took the call. |
to |
The number the box dialed. |
context |
The object you passed to placeCall. |
outcome |
completed, disconnected or failed. |
transcript |
{ role: 'user' | 'assistant'; text: string }[]: what was said on the call. |
turns |
CallTurnMetrics[]: how long each answered turn took. See below. |
startedAt |
ISO time of the answer. |
endedAt |
ISO time of the end. |
durationSec |
Seconds from the answer to the end. |
The box starts onEnd once per call. A second end report for the same call starts nothing, and a transfer to another person does not start it again.
If the voice worker crashes and never reports the end, the box ends the call
itself: about two minutes after the LiveKit room closes, or two hours after the
answer at the latest. That event has outcome: 'failed' and can have an empty
transcript and empty turns. If you hold a slot for the call, its maxHold
stays the last safety net.
How long each turn took
turns has one entry per answer the agent gave, in order, with the latencies
LiveKit measured in milliseconds. A latency it did not measure is left out: the
greeting, which answers no one, carries only ttsTtfbMs.
| Field | From → to |
|---|---|
e2eMs |
The caller's last word → the agent's first audio. The wait heard. |
endOfTurnMs |
The caller's end of speech → the agent taking the turn. See below. |
transcriptionMs |
The caller's end of speech → the final transcript. |
llmTtftMs |
The start of the reply → the agent's model's first token. |
ttsTtfbMs |
The first text sent to speech synthesis → its first audio. |
runId |
Not a latency: the agent run that answered the turn. |
const waits = (event.turns ?? []).flatMap((turn) => (turn.e2eMs === undefined ? [] : [turn.e2eMs]));
const slowest = waits.length > 0 ? Math.max(...waits) : null;With Deepgram Flux deciding the turn (turn.mode: 'stt'), Flux's own decision
is the end of speech the agent sees, so endOfTurnMs is how long that decision
took to reach the agent, measured in audio, and e2eMs starts at Flux's
decision. There is no transcriptionMs then: the transcript arrives with the
decision, so there is nothing to measure. The silence Flux waited after the caller's last word before it decided
is not reported: Flux sends no per-word timing through LiveKit Inference.
The box keeps the same numbers with the call, and files each turn's on the
agent run that answered it as voice steps, once the call has ended. In Studio
that run's trace shows a Wait step, from the caller's last word to the
agent's first audio, with End of turn, Speech to text, Box, Model first token, Text to speech and Audio out under it, on the same time axis as
the run's model call. Audio out is what the wait leaves after the first
synthesized audio, until the agent is heard: it is derived from the other
phases, not measured. They hold no words and no phone number. The type marks turns optional because a box older than the field
sends none.
What's next
- Concurrency limits: cap how many
calls run at once with
acquireSlotandreleaseSlot. - Voice: the LiveKit project, the voice worker and call guardrails.
- Building workflows: scaffold, trigger and observe a workflow.