Supervise live calls: listen, whisper, barge
Put a supervisor on a live call to listen, whisper to the agent or barge in, and switch modes through the API without anyone redialling.
Call supervision puts a team lead onto a live call in your workspace. The supervisor hears both sides from the moment they join, and you choose how far they step in:
| Mode | Supervisor hears | Agent hears supervisor | Customer hears supervisor |
|---|---|---|---|
listen | both sides | no | no |
whisper | both sides | yes | no |
barge | both sides | yes | yes |
There is one supervisor per call. The supervisor can be a browser running @sautikit/webrtc, a SIP device on one of your SIP credentials, or any phone number. Modes change only through the API: the supervisor's keypad cannot switch them, so a stray key press never puts a supervisor in front of your customer.
calls.supervise scope starts, switches and stops supervision; reading the current state needs calls.read. A signed-in console user needs the Owner or Admin role, or the Desk Supervisor licence. Device tokens are refused with 403 calls.supervise_forbidden.HD_ session id, the same as every other /v1/calls route.@sautikit/webrtc identity (the clientName from the token your server mints), a SIP credential username, or a phone number in E.164.402 wallet.insufficient_funds.calls.supervise() in @sautikit/node wraps Start call supervision. Name the supervisor and the mode they start in:
import { SautikitClient } from "@sautikit/node";
const sautikit = new SautikitClient({ apiKey: process.env.SAUTIKIT_API_KEY });
const supervision = await sautikit.calls.supervise("HD_24d73b4518bc", {
mode: "listen",
supervisor: { type: "client", identity: "mary" },
announce: "none",
label: "Mary Wanjiku",
});
console.log(supervision.supervision_id, supervision.state); // "connecting"supervisor is one of { type: "client", identity }, { type: "sip", identity } or { type: "phone", number }. label (up to 64 characters) is the supervisor's name: it appears in every supervision event and in the agent's notice. The call answers 201:
{
"supervision_id": "0f6d3c1e-5a8b-4c2d-9e7f-1a2b3c4d5e6f",
"call_id": "9d2b1f53-8c0e-4f1d-9a6b-5d3a8c47e9f0",
"session_id": "HD_24d73b4518bc",
"supervisor_leg_id": "sv_7c1d2e3f",
"mode": "listen",
"announce": "none",
"supervisor": { "type": "client", "identity": "mary", "label": "Mary Wanjiku" },
"notify_agent": true,
"state": "connecting",
"created_at": "2026-09-30T09:15:02Z"
}Sautikit then rings the supervisor. A second supervisor on the same call gets 409 calls.supervision_in_progress, naming who is already there. A call that is not answered yet, or has ended, gets 409 calls.not_live. The API reference lists every response.
When the supervisor works in your web app, let the page start the supervision itself. The browser SDK's token only opens the media socket and carries no API permissions, so the page relays start, mode changes and stop through your server, which holds the API key.
On your server, expose three routes that call @sautikit/node, always naming the signed-in user as the client supervisor. Your API key can supervise every call in the workspace, so your server is the permission check for your own users: decide there who may listen to whom.
import express from "express";
import { SautikitClient } from "@sautikit/node";
const sautikit = new SautikitClient({ apiKey: process.env.SAUTIKIT_API_KEY });
const app = express();
app.use(express.json());
// Your own rule for who may supervise, checked on every route; then pass
// Sautikit errors (409, 402, 422...) back to the page.
const supervisorOnly = (handler) => async (req, res) => {
if (!req.user?.canSupervise) return res.sendStatus(403);
try {
await handler(req, res);
} catch (err) {
res.status(err.status ?? 500).json({ code: err.code, message: err.message });
}
};
app.post("/supervision/:callId", supervisorOnly(async (req, res) => {
// req.user.clientName: the identity you minted this user's WebRTC token for.
const s = await sautikit.calls.supervise(req.params.callId, {
mode: req.body.mode,
announce: req.body.announce,
label: req.body.label,
notifyAgent: req.body.notifyAgent,
supervisor: { type: "client", identity: req.user.clientName },
});
res.json({ supervisionId: s.supervision_id, supervisorLegId: s.supervisor_leg_id, mode: s.mode });
}));
app.patch("/supervision/:callId", supervisorOnly(async (req, res) => {
await sautikit.calls.setSupervisionMode(req.params.callId, req.body.mode);
res.sendStatus(204);
}));
app.delete("/supervision/:callId", supervisorOnly(async (req, res) => {
await sautikit.calls.stopSupervision(req.params.callId);
res.sendStatus(204);
}));In the page, pass those routes to the Client as its supervision backend, then call client.supervise():
import { Client } from "@sautikit/webrtc";
const api = (method, callId, body) =>
fetch(`/supervision/${callId}`, {
method,
headers: { "Content-Type": "application/json" },
body: body ? JSON.stringify(body) : undefined,
}).then((r) => {
if (!r.ok) throw new Error(`supervision ${method} failed: ${r.status}`);
return r.status === 204 ? undefined : r.json();
});
const client = new Client({
token: session.token,
endpoint: session.endpoint,
protocol: session.protocol,
turnServer: session.turnServer,
supervision: {
start: (callId, req) => api("POST", callId, req),
setMode: (callId, mode) => api("PATCH", callId, { mode }),
stop: (callId) => api("DELETE", callId),
},
});
// From a click handler: the SDK needs microphone permission.
const sup = await client.supervise("HD_24d73b4518bc", { mode: "listen", label: "Mary Wanjiku" });
sup.on("ended", ({ reason }) => console.log("supervision ended:", reason));The SDK answers the supervisor leg by itself. It only auto-answers a leg this client asked for and is still expecting, so an ordinary incoming call is never picked up behind the user's back. If the same user has other tabs open, they ring it as a normal incoming event carrying supervisorLegId, and stop ringing once one tab answers. Set supervisionAutoAnswer: false in the Client config to receive the leg as an incoming event and answer it yourself with accept().
A SIP or phone supervisor needs none of this: their device rings like any other call and they pick it up.
Move between modes at any point after the supervisor has answered. From the browser, use the Supervision object:
await sup.setMode("whisper"); // coach the agent; the customer hears nothing
await sup.setMode("barge"); // join the conversation
await sup.stop(); // leave; the call carries onFrom your server, the same operations are calls.setSupervisionMode(callId, mode) (Change the supervisor's mode) and calls.stopSupervision(callId) (Remove the supervisor). A mode change while the supervisor is still ringing gets 409 calls.supervisor_not_connected. Stopping is idempotent: it answers 204 even when nobody is supervising. calls.getSupervision(callId) (Get the call's active supervision) returns the active supervision, or null.
A supervisor who mutes their own microphone sends no audio, so the agent and the customer hear nothing from them in whisper or barge until they unmute.
When the agent takes calls in a browser on @sautikit/webrtc, their tab receives a supervision event as a supervisor joins, changes mode and leaves, so your app can show a line such as "Mary Wanjiku is listening":
const doing = { listen: "listening", whisper: "coaching you", barge: "on the call" };
client.on("supervision", (notice) => {
if (notice.event === "ended") return hideBanner();
showBanner(`${notice.supervisorName ?? "A supervisor"} is ${doing[notice.mode]}`);
});notice.event is started, mode_changed (with previousMode) or ended (with reason). Notices go only to the tab that answered the call, nothing is sent while the supervisor is still ringing, and they are best effort with no replay: a tab that reconnects mid-call misses anything sent while it was away. Read the current state from your server with calls.getSupervision() if you need it after a reconnect.
Pass notify_agent: false (notifyAgent: false in the SDKs) to supervise without sending notices. That setting is independent of announce, which is the audible tone.
Supervision sends five workspace webhook events. Subscribe to them by name with Create a webhook:
call.supervision.ringing: the supervisor is being rung.call.supervision.started: the supervisor answered and is on the call.call.supervision.mode_changed: the mode changed through the API.call.supervision.ended: the supervision is over, with the reason and billable seconds.call.supervision.failed: the supervisor leg never connected.These are Sautikit events about the supervisor. The call's own events on your number's events_url are unchanged.
Announce. announce controls an audible tone when the supervisor joins and on every mode change: none (the default), agent (only the agent hears it) or both (the agent and the customer). The tone is never in the call recording, and the supervisor never hears it.
Recording. Supervision never starts a recording. On a recorded call, audio from barge is in the recording, because the customer heard it. listen and whisper are not.
Billing. The supervisor leg is billed like any call leg, from the moment the supervisor answers until they leave:
A supervisor who never answers, or a leg that fails, costs nothing. The charge appears on your statement as its own line, referencing the supervised call. If the wallet runs dry while a supervisor is on a call, Sautikit removes the supervisor and call.supervision.ended carries reason: "insufficient_balance". Current prices are always at /pricing.
reason: "call_transferred": a Redirect, a queue handing the caller to another agent, or a SIP transfer. Start a new supervision on the call if you want to keep listening.422 calls.unsupported_call_type.reason: "no_answer".422 calls.supervisor_unreachable.