Interruptions & cancellation
How the bot stops when the user speaks, and how @pipedog/asyncio gives JavaScript Python's asyncio cancellation.
Interruptions (barge-in) let the user talk over the bot: the bot stops right away, in-flight work
is cancelled, and the pipeline is ready for the new input. Making that work in JavaScript needs
Python's asyncio cancellation semantics, which is what @pipedog/asyncio provides.
What happens on an interruption
When a user turn starts with interruptions enabled, the user aggregator broadcasts an
InterruptionFrame upstream and downstream. Then:
Every processor cancels its current work. InterruptionFrame is a system frame, so each
processor handles it immediately instead of waiting behind queued frames. The processor
cancels the task processing its current data/control frame and discards queued ones. Frames
with interruptible = false (like FunctionCallResultFrame and EndFrame) are kept and
still processed.
The LLM stops generating: the in-flight completion is cancelled mid-stream.
TTS clears its buffers and drops pending output.
The transport flushes unplayed audio, so the bot goes silent within about one audio write.
Only the words actually spoken end up in the LLM context: the output transport pushes
TTSTextFrames in sync with playback, and the assistant aggregator commits the spoken-so-far text
when interrupted.
Triggering an interruption yourself
From inside a custom processor:
await this.broadcastInterruption();From code holding the worker, queue an InterruptionWorkerFrame; the worker turns it into an
InterruptionFrame sent through the whole pipeline:
await worker.queueFrame(new InterruptionWorkerFrame());A processor sees the interruption in processFrame() (after super.processFrame() has already
cancelled the work in progress), and can override protected async _startInterruption() (calling super._startInterruption()
first) for cleanup that must happen at that moment.
Turns with no transcript
If the user interrupts but nothing is transcribed (a cough, say), the bot has stopped and has nothing
to answer. By default the LLM then runs once, so the bot asks the user to repeat or picks up where it
left off; emptyUserTurn on LLMUserAggregatorParams changes that (see
Turns with no transcript).
Controlling interruptions
Interruptions are on by default. What starts one is the start strategy that opens the user's turn, and mute strategies decide whether the user is heard at all:
| Goal | How |
|---|---|
| Stop the bot as soon as the user speaks | the default (VADUserTurnStartStrategy) |
| Never interrupt, but answer after the bot finishes | enableInterruptions: false on the start strategies |
| Ignore short acknowledgements like "okay" | MinWordsUserTurnStartStrategy |
| Tell interruptions from backchannels with a model | KrispVivaIPUserTurnStartStrategy (bring your own Krisp SDK) |
| Discard what the user says while the bot talks | AlwaysUserMuteStrategy |
| Protect only the greeting, or tool calls | MuteUntilFirstBotCompleteUserMuteStrategy, FirstSpeechUserMuteStrategy, FunctionCallUserMuteStrategy |
| Keep a provider's turn detection from interrupting | shouldInterrupt: false on the STT service |
To turn interruptions off, set enableInterruptions: false on every start strategy, since any of
them can start a turn:
const userParams = new LLMUserAggregatorParams({
vadAnalyzer: new SileroVADAnalyzer(),
userTurnStrategies: new UserTurnStrategies({
start: [
new VADUserTurnStartStrategy({ enableInterruptions: false }),
new TranscriptionUserTurnStartStrategy({ enableInterruptions: false }),
],
}),
});Disabling interruptions is not muting. With interruptions off the user's turn still starts and
is transcribed: the bot finishes speaking and then answers. A muted user's audio and transcripts are
dropped at the user aggregator, so nothing they say over the bot reaches the context or gets an
answer. The user aggregator also drops InterruptionFrames while the user is muted, so an
interruption you queue on the worker doesn't get past it then; one broadcast by a processor after
the aggregator still does.
With an STT service that detects turns itself, set shouldInterrupt: false on the service rather
than on the strategies: the service hands the setting to the strategies it recommends. Speech-to-speech
services detect interruptions on their side, and pipedog follows their speaking events and still
flushes the local audio, so the bot goes quiet at once.
Why JavaScript needs help
Python delivers CancelledError into a coroutine, at the await it is suspended on. That is
how an interruption stops a processor mid-work: its processing task is cancelled, and whatever it
was awaiting raises.
JavaScript has no such thing. A promise cannot be interrupted from outside, and catch (e)
catches everything, including what Python would consider a cancellation. @pipedog/asyncio
emulates asyncio on top of promises and AsyncLocalStorage, which Bun, Node, Deno and Cloudflare
Workers (nodejs_compat) all provide:
| Python | @pipedog/asyncio |
|---|---|
asyncio.create_task(coro()) | createTask(() => coro()) |
task.cancel() / await task | task.cancel() / await task (a Task is thenable) |
asyncio.sleep(s) | sleep(s) (seconds) |
asyncio.wait_for(coro(), timeout=t) | waitFor(() => coro(), t) |
asyncio.gather(a(), b()) | gather(() => a(), () => b()) |
asyncio.Event, Queue, PriorityQueue, Lock, Semaphore | same names |
asyncio.CancelledError, asyncio.TimeoutError | CancelledError, TimeoutError |
asyncio.current_task() | currentTask() |
async with lock: | await lock.run(async () => { ... }) |
import {
abortable,
CancelledError,
createTask,
currentSignal,
Event,
gather,
Queue,
rethrowIfCancelled,
sleep,
TimeoutError,
waitFor,
} from "@pipedog/asyncio";
const task = createTask(async () => {
await sleep(10); // a cancellation point
return "done";
}, "slow-task");
task.cancel(); // like asyncio: the pending sleep() rejects with CancelledError
try {
await task;
} catch (e) {
console.log(e instanceof CancelledError); // true
}Cancellation points
When a task is cancelled:
- if it is suspended on a cancellation point, that await rejects with
CancelledErrorright away (the request is consumed, as in asyncio); - otherwise the request stays pending, and the next cancellation point the task reaches throws it;
task.signal, anAbortSignal, is aborted.
The cancellation points are the awaits on this package's primitives: sleep(), Event.wait(),
Queue.get() / Queue.put(), waitFor(), gather(), wait(), abortable(), and awaiting a
Task. Cancelling a task that awaits another task cancels the awaited one too, as in asyncio.
try {
// Pass a function, so it runs as a child task that can be cancelled on timeout.
await waitFor(() => sleep(5), 0.1);
} catch (e) {
if (!(e instanceof TimeoutError)) throw e;
}
const [x, y] = await gather(
async () => 1,
async () => "two",
);Always pass waitFor() a function rather than a promise, so it can run as a child task and
be cancelled on timeout. Queues and events behave like their asyncio counterparts:
const queue = new Queue<string>();
const ready = new Event();
const consumer = createTask(async () => {
await ready.wait();
for (;;) {
const item = await queue.get(); // a cancellation point
console.log("got", item);
queue.taskDone();
}
});
ready.set();
await queue.put("hello");
await queue.join();
consumer.cancel();rethrowIfCancelled
Python's except Exception: does not catch CancelledError (it derives from BaseException).
JavaScript's catch (e) does. A ported except Exception: that swallowed a cancellation would
keep a cancelled task running, so every broad catch starts with rethrowIfCancelled(e):
async function callProvider(url: string): Promise<unknown> {
try {
// fetch is not a cancellation point. Give it the current task's signal
// so cancelling the task aborts the request too.
const response = await fetch(url, { signal: currentSignal() });
return await response.json();
} catch (e) {
rethrowIfCancelled(e); // Python's `except Exception` never catches CancelledError
console.error("provider failed", e);
return null;
}
}Catching the cancellation on purpose (Python's except asyncio.CancelledError:) looks like
if (e instanceof CancelledError) { cleanup(); throw e; }.
Foreign awaits: fetch, WebSockets, SDKs
fetch, a WebSocket, or a provider SDK is not a cancellation point: a task cancelled while
awaiting one keeps waiting until it settles. Two tools fix that:
- Pass
currentSignal()(the current task'sAbortSignal, or a never-aborting one outside tasks) to anything that accepts a signal, such asfetch(url, { signal: currentSignal() }), as in the sample above. Cancelling the task then aborts the request itself. - Wrap the await in
abortable(promise)to make it a cancellation point. The foreign operation keeps running in the background unless it was also given the signal.
async function withSdk(sdkCall: () => Promise<string>): Promise<string> {
// An SDK that takes no AbortSignal: turn the await into a cancellation point.
// The SDK call itself keeps running in the background if we are cancelled.
return await abortable(sdkCall());
}Tasks in processors
Processors (and every BaseObject) create tasks through their task manager with
this.createTask(fn, name), and cancel them with await this.cancelTask(task) (which waits up to
1 second for the task to finish). Tasks created this way are tracked and cancelled when the
pipeline cleans up, so nothing is left floating. Never fire and forget a bare promise.
import { , , type } from "@pipedog/asyncio";
import { type , , , } from "@pipedog/core/frames/frames";
import { type , } from "@pipedog/core/processors/frame_processor";
/** Says "tick" every second until the pipeline ends. */
export class extends {
#tickTask: <unknown> | null = null;
override async (: , : ): <void> {
await super.(, );
if ( instanceof ) {
// Tracked by the task manager: cancelled at cleanup, never left floating.
this.#tickTask = this.(() => this.#tick(), "tick");
} else if ( instanceof ) {
.("interrupted: stop what we were saying");
}
await this.(, );
}
async #tick(): <void> {
for (;;) {
await (1.0); // seconds, never raw setTimeout
try {
await this.(new ({ : "tick" }));
} catch () {
();
await this.("tick failed", { : });
}
}
}
override async (): <void> {
await super.();
if (this.#tickTask) {
await this.(this.#tickTask);
this.#tickTask = null;
}
}
}await task waits for the task as asyncio does: if your task is cancelled while it waits, task
is cancelled too. To react to a task's end without waiting for it, use task.addDoneCallback(cb),
task.promise.then(cb) or task.then(cb). These don't tie the two tasks together, so cancelling
yours leaves task running until it is cancelled itself. (In 0.10.0 and earlier, task.then(cb)
could leave the calling task impossible to cancel and hang cleanup().)
Rules of thumb
- Every broad
catchbegins withrethrowIfCancelled(e). - Use
sleep(seconds)from@pipedog/asyncio, never a rawsetTimeout. - Pass
currentSignal()tofetchand other APIs that take anAbortSignal; wrap the rest inabortable(). - Never leave a promise floating: use
this.createTask()in processors,createTask()elsewhere. - Never
returnaTask(or any thenable) from an async function unless you mean to await it.
The asyncio API reference lists every export.
pipedog is an unofficial community TypeScript port of Pipecat. It is not affiliated with or endorsed by Daily or the Pipecat project.
Portions adapted from the Pipecat documentation, Copyright (c) 2024–2025 Daily, BSD-2-Clause (see apps/docs/LICENSE-pipecat-docs).
Turn events & idle users
The events the context aggregators emit around each turn, prompting a user who has gone quiet, and the observers that track turns and response latency.
TTS audio cache
Keep the audio of fixed lines (a greeting, a disclaimer) and play it without asking the TTS provider again, with TTSAudioCache and warmAudioCache().