@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.
| Pattern | Topic | Direction |
|---|---|---|
Observe | events | Out: transcript, latency, lifecycle |
Inbox | inbox | In: 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.
Giving the call one async tool
orchestratedModel wraps the model on the phone so it can reach an orchestrator, and returns something the voice engine uses as its model with no other changes.
import { NoLagVoice, orchestratedModel, ORCHESTRATOR_ROOM } from '@nolag/voice'
// One connection for the whole process, opened at startup. The orchestrator
// room is static, so it already existed when this connection authenticated.
const bridge = new NoLagVoice({ agents }).orchestrator()
await bridge.ready() // settle presence before the first caller waits on it
const session = new VoiceSession({
transport,
providers: {
...providers,
llm: orchestratedModel({
model: providers.llm, // the fast model that carries the conversation
bridge,
floor: session, // so an answer can be spoken outside a turn
callId
})
},
systemPrompt
})One tool, not twenty
A small model handed twenty tools has to choose between them, and choosing badly is where small models fail. Handed one, its only judgement is "do I need help, and how do I phrase it". Which tool to actually use is decided by the orchestrator, which is large enough to decide well.
The ask travels in the reply, not as a function call
The engine streams: synthesis of the first finished sentence starts while the model is still writing, and that head start is most of what makes an agent feel responsive. A function-calling round trip has to complete before you know whether it was a call or an answer, which throws the head start away on every turn, including the great majority that never need help.
So the model asks in-band, with [[ask: ...]], and the marker is filtered out of what gets spoken. Turns that do not ask cost nothing, and it works with providers that have no tool-calling API at all. A garbled marker fails safe: stripped from speech rather than read aloud.
Waiting out loud is the feature
| Answer arrives | What the caller experiences |
|---|---|
Within graceMs (1200ms) | Folded into the reply. They never learn anything was asked |
| Later | "Let me check that for you" now, the answer spoken in the next gap |
| Never | A spoken apology, never silence |
An answer is never spliced into audio that is already playing and never spoken over the caller, because there is one buffer to the far end. If the floor never frees, the answer is dropped rather than said late.
The broker never delivers a message back to the actor that published it, so an orchestrator sharing a token with the call server never sees a task at all. It presents as an ask that times out while everything else looks healthy: both processes connect, both appear in presence, and the capability is discovered exactly as it should be.
Concurrent connections on one token are fine — see actor types for what a persistent session does and does not share.
The tasks topic broadcasts by default, so N replicas each run the same expensive inference and N answers race back to one call. orchestratorPoolOptions() opts into one-of-N delivery. Both halves matter: the load-balance group defaults to the actor token id, so replicas holding different tokens each form a group and each still get a copy.
Installation
npm install @nolag/voice @nolag/agents @nolag/js-sdkCreate 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.
| Member | Returns | Description |
|---|---|---|
publishCall(callId, handlers?) | CallPublisher | Publishes the call and accepts steering |
watchCall(callId, handlers?) | CallWatcher | Streams the call and can steer it |
orchestrator(options?) | OrchestratorBridge | The call's link to something that can do the work |
agentsInstance | NoLagAgents | The 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
| Event | Detail |
|---|---|
call-started | callId, peer, outbound |
call-ended | reason |
screening-detected | kind (identify, hold, voicemail), turn |
barge-in | The caller interrupted the agent |
turn-complete | sttMs, llmMs, firstAudioMs, clips, totalMs |
error | message |
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
| Function | Purpose |
|---|---|
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_TOPICS | The 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.