pipedog is an unofficial community port of Pipecat, not affiliated with Daily.
pipedog

Frame processors

Write a custom FrameProcessor - handle frames, push them on, pick a direction, set up resources and listen to events.

Frame processors are the workers of a pipeline. Each has a specific job (transcribe audio, generate text, play audio, log metrics...). They:

  • receive frames from the previous processor,
  • process the data,
  • create new frames with their output,
  • pass frames along to the next processor.

pipedog ports Pipecat's built-in processors (aggregators, filters, observers, and services as their packages land). Most applications also need some custom frame processing logic; this page shows how to write it.

Example: MetricsFrame logger

This custom processor formats and logs every MetricsFrame:

function formatMetrics(metrics: MetricsData[]): string {
  return metrics
    .map((metric) => {
      const fields = Object.entries(metric)
        .map(([key, value]) => `\t${key}=${JSON.stringify(value)}`)
        .join("\n");
      return `${metric.constructor.name}\n${fields}`;
    })
    .join("\n");
}

/** Formats and logs every MetricsFrame. */
export class MetricsFrameLogger extends FrameProcessor {
  override async processFrame(frame: Frame, direction: FrameDirection): Promise<void> {
    await super.processFrame(frame, direction);

    if (frame instanceof MetricsFrame) {
      logger.info(`${frame.name}\n${formatMetrics(frame.data)}`);
    }

    // SUPER IMPORTANT: always push every frame!
    await this.pushFrame(frame, direction);
  }
}

Add it to a pipeline wherever it should see the frames, for example at the very end so it receives every MetricsFrame the services produce:

const pipeline = new Pipeline([
  transportInput,
  stt,
  userAggregator,
  llm,
  tts,
  transportOutput,
  assistantAggregator,
  new MetricsFrameLogger(), // our custom processor
]);

Key requirements

Custom processors extend FrameProcessor from @pipedog/core/processors/frame_processor. The base class handles StartFrame, CancelFrame, InterruptionFrame and pause / resume frames for you, and provides processFrame() and pushFrame():

  • processFrame(frame, direction) receives every frame and is where your conditional logic goes.
  • pushFrame(frame, direction?) sends a frame on. Normally downstream, but you can push upstream, or both ways.

Two calls are essential:

  1. super(options) in the constructor, passing the whole options object (that is how Python's **kwargs flows to the base class).
  2. await super.processFrame(frame, direction) first thing in processFrame().
export interface MyCustomProcessorOptions extends FrameProcessorOptions {
  greeting?: string;
}

export class MyCustomProcessor extends FrameProcessor {
  protected _greeting: string;

  constructor(options: MyCustomProcessorOptions = {}) {
    super(options); // ✅ Required: forwards name, enableDirectMode, metrics...
    this._greeting = options.greeting ?? "hello";
  }

  override async processFrame(frame: Frame, direction: FrameDirection): Promise<void> {
    await super.processFrame(frame, direction); // ✅ Required

    if (frame instanceof TranscriptionFrame) {
      console.log(`${this._greeting}, user ${frame.userId} said: ${frame.text}`);
    }

    await this.pushFrame(frame, direction); // ✅ Required: pass the frame through
  }
}

Constructor options extend FrameProcessorOptions (see the FrameProcessor API): name, enableDirectMode (skip the internal queues and process frames right away) and metrics.

Critical responsibility: frame forwarding

Processors receive all frames pushed through the pipeline, which gives them a lot of power and a great responsibility: they must push every frame on. A processor that drops frames it doesn't care about blocks the pipeline, and your application stops working in confusing ways. Note the unconditional await this.pushFrame(frame, direction) at the end of every example here.

Frame direction

Frames flow in one of two directions:

export const FrameDirection = { DOWNSTREAM: 1, UPSTREAM: 2 } as const;

Downstream (the default) goes from input to output: audio enters at the transport input, gets transcribed, runs through the LLM, turns into speech and reaches the transport output. Upstream sends information back towards the start: errors travel upstream, and the assistant context aggregator at the end pushes context frames upstream to the LLM.

// Push downstream (the default)
await this.pushFrame(frame, FrameDirection.DOWNSTREAM);

// Push upstream, back towards the start of the pipeline
await this.pushFrame(new TextFrame({ text: "hi" }), FrameDirection.UPSTREAM);

// Create two instances, push one each way, linked by `broadcastSiblingId`
await this.broadcastFrame(UserStartedSpeakingFrame);

// Copy a frame you didn't create and push it both ways
await this.broadcastFrameInstance(frame);

Prefer broadcastFrame(FrameClass, options) when you create the frame; use broadcastFrameInstance(frame) for a frame you received, which copies every field except id and name.

Setup and cleanup

A processor is set up before any frame reaches it, and cleaned up when the pipeline tears down. That is where a processor that needs a connection, a client, or the pipeline's audio configuration gets it:

export class ConnectedProcessor extends FrameProcessor {
  protected _sampleRate = 0;
  protected _client: Client | null = null;

  override async setup(setup: FrameProcessorSetup): Promise<void> {
    await super.setup(setup); // ✅ Required
    this._sampleRate = setup.audioOutSampleRate;
    this._client = await connect();
  }

  override async cleanup(): Promise<void> {
    await super.cleanup(); // ✅ Required
    await this._client?.close();
  }
}

FrameProcessorSetup carries clock, taskManager, pipelineWorker, audioInSampleRate (default 16000), audioOutSampleRate (default 24000), enableMetrics, enableTracing, enableUsageMetrics, observer, reportOnlyInitialTtfb and tracingContext. The processor also exposes metricsEnabled, usageMetricsEnabled, reportOnlyInitialTtfb and, once set up, processorSetup. Don't read this configuration off the StartFrame: upstream deprecated those StartFrame fields and pipedog doesn't have them.

Processors are set up concurrently. A processor that throws while setting up pushes an ErrorFrame upstream, so the application learns its pipeline came up degraded; one that throws while being cleaned up is logged and doesn't stop the others from being released.

Holding frames until a condition is met

A processor that connects during setup may receive frames before it is ready. pauseProcessingAllFramesUntil(ready, { timeout }) holds everything arriving until ready() resolves, then delivers it in order. The pause takes hold from the frame after the one being processed, and is always lifted: when ready resolves, when timeout seconds (default 5) elapse, or at cleanup.

export class SlowToConnect extends FrameProcessor {
  protected _ready = new Event();

  override async setup(setup: FrameProcessorSetup): Promise<void> {
    await super.setup(setup);
    // Hold every frame until connected, for at most 10 seconds.
    await this.pauseProcessingAllFramesUntil(() => this._ready.wait(), { timeout: 10 });
    this.createTask(async () => {
      await connect();
      this._ready.set();
    }, "connect");
  }
}

Errors

Report errors with pushError(), which builds an ErrorFrame, classifies it, fires on_error and pushes it upstream. An error that can't be recovered from (a rejected API key, say) makes the processor unusable (isUsable becomes false) and the worker applies its processorUnusablePolicy.

try {
  await riskyOperation();
} catch (e) {
  // A recoverable error: report it and keep going.
  await this.pushError("Failed to process audio chunk, skipping", { exception: e });
}

// An error that leaves this processor unable to do any more work.
await this.pushError("Critical operation failed", { forceTreatAsPermanent: true });

Every broad catch in a processor should start with rethrowIfCancelled(e); see Interruptions & cancellation.

Event handlers

Every frame processor, including services and transport inputs/outputs, emits these events. Event names are the same strings as in Pipecat. Python's @obj.event_handler("on_x") decorator becomes obj.eventHandler("on_x", handler) (which returns the handler) or obj.addEventHandler("on_x", handler). The first argument of every handler is the processor.

EventArgumentsDescription
on_error(processor, error: ErrorFrame)An error occurred in this processor, before its ErrorFrame is pushed upstream.
on_usable_changed(processor, isUsable: boolean)The processor stopped (or started again) being able to do its job.
on_before_process_frame(processor, frame)A frame is about to be processed.
on_after_process_frame(processor, frame)A frame has just been processed.
on_before_push_frame(processor, frame)A frame is about to be pushed.
on_after_push_frame(processor, frame)A frame has just been pushed.
const tts = new MyCustomProcessor({ name: "tts" });

// Like Python's `@tts.event_handler("on_error")` decorator.
tts.eventHandler("on_error", async (processor: FrameProcessor, error: ErrorFrame) => {
  console.log(`Error in ${processor}: ${error.error}`);
  if (error.exception) {
    console.log("Exception:", error.exception);
  }
  if (error.processor && !error.processor.isUsable) {
    console.log("This processor can no longer do its job");
  }
});

tts.eventHandler("on_usable_changed", async (processor: FrameProcessor, isUsable: boolean) => {
  if (!isUsable) console.warn(`${processor} is down`);
});

// The same handler on several processors.
async function handleServiceError(processor: FrameProcessor, error: ErrorFrame): Promise<void> {
  logger.error(`Service error in ${processor.name}: ${error.error}`);
}
const stt = new MyCustomProcessor({ name: "stt" });
const llm = new MyCustomProcessor({ name: "llm" });
for (const service of [stt, llm, tts]) {
  service.addEventHandler("on_error", handleServiceError);
}

// Frame hooks run inline: keep them fast.
llm.eventHandler("on_before_push_frame", async (processor: FrameProcessor, frame: Frame) => {
  console.log(`${processor} is about to push: ${frame}`);
});

All of these except on_usable_changed are synchronous: handlers run inline and block the pipeline until they complete. Use them for fast work like logging, counting or setting flags, not for I/O. on_usable_changed runs in its own task.

Example: response logger

Watching for frames that bracket a piece of work is a common pattern. LLMFullResponseStartFrame and LLMFullResponseEndFrame bracket every LLM response:

export class ResponseLogger extends FrameProcessor {
  override async processFrame(frame: Frame, direction: FrameDirection): Promise<void> {
    await super.processFrame(frame, direction);

    if (frame instanceof LLMFullResponseStartFrame) {
      logger.info("LLM response started");
    } else if (frame instanceof LLMFullResponseEndFrame) {
      logger.info("LLM response finished");
    }

    await this.pushFrame(frame, direction);
  }
}

Testing a processor

runTest() from @pipedog/core/tests/utils (the port of pipecat.tests.utils.run_test) runs a processor in a real pipeline, sends frames, and checks the frame types that come out:

import { expect, test } from "bun:test";
import { type Frame, TextFrame } from "@pipedog/core/frames/frames";
import { type FrameDirection, FrameProcessor } from "@pipedog/core/processors/frame_processor";
import { runTest } from "@pipedog/core/tests/utils";

class UpperCaseProcessor extends FrameProcessor {
  override async processFrame(frame: Frame, direction: FrameDirection): Promise<void> {
    await super.processFrame(frame, direction);
    if (frame instanceof TextFrame) {
      await this.pushFrame(new TextFrame({ text: frame.text.toUpperCase() }), direction);
    } else {
      await this.pushFrame(frame, direction);
    }
  }
}

test("test_upper_case", async () => {
  const [down] = await runTest(new UpperCaseProcessor(), {
    framesToSend: [new TextFrame({ text: "hello" })],
    expectedDownFrames: [TextFrame],
  });
  expect((down[0] as TextFrame).text).toEqual("HELLO");
});

Best practices

  1. Always call the parent: super(options) and await super.processFrame(frame, direction).
  2. Forward all frames with await this.pushFrame(frame, direction).
  3. Handle frames conditionally with instanceof checks.
  4. Handle errors properly: rethrowIfCancelled(e) first in every catch, then pushError().
  5. Position carefully: put the processor where it receives the frames it needs.
  6. Track your tasks: start background work with this.createTask(), never a floating promise.

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).

On this page