Recording a call
The full Node.js recording surface — turns, operations, milestones, samples, audio attribution, websockets and AsyncLocalStorage context.
This page assumes you have an observer. See Overview if not.
The shape of a recorded call
const session = vaani.startSession({ agentId: 'support-bot', metadata: { env: 'prod' } });
const turn = session.startTurn();
const op = turn.startOperation({ type: 'llm', provider: 'openai', model: 'gpt-4o-mini' });
op.event('first_token');
op.end({ status: 'ok', response: { tokens: 128 } });
turn.end();
const finalized = await session.end({ outcome: 'completed' });
await vaani.uploadPackage(finalized);Sessions
const session = vaani.startSession({
sessionId: 'call-8f21', // optional; a UUID is generated
agentId: 'support-bot',
metadata: { env: 'prod', version: '2026.08.1', region: 'eu-west-1' },
});| Member | Purpose |
|---|---|
session.id | The session id |
session.now() | Milliseconds on the session clock |
session.startTurn(input) | Open a turn |
session.startOperation(input) | Open an operation |
session.recordInboundAudio(chunk, format) | Caller audio → right channel |
session.recordOutboundAudio(chunk, format) | Agent audio → left channel |
session.recordWebSocketEvent(input) | Neutral socket lifecycle data |
session.run(fn) | Ambient context for this session |
session.withEndpoint(id, fn, options) | Force an endpoint rule |
session.withTurn(turnId, fn) | Tag ambient work with a turn |
session.bind(handler) | Wrap a callback with the context |
session.deferCapture(promise) | Hold end() open for late capture |
session.end(input) | Finalize; resolves to the package handle |
Turns
const turn = session.startTurn(); // generated id
const turn = session.startTurn({ turnId: 'turn-7' });
turn.id;
turn.ended;
turn.end();turn.startOperation(input) prefills turnId. turn.run(fn) scopes ambient
instrumentation to this turn.
Operations
const op = session.startOperation({
type: 'stt', // stt | llm | tts | tool
turnId: turn.id,
scope: 'turn', // or 'connection'
endpointId: 'stt',
provider: 'deepgram',
model: 'nova-3',
transport: 'manual',
startedAtMs: undefined, // defaults to session.now()
eventId: undefined, // generated
request: { language: 'en' },
});An unknown type throws. Attribution fields are optional; scope defaults to
turn and transport to manual.
| Member | Purpose |
|---|---|
op.id | Operation id |
op.eventId | Id of the event this span will be written as |
op.ended | Whether it has been written |
op.event(name, data) | Record a milestone |
op.sample(name, data, { limit }) | Retain a bounded series |
op.setTurn(turnId) | Reattach to a turn discovered late |
op.setRequest(request, bounded) | Attach the request after the fact |
op.end({ status, response, error, endedAtMs, payloadBounded }) | Close and write |
Nothing is written until end(). An operation that is never ended is
deliberately absent from the package rather than persisted as a span with no
outcome, which would skew every duration percentile. session.end() closes
anything still open; a hard crash does not.
Calls on an already-ended operation are no-ops, so a late callback cannot throw inside your agent.
Milestones
op.event('speech_started');
op.event('first_partial', { textLength: 12 });
op.event('first_byte', { occurredAtMs: session.now() });Repeated names accumulate — first occurred_at_ms, latest last_at_ms, a
count, and the merged payload — which is what makes it safe to call from a
websocket frame handler running hundreds of times per call.
See Milestones and samples for the names the dashboard recognises.
Samples
op.sample('partial_transcript', { text: partial }, { limit: 100 });Bounded at limit per name (default 100). At the cap, further samples are
dropped and the bucket is marked truncated: true.
Payloads pass through the payloadMaxBytes bound (16 KiB by default). Oversized
values become { _truncated: true, _original_bytes, _preview }; values that
cannot be serialised become { _capture_error }. The event is still written
either way — capture never fails an operation.
Audio
const FORMAT = { encoding: 'pcm_s16le', sampleRateHz: 16000, channels: 1 };
session.recordInboundAudio(callerPcm, FORMAT); // right channel
session.recordOutboundAudio(agentPcm, FORMAT); // left channelThe chunk must be a Buffer or Uint8Array, the encoding must be pcm_s16le,
and sampleRateHz and channels must be positive integers. The format cannot
change within a session.
Attributing agent audio to a reply
The Node SDK accepts three extra format fields:
Prop
Type
const tts = turn.startOperation({ type: 'tts', provider: 'elevenlabs' });
for await (const chunk of ttsStream) {
session.recordOutboundAudio(chunk, { ...FORMAT, turnId: turn.id, operationId: tts.id });
}
tts.end({ status: 'ok' });The agent track uses a playout clock: its clock advances by the PCM duration of each chunk, not by arrival time, so bursty TTS keeps the real pauses between replies. See the playout clock.
Websockets
import WebSocket from 'ws';
const socket = new WebSocket('wss://api.deepgram.com/v1/listen');
const handle = vaani.observeWebSocket(socket, {
session,
url: socket.url,
endpointId: 'stt', // optional; classified from the URL if omitted
});
// ...
handle.detach();Creates a span with scope: 'connection' recording connected, sent_frame,
received_frame (accumulating byte counts and totals) and the close code.
Frame contents and authentication headers are never recorded. A session-long socket is a connection span, never scored as a turn — per-turn STT timing must come from explicit operations and milestones.
For transports the SDK cannot observe:
session.recordWebSocketEvent({ event: 'closed', url, code: 1000 });Ambient context
Backed by AsyncLocalStorage:
await session.run(() => callTheModel());
await session.withTurn(turn.id, () => callTheModel());
await session.withEndpoint('llm', () => providerSdk.complete(prompt), { turnId: turn.id });
const context = vaani.currentContext(); // { session, endpointId, turnId } or nullwithEndpoint throws on an unknown endpoint id — a typo is an error, not a call
that quietly goes unattributed.
For event emitters:
socket.on('message', session.bind(handleMessage));AsyncLocalStorage context does not cross a process or worker boundary. If your
agent hands work to a worker thread or a queue, pass the session and turn ids
explicitly and open operations manually on the other side.
Deferring capture past the response
const response = await downstream();
session.deferCapture(recordBody(response));
return response;session.end() waits for deferred work, so a body read that outlives the
response still lands in the package.
Ending and uploading
const finalized = await session.end({ outcome: 'completed' });
finalized.sessionId;
finalized.directory;
await vaani.uploadPackage(finalized);end() is idempotent. At shutdown, await vaani.flush() finalizes everything
still open.