Effect
Use pipedog from Effect with @pipedog/effect. Run a pipeline as a scoped resource, stream its frames, feed it from a Stream, and write frame processors as Effect functions with services from Layers.
Effect apps can drive pipedog with @pipedog/effect, a small adapter
that maps pipedog's asyncio-style tasks onto Effect's fibers and scopes. Pipelines, processors and
services stay the same; the adapter covers starting and stopping a pipeline, passing frames in and
out, and writing processors as Effects. It is pipedog-only: Pipecat has no counterpart.
Installation
bun add @pipedog/effect effecteffect is a peer dependency (version 4).
Runtimes
| Runtime | Works | What it needs |
|---|---|---|
| Bun | ✅ yes | Nothing. |
| Node.js | ✅ yes | Nothing. |
| Deno | ✅ yes | Nothing. |
| Cloudflare Workers | ✅ yes | Nothing. |
Generated from pipecat.runtimes and pipecat.compat in packages/effect/package.json: ✅ means the compat smoke test passes on that runtime.
The smoke test on every runtime runs a pipeline with an Effect processor from end to end, interrupts a running pipeline and checks that no task is left, and cancels a pipeline while a processor's Effect is running and checks that the Effect is interrupted.
The API
| Export | What it does |
|---|---|
startPipeline(worker, options?) | Runs a PipelineWorker for the lifetime of the current Scope and returns a PipelineHandle. |
runPipeline(worker, options?) | startPipeline and join in a scope of their own. |
makeProcessor(fn, options?) | Builds a FrameProcessor from (frame, direction, ctx) => Effect. Its services come from the context. |
PipelineError | The tagged error a failed run fails with. |
A PipelineHandle has:
| Member | |
|---|---|
join | Waits for the pipeline to end. See How a run ends. |
queueFrame(frame, direction?) | worker.queueFrame as an Effect. |
queueFrames(framesOrStream, direction?) | Queues an iterable, or every frame of a Stream until it ends. |
frames(...types) | A scoped subscription to the frames of those classes that reach the end of the pipeline, as a Stream that ends with the pipeline. |
stopWhenDone | Queues an EndFrame. |
cancel(reason?) | Cancels the pipeline. |
worker | The worker. |
frames() subscribes when it runs, not when the stream is read, so subscribe before queueing the
frames you want to see. Frames are buffered without bound until the stream reads them.
Options
Prop
Type
A voice agent with Effect
The example below is the voice bot with one addition, written in Effect: every final transcription goes through a moderation service before it reaches the LLM.
The service is an ordinary Effect service, with a live Layer that calls OpenAI's moderation endpoint:
import * as Context from "effect/Context";
import * as Data from "effect/Data";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Schedule from "effect/Schedule";
class ModerationError extends Data.TaggedError("ModerationError")<{ cause: unknown }> {}
/** Decides whether a user's words may go to the LLM. */
class Moderation extends Context.Service<
Moderation,
{ readonly isAllowed: (text: string) => Effect.Effect<boolean, ModerationError> }
>()("Moderation") {}
/** OpenAI's moderation endpoint. */
const ModerationLive = Layer.succeed(Moderation, {
isAllowed: (text) =>
Effect.tryPromise({
try: async (signal) => {
const response = await fetch("https://api.openai.com/v1/moderations", {
method: "POST",
headers: {
authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
"content-type": "application/json",
},
body: JSON.stringify({ model: "omni-moderation-latest", input: text }),
signal,
});
const body = (await response.json()) as { results: { flagged: boolean }[] };
return !body.results[0]?.flagged;
},
catch: (cause) => new ModerationError({ cause }),
}),
});
The processor is an Effect function. It sees every frame, as processFrame does, and passes on what
should go on with ctx.push. makeProcessor returns an Effect that needs Moderation, so the
processor gets whichever implementation the program is given:
import { TranscriptionFrame, TTSSpeakFrame } from "@pipedog/core/frames/frames";
import { FrameDirection } from "@pipedog/core/processors/frame_processor";
import { makeProcessor } from "@pipedog/effect";
/** Lets a final transcription through only if Moderation allows it. */
const moderateTranscriptions = makeProcessor((frame, direction, ctx) =>
Effect.gen(function* () {
if (!(frame instanceof TranscriptionFrame) || direction !== FrameDirection.DOWNSTREAM) {
return yield* ctx.push(frame, direction);
}
const moderation = yield* Moderation;
const allowed = yield* moderation.isAllowed(frame.text).pipe(
Effect.retry({ times: 2, schedule: Schedule.exponential("100 millis") }),
Effect.timeout("2 seconds"),
// Fail open: a slow or failing moderation call must not stall the conversation.
Effect.orElseSucceed(() => true),
);
if (allowed) {
yield* ctx.push(frame);
} else {
yield* ctx.push(new TTSSpeakFrame({ text: "Sorry, I can't help with that." }));
}
}),
);
A session builds the pipeline and runs it for as long as its scope is open:
import { startPipeline } from "@pipedog/effect";
/** One voice session on `transport`, for as long as the scope is open. */
const session = (transport: BaseTransport) =>
Effect.gen(function* () {
const context = new LLMContext();
const aggregators = new LLMContextAggregatorPair(context, {
userParams: new LLMUserAggregatorParams({ vadAnalyzer: new SileroVADAnalyzer() }),
});
const worker = new PipelineWorker(
new Pipeline([
transport.input(),
new OpenAISTTService(),
yield* moderateTranscriptions,
aggregators.user(),
new OpenAILLMService({
settings: new OpenAILLMSettings({ systemInstruction: "You are a helpful assistant." }),
}),
new OpenAITTSService({ sampleRate: 24000 }),
transport.output(),
aggregators.assistant(),
]),
{
cancelOnIdleTimeout: false,
params: new PipelineParams({ audioInSampleRate: 16000, audioOutSampleRate: 24000 }),
},
);
// A hang-up cancels the pipeline, which interrupts this fiber's `join`.
transport.eventHandler("on_client_disconnected", async () => {
await worker.cancel({ reason: "client disconnected" });
});
const pipeline = yield* startPipeline(worker);
yield* Effect.logInfo(`session ${worker.name} started`);
yield* pipeline.join;
}).pipe(Effect.scoped);
Your server forks one fiber per connection, with the live services:
import * as Fiber from "effect/Fiber";
/** Called by your server for each connected client. */
export function onConnection(transport: BaseTransport): () => void {
const fiber = Effect.runFork(session(transport).pipe(Effect.provide(ModerationLive)));
// Interrupting the fiber cancels the pipeline and waits until it has stopped.
return () => void Effect.runFork(Fiber.interrupt(fiber));
}
In a test, swap the Layer and look at what reaches the end of the pipeline:
import { EndFrame, TextFrame } from "@pipedog/core/frames/frames";
import * as Stream from "effect/Stream";
const BlockEverything = Layer.succeed(Moderation, { isAllowed: () => Effect.succeed(false) });
const moderationTest = Effect.gen(function* () {
const worker = new PipelineWorker(new Pipeline([yield* moderateTranscriptions]), {
cancelOnIdleTimeout: false,
});
const pipeline = yield* startPipeline(worker);
const spoken = yield* pipeline.frames(TTSSpeakFrame);
const passed = yield* pipeline.frames(TextFrame);
yield* pipeline.queueFrames([
new TranscriptionFrame({ text: "something awful", userId: "u1", timestamp: "" }),
new EndFrame(),
]);
yield* pipeline.join;
return { spoken: yield* Stream.runCollect(spoken), passed: yield* Stream.runCollect(passed) };
}).pipe(Effect.scoped, Effect.provide(BlockEverything));How a run ends
| The pipeline… | join |
|---|---|
ends with an EndFrame or StopFrame | succeeds |
is cancelled: cancel(), a CancelWorkerFrame, a processor that cancels it, or the scope closing | is interrupted |
is cancelled after an error from a processor that can no longer work (ProcessorUnusablePolicy.CANCEL) | fails with PipelineError, its frame set |
gets any ErrorFrame, with failOnError: true | is cancelled, and join fails with PipelineError |
| throws from the worker | fails with PipelineError, its cause set |
Without failOnError, ErrorFrames are handled as usual: logged, passed to on_pipeline_error, and
the pipeline goes on.
Cancellation
Effect interrupts fibers; pipedog cancels tasks, as asyncio does (see Interruptions and cancellation). The adapter maps one onto the other in both directions, and never lets either side outlive the other:
- Interrupting the Effect cancels the pipeline.
startPipelineis anacquireRelease: closing the scope (because the fiber was interrupted, timed out, or simply finished) cancels a pipeline that is still running and waits until the runner has finished, its processors cleaned up and its tasks done. Release is uninterruptible, as in any Effect finalizer. - Cancelling the pipeline interrupts the Effect.
joinis interrupted when the pipeline is cancelled from its side. A processor's Effect is interrupted when its task is cancelled, which happens when the pipeline is cancelled and when anInterruptionFrame(a user barging in) cancels the processing of an interruptible frame. The processor waits for the Effect's finalizers before it lets the cancellation through. - Every call into the pipeline runs in a task of its own.
queueFrame,stopWhenDone,canceland the rest start an asyncio task and wait for it: interrupting the Effect cancels that task and waits for it; the task ending cancelled interrupts the Effect. - Frames a processor's Effect pushes are pushed by the processor's own task, in order, as a
Python processor pushes from
process_frame.
startPipeline turns the runner's signal handling off: an Effect app handles SIGINT with its
runtime (NodeRuntime.runMain and friends interrupt the main fiber), and that interruption cancels
the pipeline.
A failure or defect of a processor's Effect becomes pushError on the processor: an ErrorFrame
upstream, with the error as its exception. An Effect that interrupts itself drops the frame.
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).