npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@sayna-ai/node-sdk

v0.0.23

Published

Node.js SDK for Sayna.ai server-side WebSocket connections

Readme

@sayna/node-sdk

Sayna's Node.js SDK enables real-time voice interactions through WebSocket connections. Send audio for speech recognition, receive synthesized speech, and manage voice sessions from your Node.js applications.

Features

  • WebSocket-based real-time voice communication
  • Speech-to-text (STT) with configurable providers
  • Text-to-speech (TTS) with audio streaming
  • LiveKit integration support
  • Type-safe message handling

Installation

npm install @sayna/node-sdk

Usage

import { SaynaClient } from "@sayna/node-sdk";

const client = new SaynaClient(
  "https://api.sayna.ai",
  { provider: "deepgram", model: "nova-2" },
  { provider: "cartesia", voice_id: "example-voice" }
);

client.registerOnSttResult((result) => {
  console.log("Transcription:", result.transcript);
});

client.registerOnTtsAudio((audio) => {
  // Handle audio buffer
});

await client.connect();
await client.speak("Hello, world!");

The constructor also accepts an 8th positional argument loadingAudio?: LoadingAudioConfig for a server-side "thinking" audio loop on a dedicated LiveKit track; see Loading Indicator below.

API

REST API Methods

These methods use HTTP endpoints and don't require an active WebSocket connection:

await client.health()

Performs a health check on the Sayna server.

Returns: Promise<{ status: string }> - Status object with "OK" when healthy.

Example:

const health = await client.health();
console.log(health.status); // "OK"

await client.getVoices()

Retrieves the catalogue of text-to-speech voices grouped by provider.

Returns: Promise<Record<string, Voice[]>> - Object where keys are provider names and values are arrays of voice descriptors.

Example:

const voices = await client.getVoices();
for (const [provider, voiceList] of Object.entries(voices)) {
  console.log(
    `${provider}:`,
    voiceList.map((v) => v.name)
  );
}

await client.speakRest(text, ttsConfig)

Synthesizes text into audio using the REST API. This is a standalone method that doesn't require an active WebSocket connection.

| parameter | type | purpose | | ----------- | ----------- | --------------------------------------- | | text | string | Text to synthesize (must be non-empty). | | ttsConfig | TTSConfig | Text-to-speech provider configuration. |

Returns: Promise<ArrayBuffer> - Raw audio data.

Example:

const audioBuffer = await client.speakRest("Hello, world!", {
  provider: "elevenlabs",
  voice_id: "21m00Tcm4TlvDq8ikWAM",
  model: "eleven_turbo_v2",
  speaking_rate: 1.0,
  audio_format: "mp3",
  sample_rate: 24000,
  connection_timeout: 30,
  request_timeout: 60,
  pronunciations: [],
});

await client.getLiveKitToken(roomName, participantName, participantIdentity)

Issues a LiveKit access token for a participant.

| parameter | type | purpose | | --------------------- | -------- | -------------------------------------- | | roomName | string | LiveKit room to join or create. | | participantName | string | Display name for the participant. | | participantIdentity | string | Unique identifier for the participant. |

Returns: Promise<LiveKitTokenResponse> - Object containing token, room name, participant identity, and LiveKit URL.

Example:

const tokenInfo = await client.getLiveKitToken(
  "my-room",
  "John Doe",
  "user-123"
);
console.log("Token:", tokenInfo.token);
console.log("LiveKit URL:", tokenInfo.livekit_url);

await client.getSipHooks()

Retrieves all configured SIP webhook hooks from the runtime cache.

Returns: Promise<SipHooksResponse> - Object containing an array of configured hooks.

Example:

const response = await client.getSipHooks();
for (const hook of response.hooks) {
  console.log(`Host: ${hook.host}, URL: ${hook.url}, Auth ID: ${hook.auth_id}`);
}

await client.setSipHooks(hooks)

Sets or updates SIP webhook hooks in the runtime cache. Hooks with matching hosts will be replaced; new hosts will be added.

| parameter | type | purpose | | --------- | ----------- | ---------------------------------------- | | hooks | SipHook[] | Array of SIP hook configurations to set. |

Each SipHook object contains:

| field | type | description | | --------- | -------- | --------------------------------------------------------------------------------------------------------------------- | | host | string | SIP domain pattern (case-insensitive). | | url | string | HTTPS URL to forward webhook events to. | | auth_id | string | Tenant identifier for this hook. Required but may be empty for unauthenticated mode. Treat as opaque; pass unchanged. |

Returns: Promise<SipHooksResponse> - Object containing the merged list of all configured hooks.

Example:

const response = await client.setSipHooks([
  {
    host: "example.com",
    url: "https://webhook.example.com/events",
    auth_id: "tenant-123",
  },
  {
    host: "another.com",
    url: "https://webhook.another.com/events",
    auth_id: "",
  }, // Empty for unauthenticated mode
]);
console.log("Total hooks configured:", response.hooks.length);

Room Ownership and Access

When authentication is enabled, the server enforces room-level access control:

  • Room names are clean: The SDK does not rewrite or prefix room names. Pass room names as-is.
  • Room listings are scoped: getLiveKitRooms() returns only rooms accessible to your authenticated context.
  • 403 on token requests: getLiveKitToken() returns a 403 error if the room exists but is owned by another tenant. Do not retry with a modified room name.
  • 404 masks access denial: For room-scoped operations (getLiveKitRoom(), removeLiveKitParticipant(), muteLiveKitParticipantTrack(), sipTransferRest()), a 404 response can mean "not found" or "not accessible."
  • Inbound SIP rooms: Rooms created by inbound SIP calls are owned by the routing configuration's auth_id. Access depends on your authentication context.
  • WebSocket errors: Ownership/access errors during WebSocket configuration are surfaced via the error callback. Retry with the correct room name if needed.

Errors include HTTP status and endpoint information for easier debugging:

try {
  await client.getLiveKitToken("some-room", "user", "user-123");
} catch (error) {
  if (error instanceof SaynaServerError) {
    console.log(`Status: ${error.status}, Endpoint: ${error.endpoint}`);
    // Status: 403, Endpoint: livekit/token
  }
}

WebSocket API Methods

These methods require an active WebSocket connection:

new SaynaClient(url, sttConfig, ttsConfig, livekitConfig?, withoutAudio?, apiKey?, streamId?, loadingAudio?)

| parameter | type | purpose | | --------------- | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | | url | string | Sayna server URL (http://, https://, ws://, or wss://). | | sttConfig | STTConfig | Speech-to-text provider configuration. | | ttsConfig | TTSConfig | Text-to-speech provider configuration. | | livekitConfig | LiveKitConfig | Optional LiveKit room configuration. | | withoutAudio | boolean | Disable audio streaming (defaults to false). | | apiKey | string | Optional API key for HTTP and WebSocket auth (defaults to SAYNA_API_KEY env). | | streamId | string | Optional session identifier for recording paths; server generates a UUID when omitted. | | loadingAudio | LoadingAudioConfig | Optional "thinking" audio clip sent in the initial config frame; loops on a dedicated LiveKit track when loadingStart() runs. See Loading Indicator. |

await client.connect()

Establishes WebSocket connection and sends initial configuration. Resolves when server sends ready message.

client.registerOnSttResult(callback)

Registers a callback for speech-to-text transcription results.

client.registerOnTtsAudio(callback)

Registers a callback for text-to-speech audio data (ArrayBuffer).

client.registerOnError(callback)

Registers a callback for error messages.

client.registerOnMessage(callback)

Registers a callback for participant messages.

client.registerOnParticipantConnected(callback)

Registers a callback for participant connection events.

client.registerOnParticipantDisconnected(callback)

Registers a callback for participant disconnection events.

client.registerOnTrackSubscribed(callback)

Registers a callback for track subscription events.

client.registerOnTtsPlaybackComplete(callback)

Registers a callback for TTS playback completion events.

Unknown or malformed websocket control messages are logged and ignored so protocol drift does not break the client receive loop. Actual server error messages still reach client.registerOnError(callback).

await client.speak(text, flush?, allowInterruption?)

Sends text to be synthesized as speech.

| parameter | type | default | purpose | | ------------------- | --------- | ------- | -------------------------------- | | text | string | - | Text to synthesize. | | flush | boolean | true | Clear TTS queue before speaking. | | allowInterruption | boolean | true | Allow speech to be interrupted. |

await client.onAudioInput(audioData)

Sends raw audio data (ArrayBuffer) to the server for speech recognition.

await client.sendMessage(message, role, topic?, debug?)

Sends a message to the Sayna session with role and optional metadata.

await client.clear()

Clears the text-to-speech queue.

Loading Indicator

The loading indicator loops a short audio clip into the LiveKit room while the application is "thinking" (e.g. while a large-language-model call is in flight). The clip is decoded once on the server when the WebSocket config frame is sent and replayed seamlessly on a dedicated LiveKit audio track named "loading-audio", which is separate from the speech track "tts-audio". STT and TTS streams are unaffected by the loop. See ../sayna/docs/websocket.md#loading-indicator for the authoritative protocol definition.

The clip is configured through the LoadingAudioConfig object passed as the 8th positional argument to the SaynaClient constructor:

interface LoadingAudioConfig {
  /** Base64-encoded WAV or raw 16-bit little-endian PCM. Required. */
  data: string;
  /** Container hint; omit to let the server auto-detect from the RIFF/WAVE signature. */
  format?: "wav" | "pcm";
  /** Sample rate in Hz. Required for raw PCM; ignored for WAV. */
  sample_rate?: number;
  /** Channel count for raw PCM. Defaults to 1 server-side; ignored for WAV. */
  channels?: 1 | 2;
  /** Playback volume in [0.0, 1.0]. Defaults to 1.0; clamped server-side. */
  volume?: number;
}

The SDK does not read files or decode audio. Encode the clip to base64 in your own application code, e.g. with fs/promises:

import { readFile } from "node:fs/promises";

const data = (await readFile("./loading.wav")).toString("base64");

Full call flow:

import { readFile } from "node:fs/promises";
import { SaynaClient } from "@sayna/node-sdk";

const data = (await readFile("./loading.wav")).toString("base64");

const client = new SaynaClient(
  "https://api.sayna.ai",
  { provider: "deepgram", model: "nova-2" },
  { provider: "cartesia", voice_id: "example-voice" },
  { room_name: "my-room" },
  false, // withoutAudio
  undefined, // apiKey (defaults to SAYNA_API_KEY env)
  undefined, // streamId
  { data, format: "wav" } // loadingAudio (8th positional argument)
);

await client.connect();

// ...on user turn complete:
client.loadingStart();
// ...application does its "thinking" (LLM call, tool invocation, etc.)...
client.loadingStop();
await client.speak("Here is the answer.");

The application is responsible for calling loadingStop() before speak(). The SDK and server deliberately do not auto-stop the loop on speak() or clear() — overlapping the indicator with the answer would otherwise play both clips on top of each other.

Failures — LoadingAudioConfig decode failures detected at config time, and loading_start failures (audio disabled, no LiveKit room, no loadingAudio configured, track failed to publish) — arrive on the existing registerOnError(callback) channel. There is no separate loading_error event.

If the LiveKit room reconnects while the loop was running (publisher timeout, network blip), the loop stops. The SDK does not auto-restart it — the application must call loadingStart() again to resume.

client.loadingStart()

Begins the server-side seamless playback loop of the configured loading clip on the dedicated "loading-audio" LiveKit track. Fire-and-forget: any server-side rejection (audio disabled, no LiveKit room, no loadingAudio configured, track failed to publish) arrives asynchronously through registerOnError(callback). Throws SaynaNotConnectedError / SaynaNotReadyError if invoked before the connection is ready, and SaynaConnectionError if the transport fails to send the frame.

client.loadingStop()

Stops the loading-audio loop with a short server-side fade-out. The server never returns an error for this command (stopping a non-running loop is a no-op). Throws the same connection-state errors as loadingStart(); disconnect() does not call it for you.

await client.ttsFlush(allowInterruption?)

Flushes the TTS queue by sending an empty speak command.

await client.disconnect()

Disconnects from the WebSocket server and cleans up resources.

client.ready

Boolean indicating whether the client is ready to send/receive data.

client.connected

Boolean indicating whether the WebSocket connection is active.

client.livekitRoomName

LiveKit room name acknowledged by the server, if available (present when LiveKit is enabled).

client.livekitUrl

LiveKit WebSocket URL configured on the server, if available.

client.saynaParticipantIdentity

Identity assigned to the agent participant when LiveKit is enabled, if available.

client.saynaParticipantName

Display name assigned to the agent participant when LiveKit is enabled, if available.


Webhook Receiver

The SDK includes a WebhookReceiver class for securely receiving and verifying cryptographically signed webhooks from Sayna's SIP service.

Security Features

  • HMAC-SHA256 Signature Verification: Ensures webhook authenticity
  • Constant-Time Comparison: Prevents timing attack vulnerabilities
  • Replay Protection: 5-minute timestamp window prevents replay attacks
  • Strict Validation: Comprehensive checks on all required fields

new WebhookReceiver(secret?)

Creates a new webhook receiver instance.

| parameter | type | purpose | | --------- | -------- | --------------------------------------------------------------------------- | | secret | string | HMAC signing secret (min 16 chars). Defaults to SAYNA_WEBHOOK_SECRET env. |

Example:

import { WebhookReceiver } from "@sayna/node-sdk";

// Explicit secret
const receiver = new WebhookReceiver("your-secret-key-min-16-chars");

// Or use environment variable
process.env.SAYNA_WEBHOOK_SECRET = "your-secret-key";
const receiver = new WebhookReceiver();

receiver.receive(headers, body)

Verifies and parses an incoming SIP webhook.

| parameter | type | purpose | | --------- | ------------------------------------------------- | --------------------------------------------- | | headers | Record<string, string \| string[] \| undefined> | HTTP request headers (case-insensitive). | | body | string | Raw request body as string (not parsed JSON). |

Returns: WebhookSIPOutput - Parsed and validated webhook payload.

Throws: SaynaValidationError if signature verification fails or payload is invalid.

Express Example

import express from "express";
import { WebhookReceiver } from "@sayna/node-sdk";

const app = express();
const receiver = new WebhookReceiver("your-secret-key-min-16-chars");

app.post(
  "/webhook",
  express.json({
    verify: (req, res, buf) => {
      (req as any).rawBody = buf.toString("utf8");
    },
  }),
  (req, res) => {
    try {
      const webhook = receiver.receive(req.headers, (req as any).rawBody);

      console.log("Valid webhook received:");
      console.log("  From:", webhook.from_phone_number);
      console.log("  To:", webhook.to_phone_number);
      console.log("  Room:", webhook.room.name);
      console.log("  SIP Host:", webhook.sip_host);
      console.log("  Participant:", webhook.participant.identity);

      res.status(200).json({ received: true });
    } catch (error) {
      console.error("Webhook verification failed:", error.message);
      res.status(401).json({ error: "Invalid signature" });
    }
  }
);

Fastify Example

import Fastify from "fastify";
import { WebhookReceiver } from "@sayna/node-sdk";

const fastify = Fastify();
const receiver = new WebhookReceiver();

fastify.post(
  "/webhook",
  {
    config: { rawBody: true },
  },
  async (request, reply) => {
    try {
      const webhook = receiver.receive(request.headers, request.rawBody);
      return { received: true };
    } catch (error) {
      reply.code(401);
      return { error: error.message };
    }
  }
);

WebhookSIPOutput Type

The receive method returns a WebhookSIPOutput object with the following structure:

| field | type | description | | ---------------------- | ----------------------- | -------------------------------------------- | | participant | WebhookSIPParticipant | SIP participant information. | | participant.identity | string | Unique identity assigned to the participant. | | participant.sid | string | Participant session ID from LiveKit. | | participant.name | string? | Display name (optional). | | room | WebhookSIPRoom | LiveKit room information. | | room.name | string | Name of the LiveKit room. | | room.sid | string | Room session ID from LiveKit. | | from_phone_number | string | Caller's phone number (E.164 format). | | to_phone_number | string | Called phone number (E.164 format). | | room_prefix | string | Room name prefix configured in Sayna. | | sip_host | string | SIP domain extracted from the To header. | | sip_headers | Record<string, string>? | SIP headers from the incoming call (optional). |

Development

bun install
bun run typecheck
bun run build

The repository uses Bun for dependency management and builds. The build script emits ready-to-publish JavaScript and type definitions in dist/.