Agent Playbook, build tutorial
A walkthrough of how to build a screening agent that helps you read a pile of applications fairly: it extracts a job-relevant profile with protected attributes redacted, scores each candidate against an explicit rubric with a written reason per criterion, and ranks them, while a person makes every decision. The agent recommends; it never auto-rejects or auto-hires. Every code step is shown in three frameworks, Google ADK, the Claude SDK, and LangGraph, so you can build it on the stack your team already knows. The code here is the agent's core logic; standing it up in production also means adding the setup, integrations, and hardening around it.
A single open role can draw hundreds of applications, so screening by hand is slow and uneven: the bar drifts with fatigue, different screeners weigh things differently, and the name, photo, and age sitting at the top of a resume can sway a call before the experience is even read. The result is time lost and decisions that are hard to explain later.
A screening agent does the first pass under a fixed rubric. It redacts protected attributes, scores every candidate against the same job-relevant criteria with a reason for each, and hands a ranked, explained shortlist to a person who decides. It reduces bias and makes screening consistent and auditable, but it does not remove bias, and it never makes the call itself. For the upstream and adjacent work, this pairs with the document-processing and lead-qualification playbooks.
You do not build the agent loop from scratch. A framework gives you the moving parts, and you supply the logic: a step is a model plus an instruction plus a typed schema or a few tools. The code tabs on the build steps show the same extract, score, and guardrail flow three ways.
Google ADK composes the steps with a SequentialAgent and a Runner, the Claude SDK drives a tool-use loop, and LangGraph builds an explicit graph. The redaction, the weighted ranking, the bias guardrails, and the human-in-the-loop routing are deterministic and identical in all three; only the orchestration and the model calls differ.
Every application flows through one loop: ingested and parsed, profiled with protected attributes redacted, scored against the rubric, then ranked. Bias guardrails run, and the ranked, explained shortlist goes to a human who decides. Nothing is auto-rejected. Guardrails for redaction, audit, and the human-decides rule wrap the whole thing.
A role with a written rubric and weights, the applications to screen, and API access to your ATS (applicant tracking system) or a careers inbox.
A model API key (Gemini, Claude, or both), your ATS, and a review queue where recruiters see the ranked, explained shortlist and decide.
Which attributes to redact, the rubric and weights, fairness and disparate-impact monitoring, DPDP Act 2023 handling, and who makes the final call.
Install the packages and set your Gemini key. That is enough to run the runnable file below, which redacts, profiles, and scores a sample resume against a rubric locally. For production you also connect, with API credentials, your ATS or careers inbox and the review queue where a person sees the shortlist and decides.
# Python 3.10 or newer
python -m venv venv && source venv/bin/activate
# The runnable path below uses Google ADK + Gemini:
pip install google-adk google-genai pydantic
# Your Gemini key from aistudio.google.com/apikey:
export GEMINI_API_KEY="your-key-here" | Layer | Pick | Why |
|---|---|---|
| Application intake | Your ATS (applicant tracking system) API, or an email and form inbox, resume parsed to text | Read each new application from the tracking system your team already uses, or from a careers inbox, and turn the resume PDF or document into clean text the rest of the pipeline can read. The same store works across sources. |
| Profile extractor | A model (Gemini 2.5 Flash, Claude) with a typed schema | Pull only the job-relevant fields into a typed profile, skills, years of experience, qualifications, and at the same moment redact protected attributes (name, gender, age, photo) so they never reach scoring. The code tabs show this three ways. |
| Rubric scorer | A model plus deterministic code, one score per criterion with a written reason | Score each role criterion against an explicit written rubric and record a reason for every score, so the result is explainable per criterion, not a single black-box number. |
| Ranker | Deterministic: a weighted sum of the rubric scores | Combine the per-criterion scores into one ranking using the weights you set for the role. Plain arithmetic, testable and auditable, not a model guess. |
| Bias guardrails | Deterministic: redaction plus proxy and disparate-impact checks | Strip protected attributes before scoring, then audit for proxy signals and for disparate impact across groups. These reduce bias; they do not remove it, and human oversight stays essential. |
| Human review and audit | A review queue, plus an immutable audit log | Every shortlist goes to a person who decides; the agent only recommends. The log holds each candidate's criteria, scores, reasons, and what the reviewer did, so any decision can be explained later. |
The extract, score, and assemble steps show the same logic in Google ADK, the Claude SDK, and LangGraph. Use the tabs to switch. The intake, ranking, guardrail, and human-routing steps are deterministic Python and identical everywhere. These are the building blocks, and they call your own systems (the ATS, the review queue) by name; the complete file you can run today is in "Put it together" below.
Start with the connection, not the cleverness. The agent reads each new application through your ATS (applicant tracking system) API or from a careers inbox, and turns the resume PDF or document into clean text. It keys each one on the application id, which makes the pipeline idempotent: an application is screened once, even if the job re-runs or a webhook fires twice. Deterministic, and the same in every framework.
import fitz # PyMuPDF, for resume PDFs
def pull_new(ats, seen: set) -> list[dict]:
out = []
for app in ats.list_applications(status="new"):
if app["id"] in seen: # already screened
continue
seen.add(app["id"])
text = parse_resume(app["resume_url"]) # PDF / docx -> plain text
out.append({"id": app["id"], "role": app["role_id"], "text": text})
return out
def parse_resume(url: str) -> str:
doc = fitz.open(stream=download(url), filetype="pdf")
return "\n".join(page.get_text() for page in doc)
# Application id makes it idempotent: each candidate is screened once, even on a re-run. Before anything is scored, the agent pulls only the job-relevant fields into a typed profile, skills, years of experience, qualifications, and at the same moment redacts protected attributes so they never reach scoring: name, gender, age, photo, religion, and marital status. Because the schema is typed, the output is structured data, and because protected fields are dropped here rather than later, the scorer never sees them. This reduces bias; it does not remove it. The tabs show the same redacting extraction three ways.
from google import genai
from google.genai import types
from pydantic import BaseModel
client = genai.Client()
class Profile(BaseModel):
skills: list[str]; years_experience: float
qualifications: list[str]; relevant_titles: list[str]
# Note: NO name, gender, age, photo, religion, or marital status.
def extract_profile(resume_text: str) -> Profile:
res = client.models.generate_content(
model="gemini-2.5-flash",
contents=["Extract only the job-relevant fields below. Do NOT include "
"name, gender, age, photo, religion, or marital status.\n" + resume_text],
config=types.GenerateContentConfig(
response_mime_type="application/json", response_schema=Profile))
return res.parsed # a typed Profile, protected fields excluded from anthropic import Anthropic
client = Anthropic()
# Schema deliberately omits name, gender, age, photo, religion, marital status.
SCHEMA = {"type": "object", "properties": {
"skills": {"type": "array", "items": {"type": "string"}},
"years_experience": {"type": "number"},
"qualifications": {"type": "array", "items": {"type": "string"}},
"relevant_titles": {"type": "array", "items": {"type": "string"}}},
"required": ["skills", "years_experience", "qualifications"]}
def extract_profile(resume_text: str) -> dict:
res = client.messages.create(model="claude-opus-4-8", max_tokens=1024,
tools=[{"name": "save_profile", "input_schema": SCHEMA}],
tool_choice={"type": "tool", "name": "save_profile"},
messages=[{"role": "user", "content":
"Extract only the job-relevant fields. Do not include name, gender, "
"age, photo, religion, or marital status.\n" + resume_text}])
return res.content[0].input from langchain_google_genai import ChatGoogleGenerativeAI
from pydantic import BaseModel
class Profile(BaseModel):
skills: list[str]; years_experience: float
qualifications: list[str]; relevant_titles: list[str]
# No name, gender, age, photo, religion, or marital status.
extractor = ChatGoogleGenerativeAI(
model="gemini-2.5-flash").with_structured_output(Profile)
def extract_profile(resume_text: str) -> Profile:
return extractor.invoke(
"Extract only the job-relevant fields. Do not include name, gender, "
"age, photo, religion, or marital status.\n" + resume_text) This is where the agent earns its keep, and where it stays honest. For each criterion in the role's written rubric, the model returns a score and a written reason for that score, judging the redacted profile against the role only. The output is per-criterion and explainable, not one opaque overall number. The reasons are what a reviewer reads and what the audit log keeps. The tabs show the same scoring three ways.
from google import genai
from google.genai import types
from pydantic import BaseModel
client = genai.Client()
class CriterionScore(BaseModel):
criterion: str; score: int; reason: str # 0-5, with a written reason
class Scored(BaseModel):
scores: list[CriterionScore]
def score(profile: dict, rubric: list[dict]) -> Scored:
res = client.models.generate_content(
model="gemini-2.5-flash",
contents=[f"Score this profile against each rubric criterion 0-5 and give "
f"a one-line reason per criterion. Judge job-relevant evidence "
f"only.\nRubric: {rubric}\nProfile: {profile}"],
config=types.GenerateContentConfig(
response_mime_type="application/json", response_schema=Scored))
return res.parsed # per-criterion scores, each with a reason from anthropic import Anthropic
client = Anthropic()
SCHEMA = {"type": "object", "properties": {
"scores": {"type": "array", "items": {"type": "object", "properties": {
"criterion": {"type": "string"}, "score": {"type": "integer"},
"reason": {"type": "string"}},
"required": ["criterion", "score", "reason"]}}},
"required": ["scores"]}
def score(profile: dict, rubric: list) -> dict:
res = client.messages.create(model="claude-opus-4-8", max_tokens=1024,
tools=[{"name": "save_scores", "input_schema": SCHEMA}],
tool_choice={"type": "tool", "name": "save_scores"},
messages=[{"role": "user", "content":
f"Score this profile against each rubric criterion 0-5 with a reason. "
f"Judge job-relevant evidence only.\nRubric: {rubric}\nProfile: {profile}"}])
return res.content[0].input # never auto-decides; only scores with reasons from langchain_google_genai import ChatGoogleGenerativeAI
from pydantic import BaseModel
class CriterionScore(BaseModel):
criterion: str; score: int; reason: str
class Scored(BaseModel):
scores: list[CriterionScore]
scorer = ChatGoogleGenerativeAI(
model="gemini-2.5-flash").with_structured_output(Scored)
def score(profile: dict, rubric: list) -> Scored:
return scorer.invoke(
f"Score this profile against each rubric criterion 0-5 with a reason. "
f"Judge job-relevant evidence only.\nRubric: {rubric}\nProfile: {profile}") With per-criterion scores in hand, the ranking is plain arithmetic, not another model call. Each criterion carries a weight you set for the role, and the agent computes a weighted sum to order candidates. Because it is deterministic, the same inputs always give the same order, and you can show exactly how a total was reached. This is a recommendation for a person to review, never a decision. Identical in every framework.
def total_score(scores: list[dict], weights: dict[str, float]) -> float:
# weighted sum of per-criterion scores; weights are set per role
return sum(s["score"] * weights.get(s["criterion"], 1.0) for s in scores)
def rank(candidates: list[dict], weights: dict[str, float]) -> list[dict]:
for c in candidates:
c["total"] = total_score(c["scores"], weights)
return sorted(candidates, key=lambda c: c["total"], reverse=True)
# Plain arithmetic: same inputs, same order, fully explainable. A recommendation,
# not a decision; a person reviews the ranked list. Redacting protected attributes is necessary but not sufficient, because proxies leak. So the agent runs deterministic checks: it confirms no protected field reached the scorer, scans the text for proxy signals (a postcode, a college that stands in for background, a graduation year that reveals age), and measures disparate impact, comparing shortlist rates across groups where that data is held separately and lawfully for monitoring. These reduce bias and surface problems for a human to act on; they do not remove bias. The same code everywhere.
PROTECTED = {"name", "gender", "age", "photo", "religion", "marital_status"}
PROXY_HINTS = ("pin code", "postcode", "graduated", "date of birth", "dob")
def guardrail_check(profile: dict, raw_text: str) -> list[str]:
flags = []
if PROTECTED & set(profile): # a protected field leaked in
flags.append("protected_attribute_present")
if any(h in raw_text.lower() for h in PROXY_HINTS):
flags.append("possible_proxy_signal") # for human attention
return flags
def disparate_impact(shortlisted: dict, applied: dict) -> dict:
# rates compared across groups, using data held separately for monitoring only
rates = {g: shortlisted.get(g, 0) / max(applied.get(g, 1), 1) for g in applied}
top = max(rates.values()) or 1
# flag any group whose selection rate is below 80% of the highest (a common check)
return {g: r for g, r in rates.items() if r < 0.8 * top}
# Reduces bias and surfaces issues for review. It does not remove bias; people decide. This is the rule the whole design exists to protect: the agent never auto-rejects and never auto-hires. It produces a ranked, explained shortlist and hands it to a person, who reads the per-criterion reasons and decides who advances. A candidate flagged by the guardrails, or one the rubric scores near a threshold, is surfaced for closer human attention rather than silently dropped. Deterministic, and the same in every framework.
def route_for_review(ranked: list[dict], flags: dict[str, list]) -> None:
for c in ranked:
review_queue.add(candidate=c["id"], scores=c["scores"],
total=c["total"], flags=flags.get(c["id"], []))
# The agent NEVER auto-rejects or auto-hires. It recommends; a person decides.
def on_human_decision(candidate_id: str, decision: str, reviewer: str) -> None:
# the only place an outcome is set, and only by a named person
audit.log("decision", candidate_id, {"decision": decision, "by": reviewer})
# No code path closes out a candidate without a human. Recommendations only. An explainable decision is one you can reconstruct, so the agent writes everything down. For each candidate it logs the rubric used, the score and the written reason per criterion, the weighted total, any guardrail flags, and the reviewer's decision with their name and time. If a candidate or a regulator later asks why someone was passed over, the answer is in the record, not in someone's memory. Deterministic, and the same in every framework.
def log_screening(candidate: dict, rubric: list, flags: list) -> None:
audit.write({
"candidate_id": candidate["id"],
"rubric": rubric, # the exact criteria and weights used
"scores": candidate["scores"], # per-criterion score + reason
"total": candidate["total"],
"guardrail_flags": flags,
"model": "gemini-2.5-flash",
"at": now(),
}) # immutable; the human decision is appended later, with the reviewer's name
# Every score, reason, and flag is recorded, so any shortlist can be explained. The pieces run on each new batch of applications. ADK composes extract, score, and the guardrail step under a SequentialAgent and a Runner, the Claude SDK runs them in a tool-use loop, and LangGraph builds an explicit extract-score-rank graph. Whichever you pick, the same guardrails apply: the application id makes every run idempotent, protected attributes are redacted before scoring, the agent never auto-rejects or auto-hires, fairness and disparate impact are monitored, every score and reason is written to an immutable audit log, and candidate personal data is handled in line with the DPDP Act 2023, whose core obligations phase in through 2027.
from google.adk.agents import SequentialAgent
from google.adk.runners import Runner
from google.adk.sessions import InMemorySessionService
screen_pipeline = SequentialAgent(name="screen_pipeline",
sub_agents=[extractor, scorer, guardrail]) # each an LlmAgent with the tools
runner = Runner(agent=screen_pipeline, app_name="screening",
session_service=InMemorySessionService())
def guard(app: dict) -> str | None:
if store.seen(app["id"]): return "already_screened" # idempotent
if PROTECTED & set(app.get("profile", {})):
return "redaction_failed" # never score with protected attributes present
return None
# Run on each new batch via cron / Cloud Scheduler. The agent recommends; people decide. from anthropic import Anthropic
client = Anthropic()
# Note: no reject_tool and no hire_tool exist. Recommendations only.
TOOLS = [extract_tool, score_tool, rank_tool, guardrail_tool] # JSON schemas
def run_screening(messages: list):
while True:
resp = client.messages.create(model="claude-opus-4-8", max_tokens=2048,
tools=TOOLS, messages=messages)
if resp.stop_reason != "tool_use":
return resp
messages.append({"role": "assistant", "content": resp.content})
for b in resp.content:
if b.type == "tool_use":
out = dispatch(b.name, b.input) # no auto-reject / auto-hire tool
messages.append({"role": "user", "content": [
{"type": "tool_result", "tool_use_id": b.id, "content": str(out)}]}) from langgraph.graph import StateGraph, START, END
g = StateGraph(dict)
g.add_node("extract", lambda s: {"profile": extract_profile(s["text"])})
g.add_node("score", lambda s: {"scores": score(s["profile"], s["rubric"])})
g.add_node("guardrail", lambda s: {"flags": guardrail_check(s["profile"], s["text"])})
g.add_edge(START, "extract")
g.add_edge("extract", "score")
g.add_edge("score", "guardrail")
g.add_edge("guardrail", END)
screen_pipeline = g.compile() # invoke per batch; output is a shortlist for a human
Here is a runnable file. It redacts protected attributes from a sample
resume, extracts a job-relevant profile, and scores it against a small
rubric with a reason per criterion, and it does not decide anything. The
sample is inline text; production reads the ATS, as in the build steps.
Save it as main.py and run python main.py.
# main.py -- run: python main.py
# A runnable reference: redacts protected attributes from a sample resume,
# extracts a job-relevant profile, and scores it against a small rubric with a
# reason per criterion. It does NOT decide anything: it recommends, a person
# decides. The sample is inline text; production reads the ATS, as in the steps.
import asyncio
from google import genai
from google.genai import types
from google.adk.agents import LlmAgent
from google.adk.runners import InMemoryRunner
client = genai.Client() # reads GEMINI_API_KEY
# ---- a sample resume (replace with a real one from your ATS) ----
SAMPLE_RESUME = """Priya Sharma, 29, female. Pune.
Backend engineer, 5 years. Python, Django, PostgreSQL, AWS. Built and ran a
payments service handling 2M requests a day. B.Tech, Computer Science, 2017."""
# -----------------------------------------------------------------
# The written rubric for the role (criteria you set before screening).
RUBRIC = ["Python and backend depth", "Years of relevant experience",
"Cloud and database skills", "Evidence of scale or ownership"]
def redact_and_extract(resume_text: str) -> str:
"""Drop protected attributes and return only job-relevant fields, as JSON."""
return client.models.generate_content(
model="gemini-2.5-flash",
contents=["Return JSON with keys skills, years_experience, qualifications, "
"relevant_titles. Do NOT include name, gender, age, photo, "
"religion, or marital status.\n" + resume_text]).text
def score_against_rubric(profile_json: str) -> str:
"""Score the redacted profile per rubric criterion, with a reason each."""
return client.models.generate_content(
model="gemini-2.5-flash",
contents=[f"Score this profile 0-5 against each criterion and give a "
f"one-line reason per criterion. Job-relevant evidence only.\n"
f"Rubric: {RUBRIC}\nProfile: {profile_json}"]).text
agent = LlmAgent(
name="screening_agent", model="gemini-2.5-flash",
instruction=("Call redact_and_extract on the resume, then score_against_rubric "
"on the result. Report the redacted profile and the per-criterion "
"scores with reasons. State clearly this is a recommendation for a "
"human reviewer, and that the agent does not reject or hire anyone."),
tools=[redact_and_extract, score_against_rubric])
async def main():
runner = InMemoryRunner(agent=agent, app_name="screening")
session = await runner.session_service.create_session(app_name="screening", user_id="demo")
msg = types.Content(role="user",
parts=[types.Part(text=f"Screen this resume:\n{SAMPLE_RESUME}")])
async for event in runner.run_async(user_id="demo", session_id=session.id, new_message=msg):
if event.is_final_response():
print(event.content.parts[0].text)
if __name__ == "__main__":
asyncio.run(main()) The agent reports the redacted profile (skills, years, qualifications, with the name, age, and gender dropped) and a score with a reason for each rubric criterion, framed as a recommendation for a human reviewer. The exact wording is model-generated, so it varies run to run; the agent never rejects or hires anyone.
A recruiter sees one screen: candidates ranked by rubric score, the redacted profile, the per-criterion scores and reasons, and the decision, which a person always makes. The agent recommends; it never rejects on its own.
| Approach | Best for | Effort | Cost | Control |
|---|---|---|---|---|
| Custom agent (build in-house) | You want your own rubric, redaction and audit rules, ATS wiring, and the candidate data in-house | High | Build cost + API and model usage | Full |
| Screening SaaS (a scrutinised category, vendors vary widely) | A fast start with standard scoring, if you accept their model and audit it yourself for fairness | Low | Subscription per seat or per role | Medium |
| No-code (a keyword filter in your ATS or a spreadsheet) | Very low volume, simple must-have keywords, a quick first pass | Low | Cheap, but crude and easy to game | Low |
Recruitment AI is scrutinised and regulated, so this is decision support, not an automated decision. The agent recommends; a human makes every call, and there is no code path that rejects or hires a candidate on its own. Bias is mitigated by redacting protected attributes (name, gender, age, photo, religion, marital status) before scoring and by auditing for proxy signals and disparate impact, which reduces but does not remove bias. Scoring is explainable: per criterion, against a written rubric, with a reason for each.
Every shortlist carries a full audit trail, the rubric, the scores, the reasons, the guardrail flags, and the reviewer's decision, so any outcome can be explained later. Candidate personal data is handled in line with the DPDP Act 2023, whose core obligations phase in through 2027, and you can run the pipeline in your own cloud with access scoped to the roles it screens. Human oversight is required here, and the design keeps it required.
These are directional, not promises, and depend on your hiring volume and the roles you screen. The point is the shape of the change: less time reading resumes, more consistent screening, lower bias exposure, and decisions you can explain.
| Metric | Manual | Agent |
|---|---|---|
| Time spent reading resumes | Hours per role screening every applicant by hand, often only the first page | Every application read in full and scored against the rubric, so reviewers start from a ranked, explained shortlist |
| Consistency of screening | Different screeners weigh things differently, and fatigue late in a pile changes the bar | The same rubric and weights applied to every candidate, with a reason logged for each score |
| Bias exposure | Name, gender, age, and photo are visible while screening and can sway a call | Protected attributes redacted before scoring, with a proxy and disparate-impact audit, which reduces but does not remove bias |
| Explainability of a decision | Hard to reconstruct why a candidate was passed over | Per-criterion scores and reasons are logged, so any shortlist can be explained and reviewed |
Time and consistency gains depend on hiring volume and role mix. The agent reduces bias exposure but does not remove bias, and a human makes every decision. Figures are ranges, not guarantees.
Everything you need to know about the service and how it works. Can’t find an answer? Mail us at info@galific.com
Galific designs, builds, and runs screening agents like this on ADK, the Claude SDK, or LangGraph, wired into your ATS, with protected attributes redacted, scoring explainable per criterion, and a person making every decision. Or explore the ready-made versions in our agent suite.