Voice agent in Python: Deepgram listens, Claude replies, Cartesia speaks
One Python script that transcribes an audio question with Deepgram, gets a short spoken-style reply from Claude, and saves Cartesia speech as reply.wav. Turn-based.
Create each key on its own page, paste it below, then copy the finished lines into your project's .env file. What you paste stays in this tab. It is never sent to Wirestack or saved.
One Python script, agent.py, that answers a spoken question out loud. You give it an audio file path or a public audio URL. Deepgram turns the speech into text. Claude Sonnet 5.5 writes a short reply meant to be heard, not read. Cartesia turns that reply into speech and saves it as reply.wav.
This is a turn-based script. It handles one question per run and finishes each step before the next one starts. It is not a real-time voice agent: nothing streams, and there is no live microphone or interruption handling.
Before you start
Set up each piece first, in this order, so you know each one works on its own.
Deepgram (listens). Follow the Deepgram piece and run its transcribe.py. Env var: DEEPGRAM_API_KEY.
Claude (thinks). Follow the Claude piece to get a key. You do not need FastAPI or uvicorn for this bundle. Env var: ANTHROPIC_API_KEY (the Anthropic SDK reads it on its own).
Cartesia (speaks). Follow the Cartesia piece and run its tts_to_file.py. Env var: CARTESIA_API_KEY.
Install everything the script uses, with the versions the pieces pin:
Put the three keys in a .env file next to agent.py. The script loads that file itself. If you saved the keys on this computer with npx wirestack keys add <tool>, run npx wirestack keys fill in the project folder and it writes them into .env. Or paste them in by hand:
import osimport sysimport anthropicfrom cartesia import Cartesiafrom deepgram import DeepgramClientfrom dotenv import load_dotenvload_dotenv()sys.stdout.reconfigure(encoding="utf-8")SYSTEM = ( "You are a voice assistant. Your reply will be read aloud. " "Answer in two or three short sentences of plain text. " "Do not use markdown, lists, headings, links or emojis.")def listen(source): deepgram = DeepgramClient(api_key=os.getenv("DEEPGRAM_API_KEY")) if source.startswith(("http://", "https://")): response = deepgram.listen.v1.media.transcribe_url( url=source, model="nova-3", smart_format=True, ) else: with open(source, "rb") as audio_file: response = deepgram.listen.v1.media.transcribe_file( request=audio_file.read(), model="nova-3", smart_format=True, ) return response.results.channels[0].alternatives[0].transcriptdef think(question): client = anthropic.Anthropic() message = client.messages.create( model="claude-sonnet-5-5", max_tokens=1024, system=SYSTEM, messages=[{"role": "user", "content": question}], ) return "".join(block.text for block in message.content if block.type == "text").strip()def speak(text, path="reply.wav"): client = Cartesia(api_key=os.getenv("CARTESIA_API_KEY")) response = client.tts.generate( model_id="sonic-3.6", transcript=text, voice="6ccbfb76-1fc6-48f7-b71d-91ac6298247b", output_format={"container": "wav", "encoding": "pcm_s16le", "sample_rate": 44100}, language="en", ) response.write_to_file(path) return pathdef main(): if len(sys.argv) != 2: sys.exit("Usage: python agent.py <audio file path or URL>") question = listen(sys.argv[1]) if not question: sys.exit("Deepgram returned an empty transcript.") print(f"You said: {question}") reply = think(question) print(f"Claude: {reply}") path = speak(reply) print(f"Saved audio to {path}")if __name__ == "__main__": main()
The first lines load your keys from .env before any client is created, and make printed text UTF-8, so characters like dashes do not come out garbled, or crash the script, on Windows.
listen() is the Deepgram piece. A URL goes to transcribe_url, the same call the piece uses. A local path goes to transcribe_file, which Deepgram's pre-recorded docs show for files. Both use nova-3 with smart_format=True. The transcript is read from results.channels[0].alternatives[0].transcript, the path shown in Deepgram's Python SDK readme.
think() uses the same Anthropic SDK and model as the Claude piece (claude-sonnet-5-5), but calls client.messages.create without streaming, as on Claude's Get started page. The system prompt asks for a few plain sentences, because markdown symbols sound wrong when spoken. Only text blocks are joined, so any other block types in the reply are skipped.
speak() is the Cartesia piece with the reply passed as transcript. It keeps the same model, voice and WAV format, and writes reply.wav. The piece's generation_config emotion setting is left out so the call matches Cartesia's Generate to File example. Add it back if you want a different delivery.
main() runs the three steps in order and prints each stage, so you can see where it stops if one step fails.
Test it
Use Deepgram's sample clip, the same one the Deepgram piece uses:
Shell
python agent.py https://dpgr.am/bueller.wav
Or record your own question and pass the file path:
Shell
python agent.py question.wav
You should see the transcript, Claude's reply, and Saved audio to reply.wav. Open reply.wav in any audio player, or run ffplay -f wav reply.wav if you have FFmpeg.
Not confirmed
This combined script has not been run. Each call comes from the pieces or official pages, but they have not been tested together.
It is not real-time. It waits for the full transcript, then the full reply, then the full audio. A live voice agent would need streaming speech-to-text, streaming replies and streaming text-to-speech, which this bundle does not cover.
Deepgram's pre-recorded page does not show the Python path to the transcript. results.channels[0].alternatives[0].transcript comes from the Deepgram Python SDK readme and the JSON shape on the docs page.
No official page fetched for this bundle shows a Python messages.create call with system set. The API reference lists system as a string field, and the Python SDK is assumed to accept it the same way.
Claude is told to reply in a few plain sentences, but that is a prompt, not a hard limit. Replies can still be longer or contain symbols.
load_dotenv() does not replace a key that is already set in the terminal, per python-dotenv's README. If a key is set in both places, the terminal value wins.
In a test of a different Wirestack bundle on Python 3.14, installing its packages took more than 10 minutes, because some dependencies built from source. This bundle's packages were not timed. An earlier Python 3 version may install faster. This comes from one test and is not confirmed.
The pinned package versions come from the pieces. They were not checked against each other for conflicts.