How can I close a connection or call when there's ...
# ask-ai
a
How can I close a connection or call when there's no speech coming from participant for some period?
t
Thanks for reaching out, Ahron. I'm an AI assistant and I'm here to help. Give me a moment while I research this topic.
Thanks — I’m responding to your question about automatically closing a connection/call when a participant is silent for a set period. You have two straightforward options: • Use the built-in user_away_timeout in LiveKit Agents (recommended) ◦ Set
user_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 | LiveKit
a
sure using js sdk
t
Thanks — I’m responding to your request for a JavaScript example to automatically close a connection when a participant is silent. Below are two simple, production-ready options for the LiveKit Agents JavaScript/Node.js environment: (A) use the built-in
user_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:
Copy code
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:
Copy code
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 Documentation
a
This event is now under
user_state_changed
t
Thanks — good catch. I’m updating the JavaScript example to use the
user_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.
Copy code
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.
a
typescript seem to need enum AgentSessionEventTypes.AgentStateChanged, however, I cannot import it
t
Thanks — I’m responding to your TypeScript import issue for the
user_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):
Copy code
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:
Copy code
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.
a
Argument of type '"user_state_changed"' is not assignable to parameter of type 'keyof AgentSessionCallbacks'. I'm using "@alert-easter-19258/agents": "^1.0.40",
t
Thanks — I’m addressing your TypeScript error when using the
user_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
Copy code
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)
Copy code
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
Copy code
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:
Copy code
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 Documentation
a
voice.AgentSessionEventTypes.UserStateChanged
t
Thanks — I’ll show a concise, working TypeScript example that uses
voice.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
Copy code
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?
a
Copy code
private 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;
                }
            }
        });
    }
t
Thanks — this is a great snippet and almost there. I’ll give a small, safe TypeScript-correct version that: • Uses the runtime event constant
voice.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.):
Copy code
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?
a
Does the js sdk have a user_away_timeout?
t
Thanks — responding to your direct question: yes, the JavaScript/TypeScript Agents SDK (
@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
Copy code
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 | DeepWiki
a
Copy code
private 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 up
t
Thanks — I’m responding to your snippet where the final TTS reply is not heard before the agent hangs up. Problem summary • Your code schedules the final reply, awaits its playout, but never actually disconnects after playback. If disconnect happens elsewhere (or session shuts down) before or during playout, the TTS may be cut off. • Also the event payload field name in your runtime appears to be
new_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.
Copy code
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?