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.
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@whissle/sdk0.3.0@whissle/cli1.0.2wsk_ 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 - transcriptsThe 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.
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.
- Sign in at whissle.ai.
- Settings → API Keys → Create.
- Grant
agents:read,agents:write,kb:read,kb:write,calls:read. - Copy the secret. It is shown once and cannot be retrieved later.
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.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 roleA healthy reply names your workspace:
{
"base_url": "https://aws-gateway-backend.whissle.ai/bot",
"organization": { "id": "65c2980e-…", "name": "Your Workspace" },
"role": "owner"
}/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 zeroRun 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 startIt 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
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…", …]
}
}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.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 itsdefault_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.
kb.sync() exists because kb.add in a loop accumulates revisions, and retrieval will happily quote the stale copy.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();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.
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| Event | Use it for |
|---|---|
| connected · bot-ready | Connected, then actually listening — show the second |
| tool-started · tool-progress · tool-finished | What the agent is doing, with args, result and citations |
| thinking | Explain a pause — one boolean, one edge each way |
| speaking-started · speaking-stopped | Who has the floor |
| agent-partial · agent-word | Render a reply as it happens |
| listening-started · listening-stopped | A barge-in happened |
| user-metadata | Emotion / intent, with NEUTRAL suppressed honestly |
| signal | The live signal stream (schema v1) |
| avatar-ready · avatar-failed | Swap the placeholder; fall back to audio-only |
| mic-lost · mic-restored | A pulled headset, said plainly instead of silence |
| error · disconnected | Ending, both ways |
play() never waits on the network.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-pagingThe 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> --waitMaking 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.
- Replace
agents.json. An app for accountants, triage nurses or a support line is this code with different entries. - Rewrite the prompt builders. Two small functions in
server.mjsdecide how strict an examiner is and what its rubric rewards — the highest-leverage twenty lines in the example. - 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? - Put the id map in your database, so a restart re-provisions nothing.
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.
Reference
| What | Where |
|---|---|
| The example | agents_js_sdk/examples/interview-platform |
| Source | github.com/WhissleAI/agents_js_sdk · public |
| Console | whissle.ai |
| API base | https://aws-gateway-backend.whissle.ai/bot |
CLI, by what you are trying to do
| Check my key | whissle whoami |
| See the agents | whissle agents list |
| See the blueprint types | whissle agents types |
| See what an agent knows | whissle kb list <agent-id> |
| Talk to an agent by text | whissle chat <agent-id> |
| Mint a session by hand | whissle embed token <agent-id> |
| Recent sessions (voice + text) | whissle sessions list --limit 20 |
| Which model answered a turn | whissle sessions trace <id> |
| A transcript | whissle calls transcript <call-id> |
| A score | whissle calls result <call-id> --wait |
| Wallet balance | whissle usage |
Every command takes --json for scripting.