VaaniEval
Quickstart

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.txt

Start it

uvicorn app.main:app --reload --port 8000

The 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-sdk

The 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).

package.json
{
  "type": "module"
}

3. Record a call

record-a-call.mjs
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.mjs

4. Open the call

Refresh http://localhost:8000/, click the call in the rail, and expand the trace.

The trace view expanded to milestone level: a TTS span with 'text handed to the voice', 'first audio byte' and 'audio streaming' milestones, and an LLM span whose framework operation covers two HTTP attempts, one of which failed and was retried.

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:

HelperUse 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/sessionsPUT 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

Next

On this page