# External agents and /media/ credentials

> Run your own voice agent outside the engine: the agent handle, the /media/ credential lifecycle, worker presence, and bulk provisioning.

An **external agent** is a voice agent that you run yourself — a LiveKit
Agents worker or any comparable framework — outside the TeleQuick
engine. The engine still owns the call. Media rides TeleQuick QUIC
into a WebTransport endpoint your worker connects back to, so there is no
WebRTC hop and no vendor cloud in the media path.

Creating one produces two things:

- an `agent_config` row with an `external_handle` set, and
- a `media_app` credential (an **app key** plus a **secret**).

Both are minted by `mediaApps.provision`. This page covers the handle
grammar, the credential lifecycle, what the provision response does and
does not contain, how worker presence is determined, and the bulk
importer.

## What an external agent is

An in-engine agent is built from a template: `adminAgents.createConfig`
stores a system prompt and welcome message and the engine runs the
ASR/LLM/TTS pipeline. An external agent is created as a plain draft
`agent_config` row with no in-engine pipeline; `mediaApps.provision` then
marks it external and hydrates the **`VENDOR_BRIDGE`** pipeline, which is
what tells the engine to bridge the call out over `/media/` instead of
running its own turn loop.

Everything else about the object is unchanged. An external agent is still
an `agent_config` row, so it carries the same resource tags, appears in
Test Drive, and sits behind the same trunks, numbers, dispatch,
recordings and call-record surfaces as any other agent.

| Owned by you                              | Owned by the platform                                   |
| ----------------------------------------- | ------------------------------------------------------- |
| The agent process and its model stack      | The call leg, trunking, numbers and dispatch             |
| Connecting the worker to the connect URL   | The `/media/` endpoint and credential verification       |
| Keeping the secret safe                    | Sealing the secret at rest; issuing, rotating, revoking  |
| Nothing about the agent record itself      | The `agent_config` row, handle, tags, presence, Test Drive |

## The handle and why it must be unique

The **handle** is the token your agent registers as over TeleQuick
QUIC. It is used as the vendor-room / presence suffix, so it is the key
the platform matches a connected worker against.

Grammar: lowercase letters, digits, `.`, `_` and `-`. When the console
derives a handle from a display name it lowercases the name, collapses
every run of disallowed characters into `-`, strips leading and trailing
`-`, and truncates to 64 characters.

```
"Sales Assistant"  ->  sales-assistant
```

Handles must be unique within the org. A duplicate handle breaks presence
for **both** agents that share it, because a connected worker can no
longer be attributed to a single agent. The bulk importer therefore
validates each row against the handles already in use and rejects a
collision before it writes anything.

To change a handle after the fact, edit it inline on the external-agents
list. Saving calls `mediaApps.provision` again with the new handle. Treat
this as a deliberate action: presence keys and any vendor-side
configuration are built from the handle, so renaming one that is live
will disconnect the deployment until the worker is reconfigured.

## Provision, rotate, revoke, re-provision

Four procedures cover the whole lifecycle. All of them are org-scoped.

| Procedure                | Input                                | Returns                                   |
| ------------------------ | ------------------------------------ | ----------------------------------------- |
| `mediaApps.provision`    | `{ orgId, agentConfigId, handle }`   | `{ handle, key, mediaPath, secret? }`     |
| `mediaApps.rotateSecret` | `{ orgId, agentConfigId }`           | `{ key, secret, mediaPath }`              |
| `mediaApps.revoke`       | `{ orgId, agentConfigId }`           | —                                         |
| `mediaApps.presenceAll`  | `{ orgId }`                          | `{ agents: [{ id, connected }] }`         |

**Provision** creates the credential, sets the handle on the agent, and
sets the `VENDOR_BRIDGE` pipeline. It is also the re-provision path: call
it again for an agent whose credential was revoked, or whose credential
was never minted because provisioning failed partway through an import.
When you re-provision, pass the agent's existing `external_handle` rather
than a fresh one, so the customer's deployment keeps working.

**Rotate** issues a new secret for the existing credential. The current
secret stops working once the engine reloads, so rotate during a window
where the worker can be restarted with the new value.

**Revoke** marks the credential revoked. The agent keeps its handle and
its row, and cannot connect until it is provisioned again. Revoke is not
a one-way door — the external-agents screen shows an **Issue credential**
button for any external agent with no live credential, and that button is
`mediaApps.provision` with the preserved handle.

The list shows two independent states per row: the credential status
(`active`, or `revoked` when there is no live `media_app` row) and, when a
credential exists, worker presence.

## Connect URL, app key, and secret handling

`provision` and `rotateSecret` return a `mediaPath`. The connect URL is
that path on the engine host, over WebTransport:

```
wss://<engine-host>/media/<app-key>
```

The console derives `<engine-host>` from the hostname of the deployment's
configured QUIC URL, so a white-labelled or self-hosted deployment gets
its own host without any change to the credential.

You leave the dialog with four values:

| Value           | Where it comes from        | Notes                                            |
| --------------- | -------------------------- | ------------------------------------------------ |
| Connect URL     | `mediaPath` + engine host  | What the worker dials.                            |
| Agent handle    | `handle`                   | What the worker registers as.                     |
| App key         | `key`                      | Stable; visible on the list afterwards.           |
| Secret          | `secret`                   | Cleartext exactly once. Sealed at rest.           |

**`secret` is only present when the call actually created the
credential.** If you call `provision` for an agent that already has a
live credential, the response carries the handle, key and path but no
secret — there is nothing to reveal, because the stored value is sealed.
The console surfaces this as *"This agent already had an active
credential. Use Rotate to issue a new secret."* Rotating is the only way
to obtain a cleartext secret for an existing credential.

The same rule applies to the reveal panel after a rotate: it is shown
once, on that screen, and is not recoverable by reloading. Copy it before
you navigate away. If it is lost, rotate again.

## Pointing an existing agent framework at the transport

An unmodified LiveKit Agents worker connects through the
`livekit-plugins-telequick` transport plugin. Give it the three
values from the reveal panel — connect URL, app key and secret — and the
handle it should register as. No WebRTC and no LiveKit cloud is involved:
the plugin speaks the `/media/` endpoint directly.

## Worker presence and what it does not tell you

`mediaApps.presenceAll` returns one `{ id, connected }` entry per external
agent in the org, in a single batched call. The external-agents screen
polls it every 5 seconds, so a worker you start in another terminal
appears without reloading the page.

Presence and credential status answer different questions:

- **Credential `active`** means the platform *would accept* a connection.
  It says nothing about whether anyone is on the other end.
- **`connected: true`** means a worker is currently attached for that
  agent.

A call dispatched to an agent whose worker is absent is dead air, which
is why the presence pill is rendered separately, and why the presence
pill is hidden entirely for an agent with no credential — there is
nothing to connect with yet. Test Drive for an external agent applies the
same rule: the worker-presence pill is shown and **Start** is gated on it.

## Bulk import: CSV columns and the credentials file

Standing up a fleet through a modal, four copied values at a time, does
not scale. The **Bulk import** button on the external-agents screen takes
a CSV and walks away with one credentials file.

Download the template (`telequick-external-agents-template.csv`)
from the dialog to get the exact column set. Each row supplies the agent
`name`, plus a handle; when the handle cell is blank the importer derives
the effective handle from the name using the slug rule above. Rows are
validated before anything is written, including against the handles
already in use in the org.

Each row is imported in two steps, in this order:

1. `adminAgents.createConfig({ orgId, name })` — the draft agent row.
2. `mediaApps.provision({ orgId, agentConfigId, handle })` — the credential.

The order is deliberate. If step 2 fails, the agent row survives as an
ordinary non-external draft that you can see and fix, rather than leaving
a credential with no agent behind it.

The importer then offers a results CSV
(`telequick-external-agent-credentials.csv`) with one row per
imported agent:

| Column        | Value                                               |
| ------------- | --------------------------------------------------- |
| `name`        | The agent name from the input row.                   |
| `handle`      | The handle as provisioned.                           |
| `agent_id`    | The new `agent_config` id.                           |
| `app_key`     | The credential's app key.                            |
| `secret`      | The one-time secret.                                 |
| `connect_url` | `wss://<engine-host>/media/<app-key>`.               |

This file is the only copy of those secrets. Treat it as a secret file,
distribute it over a channel you trust, and rotate any credential whose
row you cannot account for.

## Tags delivered to your worker as call.tags

External agents are `agent_config` rows, so they take the same resource
tags as in-engine agents (`kind: 'agent_config'`). The external-agents
list resolves tags for the whole page in one bulk call and exposes the
same tag editor inline.

Tags are not cosmetic here: the engine forwards the agent's effective
tags to your worker on `job_assign` as **`call.tags`**. That is the
supported way to hand per-agent metadata — environment, team, customer,
routing hints — to a worker you host yourself, without baking it into the
worker's own configuration.

## Where this lives in the console

| Surface | What it does |
| ------- | ------------ |
| **New agent** dialog → *External agent* | Creates a single external agent: name, handle, then the one-time reveal of connect URL, handle, app key and secret. |
| **External agents** screen | The fleet list: credential status, worker presence, inline handle rename, tags, Test drive, Rotate, Revoke, Issue credential, and Bulk import. |
| **Test drive** (per agent) | Works for external agents; shows the worker-presence pill and gates Start on it. |

Rotate and revoke are reachable only from the external-agents screen.
That screen is the home for the credential lifecycle — if a secret leaks,
that is where you replace it.

## Related

- [Authentication](/concepts/authentication) — how server keys, relay tokens and secrets differ
- [Telemetry](/platform/telemetry) — CDRs and metrics for calls handled by an external agent
