--- title: 'Outbound calls' description: 'Phone a number from a workflow with placeCall, put one of your voice agents on the call, and run an onEnd workflow with the transcript once an answered call ends.' position: 6 --- # 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](/docs/home/features/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. ```sh 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. ```ts // 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: ```ts 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`: ```ts // 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. | ```ts 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](/docs/sdk/workflows/concurrency-limits): cap how many calls run at once with `acquireSlot` and `releaseSlot`. - [Voice](/docs/home/features/voice): the LiveKit project, the voice worker and call guardrails. - [Building workflows](/docs/sdk/workflows/building-workflows): scaffold, trigger and observe a workflow.