Using LiveKit with Mastra
LiveKit is an open source WebRTC platform for realtime audio and video. The @mastra/livekit package connects Mastra agents to the LiveKit Agents framework: LiveKit owns the audio loop like voice activity detection, streaming speech-to-text, semantic turn detection, barge-in, and text-to-speech. Your Mastra agent generates every reply with its own model, tools, and memory.
Use this integration when you need low-latency, interruptible voice conversations. For provider-based speech-to-speech without LiveKit, see Speech to Speech.
QuickstartDirect link to Quickstart
These steps take you from an empty project to a voice agent you can talk to. A voice session has two moving parts you set up here: an API route on your Mastra server that hands out access tokens, and a separate worker process that runs the audio pipeline and calls your agent each turn.
Install the integration package along with the LiveKit plugins for voice activity detection and turn detection:
- npm
- pnpm
- Yarn
- Bun
npm install @mastra/livekit @livekit/agents @livekit/agents-plugin-silero @livekit/agents-plugin-livekitpnpm add @mastra/livekit @livekit/agents @livekit/agents-plugin-silero @livekit/agents-plugin-livekityarn add @mastra/livekit @livekit/agents @livekit/agents-plugin-silero @livekit/agents-plugin-livekitbun add @mastra/livekit @livekit/agents @livekit/agents-plugin-silero @livekit/agents-plugin-livekitSet your LiveKit credentials inside an
.envfile. Create a free project on LiveKit Cloud, or run a local server withlivekit-server --dev:.envLIVEKIT_URL=wss://your-project.livekit.cloud
LIVEKIT_API_KEY=your-api-key
LIVEKIT_API_SECRET=your-api-secretAdd a voice agent to your Mastra instance and expose a connection route. The
liveKitConnectionRoute()helper adds aPOST /voice/livekit/connection-detailsendpoint that mints a LiveKit token and dispatches your agent into a room:src/mastra/index.tsimport { Mastra } from '@mastra/core/mastra'
import { Agent } from '@mastra/core/agent'
import { liveKitConnectionRoute } from '@mastra/livekit'
const supportAgent = new Agent({
id: 'support',
name: 'Support',
instructions: 'You are a friendly phone support agent. Keep replies short and conversational.',
model: 'openai/gpt-5-mini',
})
export const mastra = new Mastra({
agents: { support: supportAgent },
server: {
apiRoutes: [liveKitConnectionRoute({ agentName: 'mastra-voice' })],
},
})Create the worker. It runs as a separate process, answers LiveKit sessions, and calls your agent each turn. Worker APIs live on the
@mastra/livekit/workerentry point, so the Mastra server never loads the LiveKit agents runtime. This example uses LiveKit Inference model strings for speech-to-text and text-to-speech, so no provider plugins are required:src/mastra/voice-worker.tsimport { fileURLToPath } from 'node:url'
import { createLiveKitWorker, runLiveKitWorker } from '@mastra/livekit/worker'
import { mastra } from './index'
export default createLiveKitWorker({
mastra,
agent: 'support',
stt: 'deepgram/nova-3',
tts: 'cartesia/sonic-3',
turnDetection: 'multilingual',
greeting: 'Hi! How can I help you today?',
})
if (process.argv[1] === fileURLToPath(import.meta.url)) {
runLiveKitWorker({ entry: import.meta.url, agentName: 'mastra-voice' })
}The
agentoption selects which Mastra agent answers each session. Pass a fixed key as shown, or omit it to use theagentIdfrom the dispatch metadata, so one worker can serve every agent on your Mastra instance.Download the turn detection and voice activity detection models once. Then run the worker in one terminal and your Mastra server in another:
npx livekit-agents download-files
npx tsx src/mastra/voice-worker.ts dev- npm
- pnpm
- Yarn
- Bun
npm run devpnpm run devyarn devbun run devThe worker registers with your LiveKit server and waits for sessions, while
mastra devserves the connection route.Talk to your agent. Open the hosted LiveKit Agents Playground and connect it to your project to start a call without building a frontend.
To wire up your own app instead, call the connection route for a token.
POST /voice/livekit/connection-detailsaccepts optionalagentId,threadId, andresourceIdfields in the request body and returns:{
"serverUrl": "wss://your-project.livekit.cloud",
"roomName": "mastra-voice-a1b2c3d4",
"participantName": "user-1",
"participantToken": "eyJhbGci..."
}This response matches the contract used by LiveKit's frontend starters, so apps built from agent-starter-react or the LiveKit React components work without changes.
Turn detection and interruptionsDirect link to Turn detection and interruptions
LiveKit decides when the user finished speaking and when the agent was interrupted. The defaults work well; tune them with turnHandling:
export default createLiveKitWorker({
mastra,
agent: 'support',
stt: 'deepgram/nova-3',
tts: 'cartesia/sonic-3',
turnDetection: 'multilingual',
turnHandling: {
endpointing: { mode: 'dynamic', minDelay: 300, maxDelay: 3000 },
interruption: { minDuration: 500, resumeFalseInterruption: true },
},
})
turnDetection: 'multilingual': Runs LiveKit's semantic end-of-turn model locally on CPU. It reads the live transcript to avoid cutting users off mid-thought. Use'vad'or'stt'for silence-based endpointing instead.endpointing: Bounds how long the agent waits after the user stops speaking.interruption: Controls barge-in. When the user speaks over the agent, LiveKit stops playback and cancels the in-flight Mastra stream, so token generation stops too.preemptiveGeneration: Starts the Mastra agent's reply while the user is still finishing, hiding time-to-first-token. The worker disables it by default: each preemptive attempt runs the Mastra agent on an interim transcript, and every run persists the user message, which duplicates messages in the thread. Re-enable it withpreemptiveGeneration: { enabled: true }if latency matters more than exact thread history.
See the LiveKit turn detection docs for all options.
Memory and threadsDirect link to Memory and threads
When the resolved Mastra agent has memory configured, each call becomes one memory thread:
threaddefaults to thethreadIdfrom dispatch metadata, then to the room name.resourcedefaults to theresourceIdfrom dispatch metadata, then to the thread. Send your end user's id here so calls group under the right user. Mastra Studio sends the agent id, matching how its sidebar lists threads.- When the thread doesn't exist yet, the worker creates it titled "Voice call" with metadata
{ source: 'livekit' }, and the spoken greeting is saved as the first assistant message so the thread reads as a full call transcript (disable withpersistGreeting: false).
Each turn sends only the new user input; Mastra Memory supplies history, semantic recall, and working memory. Pin a session to an existing thread by passing threadId in the connection request body, which is useful for continuing a text conversation by voice. In Studio, starting a call from an open chat binds the call to that thread, and the transcript fills into the chat after each exchange.
One caveat: when a user interrupts the agent, Mastra Memory stores the full generated reply even though the user only heard part of it. LiveKit's own transcript is truncated to what was spoken.
Speak while tools runDirect link to Speak while tools run
Voice conversations can't go silent while a slow tool runs. Use toolFeedback to speak a short phrase when the Mastra agent starts a tool call:
export default createLiveKitWorker({
mastra,
agent: 'support',
stt: 'deepgram/nova-3',
tts: 'cartesia/sonic-3',
toolFeedback: ({ toolName }) =>
toolName === 'searchOrders' ? 'Let me look that up.' : undefined,
})
The phrase is spoken as part of the reply and appears in the transcript.
Generate replies with a workflowDirect link to Generate replies with a workflow
By default the worker generates each reply with a Mastra agent. To run multi-step logic per turn (for example classify intent, route, call tools in sequence, then compose a reply), generate replies with a Mastra workflow instead. Set workflow in place of agent.
LiveKit still owns the audio loop and calls into Mastra once per turn, so the workflow runs to completion each turn. There is no suspend or resume, and no conversation state is carried between turns. Pass the transcript in through workflowInput so the workflow stays stateless:
import { createLiveKitWorker, chatContextToMessages } from '@mastra/livekit/worker'
import { mastra } from './index'
export default createLiveKitWorker({
mastra,
workflow: 'phoneConversation',
workflowInput: ({ chatCtx }) => ({ history: chatContextToMessages(chatCtx) }),
replyStep: 'generateResponse',
stt: 'deepgram/nova-3',
tts: 'cartesia/sonic-3',
turnDetection: 'multilingual',
})
A workflow streams structured step events, not text. To speak tokens as they generate, the reply step pipes its agent's text into the step writer:
const generateResponse = createStep({
id: 'generateResponse',
// input and output schemas omitted
execute: async ({ inputData, mastra, writer, abortSignal }) => {
const stream = await mastra.getAgent('voice').stream(inputData.history, { abortSignal })
await stream.textStream.pipeTo(writer)
return { assistantMessage: await stream.text }
},
})
replyStep: Restricts spoken output to one step. Omit it to speak every step that writes to itswriter.resultText: A fallback that derives the reply from the final run result when no step streams text. Streaming throughwritergives lower time-to-first-token, so prefer it.abortSignal: Forward the step'sabortSignalintoagent.stream()so barge-in stops generation promptly. When the user interrupts, the worker cancels the run.generate: For full control, pass ageneratefunction instead. It can be any reply generator that turns a turn into a text stream.
With a workflow, the worker doesn't persist turns automatically the way an agent's stream() does. Persist conversation history inside the workflow, or keep the LiveKit transcript as the source of truth and pass it in each turn.
Server-initiated sessionsDirect link to Server-initiated sessions
Use dispatchVoiceSession() to add a voice agent to a room from your own code, for example to join an existing room or to drive an outbound SIP call:
import { dispatchVoiceSession } from '@mastra/livekit'
await dispatchVoiceSession({
roomName: 'support-call-42',
agentName: 'mastra-voice',
metadata: { agentId: 'support', threadId: 'thread-42', resourceId: 'user-7' },
})
ObservabilityDirect link to Observability
When the Mastra instance has observability configured, the worker traces each call. It opens one voice call span per session and nests everything under it:
- Every turn's Mastra agent run, with model generation, tool calls, and memory operations, exactly as a text chat records them.
- A child span for each LiveKit pipeline metric: speech-to-text, text-to-speech, end-of-utterance (turn detection), voice activity detection, and the model's time-to-first-token. These carry the latency and audio measurements that text traces can't show.
- A per-model usage roll-up (token, character, and audio totals for the whole call) written to the span when the session ends.
The worker is a separate process, so point storage at a backend that accepts concurrent writes from both the server and the worker. SQLite-backed LibSQL works; single-writer stores do not. Traces, memory, and threads can share one store:
import { Mastra } from '@mastra/core/mastra'
import { LibSQLStore } from '@mastra/libsql'
import { Observability, MastraStorageExporter } from '@mastra/observability'
export const mastra = new Mastra({
storage: new LibSQLStore({ url: 'file:./voice-agent.db' }),
observability: new Observability({
configs: {
default: {
serviceName: 'voice-agent',
exporters: [new MastraStorageExporter()],
},
},
}),
})
Tracing is on by default. Pass observability: false to createLiveKitWorker to turn it off.
DeploymentDirect link to Deployment
The worker is a separate process from your Mastra server. Deploy it as a long-running Node service with the production command:
node dist/voice-worker.js start
LiveKit's guidance on sizing, graceful shutdown, and hosting applies unchanged; see Deploying agents. Workers connect outbound to LiveKit, so they don't need inbound ports.
How it worksDirect link to How it works
A LiveKit voice session involves three pieces:
- Your Mastra server mints a LiveKit access token and dispatches your agent into a room. The dispatch carries metadata such as the Mastra agent id, memory thread, and resource.
- A LiveKit agent worker (a separate long-running process) receives the job and runs the audio pipeline. Audio flows between the browser and the worker over WebRTC and never passes through your Mastra HTTP server.
- Each time the user finishes a turn, the worker calls the Mastra agent's
stream()with the new input and speaks the streamed text. When the user interrupts, LiveKit cancels the stream and Mastra stops generating.
Conversation history lives in Mastra Memory, so voice sessions and text chat can share one thread.