# Embed an Agent Widget on Your Website

> Drop a voice, text, and video AI-agent widget into any page. One tag plus one backend route. The browser leg runs on MoQT over QUIC — no WebRTC SFU, and no credential in the page.

`@telequick/agent-widget` is the embeddable web front end for a TeleQuick
voice agent. It renders a connect button, an audio visualizer driven by a real
`AnalyserNode`, a live transcript, a typed-turn composer, and a camera tile —
and it opens the media itself over MoQT/QUIC.

> **NOTE:**
> **Provenance.** The widget's UI shape and interaction model derive from
>   LiveKit's [`agent-starter-react`](https://github.com/livekit-examples/agent-starter-react)
>   and `@livekit/components-react` (Apache-2.0), retargeted from a LiveKit
>   `Room` onto TeleQuick's transport. The component fork lives in
>   `telequick-components/`. Nothing in the page talks to a LiveKit
>   server: there is no `Room`, no SFU, and no WebRTC transport.

It works against **both** kinds of agent, with no change to the page:

- a **native** TeleQuick agent (the in-engine ASR→LLM→TTS pipeline), and
- an **external** agent — your own `livekit-agents` worker running over our
  transport (see [LiveKit Agent over TeleQuick Transport](/modalities/voice/integrations/livekit)).

> **NOTE:**
> **An external agent gets the mic always, and the camera and keyboard when it
>   asks.** A **native** agent gets all three. An **external** agent gets the mic,
>   plus whichever of the camera (`accepts_video` / `acceptsVideo`) and keyboard
>   (`accepts_text` / `acceptsText`) its worker declared.
> 
>   Nothing is silently dropped either way. The engine announces the real
>   capability for the call — from what the worker that took it declared — and the
>   widget shows or hides each control in response, so a user is never offered an
>   input that goes nowhere. See
>   [Camera and keyboard input](/modalities/voice/integrations/livekit#camera-and-keyboard-input).

## The shape of it

```
  your page                     your backend                the platform
┌─────────────────┐  POST      ┌──────────────────┐  mpk_  ┌──────────────────┐
│  the widget     │──────────▶ │ /api/agent-session│──────▶│ telephony.       │
│  (no key)       │  agentId   │  (holds the key)  │       │ originate        │
└────────┬────────┘ ◀──────────└──────────────────┘◀───────└──────────────────┘
         │    callSid + voiceToken + quicUrl
         │
         └── MoQT / WebTransport on :443 ──▶ engine ──▶ agent (native or yours)
```

The page never holds a privileged credential. Your backend mints a **per-call
grant** — a call sid plus a capability token scoped to that one call, 1-hour
TTL — and the widget opens the transport with it.

## 1. Install

```bash npm
npm i @telequick/agent-widget
```

React 18+ is a peer dependency.

The widget deliberately **never opens the transport itself**. It renders
against an `AgentSession` — a five-method interface (`start`, `sendText`,
`setCameraEnabled`, `setMicEnabled`, `stop`) — so the UI and the wire evolve
apart, and so the widget is testable without a browser. `createWtSession`
adapts the proven voice client to that interface:

```ts
import { createWtSession } from "@telequick/agent-widget";
const session = createWtSession({ WtHumanAudioSession, grant });
```

> **WARNING:**
> **`WtHumanAudioSession` is not on the public registry yet.** It is the
>   browser voice client the console's Test Drive ships
>   (`wt-human-audio-client.ts`), and today it is a **single file you vendor**
>   into your app rather than a package you install. Until it is published,
>   either vendor that file — ask support@telequick.dev for the current copy — or
>   implement `AgentSession` yourself over the public MoQT helpers in
>   [Browser Voice Agent](/modalities/voice/recipes/browser-agent)
>   (`captureMicrophone`, `OpusPlayer`, `attachCaller` from
>   `telequick_connect/moqt`). Everything else on this page is unaffected by which
>   one you pick.

## 2. Mint the grant on your backend

One call against the administration API with an org-scoped `mpk_…` key. Mint
the key in the console under **Settings → API keys** with scope
`mcp:telephony:write`.

  
```ts app/api/agent-session/route.ts
import { createAdminClient } from "@telequick/admin-sdk";

const orgId = process.env.TELEQUICK_ORG_ID!;
const admin = createAdminClient({
  baseUrl: "https://portal.telequick.dev",
  apiKey:  process.env.TELEQUICK_API_KEY!,
  orgId,
});

export async function POST(req: Request) {
  const { agentId } = await req.json();

  // Where the browser's QUIC leg terminates.
  const gw = await admin.telephony.gatewayConnectInfo.query({ orgId });
  if (!gw.enabled) return new Response("gateway unavailable", { status: 503 });

  // A browser session is a call with no PSTN leg: the `__test_drive__` trunk
  // is the browser-session trunk, and `to` addresses the agent directly.
  const res = await admin.telephony.originate.mutate({
    orgId,
    trunk_id:         "__test_drive__",
    to:               `agent:${agentId}@${orgId}`,
    default_app:      "AI_BIDIRECTIONAL_STREAM",
    default_app_args: agentId,
    // Optional per-call context. Merged OVER the agent's own metadata
    // (per-call keys win) and delivered as ctx.job.metadata to an external
    // agent — an order id, a campaign, the signed-in user's plan.
    metadata: { source: "website", plan: "pro" },
  });
  if (res.error_message) return new Response(res.error_message, { status: 400 });

  return Response.json({
    callSid:    res.call_sid,
    voiceToken: res.voice_token,   // per-call capability, 1 h TTL
    quicUrl:    gw.url,
  });
}
```
  
  
```python agent_session.py
import os, json, urllib.request

PORTAL = "https://portal.telequick.dev"
KEY    = os.environ["TELEQUICK_API_KEY"]
ORG    = os.environ["TELEQUICK_ORG_ID"]

def _trpc(method: str, path: str, payload: dict) -> dict:
    url = f"{PORTAL}/trpc/{path}"
    data = None
    if method == "GET":
        url += "?input=" + urllib.parse.quote(json.dumps(payload))
    else:
        data = json.dumps(payload).encode()
    req = urllib.request.Request(url, data=data, method=method, headers={
        "authorization": f"Bearer {KEY}", "content-type": "application/json",
    })
    with urllib.request.urlopen(req, timeout=30) as r:
        return json.loads(r.read()).get("result", {}).get("data", {})

def agent_session(agent_id: str) -> dict:
    gw = _trpc("GET", "telephony.gatewayConnectInfo", {"orgId": ORG})
    res = _trpc("POST", "telephony.originate", {
        "orgId": ORG,
        "trunk_id": "__test_drive__",
        "to": f"agent:{agent_id}@{ORG}",
        "default_app": "AI_BIDIRECTIONAL_STREAM",
        "default_app_args": agent_id,
    })
    if res.get("error_message"):
        raise RuntimeError(res["error_message"])
    return {
        "callSid": res["call_sid"],
        "voiceToken": res.get("voice_token", ""),
        "quicUrl": gw["url"],
    }
```
  

> **WARNING:**
> **Never ship the `mpk_…` key to the browser.** It is org-scoped and acts as
>   its creator. The page only ever sees the per-call grant, which authorises one
>   call's namespaces and expires in an hour.

## 3a. Embed it as a tag

The shortest path — no React in your own build. Drop the element and one
module script:

```html
<telequick-agent
  agent-id="71156156"
  session-url="/api/agent-session"
  modalities="audio,text,video"></telequick-agent>

<script type="module">
  import { configureAgentWidget } from "@telequick/agent-widget/embed";
  import { WtHumanAudioSession } from "./vendor/wt-human-audio-client.js";

  // Wire the transport once, before the element upgrades.
  configureAgentWidget({ WtHumanAudioSession });
</script>
```

| Attribute | Default | Meaning |
| --- | --- | --- |
| `agent-id` | *(required)* | The TeleQuick agent that answers. Its console id |
| `session-url` | `/api/agent-session` | Your backend route from step 2. `POST { agentId }` → the grant |
| `modalities` | all three | Comma-separated subset of `audio,text,video` |
| `title` | — | Optional heading rendered above the widget |

The element fetches the grant on `connectedCallback` and mounts the widget. A
missing `agent-id`, or a `configureAgentWidget` you forgot to call, logs an
error to the console rather than rendering a dead button.

> **NOTE:**
> The snippet uses bare module specifiers, so it needs either a bundler step or
>   an [import map](https://developer.mozilla.org/docs/Web/HTML/Element/script/type/importmap)
>   on the page. Bundling is the usual answer: the result is one script tag you
>   host yourself, which is also what lets you pin a version.

## 3b. Or mount the React component

Use this when the widget lives inside an app you already build, and you want
the state transitions for your own chrome.

```tsx
import { AgentWidget, createWtSession } from "@telequick/agent-widget";
import { WtHumanAudioSession } from "./vendor/wt-human-audio-client.js";

const grant = await fetch("/api/agent-session", {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({ agentId: "71156156" }),
}).then((r) => r.json());

const session = createWtSession({ WtHumanAudioSession, grant });

<AgentWidget
  session={session}
  modalities={{ audio: true, text: true, video: false }}
  title="Talk to sales"
  onState={(s) => console.log("session state:", s)}   // idle | connecting | live | reconnecting | ended | error
/>;
```

- **``** (`AgentSession`, required) — What opens the transport. `createWtSession({ WtHumanAudioSession, grant })`   builds one; any object implementing `AgentSession`   (`start` / `sendText` / `setCameraEnabled` / `setMicEnabled` / `stop`) also   works, which is what makes the widget testable without a browser.
- **``** (`{ audio?, text?, video? }`, default `all three`) — Which controls the embedder wants.
- **``** (`(s: SessionState) => void`) — Called on every transition, for your own page chrome.
- **``** (`string`) — Optional heading above the widget.

## Modalities are negotiated, not assumed

A control appears only when **both** your `modalities` config **and** the
agent's announced capabilities allow it. The engine announces once per session
what the agent can accept besides the microphone, so a control never appears
that would silently do nothing:

| Agent | Mic | Keyboard | Camera |
| --- | --- | --- | --- |
| Native, cascaded STT→LLM→TTS | yes | yes | no — there is no vision node in the pipeline |
| Native, multimodal realtime | yes | yes | yes |
| **External** (your own worker) | yes | yes — when the worker declares it | yes — when the worker declares it |

The disabled control carries the reason the engine sent
(`AgentCapabilities.videoReason`) as its tooltip, so the page explains itself —
an external worker that never asked for frames reads *"External agent: this
worker did not declare video support."*

> **NOTE:**
> Camera frames are rate-limited **server-side** to one frame per second by
>   default. Video into a realtime model is billed per frame, and a browser
>   sending 30 fps would multiply the bill by thirty for no gain. Frames are also
>   capped at 1 MB each, typed turns at 4 KB.

## The wire, for reference

You never write this — the client does — but it is what to look for in a
network trace.

| Direction | Track | Payload | Reaches an external agent? |
| --- | --- | --- | --- |
| mic | `voice/<sid>/human` | PCM16 8 kHz, one object per 20 ms | **yes** — republished onto the worker's media plane |
| typed | `voice/<sid>/text` | `{"v":1,"text":"…"}` per message | yes — when the worker declared text |
| camera | `voice/<sid>/video` | one JPEG per frame, ~1 fps | yes — when the worker declared video |
| agent reply | `agentout/<sid>/audio` | the agent's voice | yes |
| transcript | `agentout/<sid>/transcript` | turns + capabilities | yes |

## Hanging up

The browser closing its MoQT session tells the engine nothing on its own: the
agent session stays up with its provider WebSocket open, and for an external
agent your worker stays assigned to a job with no caller. End the session from
your backend when the widget reports `ended`:

```ts
await admin.telephony.testDriveStop.mutate({ orgId, call_sid: callSid });
```

## Browser support

The media plane is **WebTransport-only** in the browser today — there is no
WebSocket fallback rung in the browser SDK; that ladder ships only in the
native cores. In practice: a current Chromium, Edge, Firefox 133+, or Safari
Technology Preview. See
[Browser compatibility](/modalities/voice/transport-web/browser-compatibility).

## Related

  - **[LiveKit Agent over TeleQuick Transport](/modalities/voice/integrations/livekit)** — Point the widget at your own `livekit-agents` worker. The page does not change.
  - **[Outbound Calls from a LiveKit Agent](/modalities/voice/recipes/livekit-outbound)** — The same worker, dialling out: one call or a paced campaign.
  - **[Browser Voice Agent](/modalities/voice/recipes/browser-agent)** — The layer underneath: capture, encode, and play back by hand.
  - **[Recordings API](/modalities/voice/api/recordings)** — Fetch the audio for any session the widget started.
