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.
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:
- Node.js 18+
- 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:
- ipcMain.handle — classic request/response. The renderer asks, the main process answers, done.
- 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: trueandnodeIntegration: falseare set in theBrowserWindowconfig. The preload script is the only allowed bridge. Never exposeipcRendererdirectly.
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.
- 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. - 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
.jsxfiles 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. - 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