← Back to DevBytes

Building a Voice Assistant Backend with CrewAI: Complete Guide

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:

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.

— Ad —

Google AdSense will appear here after approval

← Back to all articles