šŸŽ™ļø Build an AI Interview Voice Agent with LiveKit & Maxim

šŸŽ™ļø Build an AI Interview Voice Agent with LiveKit & Maxim

Listen to Your Future Interviewer in Action

AI Interviewer: "Good morning! I'm excited to discuss this Senior React Developer position with you. I've reviewed the job description, and I see you'll be working on high-performance web applications with TypeScript and modern React patterns. Let's start with something I'm curious about, can you walk me through your experience with React Server Components?"

You: "Well, I've been using them in my current project for about six months now. They're really powerful for reducing client-side JavaScript..."

AI Interviewer: "Interesting! I just checked the latest React documentation, and Server Components are indeed becoming crucial for performance optimization. Can you give me a specific example of how you've used them to solve a real performance bottleneck?"

You: "Actually, I implemented them for our product catalog page where we were having issues with..."


STOP. Did you catch what just happened there?

That AI interviewer didn't just ask a generic question, it:

  • Referenced the specific job description
  • Performed a real-time web search mid-conversation
  • Asked intelligent follow-up questions
  • Sounded completely natural

And you're about to build this exact system.

This tutorial will guide you through building a real-time, voice-based Interview Agent using LiveKit for audio and Maxim for observability. The agent conducts mock interviews tailored to your provided Job Description (JD), supports web search, and logs all activity for transparency and debugging.


Why This Project?

  • Practice technical or behavioral interviews with an AI agent that adapts to your JD
  • Gain full observability into every agent action and event
  • Use modern, production-grade tools for real-time audio and AI

Prerequisites

  • Python 3.8+
  • LiveKit server credentials (URL, API key, secret)
  • Maxim account (API key, log repo ID)
  • Tavily API key (for web search tool)
  • Google Cloud credentials (for Gemini LLM and voice)

Resources

  1. Github Repository with all the project files & working code - Github Repo
  2. Maxim + LiveKit Integration Docs - Maxim <> LiveKit Integration
  3. Get started with Maxim - Get Started

Project Setup

Configure your environment variables in .env:

LIVEKIT_URL=https://your-livekit-server-url
LIVEKIT_API_KEY=your_livekit_api_key
LIVEKIT_API_SECRET=your_livekit_api_secret
MAXIM_API_KEY=your_maxim_api_key
MAXIM_LOG_REPO_ID=your_maxim_log_repo_id
TAVILY_API_KEY=your_tavily_api_key
# For Google Gemini (if needed):
# GOOGLE_API_KEY=your_google_api_key
# or
# GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account.json

Install dependencies:

pip install -r requirements.txt

Add dependencies to requirements.txt:

ipykernel>=6.29.5
livekit>=0.1.0
livekit-agents[google,openai]~=1.0
livekit-api>=1.0.2
maxim-py==3.9.0
python-dotenv>=1.1.0
tavily-python>=0.7.5

Set up a virtual environment:

python3 -m venv venv
source venv/bin/activate

Create a project directory and navigate into it:

mkdir interview_voice_agent
cd interview_voice_agent


Code Walkthrough: Key Components

Below, each section of the code is presented with a technical explanation.

1. Imports and Initialization

import logging
import os
import uuid
import dotenv
from livekit import agents
from livekit import api as livekit_api
from livekit.agents import Agent, AgentSession, function_tool
from livekit.api.room_service import CreateRoomRequest
from livekit.plugins import google
from maxim import Maxim
from maxim.logger.livekit import instrument_livekit
from tavily import TavilyClient

dotenv.load_dotenv(override=True)
logging.basicConfig(level=logging.DEBUG)

logger = Maxim().logger()
TAVILY_API_KEY = os.getenv("TAVILY_API_KEY")

Explanation:

  • Imports all required libraries for real-time audio, AI agent orchestration, logging, and web search.
  • Loads environment variables and configures logging for debugging and traceability.
  • Initializes the Maxim logger and retrieves the Tavily API key for web search functionality.

2. Maxim Event Instrumentation

def on_event(event: str, data: dict):
    if event == "maxim.trace.started":
        trace_id = data["trace_id"]
        trace = data["trace"]
        logging.debug(f"Trace started - ID: {trace_id}", extra={"trace": trace})
    elif event == "maxim.trace.ended":
        trace_id = data["trace_id"]
        trace = data["trace"]
        logging.debug(f"Trace ended - ID: {trace_id}", extra={"trace": trace})

instrument_livekit(logger, on_event)

Explanation:

  • Defines a callback to log when Maxim traces start and end, providing visibility into the agent's lifecycle.
  • instrument_livekit integrates Maxim with LiveKit, ensuring all relevant events are captured for observability.

3. InterviewAgent Class

class InterviewAgent(Agent):
    def __init__(self, jd: str) -> None:
        super().__init__(instructions=f"You are a professional interviewer conducting a Mock Interview with the job description: {jd}\\nAsk relevant interview questions, listen to answers, and follow up as a real interviewer would.")

    @function_tool()
    async def web_search(self, query: str) -> str:
        if not TAVILY_API_KEY:
            return "Tavily API key is not set. Please set the TAVILY_API_KEY environment variable."
        tavily_client = TavilyClient(api_key=TAVILY_API_KEY)
        try:
            response = tavily_client.search(query=query, search_depth="basic")
            if response.get('answer'):
                return response['answer']
            return str(response.get('results', 'No results found.'))
        except Exception as e:
            return f"An error occurred during web search: {e}"

Explanation:

  • Defines the main agent class, which is initialized with a Job Description (JD) and uses it to guide the interview.
  • The web_search method is exposed as a tool, allowing the agent to perform real-time web searches using Tavily.
  • Handles missing API keys and exceptions gracefully, returning informative error messages.

Focus on the System Instructions provided, you can modify it as per your requirements (incase you are building an agent for some other usecase) -

You are a professional interviewer. 
The job description is: {jd}\\nAsk relevant interview questions, 
listen to answers, and follow up as a real interviewer would.

4. Entrypoint: Starting the Interview Session

async def entrypoint(ctx: agents.JobContext):
    print("\\nšŸŽ¤ Welcome to your AI Interviewer! Paste your Job Description below.\\n")
    jd = input("Paste the Job Description (JD) and press Enter:\\n")
    room_name = os.getenv("LIVEKIT_ROOM_NAME") or f"interview-room-{uuid.uuid4().hex}"
    lkapi = livekit_api.LiveKitAPI(
        url=os.getenv("LIVEKIT_URL"),
        api_key=os.getenv("LIVEKIT_API_KEY"),
        api_secret=os.getenv("LIVEKIT_API_SECRET"),
    )
    try:
        req = CreateRoomRequest(
            name=room_name,
            empty_timeout=600,# keep the room alive 10m after empty
            max_participants=2,# interviewer + candidate
        )
        room = await lkapi.room.create_room(req)
        print(f"\\nRoom created! Join this link in your browser to start the interview: {os.getenv('LIVEKIT_URL')}/join/{room.name}\\n")
        session = AgentSession(
            llm=google.beta.realtime.RealtimeModel(model="gemini-2.0-flash-exp", voice="Puck"),
        )
        await session.start(room=room, agent=InterviewAgent(jd))
        await ctx.connect()
        await session.generate_reply(
            instructions="Greet the candidate and start the interview."
        )
    finally:
        await lkapi.aclose()

Explanation:

  • Prompts the user for a Job Description and creates a new LiveKit room for the interview session.
  • Initializes the agent session with the provided JD and configures the LLM and TTS voice.
  • Prints a join link for the user to access the interview room in their browser.
  • Ensures proper cleanup of resources after the session ends.

5. Main Block

if __name__ == "__main__":
    opts = agents.WorkerOptions(entrypoint_fnc=entrypoint)
    agents.cli.run_app(opts)

Explanation:

  • Entry point for the script. Configures the worker options and launches the agent using the provided entry point function.

How to Use

  1. Paste your JD: The agent will use it to generate relevant interview questions.
  2. Room is created: A join link is printed, open it in your browser to join the interview room or you can also trigger the voice agent via the console as we are doing in the below video.
  3. Interact with the AI interviewer:
    • Your voice is transcribed (STT)
    • Gemini LLM processes and generates responses
  4. Monitor in Maxim:
    • All prompts, responses, and events are logged for review and debugging

The agent replies using TTS

0:00
/0:59

Run the script:

python interview_agent.py

# or if you are using uv for dependency management

uv sync
uv run interview_agent.py console

Observability with Maxim

  • Every action, prompt, and web search is logged in your Maxim dashboard.

Use Maxim to debug, audit, and improve your agent's performance.

0:00
/0:40

Troubleshooting

  • No audio or agent is silent
    • Check your Google Cloud credentials
    • Confirm browser and microphone permissions
  • Web search not working
    • Ensure your TAVILY_API_KEY is set in .env
  • No Maxim traces
    • Verify your Maxim API key and log repo ID

Complete Code: interview_agent.py

import logging
import os
import uuid
import dotenv
from livekit import agents
from livekit import api as livekit_api
from livekit.agents import Agent, AgentSession, function_tool
from livekit.api.room_service import CreateRoomRequest
from livekit.plugins import google
from maxim import Maxim
from maxim.logger.livekit import instrument_livekit
from tavily import TavilyClient

dotenv.load_dotenv(override=True)
logging.basicConfig(level=logging.DEBUG)

logger = Maxim().logger()
TAVILY_API_KEY = os.getenv("TAVILY_API_KEY")

def on_event(event: str, data: dict):
    if event == "maxim.trace.started":
        trace_id = data["trace_id"]
        trace = data["trace"]
        logging.debug(f"Trace started - ID: {trace_id}", extra={"trace": trace})
    elif event == "maxim.trace.ended":
        trace_id = data["trace_id"]
        trace = data["trace"]
        logging.debug(f"Trace ended - ID: {trace_id}", extra={"trace": trace})

instrument_livekit(logger, on_event)

class InterviewAgent(Agent):
    def __init__(self, jd: str) -> None:
        super().__init__(instructions=f"You are a professional interviewer. The job description is: {jd}\\nAsk relevant interview questions, listen to answers, and follow up as a real interviewer would.")

    @function_tool()
    async def web_search(self, query: str) -> str:
        if not TAVILY_API_KEY:
            return "Tavily API key is not set. Please set the TAVILY_API_KEY environment variable."
        tavily_client = TavilyClient(api_key=TAVILY_API_KEY)
        try:
            response = tavily_client.search(query=query, search_depth="basic")
            if response.get('answer'):
                return response['answer']
            return str(response.get('results', 'No results found.'))
        except Exception as e:
            return f"An error occurred during web search: {e}"

async def entrypoint(ctx: agents.JobContext):
    print("\\nšŸŽ¤ Welcome to your AI Interviewer! Paste your Job Description below.\\n")
    jd = input("Paste the Job Description (JD) and press Enter:\\n")
    room_name = os.getenv("LIVEKIT_ROOM_NAME") or f"interview-room-{uuid.uuid4().hex}"
    lkapi = livekit_api.LiveKitAPI(
        url=os.getenv("LIVEKIT_URL"),
        api_key=os.getenv("LIVEKIT_API_KEY"),
        api_secret=os.getenv("LIVEKIT_API_SECRET"),
    )
    try:
        req = CreateRoomRequest(
            name=room_name,
            empty_timeout=600,# keep the room alive 10m after empty
            max_participants=2,# interviewer + candidate
        )
        room = await lkapi.room.create_room(req)
        print(f"\\nRoom created! Join this link in your browser to start the interview: {os.getenv('LIVEKIT_URL')}/join/{room.name}\\n")
        session = AgentSession(
            llm=google.beta.realtime.RealtimeModel(model="gemini-2.0-flash-exp", voice="Puck"),
        )
        await session.start(room=room, agent=InterviewAgent(jd))
        await ctx.connect()
        await session.generate_reply(
            instructions="Greet the candidate and start the interview."
        )
    finally:
        await lkapi.aclose()

if __name__ == "__main__":
    opts = agents.WorkerOptions(entrypoint_fnc=entrypoint)
    agents.cli.run_app(opts)

The Future is Now: What's Next?

You've just built something that most companies pay thousands of dollars for. But why stop here? Consider these next-level enhancements:

  • Multi-agent panel interviews where different AI personalities evaluate different aspects
  • Real-time performance scoring with detailed feedback
  • Integration with resume parsing for personalized question generation
  • Code challenge capabilities for technical interviews
  • Emotion detection to gauge candidate stress levels, OpenAI & Gemini models have vision capabilities
  • Multi-language support for global hiring

Your AI Interview Revolution Starts Here

Your interview agent doesn't just ask questions; it thinks, researches, adapts, and learns. It's the kind of technology that transforms not just how we prepare for interviews, but how we think about human-AI collaboration entirely.

So fire up that terminal, paste in your code, and watch as your AI interviewer comes to life. Because in the rapidly evolving landscape of technology, the most powerful tool you can have is the one you built yourself.

Ready to change the game? Your AI interviewer is waiting.