Developer docs

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.

Every snippet below is filled in with your real publishable key and this server's origin as soon as the page loads. Use the Copy button on any code block to grab the resolved text.

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.

html
<!-- Double embed -->
<script src="https://your-double-host/w.js" data-key="pk_live_xxxxxxxxxxxx" async></script>

Pin a specific agent (optional)

By default the widget uses your live agent. To pin a particular one, add data-agent:

html
<script src="https://your-double-host/w.js"
        data-key="pk_live_xxxxxxxxxxxx"
        data-agent="agt_..." async></script>
That's the whole integration. The launcher shows an always-on AI disclosure badge, opens a 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.

CommandArgumentDescription
'open'Open the call panel.
'close'Close the panel.
'mount'objectRender the full call UI inline: Double('mount', { target: '#el' }). Equivalent to data-inline; open/close are no-ops once mounted inline.
'identify'objectAttach known visitor identity (email, name, plan, …). Merged into the session's visitor.identify.
'context'objectAttach page / context (page, cartValue, …). Injected into the agent's context at session start.

Example: identify a logged-in user and pass context

javascript
// 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'); });
Commands queue safely. If you call 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

pk_live_xxxxxxxxxxxx

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

sk_live_••••••••••••

For server-side REST. Sent as Authorization: Bearer sk_.... Authorizes all management routes (agents, profiles, knowledge, analytics, session detail).

Never ship the secret key to the browser. Anything client-side should use the publishable 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

bash
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

GET/v1/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.

bash
curl https://your-double-host/v1/bootstrap

Response

json
{
  "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.

GET/v1/agents
POST/v1/agents
GET/v1/agents/:id
PATCH/v1/agents/:id
DELETE/v1/agents/:id
POST/v1/agents/:id/synccreates / updates the avatar persona

List agents

bash
curl https://your-double-host/v1/agents \
  -H "Authorization: Bearer sk_live_your_secret_key"

Create an agent

bash
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

FieldTypeNotes
idstringAgent id (agt_...).
namestringDisplay name.
statusenumdraft | live | paused. The widget picks the live agent when no id is pinned.
profile_idstringFace + voice profile.
script_idstringSales script (stages + objections).
persona_promptstringPersona / tone instructions.
greetingstringOpening line at call start.
languagestringPrimary language, e.g. en.
objectivesarray{ key, label, description, required }.
guardrailsarray{ kind: 'never'|'always', rule, pattern? }.
widget_configobject{ accent, launcherLabel, position, bookingUrl, transferEnabled, badge }.
tavus_persona_idstringSet 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.

bash
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.

GET/v1/stock-replicassystem replicas, cached
GET/v1/profiles
POST/v1/profiles
POST/v1/profiles/:id/refreshpolls vendor status

List stock replicas

bash
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)

bash
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"
  }'
Custom profiles are created as a 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.

POST/v1/knowledge/sources
GET/v1/knowledge/search?q=&agent_id=

Add a URL or text source

bash
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

bash
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

bash
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.

POST/v1/sessionspublic · pk auth
GET/v1/sessions
GET/v1/sessions/:id
POST/v1/sessions/:id/endpk or secret

Start a session

Authorized by the publishable key. agent_id is optional. Omit it to use your live agent.

bash
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

json
{
  "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

bash
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

GET/v1/analytics/overview?days=30
bash
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: [...] }.

GET/v1/analytics/sources?by=path|source|campaign
GET/v1/export/leads.csv
GET/v1/export/sessions.csv

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.

GET/v1/agents/:id/intelplaybook + freshness
POST/v1/agents/:id/intel/rebuildfrontier model, ~30s
GET/v1/agents/:id/readinesspublish checklist
POST/v1/agents/:id/simulategraded test conversation
bash
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/billingplan, trial, usage, limits
POST/v1/billing/trialowner · starts the 14-day trial
POST/v1/billing/planowner · { "plan": "growth" }

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

GET/v1/membersmembers + pending invites
POST/v1/members/inviteadmin · returns invite URL
PATCH/v1/members/:userIdadmin · change role
DELETE/v1/members/:userIdadmin
PATCH/v1/workspaceadmin · name / settings
POST/v1/workspace/rotate-keysowner
GET/v1/auditadmin · audit log
GET/v1/integrationswebhook endpoints
POST/v1/integrations{ kind: "webhook", config: { url, secret? } }
DELETE/v1/integrations/:id

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 }.

json
{
  "event": "session.ended",
  "ts": "2026-07-04T18:03:22.184Z",
  "data": {
    "session_id": "ses_...",
    "agent_id": "agt_..."
  }
}

Events

EventFires when
session.startedA visitor connects a live call.
session.endedA call ends (post-call pipeline runs).
outcome.createdA call reaches an outcome: lead, booked meeting, transfer.
profile.readyA custom profile finishes processing.
demo.failedA live demo could not start or run.
guardrail.triggeredA 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.

POST/mcpinitialize · ping · tools/list · tools/call

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.

json
{
  "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.

json
{
  "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

GET/.well-known/oauth-protected-resource/mcp
GET/.well-known/oauth-authorization-server
POST/oauth/registerdynamic client registration
POST/oauth/tokencode + PKCE · refresh rotation

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.

How to enable it: give the server a public HTTPS URL by setting 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".