victorious-evening-49093
12/18/2025, 12:45 AMtall-airline-16756
12/18/2025, 12:45 AMtall-airline-16756
12/18/2025, 12:45 AMlivekit-agents, livekit-server-sdk, and any native plugins or inference libraries you use. If you can, upgrade to the latest release of those packages first.
1. Inspect shutdown ordering and hooks
• Ensure all shutdown hooks and cleanup callbacks run deterministically and that long-running async tasks are canceled or awaited before final teardown.
• If you register shutdown callbacks (e.g., via ctx.add_shutdown_callback), make them cancel background workers or await their completion.
1. Make background tasks cancelable
• Any background thread, worker, or plugin task that might still run after session close should be made cancelable and explicitly stopped during shutdown.
• For async tasks, call cancel and await them to finish rather than leaving them to run after teardown.
1. Check native/plugin modules
• If you use native extensions (model inference engines, C++ helpers, or other plugins), inspect their cleanup: background threads inside native modules commonly cause this error when they outlive associated mutexes.
• For any plugin that supports a "stop" or "destroy" API, call that during session shutdown and confirm it returns before process exit.
1. Add logging and reproduce
• Add logging around shutdown sequence and plugin cleanup to identify which component continues to run after AgentSession closed.
• Try to reproduce consistently with a controlled user disconnect; capture a full native stack trace if possible (run under a debugger or enable core dumps) to see which module raises the exception.
1. Temporary mitigation
• If you can’t immediately fix the underlying module, ensure the process is restarted automatically after crash to restore service while you debug.
If you’d like, provide:
• Versions of livekit-agents, livekit-server-sdk, and any plugins/native deps.
• Whether you use any plugins that prewarm or run background workers (name them).
• Any custom native/C++ modules or third-party inference libraries in use.
I can then suggest more specific changes (where to add cancellation, what to await in shutdown hooks, or which plugin cleanup to inspect). Would you like to share the versions and plugin list now?
Sources: Job lifecycle | LiveKit docs | Agent server options | LiveKit docsvictorious-evening-49093
12/18/2025, 12:51 AMtall-airline-16756
12/18/2025, 12:51 AMlivekit-server-sdk ^2.15.0 and @livekit/agents ^1.0.21). That helps. Next targeted steps to narrow and fix the mutex crash:
1. Reproduce with verbose shutdown logs
• Add logs immediately before and after every shutdown step (drain, stop workers, plugin stop, session close, report upload) so we can see which action finishes just before the crash.
1. Confirm plugins/native deps
• Do you use any plugins or native/compiled libraries (e.g., model inference engines, prewarm plugins, or C++ addons)? Name them and their versions. Native modules are the most likely culprits.
1. Ensure all background workers are stopped before exit
• In your shutdown sequence, explicitly:
◦ Cancel and await all async tasks.
◦ Stop any thread pools or native worker threads via their stop/destroy APIs.
◦ Call plugin cleanup functions (if present) and wait for completion.
• If you use ctx.add_shutdown_callback, make the callback synchronous or ensure it awaits worker shutdown before returning.
1. Test isolations
• Run the agent with plugins disabled (if possible). If crash stops, enable plugins one-by-one to find the offender.
• If you have custom native code, run a build with debug symbols or run under a debugger to capture a native stack trace when it crashes.
1. Collect a native stack trace / core dump
• If the crash persists, capture a native stack trace (gdb/lldb) or core dump at crash time. That stack will show which mutex and module raised the error and points to the exact cleanup race.
If you want, share:
• The list of plugins/native deps and their versions.
• The shutdown log (with added timestamps around each shutdown step).
• Whether disabling plugins prevents the crash.
I’ll use that to point to the exact shutdown step or plugin to fix and give code-level suggestions for cancellation/ordering.victorious-evening-49093
12/18/2025, 12:53 AMtall-airline-16756
12/18/2025, 12:53 AM@livekit/agents ^1.0.21 with plugins including agents-plugin-silero and you prewarm VAD with an async call that stores a native resource on proc.userData.vad. That strongly suggests the crash is caused by a native/background thread from the Silero VAD (or another native plugin like noise-cancellation) continuing to run after session teardown and attempting to lock a destroyed mutex.
What to try next (concise, ordered):
1. Stop/await VAD and plugin workers during shutdown
• On session shutdown, explicitly call the VAD/plugin cleanup API (if available) and await it before finishing shutdown. Example pattern:
a. Cancel background tasks.
b. Call await proc.userData.vad?.stop() or the plugin's destroy/close method.
c. Then close the AgentSession and finish other cleanup.
1. Make prewarm resources cancellable and reference-counted
• Instead of leaving native handles on proc.userData without teardown, track them and ensure they are destroyed when the process/session ends. If multiple sessions share a prewarmed instance, use a refcount and only destroy when last user leaves.
1. Isolate and reproduce by disabling plugins
• Run the agent with agents-plugin-silero and noise-cancellation-node disabled. If the crash stops, re-enable one plugin at a time to identify the offender.
1. Add targeted shutdown logs
• Log before/after: plugin stop, VAD stop, noise-cancel stop, AgentSession close, report upload. That will show which stop step completes just before the crash.
1. Capture native stack trace if still crashing
• If the crash persists after explicit plugin shutdown, capture a native stack trace (lldb/gdb or core dump) to see which library/mutex threw the exception.
If you want, I can provide:
• Example shutdown snippet showing how to cancel tasks and await plugin/vad stop.
• A short checklist for refcounting shared prewarmed native handles.
Which would you like next — a code snippet for shutdown cleanup or a checklist for isolating the plugin?victorious-evening-49093
12/18/2025, 12:54 AMagents-plugin-silero and noise-cancellation-node disabled?tall-airline-16756
12/18/2025, 12:55 AM@livekit/agents-plugin-silero and @livekit/noise-cancellation-node disabled, stop loading or passing them into the AgentSession and do not enable noise cancellation in inputOptions. Concretely:
1. Remove any imports and the prewarm VAD load:
// REMOVE or comment out
// import * as silero from '@livekit/agents-plugin-silero';
// proc.userData.vad = await silero.VAD.load();
1. Start the session without a vad field and without noise cancellation:
await session.start({
room: ctx.room,
agent,
inputOptions: {
audioEnabled: true,
textEnabled: true,
// do NOT include noiseCancellation or BackgroundVoiceCancellation()
},
// do NOT set turnDetection to a silero-dependent mode
});
1. (Optional) Uninstall the packages if you want them completely absent:
pnpm remove @livekit/agents-plugin-silero @livekit/noise-cancellation-node
Notes:
• Without VAD, use turnDetection: 'stt' or 'manual' if needed.
• This isolates native plugin behavior so you can see if the crash stops when those plugins are disabled.
Would you like a ready-to-run minimal agent file (complete example) to test this?
Sources: Voice AI quickstart | LiveKit docs | Enhanced noise cancellation | LiveKit docs | Silero VAD plugin | LiveKit docs | Turn detection and interruptions | LiveKit Docs | Noise & echo cancellation | LiveKit docsvictorious-evening-49093
12/18/2025, 1:05 AMtall-airline-16756
12/18/2025, 1:05 AMsession.close() and ctx.shutdown() can cause redundant/conflicting teardown and may contribute to post-shutdown races.
Use one clear shutdown path. Recommended change:
localParticipant?.registerRpcMethod('closeSession', async () => {
console.log('Received disconnect RPC; shutting down agent session');
await session.close(); // performs session cleanup and disconnect
return 'ok';
});
If instead you want the JobContext to drive shutdown, call only ctx.shutdown?.('client_requested_logout') and don't call session.close().
Also ensure before closing:
• Cancel and await any background tasks (workers, timers).
• Explicitly stop/await plugin native resources (e.g., await proc.userData.vad?.stop()).
• Remove or await any fire-and-forget tasks started during the session.
Would you like a short shutdown helper snippet that cancels tasks and stops plugin resources before calling session.close()?
Sources: Job lifecycle | LiveKit docs | Agent session | LiveKit docsvictorious-evening-49093
12/18/2025, 1:06 AMtall-airline-16756
12/18/2025, 1:06 AMsession.close().
// keep a Set of running tasks
const runningTasks = new Set<Promise<any>>();
function trackTask(p: Promise<any>) {
runningTasks.add(p);
p.finally(() => runningTasks.delete(p));
return p;
}
// Example: when creating background tasks, wrap them:
// trackTask(someLongRunningTask());
// shutdown helper
async function gracefulShutdown(proc: any, session: any) {
console.log('gracefulShutdown: cancelling background tasks');
// 1) Cancel/await tracked tasks
// If tasks are cancelable via AbortController, signal them here.
// For plain promises, we await them with a timeout to avoid hanging forever.
const TIMEOUT = 5000;
try {
await Promise.race([
Promise.allSettled(Array.from(runningTasks)),
new Promise((_, rej) => setTimeout(() => rej(new Error('tasks timeout')), TIMEOUT)),
]);
} catch (err) {
console.warn('Some background tasks did not finish before shutdown:', err);
}
// 2) Stop plugin/native resources stored on proc.userData
try {
if (proc?.userData?.vad?.stop) {
console.log('Stopping VAD');
await proc.userData.vad.stop();
}
} catch (err) {
console.warn('Error stopping VAD:', err);
}
try {
if (proc?.userData?.noiseCancellation?.stop) {
console.log('Stopping noise cancellation');
await proc.userData.noiseCancellation.stop();
}
} catch (err) {
console.warn('Error stopping noise cancellation:', err);
}
// 3) Give native modules a moment to tear down (optional small delay)
await new Promise((res) => setTimeout(res, 100));
// 4) Close the AgentSession (single authoritative shutdown)
try {
console.log('Closing AgentSession');
await session.close();
} catch (err) {
console.warn('Error during session.close():', err);
}
}
Then call it from your RPC:
localParticipant?.registerRpcMethod('closeSession', async () => {
console.log('Received disconnect RPC; shutting down agent session');
await gracefulShutdown(proc, session);
return 'ok';
});
Notes:
• Replace proc.userData.vad.stop() / proc.userData.noiseCancellation.stop() with the actual cleanup API your plugins expose if different.
• Prefer cancelable tasks (AbortController) for background work and call their cancel method before awaiting.
• Keep only one shutdown path: call session.close() (not ctx.shutdown() afterwards).
• If crash persists, run the agent with those plugins disabled to confirm the native plugin is the cause.victorious-evening-49093
12/18/2025, 1:08 AMtall-airline-16756
12/18/2025, 1:08 AMsession.close() (or ctx.shutdown() as the single shutdown path), the agent will leave the room and stop participating.
Key points:
• session.close() performs graceful disconnect: drains queued operations, closes I/O, runs shutdown hooks, and disconnects the agent from the room.
• Other participants remain in the room unless you explicitly delete the room via your server-side API.
• Use a single shutdown path (prefer session.close()), and ensure you stop/await plugin/native resources before calling it so no background threads outlive teardown.
Would you like a one-line confirmation snippet to place in your RPC handler that calls the gracefulShutdown helper and then closes the session?
Sources: Job lifecycle | LiveKit docsvictorious-evening-49093
12/18/2025, 1:09 AMtall-airline-16756
12/18/2025, 1:09 AMimport { RoomServiceClient } from 'livekit-server-sdk';
const livekitHost = '<https://your-project.livekit.cloud>'; // or your LiveKit server URL
const apiKey = process.env.LIVEKIT_API_KEY;
const apiSecret = process.env.LIVEKIT_API_SECRET;
const roomService = new RoomServiceClient(livekitHost, apiKey, apiSecret);
// delete room by name (case-sensitive)
await roomService.deleteRoom('my-room-name');
Python (async)
from livekit.api import LiveKitAPI, DeleteRoomRequest
async with LiveKitAPI("<https://your-project.livekit.cloud>", "api-key", "secret") as api:
await api.room.delete_room(DeleteRoomRequest(room="my-room-name"))
Go
roomClient := lksdk.NewRoomServiceClient("<https://your-project.livekit.cloud>", "api-key", "secret")
_, _ = roomClient.DeleteRoom(context.Background(), &livekit.DeleteRoomRequest{
Room: "my-room-name",
})
Notes
• Use your server API key/secret (do not expose on clients).
• The argument is the room name (case-sensitive).
• Call deleteRoom after any final cleanup you need (e.g., finalize recordings).
• Deleting a room immediately disconnects all participants.
Would you like a small snippet you can drop into your existing shutdown flow to call deleteRoom after gracefulShutdown?
Sources: Managing rooms | LiveKit docs | server-sdk-js | livekit/livekit_composite | DeepWiki | Job lifecycle | LiveKit docs | server-sdk-go | livekit/livekit_composite | DeepWikivictorious-evening-49093
12/18/2025, 1:10 AMtall-airline-16756
12/18/2025, 1:10 AMsession.close()), then call deleteRoom if you need to permanently close the room. Reasons:
• Graceful shutdown ensures transcripts, uploads, and plugin cleanup run and reduces risk of native race conditions (mutex errors).
• deleteRoom is immediate and may interrupt cleanup, which can leave native resources running long enough to crash the process.
If you need a simple flow:
1. gracefulShutdown(proc, session) // cancel tasks, stop plugins, await session.close()
2. call RoomServiceClient.deleteRoom(roomName)
If you prefer a single-step forced close (and accept the risks), calling deleteRoom alone will disconnect the agent. Which flow would you prefer to implement?victorious-evening-49093
12/18/2025, 1:43 AMtall-airline-16756
12/18/2025, 1:43 AMsession.input.setAudioEnabled(true/false) even if it publishes/unpublishes its audio track.
Why:
• publish/unpublish controls whether audio is sent to the room.
• setAudioEnabled controls whether the agent processes that incoming audio (STT/turn detection). If the agent's input is disabled it will ignore audio even if the track is published.
• Calling the RPC provides a clear signal of user intent (start/stop speaking), helps with turn-taking, avoids partial/garbage input, and lets the agent cancel or buffer work appropriately.
Best practice:
• Client publishes track when user presses push-to-talk.
• Client calls RPC to enable agent input at the same time.
• On release, client unpublishes track and calls RPC to disable agent input (or the agent can wait for STT endpointing if you prefer).
• Ensure agent-side logic cancels/awaits processing when input is disabled to avoid race conditions.
If you want, I can provide a minimal client+agent RPC sequence snippet for push-to-talk.
Sources: Turn detection and interruptions | LiveKit Docsvictorious-evening-49093
12/18/2025, 1:52 AMtall-airline-16756
12/18/2025, 1:52 AMperformRpc to call startTurn or session.input.setAudioEnabled(true))
1. When ending a turn (what you already do):
◦ unpublish or mute the track
◦ call endTurn / session.input.setAudioEnabled(false)
Example flow (client):
• On push-to-talk press:
a. await room.localParticipant.publishTrack(track)
b. await room.localParticipant.performRpc({ destinationIdentity: agentIdentity, method: "startTurn", payload: "{}" })
• On release:
a. await room.localParticipant.unpublishTrack(track)
b. await room.localParticipant.performRpc({ destinationIdentity: agentIdentity, method: "endTurn", payload: "{}" })
Also verify on the agent side:
• startTurn enables audio input and resets any per-turn transcription state.
• endTurn stops listening and finalizes any buffered transcription.
• Ensure the agent subscribes to transcription events each turn (no one-time listeners that are removed on first turn).
If you want, share your start-turn client code and the agent's `startTurn`/`endTurn` handlers and I’ll point out exactly what to change.victorious-evening-49093
12/18/2025, 2:08 AMtall-airline-16756
12/18/2025, 2:08 AMtrack.muted or call `track.mute()`/`track.unmute()` depending on your SDK.
• If you unpublish but keep the track object, you can republish the same track next turn (no need to recreate).
• Always coordinate with the agent: publish/unpublish (or mute/unmute) + RPC setAudioEnabled(true/false) or `startTurn`/`endTurn` for correct agent-side processing.
If you want, I can provide a minimal client code snippet showing reuse with publish/unpublish and the corresponding RPC calls.victorious-evening-49093
12/18/2025, 2:10 AMtall-airline-16756
12/18/2025, 2:10 AMsession.input.setAudioEnabled() or `startTurn`/`endTurn` so the agent knows when to process audio.
Example patterns
• Low-latency reuse (recommended):
◦ create track once, publish it, then on PTT press call RPC start and unmute; on release call RPC end and mute.
• Release resources:
◦ on PTT press create & publish track; on release unpublish and stop track.
If you want, I can give a minimal client snippet for your preferred pattern (mute/unmute or publish/unpublish). Which one would you like?victorious-evening-49093
12/18/2025, 2:52 AMtall-airline-16756
12/18/2025, 2:53 AMctx.job.metadata inside your agent entry. It's a string — parse it if you sent JSON.
Node.js example:
export default defineAgent({
entry: async (ctx: JobContext) => {
const metadata = ctx.job.metadata; // string
if (metadata) {
try {
const data = JSON.parse(metadata);
// use data fields
} catch {
console.warn('metadata is not JSON:', metadata);
}
}
},
});
Python example:
async def entry(ctx: JobContext):
metadata = ctx.job.metadata # string
if metadata:
try:
data = json.loads(metadata)
# use data fields
except json.JSONDecodeError:
print('metadata is not JSON:', metadata)
If you send metadata via API or token, confirm the exact string sent and I can suggest parsing/validation helpers.
Sources: Job lifecycle | LiveKit docs | Agent dispatch | LiveKit docs | SIP dispatch rule | LiveKit docsvictorious-evening-49093
12/18/2025, 3:44 AMtall-airline-16756
12/18/2025, 3:45 AMvictorious-evening-49093
12/18/2025, 3:45 AMtall-airline-16756
12/18/2025, 3:46 AM// assume `room` is an active LiveKit Room
// handler receives: payload (string), participant identity, topic
room.registerTextStreamHandler('lk.transcription', (payload, participant, topic) => {
try {
const msg = JSON.parse(payload);
// common fields: msg.text (the transcript), msg.type, msg.metadata...
console.log('Agent text response:', msg.text);
// update UI with msg.text
} catch (err) {
console.warn('Non-JSON transcription payload:', payload);
}
});
Notes:
• The transcription for agent output may not include lk.transcribed_track_id when audio output is disabled.
• If you need the raw LLM response (beyond the published transcription), have the agent send it explicitly via an RPC or a custom data message; transcription is the standard way to get the agent’s text output.victorious-evening-49093
12/18/2025, 4:01 AMtall-airline-16756
12/18/2025, 4:01 AMlk.transcription) is published to the room afterward. In other words: ConversationItemAdded → agent output pipeline runs → lk.transcription is sent (synchronized with speech if enabled).
What this means for your client:
• Listen for lk.transcription to receive the agent’s text responses.
• ConversationItemAdded is an agent-side event you can use for internal logic; the client receives the transcription stream after that event completes.
• If you need to react earlier or get raw LLM data, have the agent send an RPC or custom data message at ConversationItemAdded time.
If you want, I can show a minimal agent-side hook that publishes the transcription or sends an RPC to clients right when ConversationItemAdded fires.
Sources: Events and error handling | LiveKit docs | Text and transcriptions | LiveKit docs | Pipeline nodes and hooks | LiveKit docsvictorious-evening-49093
12/18/2025, 4:03 AMlk.transcription is sent (synchronized with speech if enabled)."tall-airline-16756
12/18/2025, 4:03 AMvictorious-evening-49093
12/18/2025, 4:18 AMtall-airline-16756
12/18/2025, 4:18 AMConversationItemAdded by intercepting the LLM/tts pipeline and publishing the text yourself (data channel) while the pipeline continues. Two common hooks:
1) Intercept streaming LLM output (earliest)
• Override llm_node (or wrap the llm streaming code) and publish each chunk as it arrives.
• Publish via participant data channel (custom topic), e.g. room.local_participant.publish_data(chunk.text, topic='lk.llm_raw_output').
• Continue yielding chunks so the normal pipeline still commits the item and runs TTS.
2) Intercept final text just before TTS
• Override tts_node and publish the assembled text (partial or final) before synthesizing.
• Useful if you want the final cleaned text but still want TTS sync behavior.
Notes:
• Use a distinct data topic (not lk.transcription) and have the client register a text stream / data handler for that topic.
• This does not bypass the pipeline — it simply forwards text earlier; conversation_item_added will still fire later when the item is committed.
• Be mindful of duplicate UI updates (early text vs. later transcription); include metadata (e.g., partial/final) in your messages.
If you want, tell me which language (Node/Python) you use and I’ll provide a minimal code snippet for the appropriate hook.
Sources: Events and error handling | LiveKit docs | Pipeline nodes and hooks | LiveKit docs | Remote method calls | LiveKit docsvictorious-evening-49093
12/18/2025, 4:56 AMtall-airline-16756
12/18/2025, 4:57 AMllm_node. It publishes partial LLM chunks to a custom data topic (lk.llm.partial) so clients receive tokens before conversation_item_added.
Agent (Node.js / TypeScript)
import {
defineAgent,
voice,
llm,
type JobContext,
} from '@livekit/agents';
// Custom agent class that intercepts llm_node streaming output
class StreamingLLMAgent extends voice.Agent {
// Note: method name follows SDK casing; adapt if your SDK differs
async llm_node(
chatCtx: llm.ChatContext,
tools: llm.ToolContext,
modelSettings: voice.ModelSettings,
): Promise<AsyncIterable<llm.ChatChunk> | null> {
const upstream = await voice.Agent.default.llm_node(this, chatCtx, tools, modelSettings);
if (!upstream) return null;
// Create an async generator that forwards chunks and publishes them early
async function* intercept() {
for await (const chunk of upstream) {
// Extract text from chunk (supports delta/content shapes)
let text = '';
if (typeof chunk === 'string') {
text = chunk;
} else if (chunk?.delta?.content) {
text = chunk.delta.content;
} else if (chunk?.text) {
text = chunk.text;
}
if (text) {
try {
// publish partial text immediately to a custom topic
// use localParticipant.publishData for small payloads (text)
// Buffer is required by publishData
await (this.session?.room?.localParticipant?.publishData(
Buffer.from(text),
{ topic: 'lk.llm.partial' },
));
} catch (err) {
console.warn('publishData failed for partial LLM chunk:', err);
}
}
yield chunk; // continue normal pipeline
}
}
return intercept.call(this);
}
}
export default defineAgent({
entry: async (ctx: JobContext) => {
const agent = new StreamingLLMAgent({
instructions: 'You are a helpful assistant.',
});
const session = new voice.AgentSession({
// configure stt/llm/tts as needed, e.g.:
// llm: new SomeLLMProvider(...),
// tts: new SomeTTSProvider(...),
});
await session.start({
room: ctx.room,
agent,
inputOptions: { audioEnabled: true, textEnabled: true },
});
await ctx.connect();
},
});
Client (TS/JS) — receive partial tokens
// Listen for data_received and handle custom topic
room.on('data_received', (payload, participant) => {
if (payload.topic === 'lk.llm.partial') {
const text = new TextDecoder().decode(payload.data);
// append to UI as partial token
console.log('LLM partial:', text);
}
});
Notes and best practices
• Use a custom topic (e.g., lk.llm.partial) to avoid colliding with built-in streams.
• Throttle or buffer if chunks are very frequent to avoid frontend flood.
• Include metadata (partial vs final) if you need to distinguish streaming tokens from the final committed item.
• Keep yielding chunks so the pipeline still commits the final item and runs TTS; this only forwards early data.
• If you prefer the final pre-TTS text, intercept tts_node instead.
If you want the Python version or a variant that includes structured JSON messages (e.g., { partial:true, text:"..." }), tell me which format you prefer and I’ll provide it.
Sources: Pipeline nodes and hooks | LiveKit docs | Text and transcriptions | LiveKit docs | Events and error handling | LiveKit docsvictorious-evening-49093
12/18/2025, 5:09 AMtall-airline-16756
12/18/2025, 5:09 AMllm_node (snake_case) in this SDK version, not llmNode.
2. Signature: it must return an AsyncIterable (async generator) of chunks, not a Stream/Transform.
3. Ensure your custom Agent class is actually used when creating the session (pass instance to session.start).
4. Confirm your code runs in the agent entry (after ctx.connect / before session.start is complete as appropriate).
Working minimal example (Node.js, v1.x) — override llm_node and publish partial chunks:
import { defineAgent, voice, llm, type JobContext } from '@livekit/agents';
class StreamingAgent extends voice.Agent {
async *llm_node(
chatCtx: llm.ChatContext,
tools: llm.ToolContext,
modelSettings: voice.ModelSettings,
): AsyncIterable<any> {
const upstream = await (voice.Agent as any).default.llm_node.call(this, chatCtx, tools, modelSettings);
if (!upstream) return;
for await (const chunk of upstream) {
// extract partial text
let text = '';
if (typeof chunk === 'string') text = chunk;
else if (chunk?.delta?.content) text = chunk.delta.content;
else if (chunk?.text) text = chunk.text;
if (text) {
try {
// publish partial text to custom topic
await this.session.room.localParticipant.publishData(Buffer.from(JSON.stringify({ partial: true, text })), { topic: 'lk.llm.partial' });
} catch (e) {
console.warn('publishData failed', e);
}
}
yield chunk; // continue normal pipeline
}
}
}
export default defineAgent({
entry: async (ctx: JobContext) => {
const agent = new StreamingAgent({ instructions: 'You are helpful.' });
const session = new voice.AgentSession({
llm: /* your llm */,
tts: /* optional tts */,
stt: /* optional stt */,
});
await session.start({ room: ctx.room, agent });
await ctx.connect();
},
});
If override still doesn't run, please check and share:
• Exact file where you define the custom agent and how you import/use it (so I can confirm you're passing the instance to session.start).
• Any console logs/errors when agent starts. Add a log at top of llm_node to verify it is called.
• The LLM provider you configured (some providers may not stream; llm_node won't be invoked as a stream if upstream yields only final text).
If you send those details I’ll point out the exact fix. Which log or snippet would you like to share?victorious-evening-49093
12/18/2025, 5:16 AMtall-airline-16756
12/18/2025, 5:16 AMrealtime_audio_output_node — intercept/modify audio frames emitted by the realtime model before they're sent to the room.
• transcription_node — intercept streaming transcription text from the realtime model.
• on_user_turn_completed — run logic after user turn ends but before the model responds.
Minimal Node.js examples (adapt names for your SDK version):
1) realtime audio output (intercept frames)
class MyAgent extends voice.Agent {
async realtime_audio_output_node(audioStream, modelSettings) {
const upstream = await voice.Agent.default.realtime_audio_output_node.call(this, audioStream, modelSettings);
if (!upstream) return null;
async function* gen() {
for await (const frame of upstream) {
// modify frame if needed
yield frame;
}
}
return gen.call(this);
}
}
2) transcription node (intercept text deltas)
class MyAgent extends voice.Agent {
async *transcription_node(textStream, modelSettings) {
for await (const delta of await voice.Agent.default.transcription_node.call(this, textStream, modelSettings)) {
// inspect/modify delta
// publish early if desired:
await this.session.room.localParticipant.publishData(Buffer.from(JSON.stringify({partial:true, text:delta})), { topic: 'lk.llm.partial' });
yield delta;
}
}
}
3) on_user_turn_completed (act before response generation)
class MyAgent extends voice.Agent {
async on_user_turn_completed(turnCtx, newMessage) {
// add context, run RAG, or forward message to clients
// then call default behavior if needed
await voice.Agent.default.on_user_turn_completed.call(this, turnCtx, newMessage);
}
}
Notes:
• Method names/casing vary by SDK version — in v1.0.21 use the snake_case names shown in earlier examples (realtime_audio_output_node, transcription_node, etc.). Add logging inside your overrides to confirm they run.
• Realtime models may use different internal flows than the STT→LLM→TTS pipeline; choose the appropriate hook.
• Publish early output using localParticipant.publishData(...) on a custom topic (e.g., lk.llm.partial) and have clients listen for that topic.
• Ensure you yield upstream chunks/frames so the normal pipeline still runs (so ConversationItemAdded and TTS still occur).
If you tell me which realtime model/provider you're using and share the agent class start code, I’ll give a concrete, version-matching snippet you can drop in.
Sources: Pipeline nodes and hooks | LiveKit docsvictorious-evening-49093
12/18/2025, 5:18 AMtall-airline-16756
12/18/2025, 5:18 AMconversation_item_added event fires.
Order (simplified)
1. Realtime model produces output (audio/text)
2. realtime_audio_output_node runs (intercept/modify audio)
3. transcription_node runs (intercept/modify/forward text)
4. The processed output is committed and conversation_item_added is emitted
Implication: you can forward or modify tokens/frames in real time (e.g., publish partial text to a custom topic) inside those nodes, and the final, modified content will be what appears in conversation_item_added.
Would you like a small Node.js snippet that publishes partial tokens from transcription_node (v1.0.21-compatible)?
Sources: Pipeline nodes and hooks | LiveKit docs | Events and error handling | LiveKit docs | How to capture real-time agent transcriptions during conversationsvictorious-evening-49093
12/18/2025, 5:21 AMtall-airline-16756
12/18/2025, 5:21 AMconversation_item_added by overriding transcription_node. It publishes partial text to a custom data topic lk.llm.partial so clients receive tokens in real time.
Agent file (types + imports depend on your project)
import { defineAgent, voice, llm, type JobContext } from '@livekit/agents';
// Custom agent that intercepts transcription stream
class PartialForwardAgent extends voice.Agent {
// v1.0.21 uses snake_case node names
async *transcription_node(
textStream: AsyncIterable<string>,
modelSettings: voice.ModelSettings,
): AsyncIterable<string> {
for await (const delta of textStream) {
// delta is a partial text chunk from the realtime model
const partial = String(delta);
// publish partial to custom topic so clients get it early
try {
const lp = this.session?.room?.localParticipant;
if (lp) {
// send small JSON payload so client can distinguish partial/final
const payload = JSON.stringify({ partial: true, text: partial });
await lp.publishData(Buffer.from(payload), { topic: 'lk.llm.partial' });
}
} catch (err) {
console.warn('failed to publish partial LLM chunk', err);
}
// forward unchanged to the rest of the pipeline
yield delta;
}
}
}
export default defineAgent({
entry: async (ctx: JobContext) => {
const agent = new PartialForwardAgent({
instructions: 'You are a helpful assistant.',
});
const session = new voice.AgentSession({
// configure your realtime model provider here
// e.g., llm: new RealtimeLLMProvider(...)
});
await session.start({
room: ctx.room,
agent,
inputOptions: { audioEnabled: true, textEnabled: true },
});
await ctx.connect();
},
});
Client-side reception (JS/TS)
// listen for data_received (LiveKit JS SDK)
room.on('data_received', (payload, participant) => {
if (payload.topic === 'lk.llm.partial') {
const msg = JSON.parse(new TextDecoder().decode(payload.data));
// msg.partial === true, msg.text contains partial token(s)
console.log('LLM partial:', msg.text);
// append to UI incrementally
}
});
Notes and best practices
• Add logging at the top of transcription_node to confirm the override runs.
• Ensure your realtime model actually streams partial text to trigger transcription_node.
• Keep publishing payloads small and consider throttling or coalescing frequent chunks.
• Yield each chunk so the pipeline still commits the final item and triggers conversation_item_added.
• Use a custom topic name to avoid interfering with built-in streams.
• If you need structured messages (final vs partial), publish { partial: true/false, text, seq } and have the client merge updates.
If you want, I can provide a variant that intercepts the LLM node directly (streaming ChatChunk shape) or a version that coalesces tokens into 100ms batches before publishing. Which would you prefer?victorious-evening-49093
12/18/2025, 10:23 AMtall-airline-16756
12/18/2025, 10:23 AM// inside your agent class or entry where `this.session`/`this.session.room` is available
async function sendPartialToClient(participantIdentity: string, text: string) {
try {
const lp = this.session.room.localParticipant;
// performRpc(destinationIdentity, method, payload)
await lp.performRpc({
destinationIdentity: participantIdentity,
method: 'onPartialLLM',
payload: JSON.stringify({ partial: true, text }),
});
} catch (err) {
console.warn('RPC to client failed', err);
}
}
Call this from your `transcription_node`/`llm_node` when you want to send partial tokens.
Client (JS/TS) — register RPC handler
// run on client after joining room
room.localParticipant.registerRpcMethod('onPartialLLM', async (payloadStr) => {
const msg = JSON.parse(payloadStr);
// msg.partial, msg.text
// update UI
return 'ok';
});
How the agent finds the client identity
• You must pass the target participant identity (client identity) to the agent. Common options:
◦ Include client identity in dispatch metadata (ctx.job.metadata) and read ctx.job.metadata in entry.
◦ Agent can discover participants via session.room.participants and pick the intended one by role/metadata.
◦ Have client send its identity to the agent via an initial publishData or RPC to agent.
Pros vs publishData
• RPC
◦ Pros: guaranteed delivery semantics of RPC, typed request/response, simpler request/ack flow.
◦ Cons: requires knowing destination identity, one-to-one (not broadcast), may be slower if RPC handler does heavy work.
• publishData (data channel)
◦ Pros: easy broadcast to all clients, no need to know identity, good for streaming small frequent updates.
◦ Cons: no built-in RPC ack, clients must parse topic/payload.
Best practices
• For high-frequency partial tokens, prefer publishData (less per-message signaling). If you need ack or bidirectional fine-grained control, use RPC.
• Include sequence IDs or partial/final flags so client can merge updates.
• Throttle/coalesce tokens to avoid flooding RPC handlers.
• Ensure client registers RPC handler before agent calls RPC.
If you want, I can:
• Provide a full TypeScript agent example that discovers a target participant identity and calls RPC from inside transcription_node.
• Provide a publishData variant that batches tokens every 100ms. Which would you prefer?victorious-evening-49093
12/18/2025, 10:58 AMtall-airline-16756
12/18/2025, 10:58 AMvictorious-evening-49093
12/18/2025, 11:12 AMtall-airline-16756
12/18/2025, 11:12 AMsession.start() (once the agent is connected to the LiveKit room). The connection is established lazily so the model socket is only opened while an active session is running.
Practical notes:
• No LLM socket is opened at agent code import or before session.start().
• If you want the socket ready earlier, call session.start() sooner (after prewarm) so the realtime connection is established before user audio arrives.
• The socket may also be re-established on demand if the session is restarted or the model connection drops; ensure your session lifecycle handles reconnects or prewarming as needed.
If you share your entry/start sequence I can point to the exact spot to add logging or prewarm calls to confirm the socket opens when you expect.
Sources: Agents (Node.js) | livekit/livekit_composite | DeepWiki | OpenAI Realtime API plugin guide | LiveKit docs | Realtime model plugins | LiveKit Docsvictorious-evening-49093
12/18/2025, 11:14 AMtall-airline-16756
12/18/2025, 11:15 AMsession.start() the realtime LLM socket is established.
◦ Audio begins flowing to the model once the session is active and the agent detects user speech (via VAD or the model’s built‑in turn detection) or when you explicitly enable/send audio (e.g., session.input.setAudioEnabled(true) / RPC).
◦ For realtime models that accept raw audio, the agent sends microphone frames as soon as speech is detected (no waiting for full STT→LLM cycle).
• When streaming stops
◦ Streaming stops when the user turn ends (silence detected, model endpointing, or you call a manual end such as session.input.setAudioEnabled(false) / commit_user_turn()).
◦ Streaming also stops if the session is closed (session.close() / ctx.shutdown()), or if the realtime model socket disconnects.
◦ If you unpublish/mute the client track, the agent will stop receiving audio for that participant and thus stop sending it to the model.
• Important notes
◦ Turn detection settings (VAD thresholds, min/max endpoint delays) control how quickly streaming starts/stops—tuning them reduces or increases latency.
◦ Some realtime model providers offer server‑side endpointing; in that case the model may stop the stream based on its own detection signal.
◦ If you need immediate streaming on press (push‑to‑talk), enable audio input and ensure the local track is published/unmuted and the agent’s input is enabled at press time.
If you share your current turn detection/VAD settings and whether you rely on model endpointing or local VAD, I can suggest exact settings to minimize delay and ensure streaming starts/stops at the times you expect.
Sources: Gemini Live API plugin | LiveKit docs | OpenAI Realtime API plugin guide | LiveKit docs | Text and transcriptions | LiveKit docs | Turn detection and interruptions | LiveKit Docs | Realtime model plugins | LiveKit Docsvictorious-evening-49093
12/18/2025, 11:34 AMtall-airline-16756
12/18/2025, 11:35 AMsession.say() / generateReply()).
• The SpeechHandle tracks that speech instance (identified by the logged speech_id), including state like queued, playing, interrupted, or finished.
• It lets the agent:
◦ await completion (e.g., handle.waitForPlayout()),
◦ detect interruptions (handle.interrupted),
◦ correlate logs/metrics/events for that utterance (speech_created → TTS → playout).
• You’ll typically see follow-up events for the same speech_id (synthesis start/complete, playout events, interrupted, finished).
If you want, share the surrounding logs (speech_created, playout, interrupted) and I’ll help trace whether that handle completed or was interrupted and why.
Sources: Agent speech and audio | LiveKit docs | Events and error handling | LiveKit docsvictorious-evening-49093
12/18/2025, 11:37 AMtall-airline-16756
12/18/2025, 11:37 AMoutputOptions.audioEnabled = false, the agent may synthesize speech internally to produce synchronized transcriptions (lk.transcription) or timing metadata — the SpeechHandle coordinates those steps even though no audio track is published.
If you want to avoid SpeechHandle creation entirely (pure text-only behavior):
• Disable any TTS or audio output features in the session/provider configuration (remove TTS provider or set TTS off).
• Ensure agent code does not call say() or invoke TTS nodes; rely solely on text output nodes and transcription forwarding.
• Confirm outputOptions.textEnabled = true and audioEnabled = false, and that no pipeline nodes invoke tts_node.
If you share your agent code around reply generation (calls to generateReply(), say(), or custom nodes), I can point out the exact line causing SpeechHandle creation and suggest a code change to stop it.
Sources: How to Set Up Speech-to-Text Input with Text-Only Output in LiveKit Agents | Text and transcriptions | LiveKit docs | Agent speech and audio | LiveKit docs | Events and error handling | LiveKit docsvictorious-evening-49093
12/18/2025, 11:41 AMtall-airline-16756
12/18/2025, 11:41 AMtts from the session constructor).
• Keep outputOptions.audioEnabled = false and text/transcription enabled as needed.
Effect: no TTS provider → agent won't run the TTS pipeline → no SpeechHandle.
Example:
const session = new voice.AgentSession({
llm: myRealtimeLLM, // keep LLM if needed
// DO NOT set `tts`
stt: mySttProvider, // optional
});
await session.start({
room: ctx.room,
agent,
inputOptions: { audioEnabled: true, textEnabled: false },
outputOptions: { audioEnabled: false, transcriptionEnabled: true },
});
2) Code override (if you must keep a TTS provider or prevent SpeechHandle from a specific pipeline)
• Override the tts_node to be a no-op so the pipeline bypasses TTS and never creates a SpeechHandle.
Minimal Node.js override (v1.x snake_case name):
class NoTtsAgent extends voice.Agent {
async *tts_node(textStream, modelSettings) {
// consume text but do not synthesize audio or create a SpeechHandle
for await (const chunk of textStream) {
// optionally forward chunk downstream as text-only
yield* []; // no audio frames produced
}
}
}
Which approach to use
• If you never want audio/TTS at all: remove the TTS provider (approach 1).
• If you sometimes need TTS but want to disable it for a particular agent or flow: override tts_node to bypass synthesis (approach 2).
If you share:
• the AgentSession construction code (how you set tts), I’ll point exactly what to remove or provide the proper tts_node override tailored to your agent class.victorious-evening-49093
12/18/2025, 11:44 AMtall-airline-16756
12/18/2025, 11:45 AMaudioOutput is disabled because the agent manages an internal speech lifecycle for the model's audio/text output. To stop SpeechHandle creation entirely, do one of the following:
1) Preferred — disable audio modality (configuration)
• Start the session so the agent uses text-only modality for the realtime model (no audio output). Remove or set the model/session to text-only so the agent does not run the speech lifecycle.
◦ Example: configure the realtime model with modalities set to ["text"] or do not enable any audio output providers/voices.
◦ Ensure you do NOT provide a TTS/audio provider in the session config.
• Keep outputOptions.audioEnabled = false and text/transcription enabled as needed.
2) If you must keep realtime model but suppress speech handles for specific flows — override the realtime audio/tts node
• Implement a no-op realtime_audio_output_node / tts_node (snake_case names in v1.x) that consumes model audio/text but produces no SpeechHandle/audio frames.
• Example pattern (Node.js, v1.x):
class NoSpeechAgent extends voice.Agent {
async *realtime_audio_output_node(audioStream, modelSettings) {
// consume upstream but do not yield audio frames -> no playout/speech handle
for await (const _frame of await voice.Agent.default.realtime_audio_output_node.call(this, audioStream, modelSettings)) {
// drop frames
}
// end
}
}
• Or override tts_node similarly to avoid creating TTS playback handles.
Notes & caveats
• Realtime models often produce audio internally; switching the model to text-only or removing TTS is the cleanest way to avoid speech lifecycle artifacts.
• If you still want model audio for other uses, but want to avoid SpeechHandles for certain responses, use the node override approach selectively.
• After these changes, verify by checking logs for speech-related ids/events; SpeechHandle creation logs should no longer appear.
If you share how you construct the realtime model in your session (the exact AgentSession/RealtimeModel config), I’ll give the exact code change to disable modalities or the exact node override you can drop into your agent class. Which would you prefer — config change (modalities/text-only) or node override?
Sources: Realtime model plugins | LiveKit Docs | OpenAI and LiveKit partner to turn Advanced Voice into an API | How to Set Up Speech-to-Text Input with Text-Only Output in LiveKit Agents | Simple Content Filter | LiveKit docs | Pipeline nodes and hooks | LiveKit docsvictorious-evening-49093
12/18/2025, 11:49 AMtall-airline-16756
12/18/2025, 11:50 AMrealtime_audio_output_node (snake_case) and show how to consume the upstream audio stream, optionally modify frames, and yield frames downstream.
Notes:
• Use the snake_case node name realtime_audio_output_node for v1.x.
• Ensure your agent instance is passed to session.start(...).
• Audio frame type depends on your SDK; treat frames as opaque objects and modify fields you know (e.g., samples, volume).
• Always yield upstream frames (or modified frames) so normal pipeline continues; to suppress playback, simply do not yield frames.
1) Pass-through (log & forward frames)
import { voice, llm } from '@livekit/agents';
class MyAgent extends voice.Agent {
// keep same signature as SDK expects
async *realtime_audio_output_node(audioStream: AsyncIterable<any>, modelSettings: voice.ModelSettings): AsyncIterable<any> {
// get upstream generator (if default exists) and use it, else use provided audioStream
const upstream = await (voice.Agent as any).default.realtime_audio_output_node?.call(this, audioStream, modelSettings) ?? audioStream;
for await (const frame of upstream) {
try {
// Inspect or log minimal info (avoid heavy work here)
// console.log('audio frame timestamp', frame?.timestamp);
// Optional: modify frame (example: adjust a hypothetical volume field)
// if (frame && typeof frame.volume === 'number') frame.volume *= 0.9;
} catch (err) {
console.warn('realtime_audio_output_node processing error', err);
}
// yield the (possibly modified) frame downstream so playback occurs
yield frame;
}
}
}
2) No-op / suppress playback (consume but do not yield)
import { voice } from '@livekit/agents';
class SilentAgent extends voice.Agent {
async *realtime_audio_output_node(audioStream: AsyncIterable<any>, modelSettings: voice.ModelSettings): AsyncIterable<any> {
const upstream = await (voice.Agent as any).default.realtime_audio_output_node?.call(this, audioStream, modelSettings) ?? audioStream;
for await (const frame of upstream) {
// consume frames to keep pipeline moving, but do NOT yield them.
// This prevents audio playout and avoids creating playout SpeechHandle.
// Optionally perform light logging or forward partial text via publishData/RPC.
}
// generator ends; no frames yielded => no playback
}
}
Usage (in your agent entry)
// create agent instance
const agent = new MyAgent({ instructions: '...' });
// create session with realtime model config
const session = new voice.AgentSession({
llm: /* your realtime model provider */,
// no explicit tts provider if you want text-only behavior
});
await session.start({ room: ctx.room, agent, inputOptions: { audioEnabled: true }, outputOptions: { audioEnabled: false } });
await ctx.connect();
Best practices
• Keep per-frame work minimal; avoid heavy synchronous work in the loop.
• If modifying audio samples, be certain of the frame structure (samples, channels, sample rate) for your provider.
• If you suppress frames to prevent SpeechHandle creation, also verify transcription/timing requirements — you may need to forward text via transcription_node or publish custom data messages so clients still receive responses.
• Add small, infrequent logging only for debugging; too much logging in the audio loop can affect performance.
If you want, tell me:
• the realtime LLM provider you use (OpenAI/Gemini), and whether you need a safe example that scales audio frames (actual sample buffer manipulation), and I’ll provide a variant that shows sample-level volume scaling.victorious-evening-49093
12/18/2025, 11:58 AMtall-airline-16756
12/18/2025, 11:58 AMrealtimeAudioOutputNode will never be invoked. To intercept realtime model audio you must enable audio output, consume the stream in your node, and return null (or yield modified frames) to control whether audio is published.
Minimal working pattern (TypeScript, v1.x):
1) Start session with audio enabled:
await session.start({
room: ctx.room,
agent,
outputOptions: { audioEnabled: true, transcriptionEnabled: true },
inputOptions: { audioEnabled: true, textEnabled: false },
});
2) Override and consume the audio stream (prevents playback by returning null):
class MyAgent extends voice.Agent {
async realtime_audio_output_node(audioStream: AsyncIterable<any>, _modelSettings: any): Promise<AsyncIterable<any> | null> {
console.log('>>> realtime_audio_output_node triggered');
// consume upstream to avoid backpressure, but do not yield frames -> suppress playout
(async () => {
try {
for await (const frame of audioStream) {
// lightweight inspection/logging only
// e.g., console.debug('frame ts', frame?.timestamp);
}
} catch (err) {
console.warn('consume audioStream error', err);
}
})();
// returning null prevents audio from being published to the room
return null;
}
}
Notes
• Keep per-frame work minimal — heavy work will hurt realtime performance.
• If you only need text output, enable audioEnabled: true temporarily so the node runs, consume frames, and rely on lk.transcription for text; or configure your realtime model/modality to text-only to avoid audio generation altogether.
• Add logging inside the node to verify it runs. If it still doesn't trigger after enabling audio, share your session.start config and agent instantiation and I’ll inspect. Sources: Text and transcriptions | LiveKit docs | Pipeline nodes and hooks | LiveKit docsvictorious-evening-49093
12/18/2025, 12:03 PMtall-airline-16756
12/18/2025, 12:03 PMrealtime_audio_output_node runs.
• Returning null from realtime_audio_output_node prevents audio from being published (suppressing playout) but does not stop the SpeechHandle from being created.
• If you want no SpeechHandle at all, you must prevent the agent from producing audio at the model/pipeline level (use a text-only modality or remove/disable TTS/realtime audio output in the session/model config). Overriding realtime_audio_output_node can only control frames, not the handle creation itself.
If you want, tell me whether you prefer:
• suppressing playback but keeping the logical speech lifecycle (current approach), or
• preventing SpeechHandle creation entirely (I’ll show how to set the realtime model/session to text-only or remove TTS).victorious-evening-49093
12/18/2025, 12:04 PMtall-airline-16756
12/18/2025, 12:05 PMconst session = new voice.AgentSession({
llm: new RealtimeModelProvider({
model: 'gpt-realtime',
modalities: ['text'], // critical: request text-only modality
// no `voice` / no audio config
}),
// no tts provider
});
await session.start({
agent,
room: ctx.room,
inputOptions: { audioEnabled: true }, // accept user speech if needed
outputOptions: { audioEnabled: false, transcriptionEnabled: true }, // no audio out
});
Effect: the model will not synthesize audio, so no SpeechHandle is created.
2) If you cannot change model modalities, override realtime audio node to bypass audio generation at upstream entry
• Ensure audio output is enabled so the node is invoked, but prevent speech lifecycle by intercepting the model’s audio before SpeechHandle is created is NOT reliable — SpeechHandle is created earlier for realtime models. Instead, configure the model to not generate audio or remove the TTS pathway.
If you share which realtime provider and how you construct the RealtimeModel (exact code), I’ll give the precise config for that provider (OpenAI Realtime / Gemini Live / etc.) to ensure text-only operation. Which provider are you using?
Sources: Text and transcriptions | LiveKit docs | Realtime model plugins | LiveKit Docs | Agent speech and audio | LiveKit docs | Events and error handling | LiveKit docsvictorious-evening-49093
12/18/2025, 12:09 PMtall-airline-16756
12/18/2025, 12:10 PMlk.transcription text stream for realtime text.
• If you need to observe the handle for logging, just ignore awaiting it — it won't cause audio playback.
2) If you must avoid SpeechHandle side-effects entirely, use a provider/config that guarantees no speech lifecycle
• Ensure the realtime model is truly text‑only at the provider level (modalities=['text'] and audioOutput=false in the model/session provider). If the provider still signals "speech" lifecycle, the SDK may still emit speech events.
• If your provider supports a strict text-only realtime mode, use that exact provider flag (I can craft the exact config if you tell me the provider / plugin class you instantiate).
3) Workaround: intercept and no‑op the speech hooks
• You cannot reliably stop creation, but you can neutralize effects by hooking speech events and making them no-ops:
◦ session.on('speech_created', ev => {/* noop or immediate resolve */})
◦ override realtime_audio_output_node or tts_node to consume but not yield audio (you already did this).
If you want a concrete change now, tell me:
• Which exact provider/class you instantiate for the realtime model (the code where you do new ManualRealtimeModel(...) / new openai.realtime.RealtimeModel(...)), and
• Whether you prefer: (A) a provider config that enforces strict text-only, or (B) a small snippet to neutralize speech events (session.on('speech_created') handler + realtime_audio_output_node no-op).
I’ll give the exact code for your choice.
Sources: OpenAI Realtime API plugin guide | LiveKit docs | Azure OpenAI Realtime API and LiveKit | LiveKit docs | Realtime model plugins | LiveKit Docs | Events and error handling | LiveKit docsvictorious-evening-49093
12/21/2025, 2:03 PMtall-airline-16756
12/21/2025, 2:04 PMawait session.start({
agent,
room: ctx.room,
inputOptions: { audioEnabled: true, textEnabled: false }, // accept audio input if needed
outputOptions: { audioEnabled: false, transcriptionEnabled: false }, // disable both outputs
});
• Ensure your RealtimeModel is configured to not produce audio/text automatically (provider-specific flag). Example pseudo:
new RealtimeModel({ model: 'gpt-realtime-mini', modalities: ['text'], audioOutput: false })
• Make agent instructions explicit: “Output results only via tool calls; do not emit text or speech.”
2) If the provider or SDK still creates speech artifacts, neutralize outputs in nodes/events
• Override audio/text nodes to drop output (no playout/transcription forwarding).
◦ Suppress audio:
async realtime_audio_output_node(audioStream, modelSettings) {
// consume upstream to avoid backpressure, but do NOT yield frames
(async () => { for await (const _ of audioStream) {} })();
return null;
}
• Suppress transcription forwarding (publish nothing):
async *transcription_node(textStream, modelSettings) {
// consume but don't yield/publish transcription
for await (const _ of textStream) {}
}
• Hook or ignore speech events so SpeechHandle effects are inert:
session.on('speech_created', ev => { /* no-op */ });
Notes and cautions
• Some realtime providers may still trigger internal speech lifecycle events (SpeechHandle) even when outputs are disabled; that’s harmless if you suppress node outputs and ignore events.
• Make agent instructions enforce tool-only output to avoid the LLM generating freeform text.
• If you need absolute assurance the provider won’t produce audio/text, use a provider flag that enforces text-only modality (provider-specific). Tell me which provider/plugin class you use (OpenAI Realtime, Gemini Live, etc.) and I’ll give the exact config snippet.
Do you want the exact Node.js code for your current provider (you mentioned gpt-realtime-mini)?
Sources: Text and transcriptions | LiveKit docs | Realtime model plugins | LiveKit Docs | OpenAI Realtime API plugin guide | LiveKit docs | Gemini Live API plugin | LiveKit docs | Pipeline nodes and hooks | LiveKit docsvictorious-evening-49093
12/21/2025, 2:05 PMtall-airline-16756
12/21/2025, 2:06 PMimport * as openai from '@livekit/agents-plugin-openai';
const realtimeModel = new openai.realtime.RealtimeModel({
model: 'gpt-realtime-mini',
modalities: ['text'], // request text-only
audioOutput: false, // explicit if supported by provider wrapper
turnDetection: false, // you'll handle turns manually or via tools
});
2) Start session with outputs disabled
• Disable both audio and transcription forwarding in session.start:
const session = new voice.AgentSession({ llm: realtimeModel /* no tts */ });
await session.start({
agent,
room: ctx.room,
inputOptions: { audioEnabled: true, textEnabled: false }, // accept audio input if needed
outputOptions: { audioEnabled: false, transcriptionEnabled: false }, // disable both outputs
});
3) Enforce tool-only responses in prompts/instructions
• Tell the model to only use tool calls and never output text/audio:
"You are an assistant that MUST return results only via the 'submit_result' tool. Do NOT emit any text or speech. If you would normally respond, call the tool with the structured result."
• Use function/tool schema for the tool and ensure the model is given the tool definitions.
4) Optional safety: neutralize nodes & events in agent
• If the provider or SDK still triggers speech events, neutralize them:
// ignore speech lifecycle
session.on('speech_created', () => {/* noop */});
// suppress any audio node if invoked
class NoAudioAgent extends voice.Agent {
async realtime_audio_output_node(audioStream, _settings) {
(async () => { for await (const _ of audioStream) {} })(); // consume
return null; // prevent publish
}
async *transcription_node(textStream, _settings) {
for await (const _ of textStream) { /* drop */ }
// do not yield => no transcription forwarded
}
}
• Use this only if you still see speech/transcription events after steps 1–3.
5) Validate tool-only behavior
• Test with a prompt that triggers a tool call; verify:
◦ No lk.transcription messages are sent
◦ No audio track is published
◦ Model calls your tool and you receive the tool payload on the agent side
Notes / caveats
• Some SDK/provider implementations still create logical SpeechHandles or emit speech events even when outputs are disabled; those are inert if audio/transcription are disabled and can be safely ignored or suppressed with the event handlers above.
• Make the instruction to the model strict and include tool schemas; using modalities: ['text'] plus audioOutput:false is the most reliable approach.
• If you want, I can produce the exact constructor args for the OpenAI plugin class you use (show me the code line where you instantiate the RealtimeModel) and a drop-in agent example that performs only tool calls. Which plugin/class are you instantiating now?