Build with DelegateWorker

Headless SDK quickstart

From an empty page to a live session your own interface drives.

The headless SDK gives you a session object and nothing else — no launcher, no call interface, no styling. You start a session, react to what it reports, and end it. This page goes through the whole surface, then gives you a complete example you can paste into a page and run.

If you have not chosen a path yet, read the integration overview first. If one script tag and our interface would do, take the embed widget instead.

Install

The SDK is published as an ES module. There is no package to install — import it straight from https://app.delegateworker.com/sdk/v1/dw-voice.js.

In a plain page
<script type="module">
  import { DWVoiceSession } from 'https://app.delegateworker.com/sdk/v1/dw-voice.js';

  window.DWVoiceSession = DWVoiceSession;
</script>

Or, in an application built with a bundler:

With a bundler
import { DWVoiceSession } from 'https://app.delegateworker.com/sdk/v1/dw-voice.js';

TypeScript declarations are published at the same path with a .d.ts extension: https://app.delegateworker.com/sdk/v1/dw-voice.d.ts. Save the file beside your source and reference it so the session options and events are typed.

TypeScript
// Save the published declarations beside your source, then reference them.
// curl -o dw-voice.d.ts https://app.delegateworker.com/sdk/v1/dw-voice.d.ts

/// <reference path="./dw-voice.d.ts" />

Start a session

DWVoiceSession.start() is the only entry point. It resolves when the session is ready — the socket is open, our side has accepted the session, and microphone capture has begun. Until it resolves, there is nothing for the visitor to talk to.

const session = await DWVoiceSession.start({ embedKey: 'YOUR_EMBED_KEY' });

// Resolved: the socket is open, the session is ready, the microphone is live.
session.getStatus(); // 'connected'

Call it from a user gesture, such as a click. Browsers only grant microphone access in response to one, and a session that starts on page load will be refused.

Options

OptionWhat it does
embedKeyRequired. Identifies the worker and carries its allowed-domains list. Safe to ship in page source — see origin security.
clientToolsA map of tool name to handler: { toolName: async (params) => result }. The handler runs in your page and its return value is the tool result.
dynamicVariablesValues passed into the worker at session start — the signed-in account, a plan name, a record id. Strings, numbers, and booleans.
onStatusChangeCalled with the new status whenever it changes. See session lifecycle and events.
onTranscriptCalled with { role, text, corrected } as the conversation goes. role is worker or visitor.
onClientToolCalled with (toolName, parameters) for every tool call. A notification, not a handler — it does not answer the call.
onAgentSpeakingCalled with true while the worker is audibly speaking and false when playback drains. Useful for a speaking indicator.
onErrorCalled with (code, message). Codes are listed under session lifecycle and events.

Methods

MethodWhat it does
session.end()Ends the session: tells our side, stops the microphone, stops playback, closes the socket, and moves the status to ended.
session.getStatus()Returns the current status synchronously.
session.setMuted(muted)Mutes or unmutes microphone capture. The session stays open and the worker keeps speaking; it simply stops hearing the visitor.

Client tools: the return value is the answer

A client tool is a tool the worker calls that runs in your page instead of on a server. You register the name and its parameters in the console, then pass a handler under clientTools with the same name. When the worker calls it, the SDK runs your handler and sends the result back into the conversation.

The handler is the whole contract. Whatever it returns is what the worker hears. Return a short, factual string and the worker will speak from it.

clientTools: {
  check_order_status: async (params) => {
    const order = await lookupOrder(params.order_id);
    if (!order) return 'no order with that number';

    // This string is the worker's answer. It will speak from it.
    return 'order ' + order.id + ' shipped on ' + order.shippedOn;
  },
}

Three behaviours worth knowing before you write your first one:

  • Return a string and that string is the tool result. Return nothing and the SDK answers ok — right for a tool that only changes the page.
  • Throw, and the error message goes back as a failed tool result. The session stays open; the worker gets told the tool failed and can say so.
  • A tool with no handler is answered unhandled automatically. An unrecognised tool never stalls or crashes a session, so you can add tools in the console before your page ships support for them.

onClientTool fires for every call, handled or not. Use it for logging and analytics — it cannot answer a tool.

For how to register tools, the difference between webhook tools and tools that run in your page, and the prompt pattern that makes them feel responsive, see the tools guide.

Origin security

The embed key is publishable. It sits in your page source, in plain view, on purpose — it is an identifier, not a secret, and it grants nothing on its own.

What protects it is the allowed-domains list it carries. Every domain that may run the worker is listed against the key in the console. When a session starts, the browser origin is checked against that list on our side, where a page cannot reach it. A request from an origin that is not on the list is refused with DW_VOICE_UNAUTHORIZED, whether or not the key is correct.

Practical consequences:

  • Add every origin you serve from, including staging and preview domains, before you deploy.
  • Copying your key out of your page source gets an attacker nothing on their own domain.
  • Never put a real secret — an API token, a customer identifier you would not show the visitor — into dynamicVariables. It is page-side data.

Keys are managed in the console at app.delegateworker.com.

Complete example

A working page: a start button, a status line, a live transcript, one client tool named update_dashboard that swaps a panel while the worker talks, and a clean end. Register update_dashboard in the console as a tool that runs in your page, with one string parameter called view, then paste your embed key in.

Markup
<button id="start">Talk to the worker</button>
<button id="end" disabled>End</button>
<p id="status">idle</p>

<ul id="transcript"></ul>

<div id="dashboard">
  <section data-view="overview">Overview</section>
  <section data-view="billing" hidden>Billing</section>
  <section data-view="usage" hidden>Usage</section>
</div>
Session
import { DWVoiceSession } from 'https://app.delegateworker.com/sdk/v1/dw-voice.js';

const startButton = document.querySelector('#start');
const endButton = document.querySelector('#end');
const statusLabel = document.querySelector('#status');
const transcript = document.querySelector('#transcript');
const dashboard = document.querySelector('#dashboard');

let session = null;

function showPanel(view) {
  const panel = dashboard.querySelector('[data-view="' + view + '"]');
  if (!panel) return false;
  dashboard.querySelectorAll('[data-view]').forEach((el) => {
    el.hidden = el !== panel;
  });
  return true;
}

function appendLine(role, text) {
  const line = document.createElement('li');
  line.dataset.role = role;
  line.textContent = text;
  transcript.append(line);
}

startButton.addEventListener('click', async () => {
  if (session) return;
  startButton.disabled = true;

  try {
    session = await DWVoiceSession.start({
      embedKey: 'YOUR_EMBED_KEY',

      clientTools: {
        // Registered in the console as a tool that runs in your page.
        // Whatever this handler returns is the worker's answer.
        update_dashboard: async (params) => {
          const view = typeof params?.view === 'string' ? params.view.trim() : '';
          if (!view) return 'no view was named';
          if (!showPanel(view)) return 'there is no panel called ' + view;
          return 'the ' + view + ' panel is now on screen';
        },
      },

      // Notification only. Fires for every tool call, including ones you
      // do not handle. It never answers the call.
      onClientTool: (toolName, parameters) => {
        console.debug('tool call', toolName, parameters);
      },

      onStatusChange: (status) => {
        statusLabel.textContent = status;
        endButton.disabled = status !== 'connected';
        if (status === 'ended' || status === 'error') {
          session = null;
          startButton.disabled = false;
        }
      },

      onTranscript: (event) => {
        // event.role is 'worker' or 'visitor'.
        // event.corrected is true when a worker line has been revised.
        appendLine(event.role, event.text);
      },

      onError: (code, message) => {
        statusLabel.textContent = message ? code + ': ' + message : code;
      },
    });
  } catch (err) {
    session = null;
    startButton.disabled = false;
    statusLabel.textContent = 'the session could not be started';
  }
});

endButton.addEventListener('click', async () => {
  await session?.end();
});

// Always end the session when the page goes away.
window.addEventListener('pagehide', () => {
  void session?.end();
});

Two habits in there are worth keeping. End the session on pagehide so a navigation does not leave the microphone open. And treat onStatusChange as the single source of truth for what your interface shows — including the reconnect the SDK attempts on your behalf.

Next

  • Tools guide — registering tools, webhook tools versus tools that run in your page, and the act-then-react pattern.
  • Session lifecycle and events — every status value, error codes, transcripts after a session, and end behaviour.