Skip to main content
Venice can hear you and talk back. There’s no realtime speech-to-speech socket to connect to, which sounds like a limitation until you notice that a voice agent is really just three ordinary HTTP calls in a loop: transcribe what the user said, generate a reply, speak the reply. In this guide, we’ll build that loop as a terminal app in Python. Press Enter, speak, press Enter again, and the answer plays through your speakers. You can type a line instead if you’d rather not use the mic. This is the same STT → LLM → TTS shape as the LiveKit Agents guide, minus LiveKit, wake words, and tools. Stripping the framework out is the point: by the end you’ll know exactly which three requests do the work, and why we stream two of them. Before we continue: you’ll need a Venice API key. Export it as an environment variable:
Interested in the full code implementation? Check out the GitHub repo.

Pre-requisites

  • Python 3.11 or newer, and uv
  • A Venice API key from venice.ai
  • A microphone and speakers, if you want the full voice loop
Recording and playback go through sounddevice, which wraps PortAudio. uv sync installs the Python package, and on Windows that’s all you need. macOS and Linux want the PortAudio library too:
None of this is Venice-facing — it’s just how the samples get in and out of your machine. The app takes a --text-only flag that skips the mic entirely and still exercises chat and TTS, so you can follow along on a box with no audio hardware at all.

What We’re Building

One turn of conversation is three requests: Those model IDs are a starting point rather than a fixed list. Venice rotates the catalog, so resolve them at runtime from GET /models?type=... and GET /models/traits before you ship anything. See Deprecations for how that plays out. We’ll keep the source tree small on purpose:
The split matters more than it looks. venice.py is the part you can lift straight into a web app, a Discord bot, or a phone integration. audio.py is the only file that cares what machine it’s running on, and Venice never sees any of it — the API only ever receives a WAV blob on the way in and hands back raw PCM on the way out.

Setting Up

Create the project and add the dependencies. The OpenAI SDK does all the HTTP work, python-dotenv keeps the key out of your shell history, and sounddevice talks to the mic and speakers:
Then create .env.example so the model choices are configuration rather than something buried in the code:
Copy it to .env and paste your key in.

Pointing the SDK at Venice

Venice’s API is OpenAI-compatible, so we use the official openai client and change the base URL. That’s the whole integration. Create venice.py and start with the client:
Note that we check for the key ourselves rather than letting os.environ["VENICE_API_KEY"] throw. A KeyError traceback is a bad first experience for something as ordinary as a missing key. One more piece of housekeeping while we’re here. The SDK raises OpenAIError subclasses, and the useful detail is buried in the response body, so it’s worth unwrapping once:
Every call below funnels its failures through this, so a bad voice ID or an expired key surfaces as one readable line instead of a stack trace.

Hearing the User

POST /audio/transcriptions takes an audio file and returns text. We’re recording 16 kHz mono WAV locally, but the endpoint accepts the usual formats, so we map the file extension to a MIME type rather than hardcoding one:
Venice transcription is request/response rather than a streaming socket, which is why the recording has a definite end — we press Enter instead of running voice-activity detection. If you want VAD-based endpointing, that’s the job the LiveKit guide hands to Silero. An empty transcript is a normal outcome, not an error. Somebody will press Enter twice by accident, and a friendly “I didn’t catch that” beats an exception every time.

Streaming the Reply

Now the chat call. There are two Venice-specific settings here that make a real difference to how the agent sounds:
include_venice_system_prompt: False stops Venice prepending its own system prompt to ours. Left on, it’s roughly seventeen hundred extra input tokens per call and a second voice telling the model how to behave. disable_thinking: True (with reasoning.enabled: False for models that read the newer field) stops GLM spending its token budget on a hidden chain of thought before it says anything — which, when you’re waiting to hear a reply, is time you can hear. The prompt itself earns its length. Asking for twenty words keeps answers sounding spoken rather than written, and “omit detail rather than ending mid-sentence” is what stops a hard max_tokens cap from truncating mid-word. Banning markdown matters more than you’d think: a TTS model will happily read asterisks aloud.
The instruction to treat the user’s message as untrusted is doing real work here. Transcribed speech is user input like any other, and “ignore your previous instructions” is just as easy to say out loud as it is to type.
With that in place, the call is a normal streamed completion:
The important design decision is that this yields sentences, not tokens. TTS needs a complete clause to get the prosody right, so we buffer deltas until we have one, then hand it off. That’s what lets audio start playing while the model is still talking. The cancel event lets the caller stop draining the stream when the user hits Ctrl+C, and closing the stream in a finally block releases the connection instead of leaving it hanging until the timeout.

Splitting Sentences As They Arrive

Splitting on ., !, and ? gets you 90% of the way there and then embarrasses you the first time the model says “Dr. Smith”. So we check whether the thing before the full stop is an abbreviation before treating it as a boundary:
Note that the regex requires trailing whitespace after the punctuation. That’s deliberate: mid-stream, "Hello." might be a finished sentence or it might be the first half of "Hello.txt", and we can’t tell yet. Waiting for the space means we never cut a sentence early, at the cost of holding the last one until the stream ends — which iter_sentences handles with that final leftover flush. This is a naive splitter and it’s fine. It’s also the one piece of logic here that’s cheap to unit test, so it’s worth doing:

Speaking the Reply

POST /audio/speech is the third and last call. Two options make it feel fast:
response_format="pcm" gives us raw signed 16-bit little-endian samples at 24 kHz mono, which we can pipe straight to the speaker with no decode step. tts-kokoro otherwise defaults to MP3, and decoding an MP3 means waiting for enough of the file to arrive before you can play any of it. streaming: True is the Venice flag that starts sending audio as it’s synthesized instead of after the whole clip is done. resolve_voice is deliberately dull — it trims the string and falls back to the environment default, and does not validate against a list:
An unknown voice ID fails at the API with a clear message, which is better than a local allowlist that silently goes stale as Venice adds voices. Voices are model-specific, though, so a Kokoro voice against a different TTS model won’t work — see Text-to-Speech Models for the pairings.

Check Before You Play

Here’s the one gotcha that will make you jump out of your chair. Raw PCM has no header and no magic bytes, so if an error response gets written into the audio pipe, the speaker faithfully plays the JSON as a burst of noise at full volume. So check the status and content type before you treat the body as audio, and sniff the first chunk as a backstop:
RIFF catches a WAV response and ID3 catches an MP3, both of which mean the response_format didn’t take effect. The JSON check catches an error body. None of this is clever, and all of it is the difference between a readable error and a startled user.
Never pipe an unchecked HTTP body into a raw audio sink. There’s no format negotiation on the playback side to save you — whatever bytes arrive get played as samples.

Recording and Playback

This part is not Venice, so we’ll move quickly. audio.py opens a PortAudio input stream while the user talks and a PortAudio output stream to play the reply, both through sounddevice. We import it lazily so that a missing native library becomes a sentence rather than an OSError at startup:
Those are two genuinely different failures with two different fixes, and sounddevice reports the second one as a bare OSError from the import itself. Catching both here is what lets --text-only work on a machine that can’t load PortAudio at all. Recording is a callback that appends into a list, with a hard cap so a forgotten session doesn’t grow without limit:
The nested try/finally is deliberate. The inner one turns a cancel into a friendly AudioError, and the outer one stops and closes the stream on every path out — including cancellation — because a RawInputStream that never gets closed keeps holding the microphone after the turn is over. bytes(indata) copies rather than aliases, since PortAudio reuses that buffer for the next callback. Note that the samples never touch the disk. /audio/transcriptions needs a file-shaped upload, but “file-shaped” only means it needs a WAV header, and we can put one on in memory:
That’s fourteen lines to avoid ever writing a recording of somebody’s voice to a temp directory, which seems like a good trade. wave is in the standard library, and the bytes go straight to the file= argument we set up earlier. Playback is one stream per reply, so consecutive sentences run together as continuous speech instead of restarting the device each time:
That _pending buffer is the one detail here that will bite you if you skip it. HTTP chunk boundaries have nothing to do with sample boundaries, so a 4096-byte read can hand you an odd number of bytes and split a 16-bit sample down the middle. Write that to the device and every subsequent sample is byte-shifted, which sounds like the audio equivalent of static. So we only ever write an even number of bytes and carry the spare byte into the next call. The full class in the repo also has abort() for Ctrl+C — stop the device immediately, discard what’s buffered — and close() for the normal path, which flushes the last partial sample (padded with a zero byte) and then waits for the device to finish playing what it already has. Getting those two backwards means either clipping the last word off every reply or being unable to interrupt one.
PortAudio is the portability layer here, so the same audio.py runs on macOS, Windows, and Linux. Nothing in venice.py knows or cares which.

Overlapping the Stream and the Playback

Here’s where the streaming actually pays off. If we drain the chat stream and play audio on the same thread, playback blocks the loop and the model’s remaining tokens sit unread in a socket buffer. So we drain the stream on a side thread and hand sentences over a queue:
Putting the exception on the queue and re-raising it on the consumer side is what keeps error handling honest. A background thread that dies silently gives you a hang instead of a message, and BaseException rather than Exception means a KeyboardInterrupt inside the stream still reaches the caller. Now the turn itself: pull sentences, print each one, and feed its PCM to the player as it arrives.
The player is created lazily on the first chunk of audio rather than up front, so a TTS failure doesn’t leave an idle output stream holding the speakers open. And raise_on_error=not failed means that when the turn is already failing we tear playback down quietly instead of stacking a second error on top of the real one. Printing time-to-first-audio is a small thing that’s genuinely useful while tuning. It’s the number the user feels.

The Prompt Loop

Everything left is a while True around input():
An empty line means “listen”; anything else is treated as typed input. History is trimmed to the last eight exchanges, which is plenty for a spoken conversation and keeps the input token count flat instead of growing until something complains. The two-level error handling is worth calling out. Setup failures exit — there’s no point starting a REPL you can’t use. Per-turn failures print and return to the prompt, because a rate limit or a fluffed recording shouldn’t end the session. That warmup call earns its keep too. It lists models and sends a one-word TTS probe, which establishes the TLS connection and validates the key and the voice before the user’s first real turn rather than during it:

Running It

Press Enter, speak, press Enter again. Type a line if you’d rather not use the mic, reset to start a new conversation, q to quit. Ctrl+C during a reply stops playback and drops you back at the prompt rather than exiting. A few variations:
If it grabs the wrong microphone or speakers, ask PortAudio what it can see and put a name or index in AUDIO_SOURCE / AUDIO_SINK:
And the tests:

What to Expect on Latency

The pipeline is three sequential requests, so the numbers stack roughly like this: Expect somewhere around a second to first audio on a good connection. Two things dominate that number: whether TTS starts on the first sentence or waits for the whole reply, and whether the model burns tokens thinking before it talks. Sentence-level streaming and disable_thinking are the two changes here that you’d notice if you removed them. If you want it faster, keep replies short — the first sentence is what gates perceived responsiveness — and try a flash-class chat model. There’s more on this in the LiveKit latency notes.

Privacy Notes

Worth being explicit about what leaves the machine, since this one has a microphone in it. Audio goes to Venice to be transcribed and text comes back to be spoken; both are covered by Venice’s zero data retention policy, and nothing is stored on their side after the request. Locally, nothing is written to disk at all — the recording is assembled in a list, wrapped in a WAV header in memory, and handed to the request, so there’s no temp file to leak or clean up. The API key is read from the environment and never printed. Conversation history lives in memory only and disappears when you quit or type reset. See Privacy for the per-model tiers if you need a stronger guarantee than zero retention.

Finishing Up

The thing to take away: a voice agent on Venice is three OpenAI-compatible endpoints, two of them streamed. Everything else in this project — the sentence splitter, the audio streams, the queue — exists to make those three calls feel like a conversation. venice.py is the part worth stealing. Swap app.py for a web handler or a phone integration and the API layer doesn’t change. Some things worth doing next:

Give it tools

Add function calling to the chat step and the agent can look things up mid-conversation.

Let it search

Set enable_web_search in venice_parameters and answers stop being limited to training data.

Clone a voice

Swap the Kokoro voice ID for one you cloned yourself.

Put it in a room

Hand the same three stages to LiveKit for VAD, barge-in, and multi-participant calls.
Thanks for reading! Hopefully this has taken some of the mystery out of voice agents — they’re a lot less exotic than they sound once you see the three requests underneath.