Build with Double.
Double is an embeddable AI video sales agent. One script tag drops a launcher on your site; clicking it opens a live, face-to-face video call with your org's agent: no forms, no calendar, no wait. This page covers the embed, the browser JavaScript API, authentication, the full REST API, webhooks, and how the conversation is driven.
Quickstart
Add one <script> tag to your site. It renders a launcher bubble in the corner; when a visitor clicks it, Double opens a live video call with your org's live agent. No agent id is required. The server falls back to your live agent automatically.
<!-- Double embed -->
<script src="https://your-double-host/w.js" data-key="pk_live_xxxxxxxxxxxx" async></script>
Want to call the JavaScript API before the loader finishes downloading? Add this stub above the script tag. Commands are queued and replayed once Double is ready.
<script>
window.DoubleQueue = window.DoubleQueue || [];
window.Double = window.Double || function(){ (window.DoubleQueue=window.DoubleQueue||[]).push(arguments); };
</script>
<script src="https://your-double-host/w.js" data-key="pk_live_xxxxxxxxxxxx" async></script>
Prefer the call as part of the page, like a /talk page, a demo hub, or a docs sidebar? Add data-inline pointing at any container and the full call UI renders inside it instead of a corner launcher. Same pre-call disclosure and mic check; the iframe fills the container (min-height 560px), and when the visitor ends the call it resets to a fresh pre-call screen.
<div id="double-call" style="height:80vh"></div>
<script src="https://your-double-host/w.js"
data-key="pk_live_xxxxxxxxxxxx"
data-inline="#double-call" async></script>
Or mount programmatically once the container exists (works with the async-safe stub too):
Double('mount', { target: '#double-call' });
Pin a specific agent (optional)
By default the widget uses your live agent. To pin a particular one, add data-agent:
<script src="https://your-double-host/w.js"
data-key="pk_live_xxxxxxxxxxxx"
data-agent="agt_..." async></script>
420×640 panel (full-screen on mobile), and runs the pre-call disclosure & mic check before connecting the call.JavaScript API
Once the loader is on the page it exposes a single global function, window.Double(cmd, arg), that talks to the widget iframe over postMessage.
| Command | Argument | Description |
|---|---|---|
| 'open' | — | Open the call panel. |
| 'close' | — | Close the panel. |
| 'mount' | object | Render the full call UI inline: Double('mount', { target: '#el' }). Equivalent to data-inline; open/close are no-ops once mounted inline. |
| 'identify' | object | Attach known visitor identity (email, name, plan, …). Merged into the session's visitor.identify. |
| 'context' | object | Attach page / context (page, cartValue, …). Injected into the agent's context at session start. |
Example: identify a logged-in user and pass context
// Tell the agent who's on the page…
Double('identify', {
email: 'dana@acme.com',
name: 'Dana Ortiz',
plan: 'growth'
});
// …and what they're looking at right now.
Double('context', {
page: 'pricing',
cartValue: 1490,
planViewed: 'scale'
});
// Open the call from your own button.
document.querySelector('#talk-to-sales')
.addEventListener('click', function(){ Double('open'); });
Double(...) before w.js has loaded, use the async-safe stub from the Quickstart. Queued calls are replayed in order once the widget is ready, so you never lose an identify or context.Authentication
Double uses two keys. Pick the one that matches where your code runs.
Publishable key
For the browser / widget. Safe to expose. Passed as the data-key attribute, as a ?pk= query param, or in a request body as { pk }. Authorizes the public routes (POST /v1/sessions, GET /v1/widget-config).
Secret key
For server-side REST. Sent as Authorization: Bearer sk_.... Authorizes all management routes (agents, profiles, knowledge, analytics, session detail).
pk_ key only. Keep sk_ on your server. Secret keys are stored hashed and shown exactly once — at signup and when you rotate keys in Dashboard → Settings. Lost it? Rotate.Where to find your keys
In the Dashboard → Settings. In local mode you can also read them from GET /v1/bootstrap (below), which requires no auth from localhost and auto-resolves the default org.
Example: authenticated REST call
curl https://your-double-host/v1/agents \
-H "Authorization: Bearer sk_live_your_secret_key"
REST API
All endpoints live under /v1, speak JSON, and return errors as { error: { message, detail? } } with a 4xx/5xx status. Management routes use the secret key; public routes use the publishable key.
Bootstrap
Returns the org's keys, the active brain mode, and available models. No auth required from localhost (local mode auto-uses the default org). This is what the widget and dashboard call at boot.
curl https://your-double-host/v1/bootstrap
Response
{
"org": {
"id": "org_...",
"name": "Acme, Inc.",
"publishable_key": "pk_live_xxxxxxxxxxxx",
"settings": { }
},
"brain_mode": "hosted",
"public_url": null,
"models": { }
}
Agents
An agent binds a profile (face + voice), a script, a persona, objectives, and guardrails into one configurable rep.
List agents
curl https://your-double-host/v1/agents \
-H "Authorization: Bearer sk_live_your_secret_key"
Create an agent
curl -X POST https://your-double-host/v1/agents \
-H "Authorization: Bearer sk_live_your_secret_key" \
-H "Content-Type: application/json" \
-d '{
"name": "Maya (Inbound rep)",
"status": "draft",
"profile_id": "prof_...",
"script_id": "scr_...",
"persona_prompt": "Warm, concise solutions engineer.",
"greeting": "Hey! Want a quick look at how this works?",
"language": "en",
"objectives": [
{ "key": "email", "label": "Work email", "description": "Capture a work email", "required": true }
],
"guardrails": [
{ "kind": "never", "rule": "Never quote discounts above 20%." }
],
"widget_config": {
"accent": "#2447F5",
"launcherLabel": "Talk to Maya",
"position": "bottom-right",
"bookingUrl": "https://cal.com/acme/demo",
"transferEnabled": true,
"badge": true
}
}'
Agent JSON shape
| Field | Type | Notes |
|---|---|---|
| id | string | Agent id (agt_...). |
| name | string | Display name. |
| status | enum | draft | live | paused. The widget picks the live agent when no id is pinned. |
| profile_id | string | Face + voice profile. |
| script_id | string | Sales script (stages + objections). |
| persona_prompt | string | Persona / tone instructions. |
| greeting | string | Opening line at call start. |
| language | string | Primary language, e.g. en. |
| objectives | array | { key, label, description, required }. |
| guardrails | array | { kind: 'never'|'always', rule, pattern? }. |
| widget_config | object | { accent, launcherLabel, position, bookingUrl, transferEnabled, badge }. |
| tavus_persona_id | string | Set after /sync. |
Sync to the video engine
After editing persona / script / knowledge, sync to build or update the avatar persona(s) used at call time.
curl -X POST https://your-double-host/v1/agents/agt_.../sync \
-H "Authorization: Bearer sk_live_your_secret_key"
Profiles
A profile is the face and voice of a rep: either a ready-to-use stock replica or a custom clone.
List stock replicas
curl https://your-double-host/v1/stock-replicas \
-H "Authorization: Bearer sk_live_your_secret_key"
Returns { data: [{ replica_id, replica_name, thumbnail_video_url, thumbnail_image_url, model_name }] }.
Create a stock profile (ready immediately)
curl -X POST https://your-double-host/v1/profiles \
-H "Authorization: Bearer sk_live_your_secret_key" \
-H "Content-Type: application/json" \
-d '{
"name": "Maya",
"kind": "stock",
"replica_id": "r79e1c033f",
"thumbnail_url": "https://.../maya.png"
}'
multipart/form-data upload (name, consent_statement, subject_name, subject_email, and the training_video / consent_video / voice_sample files). They start in processing. Poll POST /v1/profiles/:id/refresh until status is ready.Knowledge
Ground the agent in your own material. Add sources by URL, raw text, or file upload; they're chunked and indexed for retrieval at call time.
Add a URL or text source
curl -X POST https://your-double-host/v1/knowledge/sources \
-H "Authorization: Bearer sk_live_your_secret_key" \
-H "Content-Type: application/json" \
-d '{
"type": "url",
"name": "Pricing page",
"url": "https://acme.com/pricing",
"agent_id": "agt_..."
}'
JSON body is { type: 'url'|'text', name, url|text, agent_id? }. To upload a file (pdf/md/txt/html) instead, send multipart/form-data with a file field plus optional name / agent_id. Omitting agent_id makes the source available to every agent in the org.
Upload a file
curl -X POST https://your-double-host/v1/knowledge/sources \
-H "Authorization: Bearer sk_live_your_secret_key" \
-F "file=@./one-pager.pdf" \
-F "name=Product one-pager" \
-F "agent_id=agt_..."
Search the index
curl "https://your-double-host/v1/knowledge/search?q=refund+policy&agent_id=agt_..." \
-H "Authorization: Bearer sk_live_your_secret_key"
Returns { results: [{ chunk_id, source_id, source_name, heading, text, score }] }.
Sessions
A session is one live call. The widget creates it with your publishable key; management routes read it with the secret key.
Start a session
Authorized by the publishable key. agent_id is optional. Omit it to use your live agent.
curl -X POST https://your-double-host/v1/sessions \
-H "Content-Type: application/json" \
-d '{
"pk": "pk_live_xxxxxxxxxxxx",
"agent_id": "agt_...",
"visitor": {
"anon_id": "v_8f21",
"identify": { "email": "dana@acme.com", "name": "Dana Ortiz" },
"page": "pricing",
"context": { "planViewed": "scale" }
}
}'
Response
{
"session_id": "ses_...",
"conversation_url": "https://rtc.double.app/...",
"conversation_id": "c_...",
"ws_url": "wss://your-double-host/ws?session_id=ses_...",
"brain_mode": "hosted",
"agent": {
"name": "Maya",
"greeting": "Hey! Want a quick look?",
"widget_config": { },
"profile_thumbnail": "https://.../maya.png",
"demo_available": true
},
"disclosure": true
}
End a session
curl -X POST https://your-double-host/v1/sessions/ses_.../end \
-H "Authorization: Bearer sk_live_your_secret_key"
GET /v1/sessions?limit=&agent_id= lists sessions; GET /v1/sessions/:id returns full detail including turns, events, and post-call analysis. Ending a call triggers the post-call analysis pipeline.
Analytics
curl "https://your-double-host/v1/analytics/overview?days=30" \
-H "Authorization: Bearer sk_live_your_secret_key"
Returns { totals: { sessions, minutes, leads, meetings, demos_started }, rates: { completion, demo_start, outcome }, by_day: [...], top_objections: [...], recent_sessions: [...] }.
Agent intel
A frontier model works in the background and seeds the live (fast) model: a durable playbook per agent (grounded facts, FAQ, objection angles, do-not-claim rules — distilled from knowledge, the script, and recent call analyses), a pre-call brief per session, and a coach note between turns. The playbook rebuilds itself when knowledge or the persona changes; rebuild on demand to review it.
curl -X POST https://your-double-host/v1/agents/agt_.../intel/rebuild \
-H "Authorization: Bearer sk_live_your_secret_key"
Returns { playbook: { facts, differentiators, faq, objection_angles, do_not_claim }, built_at, stale }. Session briefs and coach notes appear in each session's event stream as intel.brief / intel.coach.
Billing & trial
Plans: free, starter, growth, scale. The 14-day trial requires a card, includes 30 minutes of live video time, and converts automatically when it ends. Exceeding a limit returns HTTP 402.
GET /v1/billing returns { plan, status, trial: { ends_at, days_left, video_minutes }, video_budget: { limit, used, remaining }, usage, limits, plans }. Card details on the trial endpoint are validated server-side; only brand, last4, and expiry are ever stored. Wire a payment processor (Stripe Elements) before charging real cards.
Workspace & team
Webhooks
Register webhook endpoints per org via POST /v1/integrations to receive server-side events. Double sends an HTTP POST with a JSON body shaped { event, ts, data }.
{
"event": "session.ended",
"ts": "2026-07-04T18:03:22.184Z",
"data": {
"session_id": "ses_...",
"agent_id": "agt_..."
}
}
Events
| Event | Fires when |
|---|---|
| session.started | A visitor connects a live call. |
| session.ended | A call ends (post-call pipeline runs). |
| outcome.created | A call reaches an outcome: lead, booked meeting, transfer. |
| profile.ready | A custom profile finishes processing. |
| demo.failed | A live demo could not start or run. |
| guardrail.triggered | A guardrail blocked or corrected a response. |
MCP server
Everything in the product is also exposed to AI agents over the Model Context Protocol — one endpoint, JSON-RPC over HTTP POST, with 45+ tools spanning agents, faces, scripts, knowledge, demos, sessions, leads, analytics, billing, and workspace admin. Every tool executes through the same REST API documented above, so behavior, validation, and limits are identical.
Connecting (OAuth — recommended)
OAuth-capable MCP clients need only the URL; discovery, dynamic client registration, and the Authorization Code + PKCE (S256) flow are automatic. You sign in and pick a workspace on the consent screen.
{
"mcpServers": {
"double": {
"url": "https://your-double-host/mcp"
}
}
}
Connecting (manual bearer token)
For scripts and advanced setups, pass your org's secret key directly — it carries every scope.
{
"mcpServers": {
"double": {
"url": "https://your-double-host/mcp",
"headers": {
"Authorization": "Bearer sk_live_your_secret_key"
}
}
}
}
Scopes & tokens
OAuth tokens are scoped per domain — agents:read, agents:write, knowledge:write, sessions:read, billing:write, simulate:run, and so on. tools/list only shows tools your token can call; read-only tools never require a write scope. Access tokens live 24 hours; refresh tokens rotate on every use and reusing a rotated token revokes the whole family. Mutating tool calls land in the workspace audit log.
Discovery endpoints
Brain modes
The value of bootstrap.brain_mode tells you which engine drives the conversation. It is auto-detected. You don't set it directly.
hosted: default, zero-config
The avatar vendor's model drives the conversation, grounded in the persona, script, and knowledge that Double bakes into the session at start. Nothing else is required. This is the mode you get out of the box.
byo-llm: our policy loop drives every turn
When the server has a public HTTPS URL, the avatar pipeline calls Double's OpenAI-compatible bridge at /v1/llm/chat/completions on every turn. That routes the conversation through our policy loop (the script state machine, retrieval over your knowledge, guardrails, and tools), so Double drives the call rather than the vendor model alone.
PUBLIC_URL or running npm run tunnel. Double detects the URL and switches to byo-llm automatically; GET /v1/bootstrap will then report brain_mode: "byo-llm".