feat: Add job-apply-agent AgentKit#76
Conversation
📝 WalkthroughWalkthroughThis PR introduces a complete Job Apply Agent kit within the agentic kits directory. It includes environment configuration, Next.js UI components for resume and job URL input, server-side orchestration and Lamatic API integration, comprehensive documentation, and a multi-step flow configuration for resume parsing, job analysis, matching, and conditional cover-letter generation. Changes
Sequence Diagram(s)sequenceDiagram
participant User as User/Browser
participant Page as Next.js Page
participant Orch as orchestrate Action
participant Lamatic as Lamatic API
participant Flow as Job-Apply Flow<br/>(13 nodes)
User->>Page: Submit resume + job URLs
Page->>Orch: call orchestrate(input)
Orch->>Orch: Validate resume & URLs
Orch->>Lamatic: executeFlow(flowId, {resume, job_urls})
Lamatic->>Flow: Trigger flow execution
Flow->>Flow: Parse resume
Flow->>Flow: Fetch & clean job pages
Flow->>Flow: Loop: analyze each job (LLM)
Flow->>Flow: Score & match skills
alt Score >= 70
Flow->>Flow: Generate cover letter (LLM)
else Score < 70
Flow->>Flow: Skip cover letter
end
Flow->>Flow: Bundle results, sort by qualification
Flow-->>Lamatic: Return ApplyBudResponse
Lamatic-->>Orch: Return response with results
Orch-->>Page: Return OrchestrateResult
Page->>Page: Render JobResults with<br/>match scores & cover letters
Page-->>User: Display ranked opportunities
Estimated code review effort🎯 4 (Complex) | ⏱️ ~50 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment Tip You can disable the changed files summary in the walkthrough.Disable the |
There was a problem hiding this comment.
Actionable comments posted: 10
🧹 Nitpick comments (3)
kits/agentic/job-apply-agent/app/page.tsx (1)
93-350: Consider extracting styles to a CSS module for maintainability.The inline
styled-jsxblock spans ~250 lines. For a component of this size, a separate CSS module (page.module.css) would improve readability and make styles easier to maintain/reuse.kits/agentic/job-apply-agent/actions/orchestrate.ts (1)
33-40: Consider restricting URLs to HTTP/HTTPS protocols.The current URL validation accepts any syntactically valid URL, including potentially problematic protocols like
file://,javascript:, ordata:. While the URLs are likely sent to the Lamatic flow for fetching, restricting to HTTP(S) provides defense-in-depth.Suggested fix
const validUrls = input.job_urls.filter((url) => { try { - new URL(url); - return true; + const parsed = new URL(url); + return parsed.protocol === "http:" || parsed.protocol === "https:"; } catch { return false; } });kits/agentic/job-apply-agent/components/JobResults.tsx (1)
17-21: Add error handling for clipboard API.
navigator.clipboard.writeTextcan reject if clipboard permissions are denied or in non-secure contexts. Consider wrapping in try-catch to prevent unhandled promise rejection and provide user feedback on failure.Suggested fix
const copyToClipboard = async (text: string, index: number) => { - await navigator.clipboard.writeText(text); - setCopiedIndex(index); - setTimeout(() => setCopiedIndex(null), 2000); + try { + await navigator.clipboard.writeText(text); + setCopiedIndex(index); + setTimeout(() => setCopiedIndex(null), 2000); + } catch { + // Fallback or user notification could be added here + console.error("Failed to copy to clipboard"); + } };
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 031592d0-8f83-44f8-b26d-53b02b38e3bf
⛔ Files ignored due to path filters (1)
kits/agentic/job-apply-agent/package-lock.jsonis excluded by!**/package-lock.json
📒 Files selected for processing (18)
kits/agentic/job-apply-agent/.env.examplekits/agentic/job-apply-agent/.gitignorekits/agentic/job-apply-agent/README.mdkits/agentic/job-apply-agent/actions/orchestrate.tskits/agentic/job-apply-agent/app/layout.tsxkits/agentic/job-apply-agent/app/page.tsxkits/agentic/job-apply-agent/components/JobResults.tsxkits/agentic/job-apply-agent/components/JobUrlInput.tsxkits/agentic/job-apply-agent/components/ResumeInput.tsxkits/agentic/job-apply-agent/config.jsonkits/agentic/job-apply-agent/flows/job-apply-flow/README.mdkits/agentic/job-apply-agent/flows/job-apply-flow/config.jsonkits/agentic/job-apply-agent/flows/job-apply-flow/inputs.jsonkits/agentic/job-apply-agent/flows/job-apply-flow/meta.jsonkits/agentic/job-apply-agent/lib/lamatic-client.tskits/agentic/job-apply-agent/next-env.d.tskits/agentic/job-apply-agent/package.jsonkits/agentic/job-apply-agent/tsconfig.json
| export const metadata = { | ||
| title: 'Next.js', | ||
| description: 'Generated by Next.js', | ||
| } |
There was a problem hiding this comment.
Replace template metadata with product metadata.
At Line 2 and Line 3, the app still uses boilerplate values (Next.js, Generated by Next.js), which is inconsistent with this kit and affects browser title/share previews.
Suggested metadata update
export const metadata = {
- title: 'Next.js',
- description: 'Generated by Next.js',
+ title: 'ApplyBud — Job Apply Agent',
+ description: 'Analyze job links against a resume, rank matches, and auto-generate tailored cover letters for high-fit roles.',
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| export const metadata = { | |
| title: 'Next.js', | |
| description: 'Generated by Next.js', | |
| } | |
| export const metadata = { | |
| title: 'ApplyBud — Job Apply Agent', | |
| description: 'Analyze job links against a resume, rank matches, and auto-generate tailored cover letters for high-fit roles.', | |
| } |
| {urls.map((url, index) => ( | ||
| <div key={index} className="url-row"> | ||
| <input | ||
| type="url" | ||
| value={url} | ||
| onChange={(e) => updateUrl(index, e.target.value)} | ||
| disabled={disabled} | ||
| placeholder="https://jobs.example.com/role-title-12345" | ||
| /> | ||
| {urls.length > 1 && ( | ||
| <button | ||
| type="button" | ||
| onClick={() => removeUrl(index)} | ||
| disabled={disabled} | ||
| className="remove-btn" | ||
| aria-label="Remove URL" | ||
| > | ||
| ✕ | ||
| </button> | ||
| )} | ||
| </div> | ||
| ))} |
There was a problem hiding this comment.
Using array index as key can cause input state issues when removing items.
When a URL is removed from the middle of the list, React reconciles based on index, which can cause the wrong input values to appear in remaining fields (stale DOM state). Consider using a stable unique ID per URL entry.
Suggested fix using stable IDs
+"use client";
+
+import { useId } from "react";
+
interface JobUrlInputProps {
- urls: string[];
- onChange: (urls: string[]) => void;
+ urls: { id: string; value: string }[];
+ onChange: (urls: { id: string; value: string }[]) => void;
disabled?: boolean;
}Alternatively, if changing the data shape is not desirable, generate a unique ID when adding:
+let urlIdCounter = 0;
+const generateId = () => `url-${++urlIdCounter}`;
+
export function JobUrlInput({ urls, onChange, disabled }: JobUrlInputProps) {
- const addUrl = () => onChange([...urls, ""]);
+ const addUrl = () => onChange([...urls, { id: generateId(), value: "" }]);| "results": { | ||
| "type": "array", | ||
| "items": { | ||
| "type": "object", | ||
| "properties": { | ||
| "url": { "type": "string" }, | ||
| "job_title": { "type": "string" }, | ||
| "company": { "type": "string" }, | ||
| "match_score": { "type": "number" }, | ||
| "qualified": { "type": "boolean" }, | ||
| "matched_skills": { "type": "string" }, | ||
| "cover_letter": { "type": "string" } | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Output schema missing seniority field.
The JobResult interface in lib/lamatic-client.ts includes a seniority: string field (used in JobResults.tsx at line 57-59), but it's not declared in the output schema here. This creates a mismatch between the documented schema and the actual response shape.
Suggested fix
"properties": {
"url": { "type": "string" },
"job_title": { "type": "string" },
"company": { "type": "string" },
+ "seniority": { "type": "string" },
"match_score": { "type": "number" },
"qualified": { "type": "boolean" },
"matched_skills": { "type": "string" },
"cover_letter": { "type": "string" }
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| "results": { | |
| "type": "array", | |
| "items": { | |
| "type": "object", | |
| "properties": { | |
| "url": { "type": "string" }, | |
| "job_title": { "type": "string" }, | |
| "company": { "type": "string" }, | |
| "match_score": { "type": "number" }, | |
| "qualified": { "type": "boolean" }, | |
| "matched_skills": { "type": "string" }, | |
| "cover_letter": { "type": "string" } | |
| } | |
| } | |
| } | |
| "results": { | |
| "type": "array", | |
| "items": { | |
| "type": "object", | |
| "properties": { | |
| "url": { "type": "string" }, | |
| "job_title": { "type": "string" }, | |
| "company": { "type": "string" }, | |
| "seniority": { "type": "string" }, | |
| "match_score": { "type": "number" }, | |
| "qualified": { "type": "boolean" }, | |
| "matched_skills": { "type": "string" }, | |
| "cover_letter": { "type": "string" } | |
| } | |
| } | |
| } |
| "values": { | ||
| "id": "codeNode_487", | ||
| "code": "var urls = {{triggerNode_1.output.job_urls}};\nvar out = [];\nvar i = 0;\nwhile (i < urls.length) {\n var url = urls[i];\n var text = \"\";\n var status = \"ok\";\n try {\n var response = await fetch(url);\n var html = await response.text();\n var clean = html;\n var s1 = clean.indexOf('<script');\n while (s1 > -1) {\n var e1 = clean.indexOf('<' + '/script>', s1);\n if (e1 === -1) { break; }\n clean = clean.substring(0, s1) + ' ' + clean.substring(e1 + 9);\n s1 = clean.indexOf('<script');\n }\n var s2 = clean.indexOf('<style');\n while (s2 > -1) {\n var e2 = clean.indexOf('<' + '/style>', s2);\n if (e2 === -1) { break; }\n clean = clean.substring(0, s2) + ' ' + clean.substring(e2 + 8);\n s2 = clean.indexOf('<style');\n }\n var t1 = clean.indexOf('<');\n while (t1 > -1) {\n var t2 = clean.indexOf('>', t1);\n if (t2 === -1) { break; }\n clean = clean.substring(0, t1) + ' ' + clean.substring(t2 + 1);\n t1 = clean.indexOf('<');\n }\n var words = clean.split(' ');\n var filtered = [];\n var w = 0;\n while (w < words.length) {\n var word = words[w].trim();\n if (word.length > 0) { filtered.push(word); }\n w = w + 1;\n }\n text = filtered.join(' ');\n if (text.length > 6000) { text = text.substring(0, 6000); }\n } catch (e) {\n status = \"error\";\n text = \"\";\n }\n out.push({ url: url, jd_text: text, status: status });\n i = i + 1;\n}\nreturn { job_pages: out };", | ||
| "nodeName": "Fetch Job Pages" |
There was a problem hiding this comment.
Restrict and bound outbound job-page fetches.
Line 86 fetches arbitrary user-supplied URLs with no scheme/private-network validation, no timeout, and no response.ok check. In a public flow this is an SSRF vector, and slow or non-2xx targets are still treated as valid job descriptions.
| "values": { | ||
| "id": "codeNode_575", | ||
| "code": "var candidateSkills = {{InstructorLLMNode_426.output.skills}};\nvar jobRequiredRaw = {{InstructorLLMNode_219.output.required_skills}};\nvar jobPreferredRaw = {{InstructorLLMNode_219.output.preferred_skills}};\nvar jobTitle = {{InstructorLLMNode_219.output.job_title}};\nvar jobCompany = {{InstructorLLMNode_219.output.company}};\nvar jobSeniority = {{InstructorLLMNode_219.output.seniority}};\n\nvar jobRequired = jobRequiredRaw.split(',');\nvar jobPreferred = jobPreferredRaw.split(',');\nvar candidateList = candidateSkills.split(',');\n\nvar reqMatches = 0;\nvar prefMatches = 0;\nvar matchedSkills = [];\nvar i = 0;\n\nwhile (i < jobRequired.length) {\n var req = jobRequired[i].toLowerCase().trim();\n var j = 0;\n while (j < candidateList.length) {\n var cs = candidateList[j].toLowerCase().trim();\n if (cs === req || cs.indexOf(req) > -1 || req.indexOf(cs) > -1) {\n reqMatches = reqMatches + 1;\n matchedSkills.push(candidateList[j].trim());\n break;\n }\n j = j + 1;\n }\n i = i + 1;\n}\n\ni = 0;\nwhile (i < jobPreferred.length) {\n var pref = jobPreferred[i].toLowerCase().trim();\n var k = 0;\n while (k < candidateList.length) {\n var cs2 = candidateList[k].toLowerCase().trim();\n if (cs2 === pref || cs2.indexOf(pref) > -1 || pref.indexOf(cs2) > -1) {\n prefMatches = prefMatches + 1;\n matchedSkills.push(candidateList[k].trim());\n break;\n }\n k = k + 1;\n }\n i = i + 1;\n}\n\nvar reqScore = jobRequired.length > 0 ? (reqMatches / jobRequired.length) * 70 : 35;\nvar prefScore = jobPreferred.length > 0 ? (prefMatches / jobPreferred.length) * 30 : 15;\nvar score = Math.round(reqScore + prefScore);\n\nreturn {\n job_title: jobTitle,\n company: jobCompany,\n seniority: jobSeniority,\n score: score,\n qualified: score >= 70,\n matched_skills: matchedSkills.join(', ')\n};", | ||
| "nodeName": "Match Scorer" |
There was a problem hiding this comment.
Normalize LLM skill fields before scoring.
Line 185 assumes skills, required_skills, and preferred_skills are always populated comma-delimited strings. If any of them is missing, .split(",") throws; if any is "", the indexOf("") checks count it as a match and can award a perfect score to jobs with no extracted skills.
| "values": { | ||
| "id": "codeNode_621", | ||
| "code": "var loopData = {{forLoopNode_787.output}};\nvar loopResults = loopData.loopOutput;\nvar out = [];\nvar i = 0;\n\nwhile (i < loopResults.length) {\n var iter = loopResults[i];\n var scorer = iter.codeNode_575;\n var coverNode = iter.LLMNode_122;\n\n var scorerOutput = {};\n if (scorer && scorer.output && scorer.status === 'success') {\n scorerOutput = scorer.output;\n }\n\n var coverLetter = null;\n if (coverNode && coverNode.output && coverNode.output.generatedResponse) {\n coverLetter = coverNode.output.generatedResponse;\n }\n\n var jobUrl = '';\n if (loopData.currentValue && loopData.currentValue.url) {\n jobUrl = loopData.currentValue.url;\n }\n\n var item = {\n url: jobUrl,\n job_title: scorerOutput.job_title,\n company: scorerOutput.company,\n seniority: scorerOutput.seniority,\n match_score: scorerOutput.score,\n qualified: scorerOutput.qualified,\n matched_skills: scorerOutput.matched_skills,\n cover_letter: coverLetter\n };\n out.push(item);\n i = i + 1;\n}\n\nvar sorted = out.sort(function(a, b) {\n if (a.qualified !== b.qualified) { return a.qualified ? -1 : 1; }\n return b.match_score - a.match_score;\n});\n\nvar qualCount = 0;\nvar q = 0;\nwhile (q < sorted.length) {\n if (sorted[q].qualified === true) { qualCount = qualCount + 1; }\n q = q + 1;\n}\n\nreturn {\n results: sorted,\n total: sorted.length,\n qualified_count: qualCount,\n candidate: {{InstructorLLMNode_426.output.name}}\n};", | ||
| "nodeName": "Bundle Results" |
There was a problem hiding this comment.
Bundle each result with its own job URL.
Line 362 reads loopData.currentValue.url inside the aggregation loop. That is shared loop state, so every bundled item will inherit the last processed URL (or "") instead of its own posting URL. Carry the URL through the iteration output or index back into codeNode_487.output.job_pages[i].
| "description": "", | ||
| "tags": [], | ||
| "testInput": "", | ||
| "githubUrl": "", | ||
| "documentationUrl": "", | ||
| "deployUrl": "" |
There was a problem hiding this comment.
Populate flow metadata fields before publishing.
At Line 3 through Line 8, the metadata is effectively blank. This leaves the flow card/documentation entry incomplete and harder to consume.
Suggested metadata patch
{
"name": "ApplyBud",
- "description": "",
- "tags": [],
- "testInput": "",
- "githubUrl": "",
- "documentationUrl": "",
- "deployUrl": ""
+ "description": "AI-powered job application agent that scores resume-job fit and generates cover letters for qualified roles.",
+ "tags": ["jobs", "resume", "cover-letter", "agentic"],
+ "testInput": "{\"resume\":\"<paste_resume_text>\",\"job_urls\":[\"https://example.com/job-1\"]}",
+ "githubUrl": "https://github.com/Lamatic/AgentKit/pull/76",
+ "documentationUrl": "https://github.com/Lamatic/AgentKit/tree/main/kits/agentic/job-apply-agent",
+ "deployUrl": "https://job-apply-agent.vercel.app"
}| const client = new Lamatic({ | ||
| apiKey: process.env.LAMATIC_API_KEY!, | ||
| projectId: process.env.LAMATIC_PROJECT_ID!, | ||
| endpoint: process.env.LAMATIC_API_URL!, | ||
| }); |
There was a problem hiding this comment.
Make the Lamatic endpoint requirement explicit.
Lines 3-7 hard-require process.env.LAMATIC_API_URL, but kits/agentic/job-apply-agent/config.json:11-15 does not list it in requiredEnvVars. The kit can pass config validation and still instantiate Lamatic with an undefined endpoint. Either declare the env var in the kit manifest or validate it here before constructing the client.
| const response = await client.executeFlow(flowId, { | ||
| resume: input.resume, | ||
| job_urls: input.job_urls.join(","), | ||
| }); |
There was a problem hiding this comment.
Pass job_urls as an array, not a CSV string.
Line 39 flattens job_urls into one comma-delimited string, but kits/agentic/job-apply-agent/flows/job-apply-flow/config.json:19 declares the trigger input as [string], and kits/agentic/job-apply-agent/flows/job-apply-flow/config.json:86 iterates one URL per element. Multiple postings will be collapsed into one invalid fetch target, so the core multi-job path breaks.
Suggested patch
const response = await client.executeFlow(flowId, {
resume: input.resume,
- job_urls: input.job_urls.join(","),
+ job_urls: input.job_urls,
});📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const response = await client.executeFlow(flowId, { | |
| resume: input.resume, | |
| job_urls: input.job_urls.join(","), | |
| }); | |
| const response = await client.executeFlow(flowId, { | |
| resume: input.resume, | |
| job_urls: input.job_urls, | |
| }); |
|
|
||
| ## Model | ||
|
|
||
| All AI nodes use `groq/llama-3.1-8b-instant` via Groq. |
There was a problem hiding this comment.
Model name inconsistency with PR description.
The README states the model is groq/llama-3.1-8b-instant, but the PR description mentions groq/llama-3.3-70b-versatile. Please verify and align the documentation with the actual model configured in the flow.
What This Kit Does
ApplyBud is an AI-powered job application agent that evaluates job postings
against a candidate's resume, scores the match (0–100), and automatically
generates a tailored professional cover letter for any role scoring 70 or above.
Results are returned ranked by match score with qualified jobs first.
Providers & Prerequisites
groq/llama-3.3-70b-versatile(requires Groq API key via Lamatic credential namedApplyBud)How to Run Locally
cd kits/agentic/job-apply-agentnpm installcp .env.example .env.localand fill in valuesnpm run devLive Preview
https://job-apply-agent.vercel.app
Lamatic Flow
Flow ID:
56e18477-5451-4b67-9fc5-5014be67dc7cSummary
New AgentKit: ApplyBud - Job Application Agent
Core Functionality:
Kit Components:
Flow Configuration:
job-apply-flow)Configuration & Setup:
ApplyBudLAMATIC_API_KEY,LAMATIC_PROJECT_ID,LAMATIC_FLOW_ID,LAMATIC_API_URLnpm install→.env.localsetup →npm run devTech Stack:
Live Preview: https://job-apply-agent.vercel.app