← Blog

Building Voyage: An AI-Powered Interview Preparation Assistant

·7 min read·AIClaude AIMCPFastAPINext.jsInterview PrepProjects

Voyage is the interview preparation module inside Tailify — a platform that tailors resumes to job postings. The idea behind Voyage was simple: once a candidate has their resume ready, the next challenge is actually getting through the interview. Most interview prep tools give you static questions and generic answers. Voyage does something different.

This post is about what Voyage does, the technical decisions behind it, and what I learned building it.


What Voyage Does

Voyage is an AI-powered interview preparation assistant with three core features:

  1. Adaptive mock interviews — Claude conducts a realistic interview based on your resume and the specific job posting you're applying for
  2. Real-time web search — before generating questions, Voyage searches for recent information about the company, the role, and the industry to make the interview current and relevant
  3. MCP integration — users can access Voyage directly from Claude Desktop or Cursor without opening the web app

The goal is that when you finish a Voyage session, you're not just prepared for generic "tell me about yourself" questions — you're prepared for this specific job at this specific company right now.


Architecture Overview

User (Web or MCP client)
    ↓
Next.js App (frontend + API routes)
    ↓
FastAPI (Python backend)
    ├── Claude API (Anthropic)
    │   └── Tool: web_search → Tavily API
    │   └── Tool: get_user_profile → Postgres
    │   └── Tool: get_job_context → Postgres
    ├── FastMCP server (for Claude Desktop / Cursor)
    └── PostgreSQL + pgvector

Stack: Next.js 16 · FastAPI · PostgreSQL + pgvector · Claude AI · FastMCP · Tavily · Stripe · Railway


The Interview Agent

The core of Voyage is an agentic loop. Claude acts as the interviewer, using tools to gather context before the interview starts.

import anthropic
from tools import web_search, get_user_profile, get_job_context
 
client = anthropic.Anthropic()
 
VOYAGE_SYSTEM = """
You are an expert interviewer conducting a realistic job interview.
 
Before starting the interview:
1. Use get_user_profile to understand the candidate's background
2. Use get_job_context to understand the specific role requirements
3. Use web_search to find recent news about the company and industry
 
Then conduct a natural, conversational interview that:
- Starts with rapport-building questions
- Progresses to role-specific technical questions
- Includes at least one behavioral question using the STAR method
- Adapts based on the candidate's answers
 
After each answer, give brief private feedback (not shown to candidate) on:
- Relevance of answer to the question
- What was strong
- What could be improved
 
End with 2-3 personalized coaching tips.
"""
 
tools = [
    {
        "name": "web_search",
        "description": "Search the web for recent information about a company, industry, or topic",
        "input_schema": {
            "type": "object",
            "properties": {
                "query": {"type": "string", "description": "Search query"},
                "num_results": {"type": "integer", "default": 5}
            },
            "required": ["query"]
        }
    },
    {
        "name": "get_user_profile",
        "description": "Get the candidate's resume, skills, and experience",
        "input_schema": {
            "type": "object",
            "properties": {
                "user_id": {"type": "string"}
            },
            "required": ["user_id"]
        }
    },
    {
        "name": "get_job_context",
        "description": "Get the job posting details, company info, and requirements",
        "input_schema": {
            "type": "object",
            "properties": {
                "job_id": {"type": "string"}
            },
            "required": ["job_id"]
        }
    }
]
 
def start_interview(user_id: str, job_id: str):
    messages = [{
        "role": "user",
        "content": f"Start an interview for user {user_id} applying to job {job_id}. "
                   "First gather context, then begin the interview."
    }]
 
    return run_agent(messages, tools, VOYAGE_SYSTEM)

Real-Time Web Search with Tavily

import httpx
import os
 
TAVILY_KEY = os.getenv("TAVILY_API_KEY")
 
async def web_search(query: str, num_results: int = 5) -> dict:
    async with httpx.AsyncClient() as client:
        response = await client.post(
            "https://api.tavily.com/search",
            json={
                "api_key": TAVILY_KEY,
                "query": query,
                "num_results": num_results,
                "search_depth": "basic",
                "include_answer": True
            }
        )
        data = response.json()
        return {
            "answer": data.get("answer"),
            "results": [
                {
                    "title": r["title"],
                    "url": r["url"],
                    "content": r["content"][:500]
                }
                for r in data.get("results", [])
            ]
        }

I chose Tavily over the generic search APIs because it's designed for AI agents — it returns structured, relevant results and can include a direct answer to the query.


The MCP Server

The MCP integration was the most interesting part to build. With FastMCP, you expose your tools as an MCP server that Claude Desktop or Cursor can connect to.

from fastmcp import FastMCP
from voyage.agent import start_interview, continue_interview
from voyage.auth import verify_token
 
mcp = FastMCP("Voyage Interview Prep")
 
@mcp.tool()
async def begin_interview_session(
    user_token: str,
    job_url: str
) -> str:
    """
    Start a Voyage interview preparation session.
    Provide your Voyage API token and the URL of the job you're preparing for.
    Voyage will research the company and role, then conduct a personalized mock interview.
    """
    user = await verify_token(user_token)
    if not user:
        return "Invalid token. Get yours at tailify.io/settings"
 
    job = await scrape_and_save_job(job_url, user.id)
    session = await start_interview(user.id, job.id)
 
    return f"Interview started. Session ID: {session.id}\n\n{session.first_message}"
 
@mcp.tool()
async def respond_to_interviewer(
    session_id: str,
    answer: str
) -> str:
    """
    Send your answer to the current interview question and get the next question.
    """
    response = await continue_interview(session_id, answer)
    return response.next_message
 
@mcp.tool()
async def get_interview_feedback(session_id: str) -> str:
    """
    Get your detailed interview feedback after completing a session.
    Returns scores, strengths, areas for improvement, and coaching tips.
    """
    feedback = await generate_feedback(session_id)
    return format_feedback(feedback)
 
if __name__ == "__main__":
    mcp.run()

Users add this to their Claude Desktop config:

{
    "mcpServers": {
        "voyage": {
            "command": "uvx",
            "args": ["voyage-mcp"],
            "env": {}
        }
    }
}

And then they can do interview prep entirely from within Claude Desktop or Cursor without opening a browser.


Streaming the Interview

On the web frontend, I stream Claude's responses for a more natural conversation feel:

// app/api/voyage/stream/route.ts
import { NextRequest } from "next/server";
 
export async function POST(req: NextRequest) {
    const { sessionId, answer } = await req.json();
 
    const encoder = new TextEncoder();
 
    const stream = new ReadableStream({
        async start(controller) {
            try {
                const agentStream = await continueInterviewStream(
                    sessionId,
                    answer,
                );
 
                for await (const chunk of agentStream) {
                    if (chunk.type === "text") {
                        controller.enqueue(
                            encoder.encode(
                                `data: ${JSON.stringify({ text: chunk.text })}\n\n`,
                            ),
                        );
                    }
                }
 
                controller.enqueue(encoder.encode("data: [DONE]\n\n"));
                controller.close();
            } catch (err) {
                controller.error(err);
            }
        },
    });
 
    return new Response(stream, {
        headers: {
            "Content-Type": "text/event-stream",
            "Cache-Control": "no-cache",
        },
    });
}
// React client
async function sendAnswer(answer: string) {
    const response = await fetch("/api/voyage/stream", {
        method: "POST",
        body: JSON.stringify({ sessionId, answer }),
    });
 
    const reader = response.body!.getReader();
    const decoder = new TextDecoder();
 
    while (true) {
        const { done, value } = await reader.read();
        if (done) break;
 
        const lines = decoder.decode(value).split("\n\n");
        for (const line of lines) {
            if (line.startsWith("data: ") && line !== "data: [DONE]") {
                const data = JSON.parse(line.slice(6));
                setCurrentMessage((prev) => prev + data.text);
            }
        }
    }
}

Session Storage with pgvector

Interview sessions are stored in PostgreSQL. Past answers are embedded and stored with pgvector so Voyage can reference earlier parts of the conversation when generating follow-up questions.

CREATE TABLE interview_sessions (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID REFERENCES users(id),
    job_id UUID REFERENCES jobs(id),
    created_at TIMESTAMPTZ DEFAULT now(),
    completed_at TIMESTAMPTZ,
    status TEXT DEFAULT 'active'
);
 
CREATE TABLE interview_turns (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    session_id UUID REFERENCES interview_sessions(id),
    role TEXT CHECK (role IN ('interviewer', 'candidate')),
    content TEXT,
    content_vector vector(1536),
    created_at TIMESTAMPTZ DEFAULT now()
);
 
CREATE INDEX ON interview_turns
    USING ivfflat (content_vector vector_cosine_ops)
    WITH (lists = 100);
async def get_relevant_context(session_id: str, current_question: str) -> list[str]:
    query_vector = await embed(current_question)
 
    rows = await db.fetch("""
        SELECT content, role
        FROM interview_turns
        WHERE session_id = $1
        ORDER BY content_vector <=> $2
        LIMIT 3
    """, session_id, query_vector)
 
    return [f"{r['role']}: {r['content']}" for r in rows]

What I Learned

Real-time web search changes everything. Static question banks go stale fast. When Voyage searches for "Stripe engineering culture 2026" before the interview, it finds the current team structure, recent product launches, and engineering blog posts — context that makes the mock interview feel real.

MCP adoption is faster than I expected. A meaningful portion of Tailify users connected the MCP server within the first week. Developers in particular prefer staying in their editor over switching to a web app.

Streaming isn't optional for interview feel. When Claude's response streams in word by word, the interview feels like a conversation. When it appears all at once after a delay, it feels like a search engine. Streaming is non-negotiable for this use case.

Tool results need to be concise. Early versions of Voyage returned full web search results to Claude. The context window filled up fast and responses got slower. Truncating results to 500 characters per source and summarizing the search answer first cut context usage by 60%.


Voyage is live as part of Tailify. If you're preparing for a technical interview and want something that actually knows what the company is working on right now, give it a try.