Whissle · Reference implementation

Build a voice agent app

A small but complete application on Whissle — agents declared in one file, live voice calls with or without a talking face, and every past session with its transcript and score. It runs on a laptop in five minutes, and the code is public.

For engineeringRunning in ~5 minutesVerified 15 August 2026
01

What you are building

One folder — a JSON file, a 200-line server and a single HTML page — that stands up four working voice agents and the screens around them. It ships inside the public SDK repository as examples/interview-platform, and it is deliberately small enough to read in a sitting.

Small is not the same as partial. It does everything a real integration does: creates and configures agents, ingests the knowledge that makes them know your domain, authenticates a user before minting them a session, carries a live conversation with a rendered avatar, and reads back transcripts and grades afterwards. What it leaves out is a database, a build step and a login screen — the three things you already have.

Three packages, and which is which

Where the code runs decides the package, not preference. Getting this wrong is the one mistake with a security consequence.

@whissle/agents0.5.0
Runs in the browser · a token your server mints, or a publishable wpk_ key
Carries the live conversation and renders the talking face. The page uses this.
@whissle/sdk0.3.0
Runs on a server · secret wsk_ key
Creates agents, ingests knowledge, mints session tokens, reads call records. The server uses this.
@whissle/cli1.0.2
Runs in your terminal · secret wsk_ key
Setup and inspection: check a key, list agents, read what an agent knows, pull a transcript. Not a dependency of anything.
The one rule that matters
A wsk_ secret key carries full authority over your workspace. Never ship it to a browser. It stays on the server, which mints a short-lived session token — one visitor, one agent, fifteen minutes — and hands the browser only that.

How a session starts

browser :4000          your server :4000              Whissle
   |                       |                            |
   |--- POST /api/sessions ----->|                      |
   |                    who is this user?               |
   |               may they talk to this agent?         |
   |                            |--- wsk_ key --------->|
   |<-- token + transport + ICE -|                      |
   |                            |                       |
   +---------- joins the session directly -------------->|
                voice - avatar - transcripts

The server's job ends when the token is issued; media never passes through it. Keep that shape whatever else you change — your application decides who may talk to which agent, because it is the only thing that knows who the user is.

02

Get a key

Node 20 or newer and a Whissle key are the whole prerequisite list. No database, no Docker, no private repository access — the SDK repo is public and the packages install from npm.

Get your own key rather than sharing one. Sessions are billed to the workspace the key belongs to, and call records are scoped to it.

  1. Sign in at whissle.ai.
  2. Settings → API Keys → Create.
  3. Grant agents:read, agents:write, kb:read, kb:write, calls:read.
  4. Copy the secret. It is shown once and cannot be retrieved later.
Scopes are fixed at creation
A key cannot be widened afterwards. 403 … missing required scope means minting a new key, not editing this one. Since @whissle/sdk 0.3.0 that arrives as a typed WhissleAuthError with the missing scope parsed out, so you can branch on it instead of matching the string.
03

Connect the CLI

Two minutes here saves an afternoon later: if the key is wrong you want to learn it from whoami, not from an app that starts and then fails at the first call.

npm i -g @whissle/cli

whissle login          # paste the wsk_ key when prompted
                       # saved to ~/.whissle/config.json (0600)
whissle whoami         # confirms the workspace and your role

A healthy reply names your workspace:

{
  "base_url": "https://aws-gateway-backend.whissle.ai/bot",
  "organization": { "id": "65c2980e-…", "name": "Your Workspace" },
  "role": "owner"
}
The /bot prefix is required
The base URL ends in /bot, and /health answers 200 with and without it — so the obvious smoke test cannot detect a missing prefix. Check an API route instead: /bot/api/whoami returns 401 without a key, /api/whoami returns 404.

Two more, to confirm the workspace can actually run a session:

whissle agents types   # the blueprint catalogue: skills_exam, ai_tutor, …
whissle usage          # wallet balance; a 402 later means this hit zero
04

Run it

git clone https://github.com/WhissleAI/agents_js_sdk.git
cd agents_js_sdk/examples/interview-platform

npm install
export WHISSLE_API_KEY=wsk_live_…
npm start

It provisions on boot and tells you what it did:

Whissle example app → http://localhost:4000

provisioned "Interview — Electrician"   (a41f…)
provisioned "Interview — Line Cook"     (b7c2…)
provisioned "Interview coach"           (c93d…)
provisioned "Scheduling assistant"      (d05e…)

4/4 agent(s) ready.

You are running when

  • The boot log says 4/4 agent(s) ready
  • Agents lists four, two of them with an avatar code
  • A call connects and the agent speaks first, unprompted
  • The face's mouth moves on that greeting — not only on later replies
  • After hanging up, the call appears under Sessions
05

The whole app in one file

Everything specific to this app is in agents.json. Editing that file is how you change what the application is; the server and the page do not mention electricians or line cooks anywhere.

{
  "id": "line-cook",
  "name": "Interview — Line Cook",
  "type": "skills_exam",          // whissle agents types
  "avatar": "F2-TL",              // omit → voice only
  "summary": "Trade interview for a busy service line.",
  "knowledge": "Poultry to 74 °C, ground meat 71 °C, …",
  "interview": {                  // present → examiner + rubric
    "level": "entry",
    "skills":    ["Food safety and HACCP", "Cooking to temperature", …],
    "questions": ["What are the safe internal temperatures…", …]
  }
}
Why the rubric is generated, not written
An entry with an interview block gets both its prompt and its scoring rubric built from the same skill list. Hand-write the two separately and they drift within a month — you end up grading candidates on a competency the examiner stopped asking about.
06

Definitions become agents

On boot, each entry is turned into a real agent — or adopted, if one with that name already exists. Restarting the server never produces a second copy. This is @whissle/sdk, server-side:

const existing = (await whissle.agents.list()).find((a) => a.name === def.name);
const agent = existing ?? await whissle.agents.create({
  name:         def.name,
  agentType:    def.type,
  systemPrompt: def.interview ? interviewPrompt(def) : def.prompt,
  greeting:     def.greeting,
  ...(def.interview ? { scoring_prompt: interviewRubric(def) } : {}),
});

await whissle.embed.enable(agent.id, { origins: ["http://localhost:4000"] });
await whissle.kb.addSnippet(agent.id, def.knowledge, `${def.name} — reference`);

Three things that surprise people

  • Creating an agent does not apply its type's default prompt. Whatever you send is its brain. To keep a blueprint's behaviour, read whissle.agentTypes() and compose your section onto its default_prompt.
  • Embedding must be enabled or the mint refuses, and enabling it needs an origin list even though a secret-key mint ignores origins.
  • Adoption matches on name. An agent your workspace already has under the same name is reused — and its past calls will show up in this app's Sessions view.
When you update knowledge, replace — don't append
kb.sync() exists because kb.add in a loop accumulates revisions, and retrieval will happily quote the stale copy.
07

A call — with a face, or without

The browser makes exactly one request before a conversation, and it is to your server:

// server — behind YOUR auth
const session = await whissle.embed.sessionToken(agentId, {
  metadata: { user, agent: def.id },   // yours; lands on the call record
});
return json(res, 200, session);        // the WHOLE descriptor
// browser — no key, ever
const agent = new WhissleAgent({
  getToken: () => fetch("/api/sessions", {
    method: "POST", body: JSON.stringify({ agent: id }),
  }).then((r) => r.json()),
  ...(withAvatar ? { avatar: { id: "F2-TL", container: faceEl } } : {}),
});
await agent.start();
Return the whole mint, not just .token
The response also carries transport (how to connect) and ice_servers (belonging to the box on the other end). Hand back only the token and the client has to guess at both — which is how an app ends up pinned to a TURN server that was decommissioned a year ago, and it fails in the worst way available: ICE never completes and never errors, so the page just sits on “connecting” forever.

No backend at all? A publishable wpk_ key can go in the page and the SDK mints for itself — origin-bound and single-use. Never a wsk_: the SDK throws if you try, because the mint would otherwise accept it and the mistake would be silent.

08

What the page can hear

New in 0.5.0. An embedded agent used to be strictly worse than the one on whissle.ai, and three of the reasons were silent by construction — no error, no log, nothing in the console. The events below are what closed that gap.

agent.on("tool-started",  (t) => showSpinner(t.name));
agent.on("tool-finished", (t) => showResult(t.name, t.ok, t.citations));
agent.on("thinking",      (on) => setPaused(on));
agent.on("agent-partial", (t) => renderLive(t));       // as it speaks
agent.on("error",         (e) => explain(e.code));     // 402 vs 403 vs 404

await agent.sendText("can you repeat the last question?");  // type instead of talk
EventUse it for
connected · bot-readyConnected, then actually listening — show the second
tool-started · tool-progress · tool-finishedWhat the agent is doing, with args, result and citations
thinkingExplain a pause — one boolean, one edge each way
speaking-started · speaking-stoppedWho has the floor
agent-partial · agent-wordRender a reply as it happens
listening-started · listening-stoppedA barge-in happened
user-metadataEmotion / intent, with NEUTRAL suppressed honestly
signalThe live signal stream (schema v1)
avatar-ready · avatar-failedSwap the placeholder; fall back to audio-only
mic-lost · mic-restoredA pulled headset, said plainly instead of silence
error · disconnectedEnding, both ways
Sound is the part people miss
When an agent calls a tool it stops talking. The platform sends a per-tool earcon on every tool start, and before 0.5.0 this SDK dropped it — so an embed went silent for however long the tool took, with no explanation. Every caller reads that as a hang. It now plays the real bank, with a synthesised cue as the instant fallback so play() never waits on the network.
09

What happened afterwards

Changed in 0.3.0. calls means “rows from the calls table” and structurally cannot see a text thread. If your product has any text in it, read sessions instead — it is the union of voice calls and text threads, on the same calls:read scope.

const list   = await whissle.sessions.list({ limit: 50 });   // voice + text
const detail = await whissle.sessions.get(id);               // transcript, disposition
const trace  = await whissle.sessions.trace(id);             // which model answered
const result = await whissle.calls.result(id);               // the grade

for await (const s of whissle.sessions.iterate()) { … }       // auto-paging
ready: false is an answer, not a failure
Scoring runs after a call ends, so a result asked for immediately is legitimately not there yet. Render it as pending and poll — treating it as an error produces a support ticket for something that was working correctly.

The same records from a terminal, which is the fastest way to debug a session:

whissle sessions list --limit 10     # voice AND text
whissle sessions trace <id>          # which provider answered, and failovers
whissle calls transcript <call-id>
whissle calls result <call-id> --wait
10

Making it yours

Nothing about trades or interviewing is in the code — it is all in the JSON. These are the four places real change happens, roughly in the order you will want them.

  1. Replace agents.json. An app for accountants, triage nurses or a support line is this code with different entries.
  2. Rewrite the prompt builders. Two small functions in server.mjs decide how strict an examiner is and what its rubric rewards — the highest-leverage twenty lines in the example.
  3. Swap userFrom() for real authentication. It only has to answer one question before a token is minted: who is this, and may they talk to this agent?
  4. Put the id map in your database, so a restart re-provisions nothing.
What not to change
The browser holds no Whissle key and your server mints per visitor; and the client takes ICE from the mint rather than from a constant. The first is what makes it secure; the second is what stops it breaking when the platform's infrastructure moves.
11

When something goes wrong

It hangs on “connecting”, with no error anywhere

Almost always ICE, and the silence is the diagnosis: a connection with no viable candidates never completes and never fails. Use the ice_servers the mint returns. If you find yourself adding an ICE constant, that is this bug arriving.

The agent goes silent mid-call

It is probably running a tool. On @whissle/agents 0.5.0 or later, listen for tool-started and thinking and say so in your UI. Before 0.5.0 there was no signal to hang that on.

The avatar does not move, or barely moves

Check the page is on @whissle/agents 0.3.1 or later, and confirm the container element exists before start().

The microphone is refused

localhost counts as a secure context. Serve it on a LAN IP to test from a phone and the browser will refuse the microphone until it is HTTPS. Since 0.5.0 this is checked before connecting, with the fix in the message — previously the session came up and ignored the visitor for the whole call.

402, 403 and 404

The workspace is out of credit (whissle usage); the key lacks a scope, which are fixed at creation; or the id is wrong. Since 0.5.0 the browser error event carries a code, and since sdk 0.3.0 the server throws a distinct error class for each.

The Sessions list shows calls you did not make

Adoption matched an agent that already existed under the same name, and it brought its history. Rename the entry in agents.json to get a separate agent.

12

Reference

WhatWhere
The exampleagents_js_sdk/examples/interview-platform
Sourcegithub.com/WhissleAI/agents_js_sdk · public
Consolewhissle.ai
API basehttps://aws-gateway-backend.whissle.ai/bot

CLI, by what you are trying to do

Check my keywhissle whoami
See the agentswhissle agents list
See the blueprint typeswhissle agents types
See what an agent knowswhissle kb list <agent-id>
Talk to an agent by textwhissle chat <agent-id>
Mint a session by handwhissle embed token <agent-id>
Recent sessions (voice + text)whissle sessions list --limit 20
Which model answered a turnwhissle sessions trace <id>
A transcriptwhissle calls transcript <call-id>
A scorewhissle calls result <call-id> --wait
Wallet balancewhissle usage

Every command takes --json for scripting.

Build a Voice AI Agent — a complete guide