Custom Frame Processors
A Pipecat FrameProcessor is a single node in the bot pipeline: it receives frames, optionally
transforms or absorbs them, and forwards the rest. Writing one is the lowest-scope way to add
behavior that NeMo Labs Voice Agent’s builders do not provide. Examples include an automatic speech
recognition (ASR) post-corrector, a Markdown sanitizer before text-to-speech (TTS), and transcript redaction.
Prerequisites
Before you add a processor, complete the following preparation:
- Run the Quickstart with the shipped pipeline.
- Identify the frame type you need to transform and the pipeline stage that emits it.
- Choose the demo server or evaluation bot entrypoint where you want to insert the processor.
Processor or Builder Swap?
Choose a processor for frame transformations and a builder change for service construction.
A processor is the right tool when the stages themselves are fine and you only need to touch the
frames flowing between them. It requires no changes to nemo_voice_agent/, only a few lines in your
copy of the server entrypoint.
Where the Pipeline Is Assembled
Both entrypoints — examples/generic_voice_agent/server/server.py and evaluation/bot_server.py —
build the pipeline in run_bot_websocket() as a flat list, appending optional stages only when their
builder returned a value:
Insert your processor by adding one more element to that list. Refer to How It Works for what each stage does.
Insertion Points
Pick the position by the frame type you need to see. Frame classes come from
pipecat.frames.frames unless noted.
Two things decide between the last two text positions. LLMTextProcessor converts
LLMTextFrame into sentence-sized AggregatedTextFrame, as implemented by build_llm_text_processor in
nemo_voice_agent/pipecat/services/nemo/builders.py. Any regex that must match across token
boundaries belongs after it. Note that it is only present when tts.use_text_aggregator is true —
the default. When it is false, the TTS service does its own aggregation internally and only
LLMTextFrame reaches that point.
Rules
Follow these rules so unrelated frames and pipeline direction continue to work as designed:
- Subclass
FrameProcessorand overrideasync def process_frame(self, frame, direction). - Call
await super().process_frame(frame, direction)first. The base implementation handlesStartFrame,CancelFrame,InterruptionFrame, and pause/resume bookkeeping. It does not forward anything. - Always forward with
await self.push_frame(frame, direction)unless you intentionally consume the frame. Dropping a control frame stalls everything downstream. - Filter on frame type.
TranscriptionFrame,InterimTranscriptionFrame,LLMTextFrame, andAggregatedTextFrameare all subclasses ofTextFrame, so anisinstance(frame, TextFrame)test matches user speech as well as bot speech. Match the narrowest class you mean. - Check
direction.FrameDirection.DOWNSTREAMruns input toward output.FrameDirection.UPSTREAMcarries errors and control signals back. Transforms should almost always guard onDOWNSTREAMand pass upstream frames through untouched. - Frames are mutable dataclasses, so in-place edits such as
frame.text = ...work and preserve the subclass and its metadata fields. - System frames jump the queue.
InputAudioRawFrameand otherSystemFramesubclasses are dispatched ahead of queued data frames, so do not assume ordering between audio and transcripts.
Example: Strip Markdown Before TTS
LLMs emit **bold**, # heading, and - bullet markup even when the system prompt forbids it, and
the TTS voice reads the punctuation aloud. A sanitizer placed immediately before tts fixes this
deterministically instead of relying on model compliance.
Wire it into your copy of the entrypoint:
Side Effects to Watch
A processor can affect downstream context, timing, and logging even when it changes only one frame type:
- The assistant context sees your edits.
assistant_aggsits at the end of the pipeline and builds the assistant turn from text frames whoseappend_to_contextis true. These are the same objects your processor already mutated. For Markdown stripping that is desirable. For a change you want spoken but not remembered, emit a modified copy instead of editing in place. - Stateful processors need a reset hook. The
resettablelist passed to the RTVI actions is iterated by_reset_servicesinnemo_voice_agent/pipecat/processors/frameworks/rtvi_actions.py. The function calls a plain synchronousreset()on any entry that has one and skipsNone. Add your processor to that list and give it areset()method if it carries per-conversation state. Refer to RTVI Control Plane. - Configuration belongs in YAML. Accept options in
__init__and read them from a section of the server config, the way the existing builders do, rather than hardcoding them. - Reasoning spans are handled elsewhere. TTS already skips text between
tts.think_tokens. Do not reimplement that in a processor. Refer to Reasoning Mode.
Test It
Processors are unit-testable without a pipeline: keep the transform in a pure classmethod, then
drive process_frame directly with a stubbed push_frame to check forwarding and frame typing.
tests/unit/test_runtime_state_machines.py uses exactly this pattern for UserAudioBuffer.
Then run the bot end to end and listen. Refer to Quickstart. If your
processor does not process frames, confirm it is in pipeline_list because optional surrounding stages are
appended conditionally. Also confirm that the selected frame class reaches that position.
Next Steps
Continue with the guide for the extension boundary you need:
- The Builder API — swap how a stage is constructed instead of what flows between stages.
- Building Your Own Pipeline — replace
run_bot_websocket()entirely. - Extension Overview — how the extension points fit together.