Outbound calls

placeCall phones 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 the context you 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_ID in 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 +34910000000

The 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.

workflows/lead-intake.workflow.ts
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 without voice: true, an unknown onEnd workflow, a to that is not E.164, a context that is not an object or is over 4 KB, a bad ringTimeout, or a box with no LIVEKIT_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:

workflows/call-ended.workflow.ts
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 acquireSlot and releaseSlot.
  • Voice: the LiveKit project, the voice worker and call guardrails.
  • Building workflows: scaffold, trigger and observe a workflow.
BUILT WITH ❤️ FROM CANADA AND SPAIN