Realtime voice (experimental)
Realtime support is an SDK-level experimental surface for full-duplex voice models. It is not the normal text agent loop, and in its default mode it does not turn each utterance into a Flux-Lang plan. It exists for embedders that need a long-lived audio session while still routing effectful tool calls through the guarded executor. Since 0.15.0 the driver has a second, flow-driven mode: an authored flow owns the call and the realtime model is only the acoustic front-end (see Flow-driven voice below).
The public shape is RealtimeProvider, a sibling of the half-duplex Provider.
The concrete OpenAI implementation is behind the realtime cargo feature on the published
codewandler-flux-providers package (imported as flux_providers).
What exists
- The seam —
RealtimeProvider/RealtimeSession/RealtimeEvent/RealtimeConfiginflux-provider. A session exposessend_audio/send_text/cancel_response(barge-in) /send_tool_result; the event stream yields audio deltas, input/output transcripts, speech start/stop, tool calls, and per-response token usage. - One concrete provider — OpenAI Realtime, in
flux-providers::realtimebehind therealtimefeature: a WebSocket client built withopenai_realtime(api_key),openai_realtime_from_env()(readsOPENAI_KEY, thenOPENAI_API_KEY), oropenai_realtime_oauth(token). The default model id isgpt-realtime. - The driver —
VoiceSessionDriverinflux-flowruns the session event loop and feeds aVoiceSink(your callbacks for audio frames, transcripts, tool events, barge-in, and — since 0.15.0 —session_endedwhen a flow-driven call completes). Beside the default model-driven entry it exposesrun_flow_turns, where aVoiceTurnHandleron your side owns each turn. - The same safety envelope. Every tool call the voice model makes is dispatched through the
runtime's
Executor::dispatch— the identical permission / approval / redaction chain a text turn uses, with no bypass path. Tools are declared to the model once, from the same registry the executor gates. Dispatch runs off the audio loop, so a slow tool never stalls audio or barge-in. - Audio helpers — the dependency-free
flux-audiocrate provides PCM16 encode/decode, stateless and streaming resampling (phase carried across packets), and frame re-chunking. flux itself speaks the model-native format only (AudioFormat::OPENAI_PCM16, i.e. PCM16 24 kHz mono; G.711 µ-law 8 kHz is also expressible); resampling to your transport's rate is your side of the boundary.
How to enable
The SDK entry point is FlowClient::run_voice_session — build a FlowClient
with your ops and policies as usual, then hand it a realtime provider, a config, and a sink:
[dependencies]
codewandler-flux-sdk = "0.44"
codewandler-flux-providers = { version = "0.44", features = ["realtime"] }
use flux_sdk::voice::{RealtimeConfig, VoiceSink};
use flux_sdk::{CancellationToken, FlowClient};
use flux_providers::realtime::openai_realtime_from_env;
async fn run_voice(
client: &FlowClient,
sink: &mut impl VoiceSink,
) -> Result<(), Box<dyn std::error::Error>> {
let provider = openai_realtime_from_env()?; // OPENAI_KEY / OPENAI_API_KEY
let config = RealtimeConfig::voice_agent(
"gpt-realtime",
"You book appointments. Be brief.",
);
let cancel = CancellationToken::new(); // cancel to end the session, for example on hangup
client
.run_voice_session(&provider, config, sink, &cancel)
.await?;
Ok(())
}
run_voice_session declares the client's registered ops to the model, builds the executor, and
drives the session until cancel fires or the connection ends. Your VoiceSink receives audio,
transcript, tool, and lifecycle output.
The high-level SDK methods do not return or expose the connected RealtimeSession, so
run_voice_session does not currently give the caller a handle for send_audio. If your transport
must push microphone or telephony frames, use the lower-level provider/driver seam and retain a clone
of RealtimeConnection::session before handing the connection to VoiceSessionDriver::run (or
run_flow_turns). Treat an input adapter above these SDK methods as future work; their Result<()>
is not an audio-input handle.
Flow-driven voice
Since 0.15.0 an authored flow can drive the call instead of the model: the driver speaks the
flow's authored prompts (TTS via the realtime channel), the caller's reply resumes the flow's
suspension, and the model does cognition only where the flow explicitly delegates a bounded
segment (ai_segment — see durability and sessions). Classic-IVR
determinism over the same voice stack: the deterministic skeleton makes zero model calls, and
when the flow completes the driver speaks the final line, fires
VoiceSink::session_ended (your hangup/handoff hook), and ends the session.
The SDK entry point is Session::run_voice_flow(provider, config, flow, sink, cancel) — the voice
counterpart of Session::start_flow and the flow-driven sibling of the model-driven
FlowClient::run_voice_session. It needs the persistent engine, so it lives on Session rather than
FlowClient. Under the hood it drives VoiceSessionDriver::run_flow_turns with an
EngineVoiceHandler — a VoiceTurnHandler backed by a FlowEngine, which server/embedding hosts
can also drive at the driver level directly. Each turn the handler
returns a VoiceReply: Continue(text) (speak this, await the caller) or Complete(text) (speak
this final line, end the call). Ops a voice-driven flow dispatches traverse the engine's shared
executor — the same envelope as a text turn — and barge-in is unchanged.
Breaking in 0.15.0: VoiceTurnHandler::turn returns VoiceReply instead of String — return
VoiceReply::Continue(text) for the old behavior. The new start() (speak first) and
VoiceSink::session_ended hooks have defaults, so existing implementations only adjust turn.
Breaking again: turn now takes the speaker as well —
turn(&self, speaker: &Speaker, user_text: &str). A phone line has exactly one candidate, so the
realtime driver passes Speaker::sole() and behavior is unchanged; the parameter exists because a
many-party room has N speakers and a handler cannot decide whether it
was addressed without knowing who spoke. An existing implementation adds the parameter and ignores it.
Status and limits
- Experimental. The traits and event shapes are pre-1.0 and have already changed in breaking ways between releases; expect more.
- SDK-only. There is no CLI surface for realtime voice today — no
fluxsubcommand opens a voice session, and.fluxprograms cannot declare one. - One provider. OpenAI Realtime is the only implementation; the seam is provider-shaped so others can land, but none have.
- In the lower-level model-driven mode, the realtime model owns the turn. There is no authored outer-loop round-trip per utterance. flux's guarantee here is narrower and deliberate: every effectful action still crosses the guarded executor envelope, and the audit trail still records it. The flow-driven mode inverts this: the flow owns the turn, and the model speaks only inside a bounded, tool-scoped segment.
- Audio transport is yours. flux does no telephony, WebRTC, or device IO; it exchanges
model-native audio bytes and leaves capture, playout, and resampling to the embedding application
(with
flux-audioavailable as a utility).
Related docs
- Providers and models — text-provider routing and credentials.
- Safety and approvals — the executor envelope realtime tool calls still use.