@nolag/voice

Turn a live phone call into a NoLag room: stream its transcript as it happens and steer the agent mid-conversation.

Overview

@nolag/voice puts a phone call into a room of its own while the call is still happening. The call publishes every line of the conversation, along with per-turn latency, and accepts instructions back. A supervisor can watch a conversation unfold and type a line the agent speaks to the caller seconds later, without touching the telephony or the models.

The real-time audio work belongs to @nolag/voice-engine, a separate package with no NoLag dependency. This one is small on purpose: it is only the part that makes a call something other software can join.

Key Features

  • Live transcript of both sides, published turn by turn
  • Steering: speak a line to the caller now, or silently adjust the agent's instructions
  • Per-turn latency, so you can see where the seconds go on a real call
  • Call lifecycle, screener detection and barge-in as observable events
  • Works in a browser, so a dashboard can watch calls without a server

How It Works

Each call joins its own room as an agent with a predictable id, call-<callid>, derived from the call id alone. That is what makes steering possible with no prior handshake: a supervisor can address a call knowing only its id, because both sides derive the same name.

Two of the Agents SDK's coordination patterns carry the whole feature.

PatternTopicDirection
ObserveeventsOut: transcript, latency, lifecycle
InboxinboxIn: steering addressed to the call

Why a call needs a room

The model answering the phone has roughly a second to reply, which means it is small and cheap, which means it is the wrong thing to decide whether a booking can be moved. It also has no tools, so left alone it will happily say "I have updated that for you" while nothing has changed.

The work that matters runs on a different clock. Looking a customer up, changing a record, waiting for a human to approve a refund: five to thirty seconds, sometimes minutes. None of that fits inside a one second budget, so it cannot live inside the call. It has to be something the call talks to.

The room is what it talks to. A larger orchestrator with the knowledge and the tools, a human supervisor who can approve, and any dashboard that wants to watch, all on the same room. Everything you need for the orchestrator side is already in @nolag/agents: Handoff to dispatch work by capability, Tools to invoke something in your own systems, Approve to gate an action on a human.

Installation

npm install @nolag/voice @nolag/agents @nolag/js-sdk

Create the app from the Voice or Agents blueprint, then set config.autoProvisionRooms to true so each call can have its own room. You also need two actor tokens: one for the call, one for anything watching. See Three rules that fail silently.

Quick Start

publishCall returns an object shaped exactly like the voice engine's session observer, so it can be handed straight over and the call streams itself into the room.

import { NoLag } from '@nolag/js-sdk'
import { NoLagAgents } from '@nolag/agents'
import { NoLagVoice, createRoomProvisioner, callRoomSlug } from '@nolag/voice'
import { VoiceSession } from '@nolag/voice-engine'

const provisioner = await createRoomProvisioner({
  apiKey: process.env.NOLAG_API_KEY,   // project key, nlg_live_...
  appSlug: process.env.NOLAG_APP       // the real slug, random suffix included
})

async function onCall(transport, callId, providers, systemPrompt) {
  const roomSlug = callRoomSlug(callId)

  // The room must exist before the connection that will use it authenticates.
  await provisioner.ensureRoom(roomSlug)

  const client = NoLag(process.env.NOLAG_ACCESS_TOKEN, { url: process.env.NOLAG_URL })
  await client.connect()

  // Your app owns the agents instance and its version.
  const agents = new NoLagAgents({
    client,
    appName: process.env.NOLAG_APP,
    agentId: `call-${roomSlug}`,
    role: 'agent',
    rooms: [roomSlug]
  })
  await agents.ready()

  const voice = new NoLagVoice({ agents })

  let session
  const publisher = voice.publishCall(callId, {
    onSay: (text) => session.say(text),           // speak this to the caller now
    onInstruct: (text) => session.instruct(text)  // silent guidance for the model
  })

  session = new VoiceSession({ transport, providers, systemPrompt, observer: publisher })

  return () => {
    agents.detach()      // you created the instance, so you release it
    client.disconnect()
  }
}

say is spoken to the caller immediately and remembered as something the agent said. instruct is never spoken: it is guidance the model sees from its next turn onward, which is what you want for "keep it brief" or "stop offering refunds".

API Reference

NoLagVoice

Takes an already-constructed, connected NoLagAgents. It never builds one, so your application owns the instance, its identity, its rooms and its lifetime.

MemberReturnsDescription
publishCall(callId, handlers?)CallPublisherPublishes the call and accepts steering
watchCall(callId, handlers?)CallWatcherStreams the call and can steer it
agentsInstanceNoLagAgentsThe injected wrapper, if you need the rest of it

publishCall handlers are { onSay, onInstruct }. watchCall handlers are { onTranscript, onEvent }. Neither handle has a detach(): you detach the agents instance you created.

CallPublisher implements onCallStarted, onCallEnded, onCallerSpeech, onAgentSpeech, onScreening, onBargeIn, onTurnComplete and onError, which is exactly the voice engine's observer shape.

Events

EventDetail
call-startedcallId, peer, outbound
call-endedreason
screening-detectedkind (identify, hold, voicemail), turn
barge-inThe caller interrupted the agent
turn-completesttMs, llmMs, firstAudioMs, clips, totalMs
errormessage

createRoomProvisioner

createRoomProvisioner({ apiKey, appSlug, apiUrl? }) returns { appId, ensureRoom }, where ensureRoom(slug) is idempotent and safe to call for a room that already exists. apiUrl defaults to production.

It fails loudly and specifically, because each failure is a setup mistake that otherwise presents as silence: an app slug that does not exist (it lists the ones that do, since the usual cause is a slug copied without its random suffix), an app with autoProvisionRooms disabled, and an app whose schema is missing topics.

Helpers

FunctionPurpose
callRoomSlug(callId)Lower-cases a call id into a room slug
callAgentId(callId)Derives the call's agent id, so a supervisor can address it
VOICE_TOPICSThe topic list a call's room needs

Three rules that fail silently

All three present as nothing happening rather than as an error, which is why they are worth reading before you have to debug them.

The broker never creates rooms implicitly, so a call's room is created through the control plane first. That is what ensureRoom is for, and why a project API key is needed at all.

A long-lived connection cannot reach a room created later, so each call opens its own connection after ensureRoom resolves. That costs nothing in practice: telephony already gives one socket per call.

The broker never delivers a message back to the actor that published it, so a dashboard sharing the call's token connects perfectly happily and then displays nothing at all.

The other half

@nolag/voice-engine does the real-time work: turn taking, interruption, adaptive voice activity detection, filler speech, call-screener and voicemail handling, and recording. It has no telephony or AI vendor baked in, and ships a browser simulator so you can build a voice agent without a phone number.

Together they are a complete voice agent: fast reflexes on the phone, real capability behind it.