Skip to main content

Overview

Voxray’s pipeline is a linear chain of processors. Each processor receives a frame, does work (filtering, transforming, calling an external API), and pushes the result downstream. Processors are registered by name in a global registry and instantiated at startup from config.json — no recompilation required to swap or reorder processors. The registry lives in pkg/pipeline/registry.go:
At startup, cmd/voxray/main.go imports all processor packages via blank imports. Each package’s init() function calls pipeline.RegisterProcessor(name, ctor), which adds the constructor to the in-memory registry. When a new transport connection arrives, ProcessorsFromConfig iterates config.Plugins, looks up each name in the registry, and calls the constructor with the matching plugin_options blob — or nil if no options were provided. If a plugin name in config.Plugins is not found in the registry, ProcessorsFromConfig returns an error and the server refuses to start. This makes config mistakes loud and explicit.

Built-in Plugins


Wiring Plugins in Config

Plugins are declared in two parallel arrays in config.json:
  • plugins — ordered list of processor names. Voxray builds the pipeline in this order, left to right.
  • plugin_options — a map from processor name to an arbitrary JSON object passed to that processor’s constructor.
The order in plugins matters: frames flow through processors in declaration order. In the example above, audio arrives at stt_mute_filter first (where it may be dropped if the bot is speaking), then passes to interruption_controller (which checks whether a barge-in should be declared), and finally reaches rtvi (which serialises bot output into RTVI protocol messages for the frontend).
If a processor name appears in plugins but not in plugin_options, the constructor receives nil for opts and must apply its own defaults. All built-in processors handle nil opts gracefully.

Common plugin option reference

frame_filter
Frames whose Go type name is not in allowed_types are dropped. wake_check_filter
Case-insensitive substring match against TranscriptionFrame text. stt_mute_filter
audio_filter
interruption_controller

RTVI Protocol

RTVI (Real-Time Voice Interface) is the client–server messaging layer used by Pipecat-based frontend clients. Voxray implements both the processor and the wire serializer.

Enabling RTVI

Two steps are required:
  1. The WebSocket client must connect to /ws?rtvi=1. The ?rtvi=1 query parameter tells the server to select the RTVI serializer instead of the default JSON or binary serializer.
  2. "rtvi" must appear in plugins. The RTVIProcessor handles the handshake and message routing.

Message types

Flow:
  1. Client connects to ws://host:port/ws?rtvi=1.
  2. Voxray pushes StartFrame into the pipeline; RTVIProcessor responds with bot-ready.
  3. Client sends client-ready; RTVIProcessor records the client version.
  4. Client sends send-text; RTVIProcessor converts it to a TranscriptionFrame and pushes it downstream (to LLM → TTS if in a voice pipeline, or to any downstream processor in a plugin pipeline).
  5. LLMTextFrame and other output frames are serialized as bot-output and sent to the client.

External Chain (Python Sidecar)

external_chain bridges the Go pipeline to a Python LangChain, LangGraph, or Strands service over HTTP. This is the recommended pattern when your agent logic requires Python-only libraries (e.g. custom LangChain tools, complex graph traversal, retrieval-augmented generation with Python vector stores).

How it works

When an LLMContextFrame arrives, external_chain extracts the last user message and POSTs it to the configured URL. The response is streamed back as LLMTextFrame instances, followed by LLMFullResponseStartFrame / LLMFullResponseEndFrame markers. Downstream processors (e.g. TTS) consume these exactly as they would from a native LLM provider.

Config

Streaming response format (when stream: true): SSE lines data: {"text":"..."} or {"content":"..."}. Each chunk is emitted as an LLMTextFrame in real time, enabling TTS to begin speaking before the full response is received. Python sidecar contract:

Writing a Custom Processor

Custom processors follow a five-step pattern: define a struct, implement the interface, register with init(), import the package, and add to config.
1

Create a Go package

Create a new directory under pkg/processors/:
2

Define the struct

Embed *processors.BaseProcessor, which provides the downstream push channel, name, and default no-op implementations:
3

Implement ProcessFrame

ProcessFrame is called for every frame that reaches this processor. Call p.PushDownstream to forward frames to the next processor in the chain. Frames you do not forward are effectively dropped.
Always call p.PushDownstream(ctx, frame) for frames you do not want to drop, including frame types your processor does not handle. Failing to forward a frame type like StartFrame or CancelFrame will break pipeline lifecycle management.
4

Register with init()

The init() function runs automatically when the package is imported. It registers your constructor under the name that config will reference:
5

Blank-import the package in main.go

Go’s init() only runs if the package is imported. Add a blank import to cmd/voxray/main.go:
This is the same pattern used by all built-in processors (e.g. pkg/processors/voice/register.go registers interruption_controller this way).
6

Add to config

Add your processor name to plugins and provide any options under plugin_options:
Restart the server. Voxray will log a startup error and refuse to run if your processor name is in plugins but not in the registry — a fast feedback loop for typos or missing imports.

Processor Interface Reference

BaseProcessor implements all methods with safe defaults. Override only ProcessFrame (and optionally Setup/Cleanup for resource lifecycle) in custom processors.

Frame direction

Most processors handle only FrameDirectionDownstream. The interruption_controller uses FrameDirectionUpstream to propagate cancellation signals back toward the TTS processor.

Pipeline Execution Model

Frames flow synchronously through the processor chain within a single goroutine per Push call. Processors must not block indefinitely — use ctx for cancellation. If a processor needs to spawn background work (e.g. an async HTTP call), it should push a result frame from that goroutine via a channel and a separate Pipeline.Push call, not block ProcessFrame. The runner feeds frames into the pipeline via a buffered queue (default capacity: 256 frames). If the pipeline falls behind, the queue fills and the transport reader blocks — providing back-pressure to the client. This is intentional and prevents unbounded memory growth under load.
Processors at the end of a plugin pipeline automatically receive a Sink appended by the pipeline builder, which writes frames to Transport.Output. You do not need to wire the sink manually.