# Turn strategies

> The start and stop strategies that decide when a user turn begins and ends, the presets that bundle them, and how to write your own.

Source: https://polvallverdu.github.io/pipedog/docs/concepts/turn-strategies/



The user aggregator decides when the user's turn starts and stops by asking a list of **turn
strategies**. Start strategies watch for the user beginning to speak; stop strategies decide when
they're done. You choose them with `userTurnStrategies` on `LLMUserAggregatorParams`.

```ts
import { UserTurnStrategies } from "@pipedog/core/turns/user_turn_strategies";
import { VADUserTurnStartStrategy /* , ... */ } from "@pipedog/core/turns/user_start/index";
import { SpeechTimeoutUserTurnStopStrategy /* , ... */ } from "@pipedog/core/turns/user_stop/index";
```

## How a turn works [#how-a-turn-works]

Every frame the user aggregator sees goes to every start and stop strategy. A strategy doesn't start
or end the turn itself: it **reports a decision** (the user started, the user is done), and the turn
controller combines the decisions of its list, following the list's `StrategyMode`.

**Turn start.** By default (`startMode: StrategyMode.FIRST`) the first start strategy that decides
starts the turn: the aggregator broadcasts a `UserStartedSpeakingFrame`, broadcasts an
`InterruptionFrame` if the strategy has interruptions enabled (the default), and emits
`on_user_turn_started`. With `StrategyMode.ALL` the turn starts once every start strategy has
decided.

**Turn stop.** By default (`stopMode: StrategyMode.ALL`) the turn ends once every stop strategy has
decided the user is done; with `StrategyMode.FIRST`, on the first one. Then the aggregator broadcasts
a `UserStoppedSpeakingFrame`, writes the user's transcript to the context, which runs the LLM, and
emits `on_user_turn_stopped`. A list with one stop strategy behaves the same in either mode. The turn
never ends while the user is audibly speaking: decisions made meanwhile wait until they stop.

**Speaking again.** When the user starts speaking again in an open turn, the stop decisions made so
far are cleared. If the turn's answer had already started, it is voided: the aggregator interrupts
it, or, for a turn that doesn't interrupt the bot, broadcasts a `UserTurnResponseCancelFrame`.

**Stop timeout.** A turn that sees no speech, transcript or other turn activity for
`userTurnStopTimeout` seconds (default 5) ends anyway, after `on_user_turn_stop_timeout`, with what
the decisions made so far said about it. It waits while a stop strategy has a decision of its own
scheduled (`decisionScheduled`, such as the LLM's re-prompt timer). It is a safety net for a turn
that never finalizes, not a way to tune responsiveness.

To end a turn, a stop strategy needs two things:

1. **A transcript.** Strategies that wait for the transcript (`waitForTranscript`, on by default)
   hold the turn until speech-to-text delivers. This is usually the largest share of the delay.
2. **Its own criterion**: a silence window, a turn model's verdict, a provider's signal, or the LLM's
   verdict.

| Stop strategy                                | Ends the turn when                                  |
| -------------------------------------------- | --------------------------------------------------- |
| `TurnAnalyzerUserTurnStopStrategy` (default) | Smart Turn predicts the user is done                |
| `SpeechTimeoutUserTurnStopStrategy`          | a fixed silence after VAD reports the end of speech |
| `ExternalUserTurnStopStrategy`               | the STT service or another processor says so        |
| `FilterIncompleteUserTurnStrategies`         | the LLM judges the utterance complete               |
| Your own                                     | your own logic                                      |

To make the bot answer sooner or later, change the stop strategy (for example `userSpeechTimeout`).
Leave the VAD's `stopSecs` (0.2 s by default) alone: it is a low-level threshold that the strategies
already account for.

The VAD's volume gate is different: line noise and clicks often pass the model's confidence check,
and `VADParams.minVolume` is what stops them. `minVolume` is a 0–1 value mapped from -110 LUFS to
-10 LUFS (the default 0.6 is -50 LUFS). To tune it on a phone line, measure the noise floor in LUFS
and set `minVolumeDb` a few dB above it instead (pipedog only):

```ts
new SileroVADAnalyzer({ params: new VADParams({ minVolumeDb: -35 }) });
```

### Where the waiting time goes [#where-the-waiting-time-goes]

* **VAD silence (`stopSecs`).** The VAD only reports that speech stopped after this much silence.
* **Transcription.** Each STT service reports its p99 time to a final transcript (`ttfsP99Latency`);
  the stop strategies use it as a deadline counted from the end of speech, and stop waiting early as
  soon as the service marks a transcript as finalized. The VAD silence and the turn model's inference
  fall within that deadline rather than adding to it.
* **The silence window** of `SpeechTimeoutUserTurnStopStrategy` is the exception: it starts when
  the VAD reports the end of speech, so the shortest answer after a pause is `stopSecs` plus
  `userSpeechTimeout` (0.8 s by default).

The built-in p99 values assume `stopSecs` of 0.2. The strategies warn when `stopSecs` reaches the
service's p99, because that leaves no time to wait for the transcript.

## Configuration [#configuration]

These are the defaults, spelled out:

```ts
const userParams = new LLMUserAggregatorParams({
  vadAnalyzer: new SileroVADAnalyzer(),
  userTurnStrategies: new UserTurnStrategies({
    // The first start strategy to fire opens the turn.
    start: [new VADUserTurnStartStrategy(), new TranscriptionUserTurnStartStrategy()],
    // Smart Turn decides when the user is done.
    stop: [
      new TurnAnalyzerUserTurnStopStrategy({ turnAnalyzer: createLocalSmartTurnAnalyzerV3() }),
    ],
  }),
  // The first start strategy to decide starts the turn; every stop strategy
  // has to agree before it ends.
  userTurnStrategiesParams: new UserTurnStrategiesParams({
    startMode: StrategyMode.FIRST,
    stopMode: StrategyMode.ALL,
  }),
  // A turn with no speech, transcript or turn activity for this long ends anyway.
  userTurnStopTimeout: 5.0,
});
```

`new UserTurnStrategies()` with no lists, or with empty ones, gets the same defaults:
`defaultUserTurnStartStrategies()` (VAD, then transcription) and `defaultUserTurnStopStrategies()`
(Smart Turn). Both functions are exported, so you can extend the defaults instead of repeating them.

| Option | Type | Required | Description |
|---|---|---|---|
| `start` | `BaseUserTurnStartStrategy[] \| null \| undefined` | no | A list of user turn start strategies used to detect when the user starts speaking. |
| `stop` | `BaseUserTurnStopStrategy[] \| null \| undefined` | no | A list of user turn stop strategies used to decide when the user stops speaking. |

### How strategies combine [#how-strategies-combine]

`userTurnStrategiesParams` on `LLMUserAggregatorParams` (also an option of `UserTurnProcessor` and
`UserTurnController`) takes a `UserTurnStrategiesParams`:

| Option | Type | Required | Description |
|---|---|---|---|
| `startMergeMode` | `MergeMode \| undefined` | no | How the bot's start strategies combine with the ones a service recommends. Defaults to `MergeMode.REPLACE`. |
| `stopMergeMode` | `MergeMode \| undefined` | no | How the bot's stop strategies combine with the ones a service recommends. Defaults to `MergeMode.REPLACE`. |
| `startMode` | `StrategyMode \| undefined` | no | Whether the turn starts on the first start strategy that decides the user started, or once every start strategy has. Defaults to `StrategyMode.FIRST`. |
| `stopMode` | `StrategyMode \| undefined` | no | Whether the turn ends once every stop strategy has decided the user is done, or on the first one that does. Defaults to `StrategyMode.ALL`. |

* `startMode` and `stopMode` take a `StrategyMode`: `FIRST`, the first strategy to decide wins, or
  `ALL`, every strategy has to agree. In an `ALL` list every strategy is a condition the turn needs,
  so a fallback such as `TranscriptionUserTurnStartStrategy` belongs in a `FIRST` list.
* `startMergeMode` and `stopMergeMode` take a `MergeMode`. A service with its own turn detection
  (an STT service, say) recommends strategies to the aggregator. With `REPLACE`, the default, your
  list is used on its own; with `EXTEND`, the strategies you gave are added to the service's (or to
  the defaults, when no service recommends any).

```ts
// Two stop strategies: by default (StrategyMode.ALL) both must agree.
// StrategyMode.FIRST ends the turn on whichever decides first.
const userParams = new LLMUserAggregatorParams({
  vadAnalyzer: new SileroVADAnalyzer(),
  userTurnStrategies: new UserTurnStrategies({
    stop: [
      new TurnAnalyzerUserTurnStopStrategy({ turnAnalyzer: createLocalSmartTurnAnalyzerV3() }),
      new SpeechTimeoutUserTurnStopStrategy({ userSpeechTimeout: 2.0 }),
    ],
  }),
  userTurnStrategiesParams: new UserTurnStrategiesParams({ stopMode: StrategyMode.FIRST }),
});
```

The other turn options on `LLMUserAggregatorParams`:

| Option | Type | Required | Description |
|---|---|---|---|
| `addToolChangeMessages` | `boolean \| undefined` | no | When true, on each `LLMSetToolsFrame` the aggregator computes the diff against the currently advertised tools and appends a developer-role message to the context describing additions/removals. Helps the LLM stay coherent across mid-conversation tool changes, mitigating several flavors of tool-call-related hallucination: calling tools that have been removed, avoiding tools that have been re-added, and hallucinating output (made-up answers or tool-call-shaped non-tool-calls) when tools are unavailable. Only standard tools are diffed; custom (LLM-specific) tools are ignored. When using `LLMContextAggregatorPair`, prefer setting this via its `addToolChangeMessages` argument instead. Defaults to false. |
| `audioIdleTimeout` | `number \| undefined` | no | Timeout in seconds to force speech stop when no audio frames are received while in SPEAKING state (e.g. user mutes mic mid-speech). Set to 0 to disable. Defaults to 1.0. |
| `userTurnStrategies` | `UserTurnStrategies \| null \| undefined` | no | User turn start and stop strategies. |
| `userTurnStrategiesParams` | `UserTurnStrategiesParams \| undefined` | no | How the user turn strategies combine with the ones a service recommends, and how they decide. Defaults to `new UserTurnStrategiesParams()`. |
| `userMuteStrategies` | `BaseUserMuteStrategy[] \| undefined` | no | List of user mute strategies. |
| `userTurnStopTimeout` | `number \| undefined` | no | Seconds without user activity before an open user turn is ended, unless a stop strategy has a decision scheduled. |
| `userIdleTimeout` | `number \| undefined` | no | Timeout in seconds for detecting user idle state. The aggregator will emit an `on_user_turn_idle` event when the user has been idle (not speaking) for this duration. Set to 0 to disable idle detection. |
| `vadAnalyzer` | `VADAnalyzer \| null \| undefined` | no | Voice Activity Detection analyzer instance. |
| `emptyUserTurn` | `EmptyUserTurnConfig \| null \| undefined` | no | How to respond to a user turn that ends with no transcript. By default, the bot answers such a turn when it interrupted the bot, and leaves it unanswered otherwise. `null` leaves every such turn unanswered. Ignored with a realtime LLM service, which hears the user's audio directly. |

`vadAnalyzer` produces the VAD frames; `VADUserTurnStartStrategy` is what turns them into a turn
start. They are separate things, and the VAD analyzer is set on the aggregator, not the transport.
`userMuteStrategies` is covered in [User muting](https://polvallverdu.github.io/pipedog/docs/concepts/user-muting.md) and `userIdleTimeout`
in [Turn events & idle users](https://polvallverdu.github.io/pipedog/docs/concepts/turn-events/#idle-users).

## Start strategies [#start-strategies]

Every start strategy accepts `enableInterruptions` (default `true`): whether starting a turn
interrupts the bot. Set it to `false` and the bot keeps talking while the user speaks; what the user
said is still transcribed, and answered after the bot finishes.

| Option | Type | Required | Description |
|---|---|---|---|
| `enableInterruptions` | `boolean \| undefined` | no | If true, the user aggregator will emit an interruption frame when the user turn starts. |
| `name` | `string \| null \| undefined` | no | Custom name; defaults to `ClassName#count`. |
| `taskManager` | `BaseTaskManager \| null \| undefined` | no | Task manager for handling tasks. |

### VADUserTurnStartStrategy [#vaduserturnstartstrategy]

Starts the turn as soon as the VAD hears speech (`VADUserStartedSpeakingFrame`). The most responsive
option, and the first default. It needs a `vadAnalyzer` on the aggregator, and has no options of its
own.

### TranscriptionUserTurnStartStrategy [#transcriptionuserturnstartstrategy]

Starts the turn when a transcript arrives. It is the second default, a fallback for speech the VAD
misses (a soft voice, for example) but the STT service still transcribes.

| Option | Type | Required | Description |
|---|---|---|---|
| `useInterim` | `boolean \| undefined` | no | Whether interim transcriptions also start a turn. |
| `name` | `string \| null \| undefined` | no | Custom name; defaults to `ClassName#count`. |
| `taskManager` | `BaseTaskManager \| null \| undefined` | no | Task manager for handling tasks. |
| `enableInterruptions` | `boolean \| undefined` | no | If true, the user aggregator will emit an interruption frame when the user turn starts. |

### MinWordsUserTurnStartStrategy [#minwordsuserturnstartstrategy]

Starts the turn only once the user has said `minWords` words, so a quick "okay" or "uh-huh" doesn't
cut the bot off. The minimum applies only while the bot is speaking; when the bot is silent, one word
starts a turn. Shorter transcripts during bot speech are discarded and never reach the context.

Use it **instead of** the VAD start strategy, which would open the turn on the first sound:

```ts
const userParams = new LLMUserAggregatorParams({
  vadAnalyzer: new SileroVADAnalyzer(),
  userTurnStrategies: new UserTurnStrategies({
    // No VAD start strategy: it would open the turn on the first sound.
    start: [new MinWordsUserTurnStartStrategy({ minWords: 3 })],
    stop: [new SpeechTimeoutUserTurnStopStrategy()],
  }),
});
```

| Option | Type | Required | Description |
|---|---|---|---|
| `name` | `string \| null \| undefined` | no | Custom name; defaults to `ClassName#count`. |
| `taskManager` | `BaseTaskManager \| null \| undefined` | no | Task manager for handling tasks. |
| `enableInterruptions` | `boolean \| undefined` | no | If true, the user aggregator will emit an interruption frame when the user turn starts. |
| `minWords` | `number` | yes | Minimum number of spoken words required to trigger the start of a user turn. |
| `useInterim` | `boolean \| undefined` | no | Whether to consider interim transcription frames for earlier detection. |
| `startOnVadWhenBotQuiet` | `boolean \| undefined` | no | Whether to start the turn as soon as VAD detects speech while the bot is not speaking, instead of waiting for the first transcript. Defaults to false (upstream behavior). The word count exists to keep short noises from interrupting the bot, so it only matters while the bot speaks. Waiting for a transcript when the bot is quiet starts the turn late: by then an end-of-turn analyzer may already have decided, the late start resets it, and the reply falls back to the STT timeout; a pause mid-answer can also split it in two. With this option the turn starts with the speech, as with `VADUserTurnStartStrategy`, and the minimum applies only over the bot. |
| `keepBotSpeakingOnTurnStart` | `boolean \| undefined` | no | Whether to keep the bot-speaking state when a user turn starts. Defaults to false (upstream behavior: the state is cleared, so the next turn counts from one word). Clearing it assumes the bot stopped because it was interrupted. When it goes on speaking (interruptions disabled, or the turn started some other way), the strategy would then treat a single word over the bot as a new turn. With this option the state only follows `BotStartedSpeakingFrame` and `BotStoppedSpeakingFrame`. |
| `isBackchannel` | `BackchannelMatcher \| null \| undefined` | no | Decides whether a transcript is a backchannel ("mm-hm", "yes, okay", "right"): the user acknowledging the bot, not taking the turn. While the bot speaks, a backchannel never starts a turn, whatever its word count. `createBackchannelMatcher()` builds one from per-language phrase lists (`BACKCHANNEL_PHRASES` by default). Defaults to null (upstream behavior). DIVERGENCE: pipedog-only. |
| `requireVadWithinSeconds` | `number \| null \| undefined` | no | While the bot speaks, count a transcript's words only if VAD heard the user within this many seconds: the user is speaking, or VAD reported the end of their speech at most this long ago. Transcripts with no speech behind them (a TV in the background, STT hallucinating words from line clicks) then can't interrupt the bot. VAD already gates on volume and confidence, so tune it with `VADParams`. Requires a VAD in the pipeline. Defaults to null (no check, upstream behavior). DIVERGENCE: pipedog-only. |

Four options change upstream behavior, and all are off by default:

* `startOnVadWhenBotQuiet` starts the turn on `VADUserStartedSpeakingFrame` while the bot is silent,
  instead of waiting for the first transcript. A turn that starts with the transcript starts late:
  the end-of-turn analyzer may already have decided, gets reset, and the reply waits for the STT
  timeout. With this option the minimum only applies over the bot, and you don't need a VAD start
  strategy next to it.
* `keepBotSpeakingOnTurnStart` keeps the bot-speaking state when a turn starts. By default it is
  cleared, which assumes the turn interrupted the bot; if the bot goes on speaking (interruptions
  disabled, say), one word would then start the next turn.
* `isBackchannel` keeps acknowledgements from interrupting the bot, however many words they have:
  "yes, okay", "mm-hm, right". While the bot speaks, a transcript it matches is discarded like a
  short one. `createBackchannelMatcher()` builds a matcher from per-language phrase lists
  (`BACKCHANNEL_PHRASES`: English, Spanish, French, German, Italian, Portuguese and Catalan),
  picked by the transcript's language; pass your own lists to extend them. With the bot silent, a
  backchannel is an answer and starts the turn as usual.
* `requireVadWithinSeconds` counts words over the bot only if the VAD heard the user: they are
  speaking, or the VAD reported the end of their speech at most that many seconds ago. A TV in the
  background, or words the STT service hallucinates from clicks, then can't interrupt. It needs a
  VAD in the pipeline; tune what the VAD counts as speech with `VADParams` (see `minVolumeDb`
  above).

```ts
new MinWordsUserTurnStartStrategy({
  minWords: 3,
  isBackchannel: createBackchannelMatcher(),
  requireVadWithinSeconds: 1.0,
});
```

### WakePhraseUserTurnStartStrategy [#wakephraseuserturnstartstrategy]

Ignores the user until they say a wake phrase, then lets the conversation run until `timeout`
seconds pass with no activity. It starts turns on its own: on the wake phrase, and, while awake,
whenever the user starts speaking or a final transcript arrives (which covers speech the VAD
missed). Transcripts that don't contain the phrase while it is asleep are dropped from the
aggregation. Matching ignores case and punctuation ("Hey, pipedog!" matches "hey pipedog").

Use it as the only start strategy:

```ts
const wakePhrase = new WakePhraseUserTurnStartStrategy({
  phrases: ["hey pipedog", "ok pipedog"],
  timeout: 10.0, // seconds of inactivity before the wake phrase is needed again
});

wakePhrase.eventHandler("on_wake_phrase_detected", async (_strategy, phrase: string) => {
  console.log(`woken up by "${phrase}"`);
});
wakePhrase.eventHandler("on_wake_phrase_timeout", async () => {
  console.log("asleep again");
});

const userParams = new LLMUserAggregatorParams({
  vadAnalyzer: new SileroVADAnalyzer(),
  userTurnStrategies: new UserTurnStrategies({
    // It starts turns on its own once awake, so it is the only start strategy.
    start: [wakePhrase],
  }),
});
```

To combine it with other start conditions, such as `MinWordsUserTurnStartStrategy`, use
`startMode: StrategyMode.ALL`, where it is one more condition a turn needs:

```ts
// Every start strategy has to agree: the wake phrase, and three words.
const userParams = new LLMUserAggregatorParams({
  vadAnalyzer: new SileroVADAnalyzer(),
  userTurnStrategies: new UserTurnStrategies({
    start: [
      new WakePhraseUserTurnStartStrategy({ phrases: ["hey pipedog"] }),
      new MinWordsUserTurnStartStrategy({ minWords: 3 }),
    ],
  }),
  userTurnStrategiesParams: new UserTurnStrategiesParams({ startMode: StrategyMode.ALL }),
});
```

| Option | Type | Required | Description |
|---|---|---|---|
| `phrases` | `string[]` | yes | List of wake phrases to detect. |
| `timeout` | `number \| undefined` | no | Inactivity timeout in seconds before returning to IDLE. In timeout mode, the timer resets on activity. In single activation mode, acts as a keepalive window after wake phrase detection. |
| `singleActivation` | `boolean \| undefined` | no | If true, the wake phrase is required before every turn. The strategy returns to IDLE after each turn completes. |
| `name` | `string \| null \| undefined` | no | Custom name; defaults to `ClassName#count`. |
| `taskManager` | `BaseTaskManager \| null \| undefined` | no | Task manager for handling tasks. |
| `enableInterruptions` | `boolean \| undefined` | no | If true, the user aggregator will emit an interruption frame when the user turn starts. |

It emits `on_wake_phrase_detected(strategy, phrase)` and `on_wake_phrase_timeout(strategy)`, and
exposes its current `state` (`"idle"` or `"awake"`). With `singleActivation: true` the phrase is
needed before every turn: activity no longer extends the window, which closes `timeout` seconds after
the phrase.

### KrispVivaIPUserTurnStartStrategy [#krispvivaipuserturnstartstrategy]

Uses Krisp's Interruption Prediction model to tell a real interruption from a backchannel ("uh-huh",
"yeah"). When the VAD hears speech it feeds the audio to the model, and starts a turn only if the
model's probability exceeds `threshold`. Pair it with `TranscriptionUserTurnStartStrategy` as a
fallback.

<Callout type="warn" title="Bring your own Krisp SDK">
  Krisp ships a native Python SDK and no JavaScript binding, so pipedog ports the strategy's logic
  but not the model runtime. Out of the box the constructor throws, because the Krisp SDK isn't
  there. To use it, register a binding with `setKrispAudioSdk()` from
  `@pipedog/core/audio/krisp_instance` and subclass the strategy to override `_createIpSession()`,
  which returns a `KrispIpSession`.
</Callout>

| Option | Type | Required | Description |
|---|---|---|---|
| `modelPath` | `string \| null \| undefined` | no | Path to the Krisp VIVA IP model file (.kef). If null, uses the KRISP_VIVA_IP_MODEL_PATH environment variable. |
| `threshold` | `number \| undefined` | no | IP probability threshold (0.0 to 1.0). When the model's output exceeds this value, the speech is classified as a genuine interruption. |
| `frameDurationMs` | `number \| undefined` | no | Frame duration in milliseconds for IP processing. Supported values: 10, 15, 20, 30, 32. |
| `apiKey` | `string \| undefined` | no | Krisp SDK API key. If empty, falls back to the KRISP_VIVA_API_KEY environment variable. |
| `name` | `string \| null \| undefined` | no | Custom name; defaults to `ClassName#count`. |
| `taskManager` | `BaseTaskManager \| null \| undefined` | no | Task manager for handling tasks. |
| `enableInterruptions` | `boolean \| undefined` | no | If true, the user aggregator will emit an interruption frame when the user turn starts. |

`modelPath` must end in `.kef`. It and `apiKey` fall back to the `KRISP_VIVA_IP_MODEL_PATH` and
`KRISP_VIVA_API_KEY` environment variables.

### ExternalUserTurnStartStrategy [#externaluserturnstartstrategy]

Lets another component decide when the turn starts. It understands two frames:

* `ProposedUserStartedSpeakingFrame`: a service with its own turn detection proposes a turn start.
  This strategy accepts it, pushes the `UserStartedSpeakingFrame` and interrupts the bot (if
  `enableInterruptions`). Subclass it to change when a proposal opens a turn.
* `UserStartedSpeakingFrame`: the turn was already decided and announced elsewhere, for example by a
  shared `UserTurnProcessor` feeding several aggregators. This strategy adopts it and emits nothing,
  so the turn isn't announced twice.

| Option | Type | Required | Description |
|---|---|---|---|
| `enableInterruptions` | `boolean \| undefined` | no | If true, broadcast an interruption when a proposal opens a turn. Ignored on the adopt path, where the emitter has already broadcast one. |
| `name` | `string \| null \| undefined` | no | Custom name; defaults to `ClassName#count`. |
| `taskManager` | `BaseTaskManager \| null \| undefined` | no | Task manager for handling tasks. |

## Stop strategies [#stop-strategies]

### TurnAnalyzerUserTurnStopStrategy [#turnanalyzeruserturnstopstrategy]

The default. Feeds audio, VAD and transcription frames to a turn analyzer, Smart Turn v3 here, which
predicts from the audio whether the user has finished or only paused. Once it says the turn is
complete, the strategy waits for the transcript: it ends the turn on a finalized transcript, or when
the STT service's p99 latency has passed since the end of speech.

```ts
const stop = new TurnAnalyzerUserTurnStopStrategy({
  turnAnalyzer: createLocalSmartTurnAnalyzerV3({ cpuCount: 2 }),
});
```

| Option | Type | Required | Description |
|---|---|---|---|
| `turnAnalyzer` | `BaseTurnAnalyzer` | yes | The turn detection analyzer instance to detect end of user turn. |
| `waitForTranscript` | `boolean \| undefined` | no | Whether to require a transcript before triggering end-of-turn. When true (default), turn-end fires only after the turn analyzer reports COMPLETE *and* either a finalized transcript arrives or the STT safety-net timeout elapses with text in hand. When false, the strategy signals turn-end as soon as the turn analyzer reports COMPLETE — independent of transcripts. Set this to false when local turn detection is the intended driver of the conversation (e.g. with a realtime LLM service consuming audio directly), so transcripts are off the latency critical path. `LLMContextAggregatorPair` flips this for you when `realtimeServiceMode: true`. |
| `name` | `string \| null \| undefined` | no | Custom name; defaults to `ClassName#count`. |
| `taskManager` | `BaseTaskManager \| null \| undefined` | no | Task manager for handling tasks. |

`createLocalSmartTurnAnalyzerV3()` from `@pipedog/audio-smart-turn` builds the analyzer with the
ONNX runtime and the model shipped with that package. Its options:

| Option | Type | Required | Description |
|---|---|---|---|
| `model` | `OnnxModelSource \| undefined` | no | The Smart Turn model: bytes, a path or a URL. Defaults to `smartTurnModelPath` if set, else the model shipped with this package. Required on Cloudflare Workers. |
| `backend` | `OnnxBackend \| Promise<OnnxBackend> \| undefined` | no | The ONNX backend (default: `createOnnxBackend()`). |
| `shareSession` | `boolean \| undefined` | no | Share the inference session across analyzers (default `true`). |
| `sampleRate` | `number \| null \| undefined` | no | Optional initial sample rate for audio processing. If provided, this will be used as the fixed sample rate. |
| `params` | `SmartTurnParams \| null \| undefined` | no | Configuration parameters for turn analysis behavior. |
| `smartTurnModelPath` | `string \| null \| undefined` | no | Path/URL to the ONNX model file. If not set, the upstream bundled model name (smart-turn-v3.2-cpu.onnx) is used. |
| `cpuCount` | `number \| undefined` | no | The number of CPUs to use for inference. Defaults to 1. |

`params` takes a `SmartTurnParams` (`@pipedog/core/audio/turn/smart_turn/base_smart_turn`):
`stopSecs` (3 s, the longest silence before the analyzer gives up waiting and calls the turn
complete), `preSpeechMs` (500 ms of audio kept from before speech starts) and `maxDurationSecs`
(8 s, the longest segment the model sees).

`waitForTranscript: false` ends the turn as soon as the model says so, without waiting for text. Use
it when the turn model drives a speech-to-speech service that hears the audio itself;
`LLMContextAggregatorPair` sets it for you in realtime mode.

### SpeechTimeoutUserTurnStopStrategy [#speechtimeoutuserturnstopstrategy]

Ends the turn after a fixed silence. After the VAD reports the end of speech it runs two timers, and
ends the turn when both have finished and a transcript has arrived:

* `userSpeechTimeout` (0.6 s): how long the user may pause and carry on. It always runs in full, and
  is the knob for responsiveness.
* The STT timer: the service's p99 time to a final transcript. A finalized transcript ends it early.

If the service finalizes its transcripts, the turn ends `userSpeechTimeout` after the VAD stop. If a
transcript arrives without any VAD stop (no VAD analyzer, for instance), `userSpeechTimeout` measures
the silence since the last transcript instead.

```ts
const stop = new SpeechTimeoutUserTurnStopStrategy({
  userSpeechTimeout: 0.8, // wait a little longer before answering
});
```

| Option | Type | Required | Description |
|---|---|---|---|
| `name` | `string \| null \| undefined` | no | Custom name; defaults to `ClassName#count`. |
| `taskManager` | `BaseTaskManager \| null \| undefined` | no | Task manager for handling tasks. |
| `userSpeechTimeout` | `number \| undefined` | no | Time to wait for the user to potentially say more after they pause speaking. Defaults to 0.6 seconds. |
| `waitForTranscript` | `boolean \| undefined` | no | Whether to require at least one transcript before triggering end-of-turn. When true (default), turn-end fires only after the user-speech timer expires *and* at least one transcript has been received. When false, the strategy signals turn-end as soon as VAD reports end of speech and the user-speech timer has elapsed — independent of transcripts. Set this to false when local turn detection is the intended driver of the conversation (e.g. with a realtime LLM service consuming audio directly), so transcripts are off the latency critical path. `LLMContextAggregatorPair` flips this for you when `realtimeServiceMode: true`. |

### ExternalUserTurnStopStrategy [#externaluserturnstopstrategy]

The stop-side counterpart of `ExternalUserTurnStartStrategy`. On a `ProposedUserStoppedSpeakingFrame`
it decides, and pushes the `UserStoppedSpeakingFrame` itself, holding the turn open until the
transcript arrives if `waitForTranscript`. On a `UserStoppedSpeakingFrame` decided elsewhere it adopts
the decision and emits nothing. It decides once per round: again only after new text, new speech
from the service, or speech the VAD heard. To shift the timing, subclass it and override
`reportUserTurnStopped()`, which both paths reach once they decide; pass its options through when
the override reports:

```ts
/** Waits a grace period after the service says the user stopped. */
export class GracePeriodUserTurnStopStrategy extends ExternalUserTurnStopStrategy {
  #pending: Task<unknown> | null = null;

  /** Cancel a pending report when the user starts speaking again. */
  override async processFrame(frame: Frame): Promise<void> {
    if (frame instanceof ProposedUserStartedSpeakingFrame) {
      await this.#cancelPending();
    }
    await super.processFrame(frame);
  }

  // The base strategy calls this to report that the user is done, so this is
  // where a subclass adjusts the timing.
  override async reportUserTurnStopped(options: ReportUserTurnStoppedOptions = {}): Promise<void> {
    if (this.#pending) return;
    this.#pending = this.createTask(async () => {
      await sleep(0.5);
      this.#pending = null;
      await super.reportUserTurnStopped(options);
    }, `${this}::grace_period`);
  }

  override async handleUserTurnStopped(): Promise<void> {
    await this.#cancelPending();
    await super.handleUserTurnStopped();
  }

  override async cleanup(): Promise<void> {
    await this.#cancelPending();
    await super.cleanup();
  }

  async #cancelPending(): Promise<void> {
    if (this.#pending) {
      const task = this.#pending;
      this.#pending = null;
      await this.cancelTask(task);
    }
  }
}
```

| Option | Type | Required | Description |
|---|---|---|---|
| `name` | `string \| null \| undefined` | no | Custom name; defaults to `ClassName#count`. |
| `taskManager` | `BaseTaskManager \| null \| undefined` | no | Task manager for handling tasks. |
| `timeout` | `number \| undefined` | no | How long (seconds) to wait after a final transcription for more of them, so a turn whose transcription arrives late or in pieces is decided once the text has settled. |
| `waitForTranscript` | `boolean \| undefined` | no | When true (default), turn-stop signaling waits for transcript text to arrive after the external stop signal. When false, the strategy signals turn-stop as soon as that signal arrives — independent of transcripts. Set this to false when local turn detection is the intended driver of the conversation (e.g. with a realtime LLM service consuming audio directly), so transcripts are off the latency critical path. `LLMContextAggregatorPair` flips this for you when `realtimeServiceMode: true`. |

### EagerUserTurnStopStrategy [#eageruserturnstopstrategy]

For STT services that predict the end of a turn before committing to it. It starts generating a
response on the prediction (an `EagerTranscriptionFrame`), so the gap before the committed end of
turn is spent generating instead of waiting. The response is held back by the LLM service's
speculation gate and discarded if the user resumes speaking, if the committed transcript doesn't match
the predicted one, or if the prediction goes unresolved for `speculationTimeout` seconds. Nothing from
a discarded speculation reaches the user or the context, and the context always records the committed
transcript.

It extends `ExternalUserTurnStopStrategy`. Install it with
[`EagerUserTurnStrategies`](#eageruserturnstrategies) rather than directly.

| Option | Type | Required | Description |
|---|---|---|---|
| `taskManager` | `BaseTaskManager \| null \| undefined` | no | Task manager for handling tasks. |
| `matchPolicy` | `EagerMatchPolicy \| null \| undefined` | no | Decides whether the committed transcript is close enough to the eager one to keep the speculative response. Defaults to `NormalizedMatch`, which ignores the capitalization and punctuation services commonly add when they commit a transcript. Pass `ExactMatch` to require the two to be identical. |
| `speculationTimeout` | `number \| undefined` | no | Seconds a prediction may go unresolved before it is withdrawn. A service that stops sending turn signals mid-speculation would otherwise leave the response held and the bot silent for the rest of the session. |
| `name` | `string \| null \| undefined` | no | Custom name; defaults to `ClassName#count`. |
| `timeout` | `number \| undefined` | no | How long (seconds) to wait after a final transcription for more of them, so a turn whose transcription arrives late or in pieces is decided once the text has settled. |
| `waitForTranscript` | `boolean \| undefined` | no | When true (default), turn-stop signaling waits for transcript text to arrive after the external stop signal. When false, the strategy signals turn-stop as soon as that signal arrives — independent of transcripts. Set this to false when local turn detection is the intended driver of the conversation (e.g. with a realtime LLM service consuming audio directly), so transcripts are off the latency critical path. `LLMContextAggregatorPair` flips this for you when `realtimeServiceMode: true`. |

`matchPolicy` is a `NormalizedMatch` by default, which ignores case, punctuation and apostrophes, or
an `ExactMatch`; subclass `EagerMatchPolicy` and implement `matches(eager, final)` for your own rule.

### LLMTurnCompletionUserTurnStopStrategy [#llmturncompletionuserturnstopstrategy]

Decides the user is done only when the LLM says so. At start it enables `filterIncompleteUserTurns`
on the LLM (on every LLM behind an `LLMSwitcher`, too), which makes the LLM begin each response with
a marker: `●` complete, `◐` cut off mid-thought, `○` needs more time. The LLM service reports each
marker upstream as a `UserTurnCompletionFrame`.

It **waits for the turn's inference** (`waitsForInference`) instead of listening to the user, so it
needs other stop strategies next to it: once the strategies that listen to the user have decided,
the controller runs the inference, and this strategy decides on its marker. `●`, a tool call, or a
response with no marker end the turn. On `◐` or `○` the turn stays open; if the user then stays
quiet for `incompleteShortTimeout` or `incompleteLongTimeout`, it ends the turn and asks the LLM to
re-engage them. This is what [`FilterIncompleteUserTurnStrategies`](#filterincompleteuserturnstrategies)
installs.

| Option | Type | Required | Description |
|---|---|---|---|
| `taskManager` | `BaseTaskManager \| null \| undefined` | no | Task manager for handling tasks. |
| `config` | `UserTurnCompletionConfig \| null \| undefined` | no | Markers, instructions, timeouts and re-prompts applied to the LLM. Defaults to `new UserTurnCompletionConfig()`. |
| `name` | `string \| null \| undefined` | no | Custom name; defaults to `ClassName#count`. |

### Strategies that wait for the inference [#strategies-that-wait-for-the-inference]

The controller runs the turn's inference itself. In a `StrategyMode.ALL` list it runs it once every
stop strategy that listens to the user has decided; a stop strategy whose `waitsForInference` is
`true` then decides on that inference, and the turn ends. In a `StrategyMode.FIRST` list the first
decision runs the inference, and the turn ends on the decision of the strategy that waits for it. A
list where every stop strategy waits for the inference has nothing to run it but the stop timeout,
and logs a warning.

```ts
// Smart Turn decides the user stopped, which runs the inference; the LLM's
// ● marker then ends the turn.
const strategies = new FilterIncompleteUserTurnStrategies({
  config: new UserTurnCompletionConfig({
    incompleteShortTimeout: 5.0,
    incompleteLongTimeout: 10.0,
  }),
});

// The same strategies, built by hand:
const byHand = new UserTurnStrategies({
  stop: [
    new TurnAnalyzerUserTurnStopStrategy({ turnAnalyzer: createLocalSmartTurnAnalyzerV3() }),
    new LLMTurnCompletionUserTurnStopStrategy(),
  ],
});

// On top of the turn detection an STT service recommends:
const extending = new LLMUserAggregatorParams({
  userTurnStrategies: new FilterIncompleteUserTurnStrategies(),
  userTurnStrategiesParams: new UserTurnStrategiesParams({ stopMergeMode: MergeMode.EXTEND }),
});
```

## Presets [#presets]

### ExternalUserTurnStrategies [#externaluserturnstrategies]

An `ExternalUserTurnStartStrategy` and an `ExternalUserTurnStopStrategy`, for turns decided by a
service with its own turn detection, a speech-to-speech service or a shared `UserTurnProcessor`.
Services with turn detection (`DeepgramFluxSTTService`, `CartesiaTurnsSTTService`,
`OpenAIRealtimeSTTService` with server VAD) recommend it to the aggregator on their own when you
don't pass `userTurnStrategies`.

| Option | Type | Required | Description |
|---|---|---|---|
| `enableInterruptions` | `boolean \| undefined` | no | Whether to broadcast an interruption when a proposal starts a turn. Services route their `should_interrupt` setting here. |
| `start` | `BaseUserTurnStartStrategy[] \| null \| undefined` | no | A list of user turn start strategies used to detect when the user starts speaking. |
| `stop` | `BaseUserTurnStopStrategy[] \| null \| undefined` | no | A list of user turn stop strategies used to decide when the user stops speaking. |

<Callout type="warn">
  A service routes its `shouldInterrupt` option to the strategies it recommends. If you set
  `shouldInterrupt: false` on the service but also pass `new ExternalUserTurnStrategies()` yourself,
  interruptions are back on. Leave `userTurnStrategies` out, or pass `enableInterruptions: false`.
</Callout>

A VAD analyzer on the aggregator is optional here: it adds STT metrics but isn't needed for turns.

### EagerUserTurnStrategies [#eageruserturnstrategies]

`ExternalUserTurnStrategies` with an [`EagerUserTurnStopStrategy`](#eageruserturnstopstrategy) as
the only stop strategy. The service owns turn detection, so this replaces the detector chain rather
than extending it: a local detector alongside would run a second inference for the same turn.

```ts
const strategies = new EagerUserTurnStrategies({
  matchPolicy: new ExactMatch(), // default: NormalizedMatch
  speculationTimeout: 5.0,
});
```

| Option | Type | Required | Description |
|---|---|---|---|
| `matchPolicy` | `EagerMatchPolicy \| null \| undefined` | no | Decides whether the committed transcript is close enough to the eager one to keep the speculative response. Defaults to NormalizedMatch. |
| `speculationTimeout` | `number \| undefined` | no | Seconds a prediction may go unresolved before it is withdrawn and the turn falls back to answering the committed transcript. |
| `start` | `BaseUserTurnStartStrategy[] \| null \| undefined` | no | A list of user turn start strategies used to detect when the user starts speaking. |
| `stop` | `BaseUserTurnStopStrategy[] \| null \| undefined` | no | A list of user turn stop strategies used to decide when the user stops speaking. |
| `enableInterruptions` | `boolean \| undefined` | no | Whether to broadcast an interruption when a proposal starts a turn. Services route their `should_interrupt` setting here. |

STT services that predict turns take `enableEagerEndOfTurn: true` (off by default, since every
prediction costs an inference, including the ones the service withdraws) and recommend this preset
themselves.

### FilterIncompleteUserTurnStrategies [#filterincompleteuserturnstrategies]

Keeps the start strategies and the stop strategies (the defaults, or yours), and adds an
`LLMTurnCompletionUserTurnStopStrategy`. Once the other stop strategies decide the user stopped, the
inference runs, and `on_user_turn_stopped` fires once the LLM marks the turn complete. It works with
either `stopMode`, and with an eager end of turn: the speculative answer goes out once the transcript
commits and the LLM marks the turn complete, in either order, and `◐` or `○` withdraws it. With
`stopMergeMode: MergeMode.EXTEND`, the LLM gate (and any stop strategies you gave) joins the ones a
service recommends instead.

| Option | Type | Required | Description |
|---|---|---|---|
| `config` | `UserTurnCompletionConfig \| null \| undefined` | no | Optional configuration applied to the LLM via the `filter_incomplete_user_turns` setting. Customizes the turn-completion instructions, incomplete-turn timeouts, and re-prompts. If null, defaults from UserTurnCompletionConfig are used. |
| `start` | `BaseUserTurnStartStrategy[] \| null \| undefined` | no | A list of user turn start strategies used to detect when the user starts speaking. |
| `stop` | `BaseUserTurnStopStrategy[] \| null \| undefined` | no | A list of user turn stop strategies used to decide when the user stops speaking. |

| Option | Type | Required | Description |
|---|---|---|---|
| `instructions` | `string \| null \| undefined` | no | Custom instructions for turn completion. If not provided, the default instructions are rendered from the configured markers. |
| `completeMarker` | `string \| undefined` | no | Marker the LLM emits when the user turn is complete. It is generated before any speakable text, so prefer a character that is a single token in the model's tokenizer. |
| `incompleteShortMarker` | `string \| undefined` | no | Marker for a turn cut off mid-thought. |
| `incompleteLongMarker` | `string \| undefined` | no | Marker for a user who needs more time. |
| `incompleteShortTimeout` | `number \| undefined` | no | Seconds to wait after a short incomplete before prompting. |
| `incompleteLongTimeout` | `number \| undefined` | no | Seconds to wait after a long incomplete before prompting. |
| `incompleteShortPrompt` | `string \| null \| undefined` | no | Custom prompt when short timeout expires. |
| `incompleteLongPrompt` | `string \| null \| undefined` | no | Custom prompt when long timeout expires. |

## Turns with no transcript [#turns-with-no-transcript]

A turn can start on the VAD and end without a transcript: a cough, background noise, speech the STT
service couldn't make out. Nothing is written to the context, and by default:

* if the turn **interrupted the bot** (or came before the bot first finished speaking), the LLM runs
  once with a developer message, so the bot asks the user to repeat or picks up where it left off;
* if the bot **was waiting** for the user, the turn goes unanswered.

`emptyUserTurn` changes this; `null` leaves every such turn unanswered.

```ts
const userParams = new LLMUserAggregatorParams({
  vadAnalyzer: new SileroVADAnalyzer(),
  emptyUserTurn: new EmptyUserTurnConfig({
    idlePrompt: "The user made a sound but said nothing clear. Ask if they said something.",
    maxConsecutiveRecoveries: 2,
  }),
});
```

| Option | Type | Required | Description |
|---|---|---|---|
| `interruptedPrompt` | `string \| null \| undefined` | no | Developer message for an empty turn that interrupted the bot. `null` leaves such turns unanswered. |
| `idlePrompt` | `string \| null \| undefined` | no | Developer message for an empty turn while the bot was idle. `null` (the default) leaves such turns unanswered. |
| `maxConsecutiveRecoveries` | `number \| undefined` | no | How many empty turns in a row are answered. Further ones are left unanswered until the user says something that is transcribed. |

Recovery is skipped while the user is muted or a function call is pending, and with a realtime LLM
service, which hears the audio itself.

## Writing a strategy [#writing-a-strategy]

Subclass `BaseUserTurnStartStrategy` or `BaseUserTurnStopStrategy` and override `processFrame()`.
It sees every frame that reaches the user aggregator and returns nothing. When the strategy decides,
it reports the decision, and the turn controller decides when the turn starts or ends:

* start strategies: `reportUserTurnStarted()`, or `triggerResetAggregation()` to drop the text
  gathered so far (what `MinWordsUserTurnStartStrategy` does with a too-short transcript);
* stop strategies: `reportUserTurnStopped()` when the user is done. The controller runs the turn's
  inference itself. A strategy starts one only to answer ahead of the turn end, with
  `startUserTurnSpeculation()`, and withdraws it with `cancelUserTurnSpeculation()`.

```ts
/** Decides the user started on a final transcript that ends with a question mark. */
export class QuestionUserTurnStartStrategy extends BaseUserTurnStartStrategy {
  override async processFrame(frame: Frame): Promise<void> {
    if (frame instanceof TranscriptionFrame && frame.text.trim().endsWith("?")) {
      // The turn controller starts the turn on this decision (StrategyMode.FIRST),
      // or once every start strategy has decided (StrategyMode.ALL).
      await this.reportUserTurnStarted();
    }
  }
}
```

A stop strategy that decides from the turn's answer rather than from the user returns `true` from
`waitsForInference`, and one that is sure to decide soon on its own (a timer of its own) returns
`true` from `decisionScheduled` meanwhile, so the stop timeout waits for it.

Override `handleUserTurnStarted()` and `handleUserTurnStopped()` to reset per-turn state; the
controller calls them on every strategy at each turn boundary, whichever strategy caused it.
`setup()` and `cleanup()` run with the aggregator, and `this.taskManager` creates tasks for timers.
