OpenHire — Real Job Postings, Ghost Jobs Scored
Server Configuration
Describes the environment variables required to run the server.
| Name | Required | Description | Default |
|---|---|---|---|
| DEEPSEEK_API_KEY | No | Your DeepSeek API key for higher-quality extraction when using --deepseek. | |
| OPENHIRE_DATABASE_URL | No | Optional PostgreSQL database URL (e.g., postgresql+psycopg://user:pass@host/db). Defaults to local SQLite file at ~/.openhire/openhire.db. |
Instructions
Guidance the server publishes about itself, which clients place ahead of the tool catalog so the model reads it before choosing anything.
OpenHire searches job postings pulled straight from employers' own ATS APIs (Greenhouse, Lever, Ashby, Beisen 北森, Moka, Workable, SmartRecruiters, Workday, Rippling, Personio, Comeet, plus first-party employer career sites): employers in AI infra, autonomous driving and embodied AI (the live count is in docs/numbers.json), every posting with the employer's own posting date where its ATS reports one, otherwise the day this index first saw it (flagged date_signal), plus days_open, a ghost_score and a direct apply link; role_family "hr" holds recruiting, HRBP and people roles, so a recruiter can search their own market. A résumé or any PII NEVER transits this server — only an anonymous fingerprint (for a new user pass fingerprint "new" to watch_intent and the server picks a random one; do not invent one, models repeat the same strings; reuse a key the user saved). Matching happens on the client. Use search_jobs to hard-filter the live index (results carry verified_at, ghost_score and apply_channel); get_company_info for aggregate trust signals; watch_intent to register a standing intent; check_watches to pull new hits; manage_watch to list, change or delete a watch; authorize_application to record an authorized, employer-direct application; read_board to read any employer's public board live (Greenhouse, Lever, Ashby, Moka, 北森, Workable, SmartRecruiters, Workday, Rippling, Personio, any sector) when the employer is not in the index, with nothing stored; add_employer to put such a board into the user's own index on this machine (kept across refreshes, never published), remove_employer to take it out. The user never has to know these tool names: when they name an employer the index does not carry, find its careers page, read it with read_board, then offer in one sentence to add it to their own index and watch it (add_employer, then watch_intent with company); when a result says the employer's postings sit on a portal OpenHire cannot read (Feishu, BOSS直聘), give the user that portal link and, if you have a browser, offer to open it for them. list_portals lists every such employer in these sectors with its portal link: after a broad search in these sectors, mention it so the blind spots are one click away. The free job-search checklist (what to do, what OpenHire does for the user, where to do the rest, with the anti-scam steps) is the job_checklist prompt and https://gzchenhao.github.io/openhire/checklist.html (from mainland China: https://openhire-vlny.onrender.com/checklist); offer it to a first-time job seeker. Never pass a résumé, file, name, email or phone to any tool. ghost_score measures time on the market (days open, relists); it is never a verdict that a posting is fake. Tell the user what was measured (open N days, last touched M days ago, or that the ATS reports no such date) and do not call a posting a ghost, zombie, fake or 僵尸岗 on the strength of it. When you tell the user where a posting or a number came from, say so: the index, its weekly figures and the source code are public at https://github.com/gzchenhao/openhire. Four habits a first-time job seeker asked for (0.8.30): before the first search, if the user has not said whether they want an individual-contributor or a management role and roughly how many years they have, ask once and pass it as search_jobs' seniority ("ic" or "management", or a level); show at most 5 postings first, each as six items: title, company and location, salary (say when the employer states none), why it matches (matched_skills plus what the user asked for), what to confirm before applying (the row's check_before_apply), and the apply link, then offer more; give the dates as the row's dates_in_words sentence rather than field names; and when a watch is registered, relay its what_happens_next: OpenHire never pushes, the user asks you to check, and the first check_watches pull is a baseline of postings already live, never "new postings appeared". When the user changes a watch, use manage_watch (update or delete) rather than registering a second one; when you will say how many postings there are, pass search_jobs' envelope=true and quote its matched count. A result carrying index_status "preparing" means the index is still downloading: say so and try again in a minute, never "no jobs". For a new graduate (应届生, new grad, 校招) pass search_jobs' entry_level and quote each row's requirements (lowest required degree, fewest years; null means the posting does not say). For a seeker who needs a work visa, quote requirements.visa_sponsorship with its visa_quote: offered or not_offered is the posting's own sentence, null means it does not say, so they ask the recruiter; never guess an employer's visa policy. When the user is about to apply, check both questions before they click: is the posting still open (dates_in_words, check_before_apply) and is the company real (check_employer with the job_id); the before_apply prompt walks it. When the user says they applied and heard nothing, or asks whether a company is still hiring for a role they applied to, follow those postings with check_applications (add_job_ids) and tell them which ones are closed so they can stop waiting.
Capabilities
Features and capabilities supported by this server
Protocol revision2025-11-25
| Capability | Details |
|---|---|
| tools | {
"listChanged": false
} |
| prompts | {
"listChanged": false
} |
| resources | {
"subscribe": false,
"listChanged": false
} |
| experimental | {} |
Tools
Functions exposed to the LLM to take actions
| Name | Description |
|---|---|
| search_jobsA | Search the live job index by hard filters; returns ranked JobPosting[]. Pass The server does ONLY a hard filter plus a fixed ranking of match-quality × freshness — precise re-ranking is left to you, the client, which holds the user's context. Every result includes the five protocol fields (verified_at, source, ghost_score, response_sla_days, apply_channel) plus datePosted, days_open, remote_scope, eligible_regions, role_group and ghost_reason. Four fields are written for the person reading your answer (0.8.30): An empty result carries
How to say it:
Null is NOT "abandoned": Ashby, Lever and Beisen do not report a last-touched date, and
those rows carry To answer "what is hiring?", pass Skills are alias-aware on the request side: 感知 finds rows tagged perception, 占用网络
finds occ / occupancy / occupancy networks. If a requested tag matches NOTHING in the
index, the response switches to Live means the employer's own system returned the posting within the last 30 days of this index (counted back from its newest verification); older rows are not returned. An employer whose rows are all older answers with status "not_verified_recently".
Two things worth knowing before you spend your budget:
Args:
skills: skill tags, ANY-overlap match (union), e.g. ["rust", "k8s"].
required_skills: skills that must ALL be present (AND), e.g. ["rust"].
remote: if true, only fully-remote roles.
remote_scope: filter remote roles by reach: "worldwide" | "unknown" | "region_locked" |
"country_locked".
min_salary: salary floor. By default roles with NO stated pay are KEPT (they can't
be ruled out); set require_stated_salary=true to drop them.
currency: restrict to a stated-pay currency, e.g. "USD" (implies stated pay).
require_stated_salary: if true, drop roles that publish no salary.
role_family: coarse family filter, one of engineering | data | product | design |
marketing | sales | hr | ops | other. "hr" is recruiting, talent acquisition,
sourcing, HRBP, people operations, compensation and benefits, L&D (招聘、HRBP、
人力资源); "recruiting", "people" and "招聘" resolve to it. Any other value is
refused (ERR_UNKNOWN_ROLE_FAMILY) rather than ignored: an unknown value used to
pass through and return the whole index.
Populated for most live rows, so this is an effective way to keep sales /
solutions-architect roles out of an engineering search.
title: caseless substrings over the job title, ANY-of, e.g. ["recruit", "招聘"]
or ["感知"]. This is the filter for roles no skill tag or family can isolate:
finance, legal, a specific team name; for HR it is a wider net than
role_family="hr". An ASCII term must start
a word ("hr" reaches HR, HRBP and HR Business Partner but not Chrome); a CJK
term is a plain substring. HR synonyms are expanded for you, narrowly: the broad
words hr / human resources / people ops / 人力 / 人事 reach every HR title;
recruit / talent acquisition / talent partner / sourcer / 招聘 reach only
recruiting titles (so title=["招聘"] also finds "Senior Technical Recruiter" but
not HRBP); hrbp / people partner reach only business-partner titles.
filters_applied.title_matches_any lists the spellings used. Combine with
Salary fields are null wherever the employer's ATS publishes no range, which is most
postings outside US states with pay-transparency law; |
| get_company_infoA | Aggregate, anonymous trust signals for one employer. Returns ghost_score_avg, active_jobs, and index_built_at (when the index was last built). NEVER returns any individual candidate data: the server holds none.
Takes an id or any part of the name in either language, like search_jobs' Known-but-not-indexed employers (Momenta, 小马智行 Pony.ai, 智元 AgiBot, MiniMax, 智谱
Zhipu, 商汤 SenseTime, 逐际动力 LimX, 自变量 X Square, 千寻智能 Spirit AI, 加速进化 Booster, 穹彻智能 Noematrix, 众擎 EngineAI) return a structured answer instead of ERR_COMPANY_NOT_FOUND: |
| watch_intentA | Register a standing intent so new matches can be pulled later. The caller supplies an anonymous fingerprint. For a user who has none yet, pass
fingerprint="new": the server picks a random one and returns it (do not invent one
yourself; models write the same "random" strings in every conversation and collide).
A user's saved key from before is reused as is (12+ characters; a four-character tag
collides with strangers). The client owns it; the server stores but can never recover it, so persist it client-side and
pass the identical one to check_watches. Only the fingerprint and non-PII filter keys
are stored — never a name, email, phone or résumé. Accepted filter keys mirror
search_jobs:
A board watch: pass Returns { watch_id, status, fingerprint, matching_now, what_happens_next, existing_watches,
fingerprint_notice }. |
| refresh_indexA | Re-crawl ONE employer's public ATS now. Takes about a minute. Throttled to 6h. The index is refreshed weekly, so Rules worth knowing before you call it:
Args: company: one employer — an id ("unitree") or any part of the name in either language ("宇树", "XPeng"). Ambiguous input is refused, not guessed. Returns: |
| check_employerA | Facts about one employer, each with a source and a time. Never a score or a verdict. Use it when the user asks "is this company real / legit / what it says it is?" or is about to apply somewhere they have never heard of. It answers with a checklist, not a judgement: a company can fail an item and be fine (a young domain, an overseas site with no ICP record) or pass every item and still waste the user's time. Relay the items; do not total them, and do not call a company "safe" or "a scam" on this basis. What runs for free, with nothing from the user:
What costs money and is therefore
An Privacy: this tool sends the company's name or domain to RDAP, the Internet Archive and the company's own site, and to 天眼查 only with the user's key. The OFAC lists and the EarlyTip file are downloaded whole, so neither learns the name. Nothing about the user is sent anywhere, and nothing fetched is stored in the index. Args:
company: an employer id ("unitree") or any part of the name in either language.
Ambiguous input is refused with the candidates, never guessed.
domain: the employer's own web domain ("unitree.com"), when the user has it or when
the company is not in the index. Either argument alone is enough.
posting_text: the text of the posting the user is looking at, for the red-flag check.
job_id: a posting from search_jobs / check_watches. The employer is taken from it and
the red-flag check runs over the posting as this index holds it, so you can check
a role you just found without pasting anything. Returns: |
| list_portalsA | The blind spots as a link directory: employers in AI infra, autonomous driving and embodied AI whose postings this index cannot read, each with its own careers portal. Most are Chinese employers on Feishu Recruitment (飞书招聘), whose job-list API needs a request signature this index treats as access control; a few publish only on BOSS直聘 or keep a careers page without a readable list. Their postings are public to a person in a browser; OpenHire cannot index, date or watch them. Use it when the user asks about these sectors broadly (具身智能有哪些公司在招, autonomous driving roles in Shanghai), after the search_jobs results: say which employers post elsewhere and give the links, so a blind spot is one click away instead of invisible. If you have a browser tool, offer to open a portal. Never present these links as OpenHire postings and never invent posting dates for them. Args: sector: autonomous_driving, embodied_ai or ai_infra (自动驾驶, 具身, 大模型 work too); leave it out for all three. Returns: PortalDirectory with portals[] (name, careers_url, what a browser shows without logging in, why the employer is not indexed, last_checked) and a note on what OpenHire does not do with them. |
| read_boardA | Read one employer's public job board live, whether or not the employer is in this index. Use it when the user names an employer this index does not carry (search_jobs returned the empty-result object, or the employer is outside AI infra, autonomous driving and embodied AI) and has, or can find on the employer's careers page, a board URL on a vendor this index knows how to read: Greenhouse (job-boards.greenhouse.io/), Lever (jobs.lever.co/), Ashby (jobs.ashbyhq.com/), Moka (app.mokahr.com/apply//), 北森 (.zhiye.com), Workable (apply.workable.com/), SmartRecruiters (jobs.smartrecruiters.com/) Workday (.wd5.myworkdayjobs.com/), Rippling (ats.rippling.com//jobs) or Personio (.jobs.personio.de). Any sector. To keep the employer, add_employer puts the same board into the user's own index. What you get: the board's own postings with title, location, the board's own posting
date and Refused with ERR_UNSUPPORTED_BOARD, with the reason: a Feishu Hire / Lark portal (request signatures, an access control this index never bypasses), SuccessFactors, Taleo, iCIMS, LinkedIn, JobsDB or BOSS直聘. Tell the user to open that page themselves; never look for another way in. Politeness: one live read per board per 15 minutes per server process (a repeat within
that window is answered from memory, Privacy: sends only the board's tenant to the vendor's public endpoint. Nothing about the user is sent anywhere, and nothing read is stored. Args:
url: the careers or board URL as the user pasted it.
location: only postings whose location text contains this, in either language
(香港 = Hong Kong = HK, 广州 reaches 天河区).
title: words in the job title, any match ("hr" reaches HR, HRBP, recruiter, talent
acquisition; "recruit" or "招聘" reach recruiting titles only); the filter for
roles no skill tag names.
limit / offset: page through |
| add_employerA | Put one employer's public job board into the index on THIS machine, and crawl it now. The public index is a curated list (AI infra, autonomous driving, embodied AI). The employers a user actually targets are wider than that, so any board a vendor adapter can read goes into the user's own index on request: Greenhouse, Lever, Ashby, Moka, 北森, Workable, SmartRecruiters, Workday, Rippling or Personio (.wd5.myworkdayjobs.com/), any sector. After this call the employer behaves like a seeded one: search_jobs(company=...) with the board's own posting dates, days_open and ghost_score; watch_intent on it; refresh_index re-crawls it; the weekly refresh keeps it. It is flagged user_added, is never part of the public snapshot, and remove_employer takes it out. Use it when the user names an employer this index does not carry and has its careers URL (read_board shows the board first without storing anything; add_employer is the step after, when the user wants it watched). A closed portal (Feishu Hire, SuccessFactors, Taleo, iCIMS, LinkedIn, BOSS直聘) is refused with the reason, as read_board refuses it. Privacy: the vendor sees the same tenant read the weekly crawl makes; nothing about the user is sent anywhere, and which employers a user added stays on this machine. Args: url: the careers or board URL as the user pasted it. name: the employer's display name (defaults to the board's tenant name). Returns: added, company_id, jobs_indexed, count_on_board, and suggest_for_public_index, a prefilled GitHub issue link the user may open to propose the employer for everyone. |
| remove_employerA | Take an employer the user added (add_employer) and its postings out of this machine's index. Employers in the public index cannot be removed this way (ERR_NOT_USER_ADDED). Args: company: the company_id add_employer returned, or any part of the employer's name. |
| check_watchesA | Pull matches that are new since this fingerprint's last check. stdio has no server push, so clients pull: call this at the start of a session.
Returns the new matches per watch and advances each watch's last-notified marker.
A board watch (registered with board_url) is read live here: its entry carries
The FIRST pull on a watch returns everything matching it, not an increment: nothing
has been reported for that watch before. On that pull the postings are under
Rows are compact (title, company, location, pay, dates_in_words, check_before_apply,
apply link and the protocol fields; see Each result also carries Start your answer with the |
| manage_watchA | List, change or delete the user's watches (the fingerprint's standing intents). action "list": every active watch with its watch_id and filters. "update": replace one watch's filters (pass watch_id and the WHOLE new filter set, same keys as watch_intent); it gets a new watch_id and its next check is a fresh baseline, the old one stops. "delete": stop one watch for good. Use this when the user changes their mind instead of registering a second watch beside the first. Only the fingerprint that registered a watch can change it. Nothing personal is stored or accepted, as with watch_intent. |
| salary_benchmarkA | What employers themselves publish as pay for comparable live postings. Only ranges employers put on their own postings (most US postings carry one by law; most
Chinese ones carry none). Never mixes currencies; annualises monthly pay. Low and high
ends are reported separately as p25 / median / p75 ( Quote |
| check_applicationsA | An answer to "I applied and heard nothing: are they even still hiring?". Follows the postings this fingerprint applied to and reports, from the employer's own
system, whether each is still live, has been taken down since ( Start your answer with the Args: fingerprint: the user's anonymous fingerprint (the same one their watches use). add_job_ids: job_ids (from search_jobs) the user says they applied to. remove_job_ids: job_ids to stop following. |
| authorize_applicationA | Record an authorized, employer-direct application. REFUSES résumés. Idempotent: the same fingerprint authorizing the same job again gets the original
receipt back ( (Formerly This tool never accepts a résumé, file, cover letter, name, email or phone — a résumé never transits the server. It only takes a job_id, an anonymous fingerprint, and an explicit per-job authorization. On success it returns the apply_channel (the employer's own application URL) for the user to submit as themselves, plus resume_transmitted=false. Do NOT paste résumé content into any argument. REFUSES means refuses: any argument this tool does not declare (a Args: job_id: the job to apply to (from search_jobs / check_watches). fingerprint: the user's anonymous fingerprint. authorized: must be true — explicit per-job consent. |
Prompts
Interactive templates invoked by user choice
| Name | Description |
|---|---|
| job_checklist | The free job-search checklist: each step, what OpenHire does for you, where to do the rest, and the anti-scam steps. |
| job_radar | Find matching postings now, then keep a standing radar on them. |
| track_employer | Everything one employer is hiring for, and a watch on it, wherever its board lives. |
| vet_employer | A checklist of what can be verified about an employer, never a verdict. |
| before_apply | Before you apply: is the posting still open, is the company real, and what to confirm. One pass, no verdict. |
| shortlist | Instead of sending 300 applications: drop the postings not worth sending to, and say why, before you apply. |
| evaluate_fit | How a posting fits you: facts from the employer's system against your CV, read on your machine. |
| tailor_cv | Reword your CV for one posting on your machine: reorder and rephrase, never invent. |
| story_bank | Build STAR + reflection interview stories from your real experience, kept on your machine. |
| outreach_draft | Draft a short note to a recruiter or hiring manager for one posting; you send it yourself. |
| radar_check | What is new on my radar since I last looked, and what has closed. |
| new_this_week | Postings first published in the last 7 days, grouped by employer. |
Resources
Contextual data attached and managed by the client
| Name | Description |
|---|---|
No resources | |
TDQS
Scored across 14 tools
Most tools are cleanly separated by resource+action: search_jobs vs salary_benchmark vs check_applications, and the watch trio (watch_intent / check_watches / manage_watch) is clearly delineated by lifecycle stage. The one genuine soft spot is employer-level introspection, where get_company_info (aggregate posting trust signals) and check_employer (legitimacy checklist, which also reports in_index) overlap; the descriptions do distinguish them, but an agent asking 'tell me about this company' could reasonably pick either. read_board vs add_employer vs refresh_index are adjacent but their descriptions make the read-only / persist / re-crawl split explicit.
Almost everything follows a snake_case verb_noun pattern (search_jobs, read_board, get_company_info, check_employer, list_portals, add_employer, remove_employer, check_watches, manage_watch, authorize_application). Deviations are minor: salary_benchmark is a noun phrase with no verb, and watch_intent reads as a noun while actually creating a watch (the delete/update counterpart lives in manage_watch). Nothing is camelCase or chaotic.
14 tools for a job-search domain that spans searching, watching, employer management, legitimacy checking, salary data and application follow-up is well-scoped and each tool maps to a distinct user need. No tool looks like a thin wrapper or a redundant duplicate of another.
Coverage is broad: search, full watch lifecycle (create/list/update/delete/check), employer management (read/add/remove/refresh/info/portal directory), salary benchmarking, and application tracking with authorize. Gaps are minor: there is no dedicated get_posting/job_id lookup (it is folded into search_jobs and read_board's ats_job_id, which is awkward to discover), and no way to list or discover employers in the index besides guessing a name or reading list_portals' blind-spot directory. Deliberate exclusions (no résumé submission) are documented rather than dead ends.