Building a Voice Assistant Backend with CrewAI: Complete Guide
Voice assistants have evolved from simple command-response systems into sophisticated AI agents capable of reasoning, planning, and executing multi-step tasks. CrewAI, an open-source framework for orchestrating role-playing autonomous AI agents, provides a powerful foundation for building these intelligent backends. In this guide, you'll learn how to build a production-ready voice assistant backend that combines CrewAI's multi-agent orchestration with speech processing capabilities.
What Is CrewAI?
CrewAI is a Python framework that allows developers to create teams of AI agents, each with specific roles, goals, and backstories, that collaborate to accomplish complex tasks. Unlike single-LLM approaches, CrewAI enables you to design workflows where agents delegate work to one another, share context, and produce structured outputs. When applied to voice assistants, this means your assistant can break down user requests into sub-tasks, route them to specialized agents, and synthesize a coherent response.
A CrewAI crew consists of three core components: Agents (the workers), Tasks (the work to be done), and the Crew itself (the orchestrator that manages execution). Each agent can be equipped with tools — functions it can call to interact with external systems, APIs, or databases.
Why Use CrewAI for Voice Assistant Backends?
Traditional voice assistant backends typically follow a rigid intent-classification pipeline: capture speech, transcribe, classify intent, fetch a response, and synthesize speech. This approach breaks down when users ask open-ended questions or make compound requests. CrewAI addresses these limitations in several ways:
- Multi-agent reasoning: Complex user queries can be decomposed and handled by specialized agents, each focused on a domain like scheduling, web search, or knowledge retrieval.
- Tool integration: Agents can call external tools — calendars, databases, APIs — giving your assistant real-world capabilities beyond text generation.
- Structured outputs: CrewAI supports Pydantic-based output schemas, which is essential when the assistant's response needs to be processed downstream before speech synthesis.
- Process control: You can choose between sequential and hierarchical processes, giving you fine-grained control over how agents collaborate.
- Observability: Built-in verbose logging and integration with tools like LangSmith make it easier to debug agent behavior in production.
Architecture Overview
Before diving into code, let's outline the architecture of our voice assistant backend. The system consists of four layers: a speech input layer that transcribes audio to text, a CrewAI orchestration layer that processes the request through multiple agents, a response formatting layer that structures the output, and a speech output layer that converts text back to audio.
The request flow works as follows: a client sends an audio file via HTTP to a FastAPI endpoint. The backend transcribes the audio using a speech-to-text service, passes the transcript to a CrewAI crew, the crew's agents process the request and produce a text response, and finally the backend synthesizes the response into audio and returns it to the client.
Setting Up the Environment
Start by creating a new Python project and installing the required dependencies. You'll need CrewAI, an LLM provider SDK, speech processing libraries, and a web framework for the API layer.
mkdir voice-assistant-crewai
cd voice-assistant-crewai
python -m venv venv
source venv/bin/activate
pip install crewai crewai-tools fastapi uvicorn python-dotenv openai pydub pydantic
Create a .env file to store your API keys. CrewAI uses LiteLLM under the hood, so you can plug in OpenAI, Anthropic, or local models. For this tutorial, we'll use OpenAI for both the LLM and speech services.
# .env
OPENAI_API_KEY=sk-your-openai-api-key-here
MODEL_NAME=gpt-4o-mini
Create the project structure with separate modules for agents, tasks, tools, speech processing, and the API server:
voice-assistant-crewai/
├── .env
├── main.py
├── agents.py
├── tasks.py
├── tools.py
├── speech.py
├── crew.py
└── requirements.txt
Defining Custom Tools
Tools are what give your agents the ability to interact with the outside world. Let's define a few practical tools that a voice assistant would need: a current time/date tool, a web search tool, and a simple note-taking tool. CrewAI provides a BaseTool class you can extend, or you can use the @tool decorator for simpler cases.
# tools.py
from crewai.tools import BaseTool
from datetime import datetime
from typing import Type
from pydantic import BaseModel, Field
import requests
class CurrentDateTimeTool(BaseTool):
name: str = "current_datetime"
description: str = "Returns the current date and time. Use when the user asks about the current time or date."
def _run(self, argument: str = "") -> str:
now = datetime.now()
return f"Current date and time: {now.strftime('%Y-%m-%d %H:%M:%S')} on a {now.strftime('%A')}."
class WebSearchInput(BaseModel):
query: str = Field(..., description="The search query to look up on the web.")
class WebSearchTool(BaseTool):
name: str = "web_search"
description: str = "Search the web for current information. Use when the user asks about recent events or facts you don't know."
args_schema: Type[BaseModel] = WebSearchInput
def _run(self, query: str) -> str:
# Using a free search API endpoint as an example.
# In production, use SerpAPI, Tavily, or similar.
try:
response = requests.get(
"https://api.duckduckgo.com/",
params={"q": query, "format": "json", "no_html": 1},
timeout=10
)
data = response.json()
abstract = data.get("AbstractText", "")
if abstract:
return abstract
related = data.get("RelatedTopics", [])
if related:
return related[0].get("Text", "No results found.")
return "No results found."
except Exception as e:
return f"Search failed: {str(e)}"
class NoteTakingInput(BaseModel):
note: str = Field(..., description="The note content to save.")
class NoteTakingTool(BaseTool):
name: str = "save_note"
description: str = "Save a note to the user's notebook. Use when the user asks you to remember or note something."
args_schema: Type[BaseModel] = NoteTakingInput
def _run(self, note: str) -> str:
with open("notes.txt", "a") as f:
f.write(f"- {note}\n")
return f"Note saved successfully: {note}"
# Instantiate tools for use in agents
current_datetime_tool = CurrentDateTimeTool()
web_search_tool = WebSearchTool()
note_taking_tool = NoteTakingTool()
Defining Agents
Now let's define the agents that will form our voice assistant crew. We'll create three agents: an intent router that analyzes the user's request, a research agent that gathers information using tools, and a response composer that crafts a natural, conversational response suitable for speech output.
# agents.py
from crewai import Agent, LLM
from tools import current_datetime_tool, web_search_tool, note_taking_tool
from dotenv import load_dotenv
import os
load_dotenv()
llm = LLM(
model=os.getenv("MODEL_NAME", "gpt-4o-mini"),
temperature=0.3,
)
intent_router_agent = Agent(
role="Intent Router",
goal="Analyze the user's voice request and determine what kind of help they need. Identify whether the request involves asking for information, saving a note, checking the time, or general conversation.",
backstory="""You are the first point of contact in a voice assistant system.
You analyze transcribed speech and determine the user's intent.
You think about what tools or information might be needed to fulfill the request.
You are concise and analytical.""",
llm=llm,
verbose=True,
allow_delegation=False,
)
research_agent = Agent(
role="Research Specialist",
goal="Gather accurate information to answer the user's question using available tools. Search the web, check the time, or save notes as needed.",
backstory="""You are a thorough research specialist within a voice assistant.
You use tools to find accurate, up-to-date information.
You always verify your sources and provide factual answers.
You are comfortable using web search, checking dates, and saving notes.""",
llm=llm,
verbose=True,
tools=[current_datetime_tool, web_search_tool, note_taking_tool],
allow_delegation=False,
)
response_composer_agent = Agent(
role="Response Composer",
goal="Transform the gathered information into a natural, conversational response suitable for text-to-speech. Keep responses concise, clear, and spoken-language friendly.",
backstory="""You are a response composer for a voice assistant.
You take raw information from research and turn it into natural spoken language.
You avoid long paragraphs, bullet points, or formatting that doesn't work in speech.
You keep responses under 3 sentences unless the user specifically asks for detail.
You sound warm, helpful, and conversational.""",
llm=llm,
verbose=True,
allow_delegation=False,
)
Notice how each agent has a distinct role, goal, and backstory. The backstory is not just flavor text — it shapes the agent's behavior by providing context that influences how the LLM approaches its task. The research agent is the only one with tools attached, since it's the one that needs to interact with external systems.
Defining Tasks
Tasks define what each agent should accomplish and how their outputs flow between agents. We'll create three tasks that chain together sequentially: intent analysis, research, and response composition.
# tasks.py
from crewai import Task
from agents import intent_router_agent, research_agent, response_composer_agent
intent_analysis_task = Task(
description="""Analyze the following user request transcribed from speech:
"{user_input}"
Determine:
1. The primary intent (information seeking, note taking, time checking, or general conversation)
2. What tools or information sources might be needed
3. Any specific entities mentioned (dates, names, topics)
Provide a clear summary of the intent and what needs to be done.""",
expected_output="A concise summary of the user's intent, key entities, and recommended approach.",
agent=intent_router_agent,
)
research_task = Task(
description="""Based on the intent analysis, gather the information needed to answer the user's request.
Use the available tools (web search, current datetime, note taking) as needed.
If the user asked to save a note, use the save_note tool.
If the user asked about the time or date, use the current_datetime tool.
If the user asked a factual question, use the web_search tool.
Provide all the raw information you found.""",
expected_output="Raw factual information and results from tool usage relevant to the user's request.",
agent=research_agent,
context=[intent_analysis_task],
)
response_composition_task = Task(
description="""Compose a natural, conversational response based on the research findings.
Guidelines:
- Write as if speaking to someone, not writing an email
- Keep it concise (usually 1-3 sentences)
- Avoid markdown, bullet points, or special characters
- Use a warm, helpful tone
- If you saved a note, confirm it was saved
- If you found information, present it clearly and conversationally
The response will be converted to speech, so it must sound natural when spoken aloud.""",
expected_output="A natural, conversational text response ready for text-to-speech conversion.",
agent=response_composer_agent,
context=[research_task],
)
The context parameter is critical here. By passing [intent_analysis_task] to the research task, the research agent receives the output of the intent analysis as context. Similarly, the response composer receives the research findings. This creates a clean information pipeline through the crew.
Building the Crew
Now we assemble the agents and tasks into a crew. The crew defines the execution process — sequential in our case, since we want a clear pipeline from intent analysis to response composition.
# crew.py
from crewai import Crew, Process
from tasks import intent_analysis_task, research_task, response_composition_task
from agents import llm
def create_voice_assistant_crew(user_input: str):
"""Create and return a configured crew for processing a voice request."""
# Update task descriptions with the actual user input
intent_analysis_task.description = intent_analysis_task.description.format(
user_input=user_input
)
crew = Crew(
agents=[intent_analysis_task.agent, research_task.agent, response_composition_task.agent],
tasks=[intent_analysis_task, research_task, response_composition_task],
process=Process.sequential,
llm=llm,
verbose=True,
)
return crew
def process_voice_request(user_input: str) -> str:
"""Process a transcribed voice request and return a text response."""
crew = create_voice_assistant_crew(user_input)
result = crew.kickoff()
return str(result)
Integrating Speech Processing
The speech layer bridges the gap between audio and text. We'll use OpenAI's Whisper model for speech-to-text and the OpenAI TTS API for text-to-speech. The pydub library handles audio format conversion.
# speech.py
import os
import tempfile
from openai import OpenAI
from dotenv import load_dotenv
load_dotenv()
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
def transcribe_audio(audio_file_path: str) -> str:
"""Transcribe an audio file to text using OpenAI Whisper."""
try:
with open(audio_file_path, "rb") as audio_file:
transcript = client.audio.transcriptions.create(
model="whisper-1",
file=audio_file,
language="en",
)
return transcript.text
except Exception as e:
raise RuntimeError(f"Transcription failed: {str(e)}")
def synthesize_speech(text: str, output_path: str = None) -> str:
"""Convert text to speech using OpenAI TTS and return the audio file path."""
if output_path is None:
# Create a temporary file for the audio output
fd, output_path = tempfile.mkstemp(suffix=".mp3")
os.close(fd)
try:
response = client.audio.speech.create(
model="tts-1",
voice="nova",
input=text,
response_format="mp3",
)
response.stream_to_file(output_path)
return output_path
except Exception as e:
raise RuntimeError(f"Speech synthesis failed: {str(e)}")
The transcribe_audio function takes a file path to an audio file and returns the transcribed text. The synthesize_speech function takes text and produces an MP3 file. We use the "nova" voice for a warm, natural tone, but you can experiment with "alloy", "echo", "fable", "onyx", or "shimmer" as well.
Building the API Layer with FastAPI
Now we tie everything together with a FastAPI application that exposes endpoints for the voice assistant. We'll create two endpoints: one that accepts audio and returns audio (full voice-in, voice-out), and one that accepts text and returns text (useful for testing and chat interfaces).
# main.py
import os
import tempfile
import shutil
from fastapi import FastAPI, UploadFile, File, HTTPException
from fastapi.responses import FileResponse, JSONResponse
from pydantic import BaseModel
from dotenv import load_dotenv
from speech import transcribe_audio, synthesize_speech
from crew import process_voice_request
load_dotenv()
app = FastAPI(
title="Voice Assistant API",
description="A CrewAI-powered voice assistant backend",
version="1.0.0",
)
class TextRequest(BaseModel):
text: str
class TextResponse(BaseModel):
transcript: str
response: str
@app.get("/health")
async def health_check():
"""Health check endpoint."""
return {"status": "healthy", "service": "voice-assistant"}
@app.post("/chat", response_model=TextResponse)
async def chat_endpoint(request: TextRequest):
"""Process a text request and return a text response.
Useful for testing the CrewAI pipeline without audio.
"""
try:
user_input = request.text.strip()
if not user_input:
raise HTTPException(status_code=400, detail="Text input cannot be empty")
response_text = process_voice_request(user_input)
return TextResponse(
transcript=user_input,
response=response_text,
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Processing failed: {str(e)}")
@app.post("/voice")
async def voice_endpoint(audio: UploadFile = File(...)):
"""Process an audio request and return an audio response.
Accepts an audio file (wav, mp3, etc.), transcribes it,
processes it through the CrewAI crew, and returns synthesized speech.
"""
temp_audio_path = None
output_audio_path = None
try:
# Save uploaded audio to a temporary file
suffix = os.path.splitext(audio.filename)[1] or ".wav"
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_file:
shutil.copyfileobj(audio.file, temp_file)
temp_audio_path = temp_file.name
# Step 1: Transcribe audio to text
transcript = transcribe_audio(temp_audio_path)
if not transcript.strip():
raise HTTPException(status_code=400, detail="Could not transcribe audio - speech not detected")
# Step 2: Process through CrewAI crew
response_text = process_voice_request(transcript)
# Step 3: Synthesize speech from response
output_audio_path = synthesize_speech(response_text)
# Return the audio file
return FileResponse(
path=output_audio_path,
media_type="audio/mpeg",
filename="response.mp3",
headers={
"X-Transcript": transcript[:200],
"X-Response-Text": response_text[:200],
}
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Voice processing failed: {str(e)}")
finally:
# Clean up temporary files
if temp_audio_path and os.path.exists(temp_audio_path):
os.unlink(temp_audio_path)
# Note: output audio is cleaned up by FastAPI after sending
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)
Running and Testing the Assistant
Start the server with the following command:
python main.py
The server will start on port 8000. You can test the text endpoint first to verify the CrewAI pipeline works correctly:
curl -X POST http://localhost:8000/chat \
-H "Content-Type: application/json" \
-d '{"text": "What time is it right now?"}'
To test the full voice pipeline, send an audio file:
curl -X POST http://localhost:8000/voice \
-F "audio=@recording.wav" \
--output response.mp3
You can also test with Python using the requests library:
import requests
# Test text endpoint
response = requests.post(
"http://localhost:8000/chat",
json={"text": "What's the latest news about artificial intelligence?"}
)
print(response.json())
# Test voice endpoint
with open("recording.wav", "rb") as f:
response = requests.post(
"http://localhost:8000/voice",
files={"audio": f}
)
with open("response.mp3", "wb") as out:
out.write(response.content)
Adding Memory for Conversational Context
A real voice assistant needs to remember previous interactions within a conversation. CrewAI supports short-term memory out of the box, but for cross-request memory, you'll need to maintain conversation history externally and inject it into the task descriptions. Here's an enhanced version of the crew module that supports conversation history:
# crew.py (enhanced with memory)
from crewai import Crew, Process
from tasks import intent_analysis_task, research_task, response_composition_task
from agents import llm
# Simple in-memory conversation store (use Redis or a database in production)
conversation_history = {}
def get_conversation_context(session_id: str) -> str:
"""Retrieve recent conversation history for a session."""
history = conversation_history.get(session_id, [])
if not history:
return "This is the start of the conversation."
context_lines = []
for entry in history[-5:]: # Keep last 5 exchanges
context_lines.append(f"User: {entry['user']}")
context_lines.append(f"Assistant: {entry['assistant']}")
return "\n".join(context_lines)
def save_to_history(session_id: str, user_input: str, response: str):
"""Save an exchange to conversation history."""
if session_id not in conversation_history:
conversation_history[session_id] = []
conversation_history[session_id].append({
"user": user_input,
"assistant": response,
})
def process_voice_request(user_input: str, session_id: str = "default") -> str:
"""Process a transcribed voice request with conversation context."""
context = get_conversation_context(session_id)
# Format task descriptions with user input and conversation context
intent_analysis_task.description = f"""Analyze the following user request transcribed from speech:
"{user_input}"
Previous conversation context:
{context}
Determine:
1. The primary intent (information seeking, note taking, time checking, or general conversation)
2. What tools or information sources might be needed
3. Any specific entities mentioned (dates, names, topics)
4. Whether this is a follow-up question that references previous context
Provide a clear summary of the intent and what needs to be done."""
crew = Crew(
agents=[intent_analysis_task.agent, research_task.agent, response_composer_task.agent],
tasks=[intent_analysis_task, research_task, response_composition_task],
process=Process.sequential,
llm=llm,
verbose=True,
)
result = crew.kickoff()
response_text = str(result)
save_to_history(session_id, user_input, response_text)
return response_text
To use session-based memory in the API, update the endpoints to accept a session_id parameter, either as a query parameter or in the request body. This allows the assistant to maintain context across multiple voice interactions from the same user session.
Best Practices
As you move from a prototype to a production system, consider the following best practices to ensure reliability, performance, and maintainability.
Optimize for Latency
Voice assistants need to feel responsive. The three-agent sequential pipeline adds latency because each agent waits for the previous one. Consider using a hierarchical process where a manager agent decides whether the full pipeline is needed — simple greetings or acknowledgments can be handled by a single fast agent without invoking the research agent. You can also use a faster, smaller model for the intent router since its task is simpler, reserving the more capable model for research and response composition.
Handle Errors Gracefully
Every external call — transcription, LLM inference, tool execution, speech synthesis — can fail. Wrap each step in try-except blocks and provide meaningful fallback responses. If the research agent's web search fails, the response composer should still be able to produce a helpful message acknowledging the limitation. Implement retry logic with exponential backoff for API calls, and set reasonable timeouts to prevent the crew from hanging indefinitely.
Use Structured Outputs
CrewAI supports Pydantic output schemas on tasks. Instead of relying on free-text output from the response composer, define a schema that includes the response text, a confidence score, and metadata about which tools were used. This makes it easier to log, monitor, and debug the system in production.
from pydantic import BaseModel, Field
class VoiceResponse(BaseModel):
response_text: str = Field(..., description="The natural language response for speech synthesis")
intent: str = Field(..., description="The classified intent of the user request")
tools_used: list[str] = Field(default_factory=list, description="List of tools that were used")
confidence: float = Field(..., description="Confidence score from 0 to 1")
# Apply to the response composition task
response_composition_task = Task(
description="...",
expected_output="A structured response with text, intent, tools used, and confidence.",
agent=response_composer_agent,
context=[research_task],
output_pydantic=VoiceResponse,
)
Secure Your API Keys and Tools
Never hardcode API keys. Use environment variables or a secrets manager. If your tools access sensitive data, implement proper authentication and authorization. Validate all user input before passing it to agents, and sanitize tool outputs to prevent prompt injection attacks. Consider running the crew with rate limits to prevent abuse.
Monitor and Log Agent Behavior
Enable CrewAI's verbose logging during development and integrate with observability platforms like LangSmith or Phoenix for production. Log the full agent chain — which agent ran, what tools were called, how long each step took, and what the intermediate outputs were. This data is invaluable for debugging unexpected behavior and optimizing performance.
Choose the Right Model for Each Agent
Not every agent needs the most powerful model. The intent router can use a smaller, faster model like GPT-4o-mini, while the research agent might benefit from a more capable model that handles tool use better. Experiment with different model assignments and measure the trade-off between response quality and latency.
Test with Real Audio
Text-based testing is convenient but doesn't capture the full complexity of voice interactions. Real speech includes background noise, accents, hesitations, and incomplete sentences. Test your transcription pipeline with diverse audio samples, and test your speech synthesis with responses of varying lengths to ensure they sound natural when spoken aloud.
Conclusion
Building a voice assistant backend with CrewAI gives you a flexible, multi-agent architecture that can handle complex user requests far beyond what a single-prompt approach can achieve. By combining CrewAI's agent orchestration with speech-to-text and text-to-speech services, you create a system where specialized agents collaborate to understand intent, gather information, and compose natural responses. The modular design means you can easily add new agents for additional capabilities — a calendar agent, an email agent, a smart-home controller — without restructuring the entire system. Start with the foundation outlined in this guide, then iterate by adding domain-specific tools, refining agent prompts, and optimizing for latency as you move toward production. The combination of CrewAI's structured agent workflows and modern speech APIs opens up a wide range of possibilities for building voice assistants that genuinely understand and act on what users ask for.