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
gitand network access to GitHub. The repository is public and supports anonymous Git access; this guide installs from Git. The anonymous npm registry check for@vaanieal/observerreturned HTTP404on 20 September 2026; no release was found under that package name.
Source installs and hosted beta
The repositories are public and support anonymous Git access. These guides install the SDKs from public Git. Anonymous registry checks on 20 September 2026 returned HTTP 404 for npm @vaanieal/observer and PyPI vaanieval-observer; no release was found under those names. Source availability does not mean the hosted service is ready for public launch: new public workspaces remain closed.
The quickstarts pin published Python 0.5.7b1 and Node 0.1.1-beta.1 source previews. Both installed previews have uploaded synthetic calls to the isolated hosted development environment. Git/build tooling and explicit revision upgrades are required; this is not registry publication or production-readiness certification.
For setup help, email shubham@vaanieval.com or book a call — a short conversation about your use case tells us whether Vaanieval fits, and helps identify the setup that matches your stack.
For an admitted hosted workspace, use the same pinned SDK below and the
three-step setup at beta.vaanieval.com. You do not
need to run the local dashboard. Public workspace admissions remain closed.
This is a source preview (0.1.1-beta.1), not an npm registry release; Git is
required and upgrades are explicit.
1. Run the dashboard
Clone and install
git clone https://github.com/shubhamofbce/vaanieval-observer-backend.git dashboard
cd dashboard
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txtStart it
uvicorn app.main:app --host 127.0.0.1 --reload --port 8000The console is at http://localhost:8000/.
Out of the box the local dashboard accepts any non-empty API key without
checking it, and has no tenant isolation. Set VAANI_REQUIRE_API_KEY=1 to
make ingest require a real key; read endpoints stay open either way. Keep it on
localhost. A key does not make it tenant-safe; hosted beta is separate work
under construction. See Self-hosting.
2. Install the SDK
npm install 'git+https://github.com/shubhamofbce/vaanieval-observer-nodejs-sdk.git#d2bb0f3629d724ea6bfc505e1b975d595dddddeb'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).
{
"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.

The documentation screenshot is a separate synthetic, tool-only fixture, not the two-turn script above or a hosted customer call.
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.