← Back to list

How to Build Your Own AI Resume Reviewer

Turn a resume PDF and a job description into structured, real-time AI feedback — using Electron, React, Groq, and a streaming UI protocol.

Aditya Singh · 2026-04-27 00:25 · 0 claps · 6.9 min read
#groq #a2ui #generative-ai-tools #nodejs #electronjs
Open on Medium ↗
Wiki topics: AI · AI · General 🌐 · Web Development 🎬 · Film & Television

How to Build Your Own AI Resume Reviewer

Turn a resume PDF and a job description into structured, real-time AI feedback — using Electron, React, Groq, and a streaming UI protocol.

Whether you’re a recruiter drowning in applications or a job seeker who wants brutally honest feedback before hitting “Submit”, AI can do a surprisingly good job at matching resumes to job descriptions. And building one yourself is more approachable than you’d think.

In this article, we’ll build a desktop app that:

  • Accepts a PDF resume and a job description
  • Sends them to an LLM (via Groq)
  • Streams the response in real time
  • Renders the output as structured React UI components

The full source code is on GitHub: github.com/adi199/resume-reviewer

Part 1: Project Setup

Prerequisites:

  1. Node.js 18+
  2. A Free Groq API Key

Initialize the project

npm create vite@latest resume-reviewer -- --template react
cd resume-reviewer
npm install electron electron-builder vite-plugin-electron
npm install @langchain/groq pdf-parse dotenv

Add an electron/ folder at the root. Your project structure will look like this:

resume-reviewer/
├── electron/
│   ├── main.js       ← Electron main process (AI, PDF, IPC)
│   ├── preload.js    ← Secure bridge to renderer
│   └── prompt.js     ← System prompt & model config
├── src/
│   ├── App.jsx       ← React UI
│   └── a2ui/
│       └── catalog.jsx  ← Component registry
└── package.json

Part 2: Electron’s Main Process — The Backend

Think of Electron as two worlds running in parallel: the main process (Node.js, full system access) and the renderer (basically a browser tab running your React app). They don’t share memory — they talk via IPC messages, like passing notes under a door.

The main process is where we do the heavy lifting: PDF parsing, calling the LLM, and pushing results back to React.

electron/main.js

import { app, BrowserWindow, ipcMain } from 'electron';
import { ChatGroq } from "@langchain/groq";
import { SYSTEM_PROMPT, MODEL } from './prompt.js';
import { createRequire } from 'module';

const require = createRequire(import.meta.url);
const pdf = require('pdf-parse');

// --- PDF Extraction ---
ipcMain.handle('extract-pdf-text', async (event, dataBuffer) => {
  const data = await pdf(Buffer.from(dataBuffer));
  return { text: data.text };
});

// --- AI Resume Analysis (Streaming) ---
ipcMain.on('analyze-resume-start', async (event, { resume, jd }) => {
  const llm = new ChatGroq({
    model: MODEL,
    apiKey: process.env.GROQ_API_KEY,
    temperature: 0.4,
    streaming: true,
  });

  const userMessage = `RESUME:\n${resume}\n\nJOB DESCRIPTION:\n${jd}`;

  const stream = await llm.stream([
    ["system", SYSTEM_PROMPT],
    ["human", userMessage],
  ]);

  let buffer = "";
  for await (const chunk of stream) {
    buffer += chunk.content;

    // Parse complete JSON lines from the stream
    if (buffer.includes("\n")) {
      const lines = buffer.split("\n");
      buffer = lines.pop(); // keep the incomplete tail

      for (const line of lines) {
        try {
          const msg = JSON.parse(line.trim());
          event.sender.send('analysis-chunk', msg);  // push to React
        } catch (_) { /* skip malformed lines */ }
      }
    }
  }
  event.sender.send('analysis-end');
});

Two patterns worth remembering here:

  1. ipcMain.handle — classic request/response. The renderer asks, the main process answers, done.
  2. ipcMain.on — fire-and-forget trigger. The main process then pushes back multiple analysis-chunk events as the LLM streams, and finally fires analysis-end. It’s like subscribing to a live feed rather than waiting for a download to finish.

Part 3: The Preload Script — A Secure Bridge

The preload script is the only place where Node.js APIs are accessible from the renderer (the React side). It exposes a clean electronAPI object via contextBridge:

electron/preload.js

const { contextBridge, ipcRenderer } = require('electron');

contextBridge.exposeInMainWorld('electronAPI', {
  // Fire and forget — streaming response comes back via listeners
  analyzeResume: (resume, jd) =>
    ipcRenderer.send('analyze-resume-start', { resume, jd }),

  // Subscribe to streamed chunks
  onAnalysisChunk: (callback) => {
    const wrapper = (event, data) => callback(data);
    ipcRenderer.on('analysis-chunk', wrapper);
    return () => ipcRenderer.removeListener('analysis-chunk', wrapper);
  },

  onAnalysisEnd: (callback) => {
    ipcRenderer.on('analysis-end', () => callback());
  },

  // PDF extraction (returns a Promise)
  extractPdfText: (pdfBuffer) =>
    ipcRenderer.invoke('extract-pdf-text', pdfBuffer),
});

Security tip: contextIsolation: true and nodeIntegration: false are set in the BrowserWindow config. The preload script is the only allowed bridge. Never expose ipcRenderer directly.

Part 4: Crafting the AI Prompt

This is where things get interesting. Most AI apps ask the LLM for text and then parse it. We’re doing something different — we’re telling the LLM to act as a UI composer, outputting one structured JSON object per line, where each line maps directly to a React component. No markdown, no text blobs, just clean machine-readable envelopes.

This is the core idea behind the A2UI protocol.

electron/prompt.js

export const MODEL = "qwen/qwen3-32b";

export const SYSTEM_PROMPT = `
You are an elite technical recruiter AI. Analyze the resume against the job description and produce a structured UI report.

Output ONE JSON object per line. Each line must be a valid, standalone message envelope.
Do NOT output markdown, code blocks, or any text outside the JSON.

COMPONENT CATALOG:
- ScoreDisplay (props: { score: number, label: string })
- SkillsAnalysis (props: { skills: [{name: string, found: boolean}] })
- InsightCard (props: { title: string, description: string, type: "strength" | "weakness" | "suggestion" })
- Section (props: { title: string })

EMIT IN THIS ORDER:
1. {"createSurface": {"surfaceId": "resume-analysis"}}
2. {"updateComponents": {"surfaceId": "resume-analysis", "components": [{"id": "score", "type": "ScoreDisplay", "properties": {"score": 85, "label": "Strong Match"}}]}}
3. {"updateComponents": {"surfaceId": "resume-analysis", "components": [{"id": "skills", "type": "SkillsAnalysis", "properties": {"skills": [{"name": "React", "found": true}]}}]}}
4. Strengths, Weaknesses, Suggestions as InsightCards inside Sections.

BE SPECIFIC. Reference actual content from the resume and JD.
`;

Notice the COMPONENT CATALOG section in the prompt. That's intentional — and we'll get back to why it has to be hardcoded there in the Key Takeaways. For now, just know: the LLM becomes a UI builder. Each line it emits is a direct instruction to render a specific React component

Part 5: The A2UI Component Catalog

The catalog is a plain JavaScript object — component name maps to a React component. When the main process sends a chunk like { type: "ScoreDisplay", properties: { score: 82 } }, the renderer does a quick lookup in this object and renders whatever component lives at that key.

Simple, extensible, and completely decoupled from the AI side.

src/a2ui/catalog.jsx

// Score badge with a progress bar
const ScoreDisplay = ({ node }) => {
  const { score, label } = node.properties;
  return (
    <Card>
      <CardContent>
        <div className="text-4xl font-bold">{score}%</div>
        <div className="text-lg">{label}</div>
        <Progress value={score} />
      </CardContent>
    </Card>
  );
};

// Skill badges — green if found, red if missing
const SkillsAnalysis = ({ node }) => {
  const { skills } = node.properties;
  return (
    <div className="flex flex-wrap gap-2">
      {skills.map((skill, i) => (
        <Badge
          key={i}
          className={skill.found ? 'bg-emerald-500/10 text-emerald-400' : 'bg-red-500/10 text-red-400'}
        >
          {skill.name}
        </Badge>
      ))}
    </div>
  );
};

// Insight cards for strengths, weaknesses, suggestions
const InsightCard = ({ node }) => {
  const { title, description, type } = node.properties;
  const icons = {
    strength: <CheckCircle2 className="text-emerald-400" />,
    weakness: <XCircle className="text-red-400" />,
    suggestion: <AlertCircle className="text-indigo-400" />,
  };
  return (
    <Card>
      <CardContent className="flex gap-4">
        {icons[type]}
        <div>
          <div className="font-bold">{title}</div>
          <div className="text-muted-foreground">{description}</div>
        </div>
      </CardContent>
    </Card>
  );
};

export const catalog = {
  ScoreDisplay,
  SkillsAnalysis,
  InsightCard,
  Section: ({ node, children }) => (
    <div>
      <h2>{node.properties.title}</h2>
      {children}
    </div>
  ),
};

Part 6: The React Frontend — Wiring It All Together

The React app listens for analysis-chunk events and appends each message to state. The render loop maps each message to its catalog component.

src/App.jsx (simplified)

function App() {
  const [a2uiMessages, setA2uiMessages] = useState([]);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    // Subscribe to streamed chunks from Electron
    const cleanup = window.electronAPI.onAnalysisChunk((msg) => {
      setA2uiMessages(prev => [...prev, msg]);
    });

    window.electronAPI.onAnalysisEnd(() => setLoading(false));

    return cleanup;
  }, []);

  const handleAnalyze = (resume, jd) => {
    setLoading(true);
    setA2uiMessages([]);
    window.electronAPI.analyzeResume(resume, jd);
  };

  return (
    <main>
      {a2uiMessages.map((msg, i) => {
        if (!msg.updateComponents) return null;
        return msg.updateComponents.components.map((node) => {
          const Component = catalog[node.type];
          if (!Component) return null;
          return <Component key={`${i}-${node.id}`} node={node} />;
        });
      })}
    </main>
  );
}

As the LLM streams JSON line by line, components pop into existence on screen — like a dashboard assembling itself in real time.

Part 7: Handling the PDF Upload

One small gotcha worth calling out: the Electron renderer (the React side) runs in a sandboxed browser context. It can’t just read files off the disk by path. So instead, we use the browser’s FileReader API to load the PDF as an ArrayBuffer, then ship that buffer over IPC to the main process, where pdf-parse can handle it.

const handleFileUpload = async (e) => {
  const file = e.target.files[0];
  const reader = new FileReader();

  reader.onload = async () => {
    const result = await window.electronAPI.extractPdfText(reader.result);
    setResume(result.text); // plain text ready for the prompt
  };

  reader.readAsArrayBuffer(file);
};

Raw bytes travel cleanly over IPC. The main process reconstructs the buffer, extracts the text, and sends it back. Clean separation, no filesystem permission headaches.

Running the App

# Clone and install
git clone https://github.com/adi199/resume-reviewer
cd resume-reviewer
npm install

# Add your key
echo "GROQ_API_KEY=gsk_..." > .env

# Start in dev mode
npm run electron:dev

On first launch, open Settings and paste your Groq API key — it’s stored locally and never sent anywhere except Groq’s API.

Key Takeaways

Let’s zoom out and look at the three architectural decisions that made this work — and why they were made.

  1. LLM call belongs in the main process, not the renderer. The renderer is a sandboxed browser context — it can’t safely hold your API key or use Node.js packages like pdf-parse. The main process has full system access, so that's where the AI logic lives. Keep the renderer dumb and reactive.
  2. The component catalog is hardcoded into the system prompt — because it has to be. The LLM runs inside the Electron main process (Node.js). It has no way to import or inspect .jsx files from the React renderer — the two environments are completely isolated. So we describe the catalog in plain text directly in the system prompt, and the LLM uses that as its "menu" when deciding what to emit.
  3. Streaming JSON lines is what makes it feel instant. Instead of one big response at the end, we parse complete JSON objects line by line as they arrive. Each line renders a component immediately — so users see the score card, then skills, then insights rolling in progressively, not a 10-second blank screen followed by everything at once.

The full project is open source at github.com/adi199/resume-reviewer. Star it if you found this useful, and feel free to open a PR!


메타데이터
post_id
491f1d33d9de
slug
how-to-build-your-own-ai-resume-reviewer-491f1d33d9de
url
https://medium.com/@adi.singh199/how-to-build-your-own-ai-resume-reviewer-491f1d33d9de
canonical_url
https://medium.com/@adi.singh199/how-to-build-your-own-ai-resume-reviewer-491f1d33d9de
author_url
https://medium.com/@adi.singh199
status
ok
fetched_at
2026-06-14 11:28:49