A job posting can pull in fifty applications before a hiring manager opens their inbox. Reading each one against the same basic requirements (does this person have the certification, the availability, the right to work in the service area) takes real time, and it’s the kind of screening that doesn’t need a person’s judgment until the borderline cases show up. This build takes a submitted application, checks it against approved criteria, logs the ones that pass, and sends every applicant a response, all before a person looks at it.
Before building this, get sign-off on more than the screening criteria. Automated hiring decisions carry real legal weight in a growing number of jurisdictions: some require a human in the loop before an adverse decision, and others require a bias audit on the screening tool or disclosure that an AI system reviewed the application. Confirm with whoever handles HR or legal compliance for the company what applies here before this goes live, not after.
The architecture at a glance
This is a single Application, no separate conversation. A public webhook receives the submitted application. A Claude Opus 4.8 node reads it against pre-screening criteria and returns a pass or fail judgment with reasoning. A Condition node reads that judgment and branches. The pass path logs the candidate to a Google Sheet, sends an acknowledgement email, and returns a pass decision. The fail path sends a different acknowledgement email and returns a fail decision. Both paths end in a Return node, so whatever system submitted the application gets a definite answer back either way.
Step 1: Add the webhook trigger
Open Applications and create a new Application triggered by a Webhook node. Name it Application Intake, so its reference key resolves to application_intake, and set its visibility to Public, since this needs to accept submissions from an external source: a careers page form, a job board integration, or an ATS.
Define the expected payload structure so downstream nodes have something reliable to read, nested under body: candidate.name, candidate.email, candidate.phone, and job.title, plus resume text or a structured application summary and any role-specific fields the company wants collected (certifications, service area, prior experience). Later steps reference these fields directly as application_intake.body.candidate.name and so on, so the nesting here isn’t cosmetic; get it wrong and every downstream reference breaks. A public webhook with no defined shape means a malformed submission can break the LLM node several steps downstream instead of failing cleanly here.
Step 2: Add the pre-screening LLM node
Add an LLM node connected to Anthropic: Claude Opus 4.8, positioned right after the webhook trigger. Name it Screen Candidate, so its reference key resolves to screen_candidate. Later steps pull fields off screen_candidate.structuredOutput directly, so the node’s name and reference key matter beyond readability alone.
In its system prompt, define the role narrowly: “You are a recruiting application pre-screener. Evaluate the applicant against the criteria below and return a structured decision. Do not consider age, gender, race, national origin, disability status, or any other protected characteristic in your evaluation, even if mentioned in the application.”
Load the approved pre-screening criteria as plain facts, not instructions: required certifications, minimum experience, availability windows, service area, and work-eligibility requirements the hiring manager has signed off on. Don’t let the node infer additional criteria a person hasn’t approved. If the resume mentions something outside the defined criteria, the model should note it in its reasoning rather than factor it into the decision on its own judgment.
Add a SCORING section to the instructions, spelled out completely enough that the model isn’t left to invent its own weighting: “Use a 0-100 score: mandatory requirements account for 80 points divided equally across all must-haves; nice-to-haves account for 20 points divided equally across supplied nice-to-haves. If there are no nice-to-haves, base the score on mandatory requirements only and scale to 100. A candidate with any missing must-have must be REJECT regardless of score. Apply policy.pass_threshold only after all must-haves are satisfied. Explain the calculation concisely.” The last two rules matter most: a high score can’t rescue a missing must-have, and the pass threshold only ever gets checked once every must-have is already satisfied. Without that ordering, a candidate could score above threshold on nice-to-haves alone while failing something the hiring manager requires.
Configure the node’s Parameters Schema for structured output rather than leaving the model to return free text:
- pass (boolean, required): whether the candidate clears every must-have and the score threshold
- score (number, required): the 0-100 score computed under the SCORING rules above
- reason (string, required): a concise explanation of the calculation, tied to the specific must-haves and nice-to-haves the application did or didn’t meet
- decision (string, required, enum: PASS, REJECT): the human-readable verdict that flows through to logging and the eventual webhook response
- matched_requirements (array of strings, required): the specific must-haves and nice-to-haves the application satisfied
- missing_requirements (array of strings, required): the specific requirements it didn’t satisfy, empty if none
- candidate_record (object, required): the structured candidate details (name, email, applied role, experience summary, availability) that Prepare Candidate Sheet Row in Step 4 will fold into a sheet row
Both pass and decision should always agree; keeping them as separate fields means the Condition node in Step 3 can branch on a clean boolean while decision still carries a readable value into the sheet and the return payload. An inconsistent schema here breaks the branch and the logging step downstream, so save it and test it with a single obvious pass case before moving on.
Step 3: Add the Candidate Passes condition
Add a Condition node named Candidate Passes… that reads the pass boolean from the LLM node’s structured output. Route true to the pass path and false to the reject path. Keep the condition reading that field directly rather than re-evaluating the application itself. Duplicating judgment logic across two nodes is how a condition and its LLM node drift out of sync over time.
Step 4: Build the pass path
On the true branch, add a Resolve Value node named Prepare Candidate Sheet Row. Its reference key resolves to prepare_candidate_sheet_row. Give it this expression:
{{ return { matched_requirements_text: (screen_candidate.structuredOutput.matched_requirements || []).join(“; “), missing_requirements_text: (screen_candidate.structuredOutput.missing_requirements || []).join(“; “), candidate_profile_text: JSON.stringify(screen_candidate.structuredOutput.candidate_record || {}) }; }}
This flattens the LLM’s structured output into three plain strings a spreadsheet column can hold. matched_requirements and missing_requirements come back as arrays, and a raw array pasted into a sheet cell reads as garbled JSON rather than something a hiring manager can scan, so the .join(“; “) calls turn each into a single readable line. candidate_record is a nested object, so JSON.stringify flattens it into one text field rather than trying to spread its keys across separate columns. The || [] and || {} fallbacks matter here too: if the LLM ever omits one of these fields, the expression still returns a valid (if empty) value instead of throwing and breaking the row before it reaches the sheet.
Connect that to a Google Sheets node named Log Passed Candidate to Candidates Sheet, set to Add Row, targeting the sheet and tab the hiring manager reviews. The node’s Values field takes a list of lists, one inner list per row, so build a single row with one cell per column:
- {{screen_candidate.structuredOutput.candidate_record.summary}} (or whichever candidate_record field the hiring manager wants leading the row)
- {{application_intake.body.candidate.name}}
- {{application_intake.body.candidate.email}}
- {{application_intake.body.candidate.phone}}
- {{application_intake.body.job.title}}
- {{screen_candidate.structuredOutput.score}}
- {{screen_candidate.structuredOutput.decision}}
- {{screen_candidate.structuredOutput.reason}}
- {{prepare_candidate_sheet_row.value.matched_requirements_text}}
- {{prepare_candidate_sheet_row.value.missing_requirements_text}}
Notice columns 9 and 10 pull from prepare_candidate_sheet_row, the Resolve Value node from the previous step, while columns 1, 6, 7, and 8 pull straight from screen_candidate. Only the array fields (matched_requirements, missing_requirements) needed flattening before they could sit in a cell; the scalar fields (score, decision, reason) can go straight from the LLM node into a column with no intermediate step. Keep this sheet separate from any sheet used for rejected candidates. Mixing the two makes the passed-candidate list harder to scan at a glance, which defeats the point of filtering in the first place.
Add a Gmail node named Email Application Acknowledgement — Pass. Send to the candidate’s email with a factual message: something like “Thanks for applying. Your application has moved to the next step, and someone from our hiring team will follow up soon.” Don’t name a specific interview date or promise an offer. The screening node cleared the applicant past the first filter; it didn’t make a hiring decision.
Finish with a Return node named Return Pass Decision, returning { decision: “PASS”, score, reason } (plus candidate_record or whatever other fields the calling system needs) as the webhook response.
Step 5: Build the reject path
On the false branch, add a Gmail node named Email Application Acknowledgement — Reject. Keep this message polite and generic: “Thank you for your interest. We’ve decided to move forward with other candidates for this role.” Don’t include the LLM’s reasoning field or any specifics about why the application didn’t clear the screen. Beyond the tone problem, exposing the model’s internal reasoning to a rejected applicant creates a paper trail that’s harder to defend if the decision is ever challenged.
Finish with a Return node named Reject Candidate, returning { decision: “REJECT”, score }, leaving reason out of the response the caller (and, downstream, the candidate-facing surface) can see.
Step 6: Test both paths and the edges between them
Submit an application that meets every criterion outright and confirm it logs to the Candidates sheet, receives the pass email, and returns a pass decision. Submit one that fails outright on a defined criterion (wrong service area, missing certification) and confirm it gets the reject email with no sheet entry.
Then test the cases that sit at the edges: an application missing a required field, a resume that satisfies most criteria but is ambiguous on one, a submission with malformed or missing JSON in the payload, and two submissions arriving back to back to confirm each one runs and returns independently. For any case where the LLM’s reasoning field reads as uncertain rather than a clean pass or fail, route that case to a human reviewer instead of trusting the model’s binary output. This is a decision worth a manual review path even though the diagram itself doesn’t show one; a borderline application deserves a person’s judgment, not a forced yes or no.
What this saves the business
Fifty applications at ten minutes each is over eight hours of a hiring manager’s week, gone before anyone books an interview. This Application does that first pass in the time it takes the webhook to fire and the LLM node to return a decision. The manager’s queue narrows to candidates who already cleared every must-have.
Candidates hear back within minutes instead of waiting weeks for a form rejection, or hearing nothing at all. And the criteria stay fixed across every applicant, unlike a person reading fifty resumes across a week, who brings different attention to the first one and the fiftieth.
None of this replaces the hiring manager’s judgment. It changes what reaches them: a shortlist instead of a stack, with the reasoning attached so the decision that matters, who gets hired, stays theirs.