Build an AI job search agent that actually finds jobs
An agent that searches jobs is three parts: a live postings source, a tool the model can call, and a loop that refines the query when results come back thin. Working code for Claude, the OpenAI Agents SDK, and LangChain - plus the four failure modes that make most job agents useless (stale listings, hallucinated companies, dead apply links, and a model that never widens its filters).
Dvir Atias
Founder, JobsPipe
“Build an AI job search agent” sounds like a weekend project, and the demo version is. You wire a model to a search API, ask it for remote Python roles, and it returns ten. Impressive for about four minutes, until you click an apply link and get a 404, or notice two of the companies do not exist.
The gap between demo and useful is entirely about the data layer and the loop around it. This post builds the working version in three frameworks and then covers the four failure modes that kill the rest.
The architecture
An agent that reliably finds real jobs has exactly three parts:
- A live postings source. Not the model’s training data, and not a web search that returns board landing pages. Postings with structured fields and verified-open status.
- A tool the model can call with the filters that matter - title, remote, geography, seniority, recency.
- A loop that reacts to results. This is the part everyone skips. Zero results should trigger a broader query, not a shrug.
Notice what is not on that list: a fine-tuned model, a vector database, a resume parser. Those are optimizations for a system that already returns real jobs. Build the boring version first.
Version 1: Claude with MCP, no code
If your agent runs in an MCP-capable client, you do not write an integration at all. Connect the server and the tool appears:
claude mcp add --transport http jobspipe https://mcp.jobspipe.dev/mcp \
--header "Authorization: Bearer jp_live_xxxxxxxxxxxxxxxx"From there, “find me remote senior Rust roles in Europe posted in the last two weeks, and tell me which ones list salary” is a single turn. The model picks the filters, calls search_jobs, and reasons over structured results rather than prose it scraped. Details in the MCP server post.
Use this when the agent is for you or your team. Move to code when you are shipping it to users.
Version 2: the OpenAI Agents SDK
A tool is a decorated function. The docstring is what the model routes on, so it earns real attention:
import os, httpx
from agents import Agent, Runner, function_tool
KEY = os.environ["JOBSPIPE_API_KEY"]
@function_tool
async def search_jobs(
title: str,
remote: bool = False,
country: str | None = None,
seniority: str | None = None,
max_age_days: int = 30,
) -> str:
"""Search live job postings across 30+ boards and ATS platforms.
Call this for any question about open roles or who is hiring.
Returns verified-open postings, newest first, with working apply links.
Args:
title: Role keywords, e.g. "backend engineer".
remote: Restrict to remote-eligible roles.
country: Two-letter ISO code, e.g. "US", "DE".
seniority: One of "junior", "mid", "senior", "lead".
max_age_days: Only postings first seen within this window.
"""
payload = {
"job_title_or": [title],
"posted_at_max_age_days": max_age_days,
"limit": 20,
}
if remote:
payload["remote"] = True
if country:
payload["country_code_or"] = [country]
if seniority:
payload["seniority_or"] = [seniority]
async with httpx.AsyncClient(timeout=20) as c:
r = await c.post(
"https://api.jobspipe.dev/v1/jobs/search",
headers={"Authorization": f"Bearer {KEY}"},
json=payload,
)
if r.status_code != 200:
return f"Search failed ({r.status_code}). Try a simpler query."
jobs = r.json().get("data", [])
if not jobs:
return (
"No matches. Widen the search: drop the seniority filter, "
"raise max_age_days, or remove the country restriction."
)
return "\n".join(
f"- {j['title']} | {j['company']} | {j.get('location','n/a')} "
f"| {j.get('compensation') or 'salary not listed'} | {j['apply_url']}"
for j in jobs
)
agent = Agent(
name="job-scout",
instructions=(
"You help people find open roles. Always call search_jobs before "
"answering - never rely on memory for job listings, it is stale. "
"If a search returns nothing, immediately retry with fewer filters "
"before telling the user there are no jobs. Never invent a company "
"or an apply URL: quote only what the tool returned."
),
tools=[search_jobs],
)
result = await Runner.run(
agent,
"Remote senior Go roles in Germany, posted in the last 2 weeks",
)
print(result.final_output)The instructions are load-bearing. “Always call search_jobs before answering” and “never invent a company or an apply URL” are not politeness - they are the two guardrails that separate this from a hallucination machine.
Version 3: LangChain
Same tool, LangChain’s shape:
from langchain_core.tools import tool
from langchain_anthropic import ChatAnthropic
from langgraph.prebuilt import create_react_agent
@tool
def search_jobs(title: str, remote: bool = False, country: str = "") -> str:
"""Search live job postings across 30+ boards. Use for any question
about open roles. Returns verified-open postings with apply links."""
... # same HTTP call as above
llm = ChatAnthropic(model="claude-sonnet-5")
agent = create_react_agent(llm, [search_jobs])
for chunk in agent.stream(
{"messages": [("user", "Remote data engineering roles in the EU")]}
):
print(chunk)The ReAct loop gives you the refinement behavior close to free: the model observes a thin result set and reasons its way to a broader query. That is the whole reason to use an agent framework here rather than a single function call.
Failure mode 1: stale listings
The most damaging failure, because it is invisible. A posting that closed three weeks ago looks identical to one posted this morning. The user finds out after writing a cover letter.
Most job APIs return everything ever indexed. Filter hard on recency - posted_at_max_age_days in the examples above - and prefer a source that actively rechecks whether postings are still live rather than one that only records when it first saw them. If your source cannot tell you the difference, your agent cannot either.
Failure mode 2: hallucinated jobs
Ask a model for open roles without a tool and it will produce a beautifully formatted list of jobs that do not exist. Ask it with a tool and it may still pad thin results with remembered ones.
Two mitigations, both cheap. Instruct explicitly: “quote only what the tool returned.” Then validate in code - every job the agent surfaces should carry an ID that came back from the tool call, and anything without one gets dropped before it reaches the user. Do not rely on the prompt alone for this.
Failure mode 3: dead apply links
Aggregators frequently return a link to their own listing page rather than the employer’s. The user clicks through, hits a wall, and stops trusting the agent.
Check what your source’s apply URL actually points at before you build on it. Ours resolves to the original ATS posting - the Greenhouse, Lever, or Workday page the company controls - which is also the only version guaranteed to reflect the current status.
Failure mode 4: the agent never widens
“Senior staff principal Rust engineer, remote, Portugal, posted this week” returns zero. A bad agent reports “no jobs found.” A good one drops the country filter, then the seniority, and reports what it relaxed.
Build this into the tool, not just the prompt. Notice that the empty branch above returns instructions rather than an empty string. The tool response is the highest-signal place to steer behavior, because the model reads it at exactly the moment it needs to decide what to do next.
What to add once it works
With the loop reliable, the obvious extensions are ranking matches against a profile (see how AI job matching actually works), scheduled runs that alert on new postings rather than requiring a query, and enrichment that pulls a company’s tech stack alongside the posting.
All three are straightforward once the foundation returns real, current, clickable jobs. None of them help if it does not.
Free API key, 30+ sources, verified-open postings - start with the boring version that works.
Get a free API keyFrequently asked questions
How do you build an AI job search agent?
You need three parts: a live postings source, a tool the model can call with real filters, and a loop that reacts when results come back thin. The data layer matters most - not the model's training data, and not a web search that returns job board landing pages. You do not need a fine-tuned model, a vector database, or a resume parser to get the first working version.
Why do AI job search agents return jobs that do not exist?
Because a model asked for open roles without a tool will generate a plausible list from memory, and even with a tool it may pad thin results with remembered ones. Two fixes, both cheap. Instruct it explicitly to quote only what the tool returned, then validate in code - drop any job the agent surfaces that does not carry an ID from an actual tool call. Do not rely on the prompt alone.
Can ChatGPT or Claude find real job listings on their own?
No, not without a tool connected. A model's training cutoff makes it useless for open roles - it will confidently describe a job that closed a year ago or invent one entirely, and there is no way to prompt around that. Connecting a live job postings source through MCP or a function-calling tool is what turns the model from a guesser into something that returns clickable listings.