Voice agent¶
core ยท v0.1.0
Talk to any AG-UI agent: speech is transcribed, each utterance starts a run, the reply is spoken sentence by sentence as it streams, and speaking over it interrupts, keeping only what was heard. Provider-agnostic on all three sides.
| Install | copit add @chanx-kit/voice-agent |
| Import from | voice_agent |
| Requires kits | ag-ui, audio-stream-in, audio-stream-out |
| Python packages | chanx>=2.10.2,<3, ag-ui-protocol>=0.1.19 |
| Keywords | voice, agent, ag-ui, speech-to-text, text-to-speech, barge-in |
| Source | kits/voice_agent |
Talk to any AG-UI agent over a chanx websocket. Speech is transcribed; each utterance starts a run once the speaker pauses; the reply is spoken sentence by sentence while it streams; speaking over the agent interrupts it, and the conversation keeps only what the user actually heard.
copit add @chanx-kit/voice-agent
copit add @chanx-kit/deepgram --only stt # hears with Deepgram
copit add @chanx-kit/elevenlabs --only tts # speaks with ElevenLabs
It composes three kits, all on one session id, all in hooks:
mic --audio--> VoiceTranscriberTopic transcribe:<session> (audio-stream-in + a provider)
| utterance_end: respond(text)
v
VoiceAgentTopic agui:thread:<session> (ag-ui + your agent)
| each sentence as it completes: speak()
v
SynthesizerTopic speak:<session> (audio-stream-out + a provider)
| audio_chunk ... playback_mark
v
speaker
Wire it up¶
from .ws_kits.deepgram.transcriber import DeepgramTranscriberTopic
from .ws_kits.elevenlabs.synthesizer import ElevenLabsSynthesizerTopic
from .ws_kits.voice_agent import VoiceAgentTopic, VoiceTranscriberTopic
class Voice(ElevenLabsSynthesizerTopic):
pass
class Agent(VoiceAgentTopic):
synthesizer = Voice
async def run_agent(self, run_input):
async for event in my_agent(run_input.messages):
yield event
class Ears(VoiceTranscriberTopic, DeepgramTranscriberTopic):
agent_topic = Agent
class VoiceConsumer(AsyncJsonWebsocketConsumer):
topics = [Ears, Agent, Voice]
The client subscribes to all three with the same session: the voice-agent UI kit does it in one hook. Swap Deepgram for the fake transcriber, or ElevenLabs for the fake synthesizer, and nothing else changes.
With a provider kit that keeps its own conversation, combine the classes and send only the new turn:
class Agent(VoiceAgentTopic, PydanticAIAgUiTopic):
synthesizer = Voice
agent = my_pydantic_agent
send_history = False
What happens¶
- Turns:
VoiceTranscriberTopiccollects an utterance's finals and, onutterance_end, callson_turn(text), which callsAgent.respond(session, text). Overrideon_turnto filter ("um"), route, or add context. - Runs start from the server with
AgUiTopic.start_run: every tab on the thread sees them, and a tab joining mid-reply is replayed the run. A typed message (a clientag_ui_run) is answered out loud the same way. - Speech starts early:
run_eventscuts the reply's text deltas into sentences (SentenceBuffer) and queues each one on the synthesizer as soon as it is complete, so the first words play while the agent is still writing. - Barge-in:
speech_startedwhile the agent is running or speaking callsAgent.interrupt(session): the synthesizer clears every listener, the run is cancelled (RUN_ERROR), the reply in memory is cut to whatplayback_marksays was heard, and aCUSTOMeventvoice_interruptedcarries that text to chat UIs. Setinterrupt_on_speech = Falseto turn it off. - Memory:
memory(InMemoryVoiceMemoryby default) keeps the conversation as AG-UI messages, since no client sends it. ImplementVoiceMemoryto persist it.
One process per session
Runs, speech queues and barge-in bookkeeping live in the process holding the session's socket, as the audio does. The channel layer carries only events.