Pinecall

VoiceSession

The core class: constructor, methods, and framework integration patterns.

Constructor#

new VoiceSession(options)
OptionTypeRequiredDescription
agentstringAgent ID to connect to
serverstringAPI base URL (default: https://voice.pinecall.io)
configRecord<string, unknown>Session config overrides (voice, STT, language, greeting)
metadataRecord<string, unknown>Metadata passed to the agent (visible as call.metadata server-side)

The constructor does not open a connection. Call connect() when you want the call to start.

const session = new VoiceSession({
  agent: "mara",
  config: {
    voice: "elevenlabs:EXAVITQu4vr4xnSDxMaL",
    stt: { provider: "deepgram", model: "nova-3", language: "es" },
    language: "es",
    greeting: "¡Hola! ¿En qué puedo ayudarte?",
  },
});

The config object uses Pinecall's shortcut syntax — same format the server SDK accepts. See STT Providers and TTS Providers.

Methods#

connect()#

Opens the WebRTC connection. Returns a Promise<void> that resolves when the connection is established.

await session.connect();

Internally it:

  1. Fetches a short-lived token from GET /webrtc/token?agent_id=<agent>
  2. Fetches ICE servers from GET /webrtc/ice-servers (falls back to Google STUN)
  3. Requests microphone access via getUserMedia
  4. Creates RTCPeerConnection, adds the mic track, opens a DataChannel
  5. Generates an SDP offer, gathers ICE candidates
  6. Sends the offer to POST /webrtc/offer with the token
  7. Applies the remote SDP answer → connection established

State transitions: idleconnectingconnected (or error).

disconnect()#

Closes the connection, stops the mic, clears timers. State returns to idle. The messages array is preserved.

session.disconnect();

toggleMute() / setMuted(muted)#

Mute or unmute the mic. Both disable the local audio track and send { action: "mute" | "unmute" } over the DataChannel so the server stops processing audio too.

session.toggleMute();
session.setMuted(true);

getState()#

Returns the current state snapshot. The returned object is stable by identity — it only changes when state mutates, which makes it safe for React's useSyncExternalStore.

const { status, phase, messages, isMuted, duration } = session.getState();

See State and Phases for the full shape.

subscribe(listener)#

Subscribes to all state changes. Returns an unsubscribe function. Designed to plug directly into reactive frameworks.

const unsubscribe = session.subscribe(() => {
  console.log(session.getState());
});

// later
unsubscribe();

destroy()#

Disconnects, clears all subscribers, and marks the instance unusable. Call this on component unmount.

session.destroy();

configure(config)#

Sends a mid-call configuration update over the DataChannel. The server hot-swaps providers without disconnecting. Use this for live language/voice/STT switching during an active call.

session.configure({
  voice: "elevenlabs:h2cd3gvcqTp3m65Dysk7",
  stt: { provider: "deepgram", model: "nova-3", language: "es" },
  language: "es",
});

Only works on a connected session. For pre-connect config updates use updateOptions().

updateOptions(patch)#

Updates options before the next connect() call. No effect on an already-connected session.

session.updateOptions({
  config: {
    voice: "elevenlabs:spanishVoiceId",
    language: "es",
    greeting: "¡Hola!",
  },
});

await session.connect(); // uses the new config

Events (EventTarget)#

VoiceSession extends EventTarget. Listen with addEventListener:

EventdetailWhen
status{ status }Connection status changed
phase{ phase }Call phase changed (listening, speaking, thinking, etc.)
message{ message }New transcript message added or existing one updated
error{ error }An error occurred
change{ state }Any state mutation (most general)
eventraw payloadEvery raw DataChannel event from the server
session.addEventListener("message", (e) => {
  const msg = e.detail.message;
  if (msg.role === "user" && !msg.isInterim) console.log("User:", msg.text);
});

session.addEventListener("event", (e) => {
  // raw — see DataChannel protocol page for the full catalog
  if (e.detail.event === "llm.tool_call") {
    console.log("Tool calls:", e.detail.tool_calls);
  }
});

The event listener is the power-user escape hatch. Every JSON message from the server's DataChannel is forwarded as-is. Use it for things the state machine doesn't expose: tool calls, audio metrics, custom events.

Framework patterns#

Vanilla JS#

import { VoiceSession } from "@pinecall/voice-core";

const session = new VoiceSession({ agent: "florencia" });
const btn = document.getElementById("call-btn");
const transcript = document.getElementById("transcript");

btn.onclick = async () => {
  if (session.getState().status === "connected") {
    session.disconnect();
    btn.textContent = "Start Call";
  } else {
    await session.connect();
    btn.textContent = "End Call";
  }
};

session.addEventListener("message", (e) => {
  const msg = e.detail.message;
  const div = document.createElement("div");
  div.className = msg.role;
  div.textContent = `${msg.role}: ${msg.text}`;
  transcript.appendChild(div);
});

session.addEventListener("phase", (e) => {
  document.body.dataset.phase = e.detail.phase;
});

React (useSyncExternalStore)#

import { useSyncExternalStore, useCallback, useState, useEffect } from "react";
import { VoiceSession } from "@pinecall/voice-core";

function useVoiceSession(agent: string) {
  const [session] = useState(() => new VoiceSession({ agent }));

  const state = useSyncExternalStore(
    useCallback((cb) => session.subscribe(cb), [session]),
    () => session.getState(),
  );

  useEffect(() => () => session.destroy(), [session]);

  return { ...state, session };
}

If you're using React and want a ready-made widget instead of building UI, use @pinecall/voice-widget — it wraps this pattern and ships an animated orb UI.

Vue 3#

import { ref, onUnmounted } from "vue";
import { VoiceSession } from "@pinecall/voice-core";

export function useVoiceSession(agent: string) {
  const session = new VoiceSession({ agent });
  const state = ref(session.getState());

  session.subscribe(() => {
    state.value = session.getState();
  });

  onUnmounted(() => session.destroy());

  return { state, session };
}

Svelte#

import { readable } from "svelte/store";
import { VoiceSession } from "@pinecall/voice-core";

export function createVoiceSession(agent: string) {
  const session = new VoiceSession({ agent });

  const state = readable(session.getState(), (set) => {
    return session.subscribe(() => set(session.getState()));
  });

  return { state, session };
}

TypeScript types#

All types are exported from the package:

import type {
  VoiceSessionOptions,
  VoiceSessionState,
  SessionStatus,      // "idle" | "connecting" | "connected" | "error"
  CallPhase,          // "idle" | "listening" | "speaking" | "pause" | "thinking"
  TranscriptMessage,
} from "@pinecall/voice-core";

What's next#