# Outbound Calls from a LiveKit Agent

> Dial one number or run a paced campaign from your livekit-agents worker. The LiveKit-shaped API in the transport plugin wraps the administration API; the same register-and-wait worker answers.

The worker you already run for inbound calls
([Run a LiveKit Agent on TeleQuick Transport](/modalities/voice/recipes/livekit-transport))
serves outbound calls too, unchanged. The engine dials the number on your
trunk, and when the callee answers it dispatches the call to your registered
worker **exactly like an inbound call**.

So there are only two moving parts:

1. **The worker** — already running, already registered. No change.
2. **The trigger** — a dial-out request. This page.

> **NOTE:**
> LiveKit agents dial out with `lkapi.sip.create_sip_participant(...)`. The
>   transport plugin ships the same gesture — `TeleQuickAPI` — so outbound code
>   written against LiveKit's API moves across with a changed import. Underneath
>   it is one call to the administration API, not a LiveKit server.

## Credentials

Dial-out is a **control-plane** action, so it uses your org-scoped `mpk_…` API
key — not the media app key/secret the worker authenticates its transport with.

| Variable | What |
| --- | --- |
| `TELEQUICK_API_KEY` | Org-scoped `mpk_…` key. Mint it in the console under **Settings → API keys** with scope `mcp:telephony:write` (add `:read` for list/abort) |
| `TELEQUICK_ORG_ID` | Your organization id |

> **WARNING:**
> The key acts **as its creator**, confined to one organization. Mint a
>   narrow one for the dialer rather than reusing an administrative key, and
>   never ship it to a browser.

## Dial one number

  
```python
from livekit.plugins import telequick

api = telequick.TeleQuickAPI()      # env: API key + org id

res = await api.sip.create_sip_participant(
    sip_trunk_id="my-trunk",
    sip_call_to="+919986398327",
    agent_id="71156156",               # the agent config whose VENDOR_BRIDGE
                                       # points at your registered worker
    sip_call_from="+18005550100",      # optional caller-id override
    metadata={"lead_id": "L-4471"},    # optional per-call context
)
print(res.call_sid, res.status)
```
  
  
```ts
import { TeleQuickAPI } from "@telequick/livekit-transport";

const api = new TeleQuickAPI();     // env: API key + org id

const res = await api.sip.createSipParticipant({
  sipTrunkId:  "my-trunk",
  sipCallTo:   "+919986398327",
  agentId:     "71156156",
  sipCallFrom: "+18005550100",
  metadata:    { lead_id: "L-4471" },
});
console.log(res.callSid, res.status);
```
  

`agent_id` is the TeleQuick agent (console id) that answers. For an external
agent that is the `VENDOR_BRIDGE` config whose `vendor_room` is the handle your
worker registered under. `room_name` / `roomName` is accepted as a LiveKit-idiom
alias for the same field.

### What the worker receives

Your worker gets a `job_assign` when the **callee answers** — the same event an
inbound call produces. One agent script therefore serves both directions;
branch on the numbers if your outbound flow differs.

  
```python
async def entrypoint(ctx):
    call_sid = ctx.job.id                       # the platform call sid
    caller = next(iter(ctx.room.remote_participants.values()), None)
    dialed = caller.attributes.get("sip.trunkPhoneNumber") if caller else ""
    meta = json.loads(ctx.job.metadata) if ctx.job.metadata else {}
    # meta == {"lead_id": "L-4471"} — per-call context merged over the
    # agent's own metadata (per-call keys win).
```
  
  
```ts
export async function entrypoint(ctx: any) {
  const callSid = ctx.job.id;                           // the platform call sid
  const dialed  = ctx.room.attributes["sip.trunkPhoneNumber"];
  const meta    = ctx.job.metadata ? JSON.parse(ctx.job.metadata) : {};
}
```
  

> **NOTE:**
> The two plugins surface SIP attributes differently today — TypeScript puts
>   them on `ctx.room.attributes`, Python on the remote participant. See
>   [What your agent sees](/modalities/voice/integrations/livekit#what-your-agent-sees)
>   for the exact table.

## Dial a list — a paced campaign

Hand the engine the whole list and it paces the dial-out itself: calls per
second and maximum concurrency are enforced **server-side** by the campaign
manager. You do not write a limiter.

  
```python
res = await api.sip.create_sip_campaign(
    name="sept-renewals",
    numbers=["+9198...", "+9197...", "+9196..."],
    sip_trunk_id="my-trunk",
    agent_id="71156156",
    calls_per_second=2,
    max_concurrent_calls=10,
)
print(res.campaign_id, res.status, res.loaded_numbers)

# Progress and control
for c in await api.sip.list_campaigns():
    print(c["campaign_id"], c["status"], c.get("loaded_numbers"))

await api.sip.abort_campaign(res.campaign_id)
```
  
  
```ts
const res = await api.sip.createSipCampaign({
  name:               "sept-renewals",
  numbers:            ["+9198...", "+9197...", "+9196..."],
  sipTrunkId:         "my-trunk",
  agentId:            "71156156",
  callsPerSecond:     2,
  maxConcurrentCalls: 10,
});
console.log(res.campaignId, res.status, res.loadedNumbers);

await api.sip.listCampaigns();
await api.sip.abortCampaign(res.campaignId);
```
  

### Campaign limits

| Field | Default | Maximum |
| --- | --- | --- |
| `numbers` | — | 50 000 per campaign (at least 1) |
| `calls_per_second` | 2 | 200 |
| `max_concurrent_calls` | 20 (2 in the starter example) | 2 000 |
| `max_duration_ms` | unset | — per-call hard stop |

A `campaign_id` is generated for you when you do not pass one. `list_campaigns`
returns the persisted campaign rows — `campaign_id`, `name`, `status`,
`total_numbers`, `loaded_numbers`, `created_at` — newest first. `abort_campaign`
stops further dialling; calls already in progress are not torn down.

## A runnable example

The starter repo ships this as `src/outbound.py`:

```bash
# one call
python src/outbound.py +919986398327

# a paced campaign
python src/outbound.py +9198... +9197... +9196... --cps 2 --concurrent 5
```

It reads `TELEQUICK_API_KEY`, `TELEQUICK_ORG_ID`, `CC_TRUNK`, and
`CC_AGENT_ID` from the environment. The receiving side is the same
`telequick_worker.py` that serves inbound.

## Beyond dial-out

`TeleQuickAPI` deliberately wraps **only** the dial-out calls an agent process
needs, in the shape LiveKit code already uses. For the rest of the control
plane — trunks, numbers, agents, CDR, recordings — use the typed
[Admin API SDK](/sdks/admin-sdk), which reaches the same BFF with the same key.

## Related

  - **[Run a LiveKit Agent on the Transport](/modalities/voice/recipes/livekit-transport)** — The worker that answers these calls.
  - **[Outbound Sales Agent](/modalities/voice/recipes/outbound-sales)** — The same flow for a native agent, with voicemail handling and warm transfer.
  - **[Recordings API](/modalities/voice/api/recordings)** — Fetch the audio for every call the campaign placed.
  - **[Embed an Agent Widget](/modalities/voice/recipes/web-embed)** — The same worker, reached from a browser instead of the PSTN.
