acoustic-pharmacist-34178
02/19/2026, 7:38 PMtall-airline-16756
02/19/2026, 7:38 PMtall-airline-16756
02/19/2026, 7:39 PMuser_away_timeout to the number of seconds of silence you want.
◦ Listen for the user_state_changed event. When the state becomes "away", close the session or disconnect the participant (for example, call session.shutdown() or disconnect the participant from the room).
◦ This is the simplest approach and integrates with the agent’s state events (speaking, listening, away).
• Implement custom silence detection (if you need more control)
◦ Track the last time the participant spoke (update a timestamp on a user_started_speaking event).
◦ Run a background timer/task that checks elapsed silence. If elapsed time > threshold, disconnect the participant or close the call.
◦ Use this when you want extra behavior before disconnecting (retries, play a message, logging, different thresholds per participant, etc.).
If you’d like, I can:
1. Show a short code snippet for the exact SDK and language you’re using (Python, JavaScript, etc.), or
2. Help you pick threshold values and retry/greeting behavior for use cases like voicemail detection or dropped-call cleanup.
Which SDK/language are you using, and do you want automatic disconnect immediately on silence or a retry/greeting flow first?
Sources: How to detect and handle silence during calls | LiveKit | How to detect and handle silence during calls | examples/frontdesk | livekit/livekit_composite | DeepWiki | How to implement call greeting retries in voice agents | LiveKitacoustic-pharmacist-34178
02/19/2026, 7:41 PMtall-airline-16756
02/19/2026, 7:42 PMuser_away_timeout with the user_state_changed event (recommended for simplicity), or (B) run a custom silence timer using user_started_speaking events for more control.
A. Quick (recommended) — built-in user_away_timeout
1. Configure user_away_timeout on the AgentSession.
2. Listen for `user_state_changed`; when new_state === "away", shut down the session / disconnect the room.
Example:
import { AgentSession, Agent } from "livekit-agents";
const session = new AgentSession({
// seconds of silence before state becomes "away"
user_away_timeout: 10.0,
// other session config: vad, stt, tts, llm...
});
session.on("user_state_changed", (ev) => {
if (ev.new_state === "away") {
// close the session/room when participant is away
session.shutdown();
// if you have access to the JobContext room, you can also call ctx.room.disconnect()
}
});
await session.start({
agent: new Agent({ instructions: "You are a helpful assistant." }),
room: jobContext.room,
});
Why use this:
• Simple to implement.
• Integrates with agent state lifecycle (speaking, listening, away).
B. Custom timer — full control (retries, prompts, different thresholds)
1. Track a timestamp when user_started_speaking fires.
2. Start a periodic check (setInterval) that disconnects after your silence threshold.
3. Optionally prompt the user (TTS) before final disconnect, or try a retry.
Example:
import { AgentSession, Agent } from "livekit-agents";
const SILENCE_THRESHOLD_SEC = 10;
let lastSpokeAt = Date.now();
let monitorHandle = null;
const session = new AgentSession({
// include vad, stt, etc.
});
session.on("user_started_speaking", () => {
lastSpokeAt = Date.now();
});
monitorHandle = setInterval(async () => {
const elapsed = (Date.now() - lastSpokeAt) / 1000;
if (elapsed > SILENCE_THRESHOLD_SEC) {
// Optionally: play a prompt before disconnecting
// await session.tts.play("Are you still there?");
// Wait some seconds for response, or disconnect immediately:
try {
// If you have the JobContext available as `ctx`:
await ctx.room.disconnect(); // ends the call
} catch (err) {
// fallback: shutdown agent session
session.shutdown();
} finally {
clearInterval(monitorHandle);
}
}
}, 1000);
// Ensure cleanup on shutdown
ctx.addShutdownCallback(() => {
if (monitorHandle) clearInterval(monitorHandle);
});
Which approach should you pick?
• Use A (user_away_timeout) if you want an easy, reliable inactivity detection that plugs into the agent lifecycle.
• Use B if you need to:
◦ Play prompts/confirmations before disconnecting,
◦ Apply different thresholds per participant,
◦ Do logging/retries or treat voicemail/auto-responders differently.
If you want, I can:
• Provide a complete copy-paste example tailored to your project structure (Express, serverless, or Agent CLI).
• Add a prompt/retry flow: e.g., play a TTS message at 80% of the timeout, then disconnect if no reply.
• Show how to disconnect just a single participant vs. disconnecting the whole room.
Which of the above would you like next, and do you have a preferred timeout value or retry behavior?
Sources: How to detect and handle silence during calls | examples/voice_agents | livekit/livekit_composite | DeepWiki | Silero VAD plugin | LiveKit Documentationacoustic-pharmacist-34178
02/19/2026, 8:01 PMuser_state_changedtall-airline-16756
02/19/2026, 8:01 PMuser_state_changed event for silence detection and automatic disconnect.
Below is a concise Node.js / LiveKit Agents example that:
• Sets user_away_timeout to mark the user as "away" after silence,
• Listens for user_state_changed and disconnects when the state becomes "away",
• Includes cleanup and an optional prompt/retry flow before disconnecting.
import { AgentSession, Agent } from "livekit-agents";
/*
Example variables you should provide:
- ctx: the JobContext for this agent run (provides ctx.room, ctx.addShutdownCallback)
- vad, stt, tts, llm: your chosen plugins (omitted for brevity)
*/
const AWAY_TIMEOUT_SECONDS = 10; // seconds of silence before user becomes "away"
const PROMPT_BEFORE_DISCONNECT = true;
const PROMPT_TIMEOUT_SECONDS = 5; // wait for reply after prompt
async function entrypoint(ctx) {
const session = new AgentSession({
vad: /* your VAD plugin */,
stt: /* your STT plugin */,
tts: /* your TTS plugin */,
llm: /* your LLM plugin */,
user_away_timeout: AWAY_TIMEOUT_SECONDS,
});
// Track whether we've already prompted to avoid repeated prompts
let prompted = false;
session.on("user_state_changed", async (ev) => {
// ev.new_state will be one of: "speaking", "listening", "away"
if (ev.new_state === "away") {
// Option A: immediate disconnect
if (!PROMPT_BEFORE_DISCONNECT) {
try {
await ctx.room.disconnect();
} catch (err) {
// fallback
session.shutdown();
}
return;
}
// Option B: prompt user once, then disconnect if still silent
if (!prompted) {
prompted = true;
try {
// play a short TTS prompt asking if they're still there
// replace with your TTS playback method if different
await session.tts.play("We haven't heard from you. Are you still there?");
} catch (err) {
// ignore TTS errors and proceed to disconnect logic
}
// wait PROMPT_TIMEOUT_SECONDS for a response (user_state_changed -> "speaking")
const start = Date.now();
const waitForReply = () => new Promise((resolve) => {
const onState = (replyEv) => {
if (replyEv.new_state === "speaking") {
// user replied — cancel disconnect
session.off("user_state_changed", onState);
resolve(true);
}
};
session.on("user_state_changed", onState);
// timeout handler
const timer = setTimeout(() => {
session.off("user_state_changed", onState);
resolve(false);
}, PROMPT_TIMEOUT_SECONDS * 1000);
});
const replied = await waitForReply();
if (!replied) {
try {
await ctx.room.disconnect();
} catch (err) {
session.shutdown();
}
} else {
// user replied; reset prompt flag so future away events can re-prompt if needed
prompted = false;
}
}
}
// Reset prompt flag when user starts speaking again
if (ev.new_state === "speaking") {
prompted = false;
}
});
// Ensure the session/monitor cleans up on shutdown
ctx.addShutdownCallback(() => {
try {
session.shutdown();
} catch (e) {
// ignore
}
});
await session.start({
agent: new Agent({ instructions: "You are a helpful assistant." }),
room: ctx.room,
});
}
export default entrypoint;
If you prefer the absolute simplest route, set user_away_timeout and in the user_state_changed handler immediately call session.shutdown() or ctx.room.disconnect() when new_state === "away".
Would you like a version that:
• Disconnects only a single participant instead of the whole room, or
• Uses different thresholds per participant, or
• Includes logging/metrics around disconnects?
Tell me which and I’ll tailor the snippet.acoustic-pharmacist-34178
02/19/2026, 8:07 PMtall-airline-16756
02/19/2026, 8:07 PMuser_state_changed event.
A few quick questions so I can give the precise fix:
1. Which package and version are you using? (e.g., livekit-agents@x.y.z, agents-js, or a monorepo checkout)
2. Are you running in Node (CommonJS / ESM) or in a bundler that enforces strict module resolution?
3. Can you paste the import line you tried and the TypeScript error message you see?
While you share that, two immediate workarounds you can try:
• Use the string event name instead of the enum (type-safe enough at runtime):
session.on("user_state_changed", (ev) => {
if (ev.new_state === "away") {
// disconnect
}
});
• If you need the enum type but it’s not exported, define a local enum matching the expected name/values and use it for typings:
enum AgentState {
Speaking = "speaking",
Listening = "listening",
Away = "away",
}
// then
session.on("user_state_changed", (ev: { new_state: AgentState }) => {
if (ev.new_state === AgentState.Away) { /* ... */ }
});
Why this happens: some SDK builds don’t export every event enum from the top-level module, so the enum symbol you expect may not be exported. If you tell me your package name/version and the exact error, I’ll provide the correct import path (or a patch) and a small TypeScript example you can drop into your project.acoustic-pharmacist-34178
02/19/2026, 8:09 PMtall-airline-16756
02/19/2026, 8:09 PMuser_state_changed event with @livekit/agents@^1.0.40.
Short answer: the event exists at runtime but the SDK’s TypeScript types don’t include that string as a key of AgentSessionCallbacks, so the compiler complains. You can safely use the event at runtime with one of these TypeScript-friendly workarounds.
Options you can use (pick one):
1) Use the string literal and relax the type check
session.on('user_state_changed' as any, (ev: any) => {
if (ev.new_state === 'away') {
session.shutdown();
}
});
• Simple and reliable. as any bypasses the compile-time key check while preserving runtime behavior.
2) Use the runtime constant (recommended if exported at runtime)
import { voice } from '@livekit/agents';
session.on(voice.AgentSessionEventTypes.UserStateChanged as any, (ev: any) => {
if (ev.new_state === 'away') {
session.shutdown();
}
});
• Uses the runtime value AgentSessionEventTypes.UserStateChanged. If your editor still flags it, add as any for the parameter.
3) Create a local enum/type and assert the event key
enum AgentState {
Speaking = 'speaking',
Listening = 'listening',
Away = 'away',
}
type UserStateChangedEvent = { new_state: AgentState };
session.on('user_state_changed' as unknown as keyof any, (ev: UserStateChangedEvent) => {
if (ev.new_state === AgentState.Away) {
session.shutdown();
}
});
• Keeps stronger typing for the event payload even if you must assert the event name.
Why this is happening
• In @livekit/agents v1.0.40 the event name user_state_changed (and related event enums/types) are not fully represented in the exported TypeScript callback keys, so TS complains even though the event is emitted at runtime.
Practical tip
• Make sure user_away_timeout (seconds) is set on your AgentSession so the away state is produced:
session.options.user_away_timeout = 10; // seconds
If you want, I can:
• Share a minimal TypeScript example file you can drop into your project (with the least intrusive as casts), or
• Suggest a small index.d.ts augmentation you can add locally to avoid any casts and keep compile-time checks.
Which would you prefer?
Sources: Agent session | LiveKit Documentation | How to detect and handle silence during calls | LiveKit | Turns overview | LiveKit Documentationacoustic-pharmacist-34178
02/19/2026, 8:18 PMtall-airline-16756
02/19/2026, 8:19 PMvoice.AgentSessionEventTypes.UserStateChanged with @livekit/agents@^1.0.40, explains why you may need a small type cast, and gives a safe pattern for handling the "away" state.
Why you see issues
• The event exists at runtime as voice.AgentSessionEventTypes.UserStateChanged, but the package’s exported TypeScript callback keys don’t include that string, so the compiler complains when you pass it directly to session.on(). A lightweight cast keeps type-safety for the payload while allowing the runtime event registration.
Minimal TypeScript example
import { voice } from "@livekit/agents";
async function entrypoint(ctx: any) {
const session = new voice.AgentSession({
// provide your vad, stt, tts, llm plugins here
// e.g., vad: plugins.silero.VAD.create(), etc.
user_away_timeout: 10.0, // seconds before state becomes "away"
});
// handler type for the event payload
type UserStateChangedEvent = {
new_state: "speaking" | "listening" | "away";
// add other fields if you rely on them (speaker id, etc.)
};
// Use the runtime constant for the event name, and cast it to satisfy TS
const evName = voice.AgentSessionEventTypes.UserStateChanged as unknown as keyof any;
session.on(evName, (ev: UserStateChangedEvent) => {
if (ev.new_state === "away") {
// disconnect the room or shutdown session
// prefer ctx.room.disconnect() if you have ctx, otherwise fallback to session.shutdown()
(async () => {
try {
await ctx.room.disconnect();
} catch {
session.shutdown();
}
})();
}
// reset or other logic when user speaks again
if (ev.new_state === "speaking") {
// optional: reset any prompt flags or timers
}
});
// ensure graceful shutdown cleanup
ctx.addShutdownCallback(() => {
try {
session.shutdown();
} catch {
/* ignore */
}
});
await session.start({
agent: new voice.Agent({ instructions: "You are a helpful assistant." }),
room: ctx.room,
});
}
Options (choose one)
• Keep the single cast above (as unknown as keyof any) — simplest and commonly used.
• If you prefer to avoid `any`/`unknown`, you can register the string literal with `as any`:
◦ session.on("user_state_changed" as any, handler);
• To keep payload typing, declare the event payload type (as shown) and annotate the handler parameter.
Notes and tips
• Make sure user_away_timeout is set (it triggers the "away" state).
• Use ctx.room.disconnect() to disconnect the call if you have JobContext; otherwise use session.shutdown().
• If you want a prompt-before-disconnect flow, play a TTS message when new_state === "away", wait a short interval for new_state === "speaking", then disconnect if no reply.
If you’d like, I can:
• Provide a typed declaration augmentation (a small local *.d.ts you can add to avoid casts), or
• Return a complete copy-paste example that includes plugin initialization (VAD/STT/TTS) for your project structure.
Which would you prefer?acoustic-pharmacist-34178
02/19/2026, 9:05 PMprivate silenceListener(): void {
const evtName = voice.AgentSessionEventTypes.UserStateChanged;
let silenceTimer: NodeJS.Timeout | null = null;
this.session.on(evtName, (ev) => {
if (ev.newState === 'away') {
this.session.generateReply({
instructions: "We didn't hear from the user for a while. Ask them if they're still here and need assistance, otherwise let them know you'll be ending the call.",
toolChoice: 'none',
});
silenceTimer = setTimeout(() => {
this.session.generateReply({
instructions: "Have not heard from the user. Ending the call due to inactivity.",
toolChoice: 'required',
});
}, this.SILENCE_THRESHOLD_MS);
}
if (ev.newState === 'speaking') {
if (silenceTimer) {
clearTimeout(silenceTimer);
silenceTimer = null;
}
}
});
}tall-airline-16756
02/19/2026, 9:05 PMvoice.AgentSessionEventTypes.UserStateChanged
• Avoids TypeScript errors by narrowing the event name with a lightweight cast
• Preserves typed event payload handling for new_state
• Ensures the prompt/reply timer is cleaned up correctly (and avoids stacking timers)
Suggested TypeScript-friendly version (replace placeholders for your environment: this.session, ctx, plugin setup, etc.):
import { voice } from "@livekit/agents";
type UserState = "speaking" | "listening" | "away";
type UserStateChangedEvent = {
new_state: UserState;
// add other fields if you need them (e.g., participant id)
};
private silenceListener(): void {
// Use runtime constant but cast it so TS won't complain about event key
const evtName = voice.AgentSessionEventTypes.UserStateChanged as unknown as keyof any;
let silenceTimer: NodeJS.Timeout | null = null;
this.session.on(evtName, (ev: UserStateChangedEvent) => {
if (ev.new_state === "away") {
// First prompt the user (non-blocking)
this.session.generateReply({
instructions:
"We didn't hear from the user for a while. Ask them if they're still here and need assistance, otherwise let them know you'll be ending the call.",
toolChoice: "none",
});
// Clear any existing timer to avoid duplicates, then start one
if (silenceTimer) {
clearTimeout(silenceTimer);
}
silenceTimer = setTimeout(async () => {
// Final prompt / action before ending the call
await this.session.generateReply({
instructions: "Have not heard from the user. Ending the call due to inactivity.",
toolChoice: "required",
});
// Prefer disconnect via JobContext room if available; fallback to session shutdown
try {
if (this.ctx?.room) {
await this.ctx.room.disconnect();
} else {
this.session.shutdown();
}
} catch (err) {
// best-effort shutdown
try {
this.session.shutdown();
} catch {
// ignore
}
} finally {
if (silenceTimer) {
clearTimeout(silenceTimer);
silenceTimer = null;
}
}
}, this.SILENCE_THRESHOLD_MS);
}
// If user speaks again, cancel pending disconnect
if (ev.new_state === "speaking") {
if (silenceTimer) {
clearTimeout(silenceTimer);
silenceTimer = null;
}
}
});
}
Notes and best practices
• Casting the event name (as shown) is a minimal, safe workaround for the current types in @livekit/agents@^1.0.40. It prevents the TS error while keeping runtime correctness.
• Use new_state (not newState) — your SDK handlers in examples have used new_state in some places; use whichever shape your runtime provides. If your runtime emits newState, change the type and checks accordingly.
• Always clear any existing timer before starting a new one to avoid multiple pending disconnects.
• Prefer ctx.room.disconnect() (JobContext) to close the call if you have `ctx`; use session.shutdown() as a fallback.
• If you want a reply-to-prompt window shorter than your user_away_timeout, start this prompt when receiving the away state — the example does that. Alternatively, you can prompt earlier (e.g., when listening) if you want to give them multiple chances.
If you want, I can:
• Provide a tiny d.ts augmentation to surface the event key types so you don’t need casts, or
• Update this snippet to use newState if your runtime uses camelCase, or
• Add logic to only disconnect a specific participant instead of the whole room.
Which would you prefer?acoustic-pharmacist-34178
02/19/2026, 9:16 PMtall-airline-16756
02/19/2026, 9:16 PM@livekit/agents) provides a user_away_timeout option on AgentSession.
Quick facts
• Where: user_away_timeout is available when using the LiveKit Agents framework in @livekit/agents (not in the standard livekit-client browser SDK).
• What it does: after the configured number of seconds of silence, the session emits a user_state_changed event with new_state === "away".
• Default: if you don't set it, it defaults to 15 seconds.
• Typical action: listen for user_state_changed and when new_state === "away" call ctx.room.disconnect() or session.shutdown().
Minimal TypeScript example
import { voice } from "@livekit/agents";
const session = new voice.AgentSession({
// vad, stt, tts, llm, etc.
user_away_timeout: 10, // seconds of silence before user becomes "away"
});
const evName = voice.AgentSessionEventTypes.UserStateChanged as unknown as keyof any;
session.on(evName, (ev: { new_state: "speaking" | "listening" | "away" }) => {
if (ev.new_state === "away") {
// prefer ctx.room.disconnect() if you have JobContext; otherwise:
session.shutdown();
}
});
TypeScript note
• The runtime event exists, but the package’s exported callback keys may not include the user_state_changed string, so you may need a small cast (as shown) or use 'user_state_changed' as any to avoid the compile error while keeping runtime behavior.
If you’d like, I can:
• Share a short .d.ts augmentation you can drop into your project to avoid casts, or
• Convert your existing snippet to a fully-typed TypeScript method that uses ctx.room.disconnect() and avoids any. Which do you prefer? Sources: Agent session | LiveKit Documentation | examples/voice_agents | livekit/livekit_composite | DeepWikiacoustic-pharmacist-34178
02/19/2026, 9:43 PMprivate silenceListener(): void {
const evtName = voice.AgentSessionEventTypes.UserStateChanged;
let silenceTimer: NodeJS.Timeout | null = null;
this.session.on(evtName, async (ev) => {
if (ev.newState === 'away') {
const handle = this.session.generateReply({
instructions: "We didn't hear from the user for a while. Ask them if they're still here and need assistance, otherwise let them know you'll be ending the call.",
toolChoice: 'none',
});
await handle.waitForPlayout();
// avoid duplicate timers
if (silenceTimer) {
clearTimeout(silenceTimer);
}
silenceTimer = setTimeout(async () => {
const handle = this.session.generateReply({
instructions: "Have not heard from the user. Ending the call due to inactivity.",
toolChoice: 'required',
});
await handle.waitForPlayout();
}, this.AWAY_THRESHOLD_MS);
}
if (ev.newState === 'speaking') {
if (silenceTimer) {
clearTimeout(silenceTimer);
silenceTimer = null;
}
}
});
}
This works fine, however, The agent doesn't say anything before hanging uptall-airline-16756
02/19/2026, 9:43 PMnew_state (snake_case) in earlier examples; your snippet uses newState. Use whichever your runtime emits — mismatched names can make the handler behave unpredictably.
What to check and fix
1. Ensure you await the final playout and only disconnect after it completes.
2. Make sure nothing else is calling session.shutdown() or ctx.room.disconnect() while the agent is playing.
3. Use the correct event field name (new_state vs newState) that your version emits and keep consistent typing.
4. Clear timers and remove the listener on shutdown so you don't race with cleanup.
Updated TypeScript-friendly pattern
• Uses runtime event constant with a lightweight cast.
• Awaits both prompt playouts and only disconnects after the final one completes.
• Prevents duplicate timers and avoids race conditions.
import { voice } from "@livekit/agents";
type UserState = "speaking" | "listening" | "away";
type UserStateChangedEvent = { new_state: UserState }; // adjust if your runtime uses newState
private silenceListener(): void {
const evtName = voice.AgentSessionEventTypes.UserStateChanged as unknown as keyof any;
let silenceTimer: NodeJS.Timeout | null = null;
let prompted = false;
this.session.on(evtName, async (ev: UserStateChangedEvent) => {
// adjust field if your runtime emits `newState`
const state = (ev as any).new_state ?? (ev as any).newState;
if (state === "away") {
// play an initial probe prompt and wait for it to finish
try {
const handle = this.session.generateReply({
instructions:
"We didn't hear from the user for a while. Ask them if they're still here and need assistance, otherwise let them know you'll be ending the call.",
toolChoice: "none",
});
await handle.waitForPlayout();
} catch (err) {
// log/ignore TTS errors but continue with timer
}
// avoid duplicate timers
if (silenceTimer) {
clearTimeout(silenceTimer);
}
// Start timer to wait for user response; after it fires play final prompt then disconnect
silenceTimer = setTimeout(async () => {
silenceTimer = null;
try {
const finalHandle = this.session.generateReply({
instructions: "Have not heard from the user. Ending the call due to inactivity.",
toolChoice: "required",
});
// wait for the final prompt to finish playout before disconnecting
await finalHandle.waitForPlayout();
} catch (err) {
// ignore/playback error and still attempt to end the call
}
// After playout, prefer ctx.room.disconnect(); fallback to session.shutdown()
try {
if (this.ctx?.room) {
await this.ctx.room.disconnect();
} else {
this.session.shutdown();
}
} catch (err) {
// best-effort shutdown
try { this.session.shutdown(); } catch {}
}
}, this.AWAY_THRESHOLD_MS);
}
if (state === "speaking") {
// cancel pending disconnect if user returned
if (silenceTimer) {
clearTimeout(silenceTimer);
silenceTimer = null;
}
prompted = false;
}
});
// cleanup on agent shutdown to avoid races
this.ctx?.addShutdownCallback(() => {
if (silenceTimer) {
clearTimeout(silenceTimer);
silenceTimer = null;
}
try { this.session.shutdown(); } catch {}
});
}
Other troubleshooting tips
• Confirm TTS playout occurs by listening to any session or plugin logs for playback start/finish. If playbacks aren’t starting, the generateReply handles may be failing earlier.
• If you still see the agent cut off the final line, add a short safety delay (e.g., 250–500 ms) after waitForPlayout before disconnecting to account for transport flush.
• Ensure any other part of your app isn’t calling disconnect or session.shutdown() earlier (search for other shutdown calls on silence events).
If you want, I can:
• Convert this to match exactly newState naming if your runtime emits camelCase, or
• Add a tiny debug wrapper you can drop in that logs playback start/finish and disconnect events so we can pinpoint the race.
Which would help most right now?