Node.js
Record your first voice-agent call from Node.js, with automatic fetch instrumentation and websocket accounting.
By the end of this page you will have a recorded call in a local dashboard, with audio you can play and a trace you can expand.
Before you start
- Node.js 20 or newer (the SDK declares
engines.node >= 20) - Python 3.11 or newer for the dashboard
- Access to the private repositories. The SDK is not published to npm — install it from Git.
VaaniEval is in closed beta
The repositories below are private while the product is in closed beta, so the links will 404 unless your GitHub account has been granted access. Neither SDK is published to a public package registry yet — both install from Git.
To get access, email shubham@vaanieval.com or book a call — a short conversation about your use case tells us whether VaaniEval fits, and gets you onboarded with the setup that matches your stack.
1. Run the dashboard
Clone and install
git clone https://github.com/shubhamofbce/vaanieval-observer-backend.git dashboard
cd dashboard
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txtStart it
uvicorn app.main:app --reload --port 8000The console is at http://localhost:8000/.
The local dashboard has no authentication and no tenant isolation, and it accepts any non-empty API key without validating it. Keep it on localhost or a private network. See Self-hosting.
2. Install the SDK
npm install github:shubhamofbce/vaanieval-observer-nodejs-sdkThe SDK is pure ESM with no runtime dependencies — it uses only Node
built-ins (node:async_hooks, node:fs/promises, node:crypto,
node:perf_hooks).
{
"type": "module"
}3. Record a call
import { VaaniObserver } from '@vaanieal/observer';
const SAMPLE_RATE = 16000;
const FORMAT = { encoding: 'pcm_s16le', sampleRateHz: SAMPLE_RATE, channels: 1 };
/** A second of audible PCM, so the recording has something to play. */
function tone(hz, ms) {
const frames = Math.floor((SAMPLE_RATE * ms) / 1000);
const buffer = Buffer.alloc(frames * 2);
for (let n = 0; n < frames; n += 1) {
buffer.writeInt16LE(Math.round(9000 * Math.sin((2 * Math.PI * hz * n) / SAMPLE_RATE)), n * 2);
}
return buffer;
}
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const vaani = new VaaniObserver({
endpoint: 'http://localhost:8000',
apiKey: 'local-dev', // the local dashboard does not validate this
spoolDirectory: './.vaani-spool',
endpoints: [{ id: 'llm', type: 'llm', url: 'https://api.openai.com/v1' }],
});
const session = vaani.startSession({ agentId: 'quickstart-agent' });
for (let index = 0; index < 2; index += 1) {
const turn = session.startTurn();
// The caller speaks.
const stt = turn.startOperation({ type: 'stt', provider: 'demo' });
stt.event('speech_started');
session.recordInboundAudio(tone(220, 900), FORMAT);
await sleep(900);
stt.event('final_transcript');
stt.end({ status: 'ok', response: { transcript: `caller line ${index + 1}` } });
// The model thinks.
const llm = turn.startOperation({ type: 'llm', provider: 'demo', model: 'demo-model' });
await sleep(400);
llm.event('first_token');
llm.end({ status: 'ok', response: { tokens: 128 } });
// The agent speaks. `operationId` attributes the PCM to this exact reply.
const tts = turn.startOperation({ type: 'tts', provider: 'demo' });
session.recordOutboundAudio(tone(440, 1200), {
...FORMAT,
turnId: turn.id,
operationId: tts.id,
});
tts.end({ status: 'ok' });
turn.end();
}
const finalized = await session.end({ outcome: 'completed' });
console.log('package written to', finalized.directory);
await vaani.uploadPackage(finalized);
console.log('uploaded session', finalized.sessionId);node record-a-call.mjs4. Open the call
Refresh http://localhost:8000/, click the call in the rail, and expand the
trace.

Automatic fetch capture
The Node SDK patches the global fetch when it is constructed. Any request made
inside session.run(...) whose URL matches one of your endpoints rules is
timed and recorded — no change at the call site:
const session = vaani.startSession({ agentId: 'support' });
const turn = session.startTurn();
await turn.run(async () => {
// Recorded as an LLM operation on this turn, because api.openai.com
// matches the configured endpoint rule.
await fetch('https://api.openai.com/v1/chat/completions', { method: 'POST', body });
});Three helpers scope that ambient context:
| Helper | Use it when |
|---|---|
session.run(fn) | Attribute work to this session |
session.withTurn(turnId, fn) | Group auto-instrumented calls under one turn |
session.withEndpoint(id, fn, { turnId }) | Force a specific endpoint rule; throws on an unknown id |
session.bind(handler) wraps a callback so it runs inside session.run — useful
for event-emitter handlers.
Streaming response bodies are never drained to capture them. Doing so would hold back the first token, which is exactly the latency this SDK exists to measure.
Websockets
Most streaming STT and TTS providers hold one socket open for the whole call, so the SDK records it as a connection-scoped span rather than as a turn:
import WebSocket from 'ws';
const socket = new WebSocket('wss://api.deepgram.com/v1/listen');
const handle = vaani.observeWebSocket(socket, { session, url: socket.url });
// ... later
handle.detach();It records connected, sent_frame and received_frame milestones with byte
counts and a running total, plus the close code. Frame contents and
authentication headers are never stored.
A session-long socket is recorded as a connection span, not a turn — per-turn
work is recorded explicitly with session.startTurn() and
turn.startOperation(). See
Sessions, turns and operations.
What just happened
The observer installed instrumentation, not a connection
new VaaniObserver(...) validates your endpoint rules and patches global
fetch. endpoint and apiKey are used only by uploadPackage().
The session spooled to disk during the call
Events and audio were appended to ./.vaani-spool/<session-id>/ through a
promise chain that preserves strict append ordering.
end() finalized the package
Open spans were closed, the two mono tracks were composed into one
timeline-aligned stereo call.audio (agent left, caller right), and
manifest.json was written last.
uploadPackage() shipped it, after the call
POST /v1/sessions → PUT each object → POST .../complete with byte sizes and
SHA-256 digests. Upload is explicit and post-call; the library never uploads on
the live media path.
Troubleshooting
Both must be set on the observer. If you only want local spooling, do not call
it — session.end() alone writes a complete package to disk.
An operation is written to events.jsonl when it ends, not when it starts.
An operation that is never end()ed is deliberately absent rather than persisted
as a span with no outcome, which would skew every duration percentile. Check that
every startOperation() has a matching end().
The chunk must be a Buffer or Uint8Array, and the format must be pcm_s16le
with an integer sampleRateHz and channels. The format cannot change within a
session.
Auto-instrumentation is inert unless there is an ambient session and the URL
matches a configured endpoint rule. Wrap the call in session.run() (or
turn.run()), and confirm your endpoints list has a rule whose url is a
prefix of the request URL. See
Endpoints and instrumentation.