Skip to main content
Glama
OpenHire — Real Job Postings, Ghost Jobs Scored

OpenHire — Real Job Postings, Ghost Jobs Scored

Server Configuration

Describes the environment variables required to run the server.

NameRequiredDescriptionDefault
DEEPSEEK_API_KEYNoYour DeepSeek API key for higher-quality extraction when using --deepseek.
OPENHIRE_DATABASE_URLNoOptional 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

CapabilityDetails
tools
{
  "listChanged": false
}
prompts
{
  "listChanged": false
}
resources
{
  "subscribe": false,
  "listChanged": false
}
experimental
{}

Tools

Functions exposed to the LLM to take actions

NameDescription
search_jobsA

Search the live job index by hard filters; returns ranked JobPosting[].

Pass envelope: true to get an object instead of a bare list: {results, matched (every row the filters match, before paging), returned, offset, has_more, next_offset, headline}. Use it whenever you will tell the user how many postings there are: a page of 10 is not the total, and an assistant once said "12 in all" when there were 15.

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): seniority is the level the title names (intern | junior | senior | staff | lead | manager | director | unknown; a product / project / account / policy / program manager is an individual contributor, so it is never "manager" on the word alone, while a title that names no level becomes "manager" when the posting's text plainly gives the role a team to run; the seniority filter's alias ic means every individual-contributor level (intern, junior, senior, staff, and unknown, because most titles name no level), so for a new graduate pass ["intern", "junior", "unknown"] and for an experienced IC ["senior", "staff", "unknown"]; listing levels leaves unknown out unless you name it. If you do not yet know whether the person leads people and how many years they have, ask before the first search rather than guessing the filter); date_source says whose date datePosted is (employer_ats | employer_self_reported | index_first_seen); dates_in_words is one sentence per language ("雇主发布日期 2026-09-23,在架 35 天;最近核验 2026-10-26…") to show verbatim instead of field names; check_before_apply lists what to confirm before applying, each item from a measured fact (no employer date, open over 90 days, no stated salary, remote scope unclear, unrecognised apply host, employer says closed), with an empty list meaning nothing stood out, not that the posting is vetted. Show at most 5 postings first, as six items each (title; company and location; salary, saying when none is stated; why it matches; what to confirm; apply link), then offer more. Each row is about 2,000 characters: ask for limit 10 or fewer per call and page with offset; a 50-row page is cut off by most clients before the end. A row whose employer published figures we could not read as pay (an hourly rate in an annual field) carries salary_raw with the employer's own numbers and a salary_unclear item instead of "no salary stated".

An empty result carries status and a fixed bilingual headline saying which kind of nothing it is: index_empty, known_not_indexed, unknown_company, unknown_tag, unknown_location (no live posting's location matches the place: try the other language, a city, or China / Bay Area) or no_match; filters_applied lists every filter you sent; index_status: "preparing" means the index is still downloading on first start, so say that and retry in a minute rather than reporting a dry market.

verified_at and ghost_score answer DIFFERENT questions and routinely disagree: a posting confirmed live today can score 1.0. Live means the employer's ATS still returns it; the score means it has been returned for a long time, or keeps being relisted. ghost_reason says which input drove the score ("age only: open 367d, never relisted") so you can tell the user that instead of a bare number. Neither field measures intent — a long-open role can equally mean hard-to-fill.

How to say it: ghost_score is a measurement of time on the market, never a verdict that a posting is fake. Do not call a posting a ghost, zombie, fake job or 僵尸岗 on the strength of it, and do not tell the user "don't apply" because of it. Report what was measured — "open 1,616 days, never relisted, the ATS reports no last-touched date" — and let the user weigh it. A 1.0 can be an abandoned req or a role that has been genuinely hard to fill for four years; the number cannot tell those apart.

response_sla_days is null on almost every row, and that is a meaningful null: it is the employer's OWN committed reply window, set only when they claim their tenant. We never infer or estimate it — the application deep-links to the employer and never touches this server, so we cannot observe a reply even in principle. Read null as "no employer has claimed this tenant", never as "missing data" or "slow".

employer_correction appears only when the employer has claimed this tenant and said something about this specific role. It is the employer's own account, verified by corporate identity, and it sits BESIDE ghost_score, never on it: status: "evergreen" explains a high score (they hire continuously, so there is no single opening to fill), it does not lower it. status: "closed" means they say they are no longer hiring even though their ATS still returns the row, so stop sending people there. date_semantics: "requisition_created" means their ATS reports the day the req was opened internally rather than the day it went live, so days_open overstates for that employer. Treat all of it as the employer's claim, attributed, not as our measurement.

days_since_update is the third date and the one that usually settles it: the employer's own last-touched timestamp from their ATS. It means the record was saved, not that the content changed or that someone is reviewing applicants: say "the employer's system shows it was last updated N days ago", never "someone is actively managing it". Two rows can both score 1.0 and mean opposite things — open 367d and untouched for 367d reads as abandoned; open 327d but touched 13 days ago reads as a tended evergreen req. Among rows where the ATS reports one at all, the share of ghost>=0.99 postings touched by the employer inside 30 days has ranged from about a third to three quarters across refreshes; read the current value from pct_ghost_hi_touched_within_30d in docs/numbers.json rather than quoting a figure.

Null is NOT "abandoned": Ashby, Lever and Beisen do not report a last-touched date, and those rows carry update_signal: "not_reported_by_ats" instead. A row carrying date_signal: "not_reported_by_ats" goes one step further: its source reports no posting date at all (Li Auto's first-party mirror is one), so its datePosted and days_open are counted from the day this index first saw it, not from the employer's own date. Read those as a lower bound on age, never as the employer's timeline. Treating that null as "nobody has touched this in a year" would describe the vendor's API, not the employer. Honest limit even when present: an ATS bumps it on any edit or re-publish, so it means "touched", not necessarily "content changed".

To answer "what is hiring?", pass company — do not filter client-side.

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 {results, unknown_skills, suggestions} so you can tell the user which word was dropped instead of presenting a half-match as a match.

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".

role_family filters exclude rows KNOWN to be another family; rows not yet classified (role_family null, typically the newest postings) are included, not hidden. remote_scope can also be "unknown": remote with a location we could not read; it is never reported as "worldwide" any more.

Two things worth knowing before you spend your budget:

  • role_group is shared by the same role posted in several cities — one employer may list one job 22 times, once per location. Those rows are genuinely distinct (each has its own job_id and apply_channel, which matters when the user has a location or visa constraint), but if you only need distinct opportunities, group by role_group and keep one per group. Measured, about 20% of a page is same-role repeats.

  • offset pages through the ranked list. After collapsing by role_group, call again with offset += limit to get more distinct roles. Fewer rows than limit means you reached the end. There is no server-side cursor to keep alive.

  • One call returns at most 100 rows. Ask for more and you get an object with results and truncated, not a silent first page: 100 of 198 looked exactly like "this employer has 100 jobs". truncated: true means a full page came back and there may be more, with the offset to call next in hint; truncated: false means fewer rows than the page size matched and there is no next page. For one employer, get_company_info's active_jobs is the true total.

  • Skill tags match separator-insensitively: "computer vision", "computer-vision" and "computer_vision" are one skill. The extractor emits all three spellings, so an exact-string search reached as little as 42% of the rows that had the skill.

  • An empty search does NOT return a bare list. It returns an object with results: [] plus hint, unknown_skills and suggestions, because [] alone cannot tell you whether you mistyped a tag or the market is genuinely dry. Read unknown_skills: if it is non-empty those tags exist nowhere in the index and you should retry with a suggestion; if it is empty your tags were fine and you should loosen a filter.

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 company to ask "does have any HR openings?" instead of paging their whole list. title_exclude: title text to keep OUT, ANY-of, a literal caseless substring with no synonyms: ["专家"] drops 产品专家 rows, ["BP"] drops 销售HRBP, ["manager"] drops every manager title. Use it when the user says "not X"; a watch accepts the same key, so the exclusion holds on every pull. seniority: keep only rows whose title names one of these levels, ANY-of: intern, junior, senior, staff, lead, manager, director, unknown; plus the aliases "ic" (intern, junior, senior, staff, unknown: everyone who leads no team, including titles that name no level, which cannot be ruled out) and "management" (lead, manager, director). Ask the user once whether they want an individual-contributor or a management role and roughly how many years they have, then pass it: an engineer searching Zoox for perception otherwise gets "Senior Manager, Perception Data" first because it is the newest. A hard filter, never a ranking input; an unknown value is refused (ERR_UNKNOWN_SENIORITY). Listing only intern / junior / unknown also drops a title that names no level when its text asks for three years or more. An empty result's relax says how many rows each filter alone hides, and how many of those name no level in the title. sector: only employers in one sector: autonomous_driving (自动驾驶 / 智驾, including the lidar, chip and truck companies built for it), embodied_ai (具身 / 机器人), ai_infra (大模型 / AI labs, chips, AI tools) or other. Use it when the user says "only robotics companies" instead of naming employers one by one. Every row carries its sector; a board the user added themselves has none and is left out. Unknown words are refused (ERR_UNKNOWN_SECTOR). entry_level: true keeps only postings open to a new graduate (应届生 / new grad): the text says campus hiring, internship or no experience required, or the title names an intern or junior level. Postings that say nothing either way are left out. Every row carries requirements {min_degree, min_years, hire_track, entry_level, visa_sponsorship, visa_quote}, read from the posting's own words with free rules (visa_sponsorship is offered / not_offered / null and visa_quote is the posting's own sentence; check_before_apply repeats it): min_degree is the lowest degree it REQUIRES (a degree it only prefers does not count), and null means the posting did not say, never "not required". Quote it as what the posting says, not as a rule the employer will enforce. posted_within_days: only postings the employer published in the last N days (1 to 365), counted from the employer's own posting date, or from the day this index first saw a row whose system gives none. Use it when the user wants fresh postings only ("只看两周内发的"); it is a filter, the ranking is unchanged. collapse_role_group: keep one row per role_group instead of one per city, and add role_group_size saying how many postings that row stands for. Cheaper when the user wants distinct opportunities; leave it false when location or visa matters, because each city row has its own job_id and apply_channel. company: restrict to one employer. Pass whatever the user said — an id ("unitree"), or any part of the name in either language ("宇树", "Unitree", "XPeng"). Exact id/name hits win; otherwise it is a caseless substring, so a broad word can match several employers. A name this index does not carry comes back as the empty-result object with unknown_companies and suggestions. Some employers are known but deliberately NOT indexed: their careers sites run on Feishu Recruitment, whose job-list API requires a request signature; we treat that as access control and do not work around it. For those (Momenta, 小马智行 Pony.ai, 智元 AgiBot, MiniMax, 智谱 Zhipu, 商汤 SenseTime, 逐际动力 LimX, 自变量 X Square, 千寻智能 Spirit AI, 加速进化 Booster, 穹彻智能 Noematrix, 众擎 EngineAI) the empty-result object carries known_not_indexed with the employer's own careers portal URL, the reason, and employer_opt_in (the employer can authorize the read-only Feishu open-platform scopes hire:site:readonly and hire:site_job_post:readonly). 蔚来 NIO is on the same list for a different reason: its own careers page publishes the postings, but its edge security policy blocks this crawler (HTTP 567) and we do not work around security controls; the employer can allowlist the crawler or authorize the same read-only scopes. Send the user to that portal; do not retry with a looser filter, and do not present the absence as "not hiring". A few more are on the same list because their own site has no readable job list (云深处 DeepRobotics publishes only on BOSS直聘; 智平方, 轻舟智航 QCraft, 鉴智 PhiGent and 松延动力 Noetix keep a page with no list, a form, a JavaScript shell or a robots-disallowed API), and 毫末智行 HAOMO has ceased operations: for it, tell the user that and send them nowhere. location: caseless substring over the employer's location text, either language ("北京", "Beijing", "Mountain View", "Remote"). Alias-aware for the cities Chinese employers spell several ways: 广州 / Guangzhou also reaches rows that name only a district ("广东·天河区", 番禺区, 黄埔区, 南沙区, 海珠区, 越秀区, 白云区), 深圳 / Shenzhen reaches 南山区, 福田区, 龙岗区, 宝安区, Beijing reaches 北京市, and "remote" reaches 远程; 上海, 杭州, 苏州, 南京, 武汉, 成都 and 合肥 match their pinyin too. Combine with remote_scope to keep or drop a country. No location filter means all locations. limit: max results (default 20); must be >= 1 (else ERR_BAD_PAGE). A negative offset clamps to 0.

Salary fields are null wherever the employer's ATS publishes no range, which is most postings outside US states with pay-transparency law; require_stated_salary and currency therefore narrow mostly to US rows, and min_salary alone KEEPS unstated rows (it cannot rule them out). Pass currency to compare in one currency; without it, stated pay is compared as an annualised number in whatever currency it was stated.

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.

posting_dates_reported is false when no live posting of this employer carries the employer's own posting date (first-party mirrors such as Li Auto report none), and postings_without_reported_date counts those rows; their days_open, and so this employer's median_days_open and ghost_score_avg, are counted from the day this index first saw each posting, a lower bound on age rather than the employer's timeline.

active_jobs and every aggregate count only postings the employer's own system returned within the last 30 days of this index; unverified_over_30d counts the ones it still holds that went unconfirmed longer (most often a careers board that moved). Those are not shown by search_jobs. Nonzero means a gap in our reading, never a fact about the employer.

claimed is true only when the employer has claimed this tenant and we verified them by corporate identity, never by payment, and it never affects ranking. It is the only signal here that comes from the employer rather than from their public ATS data, and it is what makes response_sla_days non-null on their postings.

hiring_trend counts what the employer's own system shows: postings published in the 30 days up to the index's newest verification, the 30 days before, and postings taken down in the last 30, with in_words (zh, en) to quote. Use it when the user is about to sign or bet months on an internship and asks whether the company is growing or shrinking. Report the counts; never turn them into "they are about to lay off" or "safe to join".

Takes an id or any part of the name in either language, like search_jobs' company.

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: indexed: false, careers_url (their own portal), reason (their careers site runs on Feishu Recruitment, whose job-list API requires a request signature; we treat that as access control and do not work around it) and employer_opt_in (the employer can authorize the read-only Feishu open-platform scopes hire:site:readonly and hire:site_job_post:readonly). 蔚来 NIO gets the same shape with its own reason: its careers page's security policy blocks this crawler (HTTP 567), which we do not work around. There are no trust signals in that answer because we hold none of their postings; do not read the absence as a verdict on the employer.

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: skills (ANY-overlap), required_skills (ALL/AND — use this to keep sales / solutions-architect roles out), remote (bool), role_family (e.g. "engineering"), min_salary (int), company (one employer, resolved at registration), location (substring of the location text, alias-aware like search_jobs: 广州 also reaches 广东·天河区 rows, Beijing reaches 北京市, "remote" reaches 远程), title (list of title substrings, ANY-of, synonym-expanded for HR terms like search_jobs — the way to watch for recruiting or other roles no skill tag names), title_exclude (title words to keep out on every pull, as in search_jobs: the user who wants "no 专家 roles" gets that enforced by the watch, not by you), seniority (list of levels or the aliases "ic" / "management", as in search_jobs, so a watch for an engineer never fires on a Senior Manager; what_happens_next says when titles naming no level are left out), sector (autonomous_driving / embodied_ai / ai_infra / other, as in search_jobs: one watch for "robotics companies in Shenzhen" instead of one per employer), entry_level (true: only postings open to a new graduate, as in search_jobs), posted_within_days (only postings published in the last N days, as in search_jobs), remote_scope (worldwide / region_locked / country_locked / unknown, as in search_jobs: a seeker outside the US keeps US-only remote roles out with "worldwide"). Any other key is REFUSED (ERR_UNKNOWN_FILTER) rather than silently dropped, and a role_family outside engineering | data | product | design | marketing | sales | hr | ops | other is refused too (ERR_UNKNOWN_ROLE_FAMILY); "recruiting" and "招聘" resolve to hr.

min_salary keeps rows with NO stated pay (they cannot be ruled out); it only drops rows whose stated pay is below the floor. Pay is stated mostly where law requires it (US postings on Greenhouse/Lever/Ashby), so a watch that needs a number will lean US.

A board watch: pass board_url (a Greenhouse / Lever / Ashby / Moka / 北森 / Workable / SmartRecruiters / Workday / Rippling / Personio careers URL, as for read_board) with optional location and title and nothing else; check_watches then reads that board live on every pull and reports what appeared and what disappeared since the last pull. This is how an employer outside the index is watched. Not combinable with company, skills, remote, min_salary or role_family; a closed portal (Feishu, a job site) is refused with the reason.

Returns { watch_id, status, fingerprint, matching_now, what_happens_next, existing_watches, fingerprint_notice }. matching_now is how many postings match today (all already live, none new; null for a board watch); what_happens_next is the sentence to relay: OpenHire does not push, the user asks you to check, the first check is a baseline, a scheduled reminder is a recurring task in the assistant. existing_watches > 0 means this fingerprint was already in use; unless you registered those watches yourself with it, they are a stranger's: delete this watch and register again with fingerprint "new".

refresh_indexA

Re-crawl ONE employer's public ATS now. Takes about a minute. Throttled to 6h.

The index is refreshed weekly, so search_jobs can be up to seven days behind and check_watches has nothing new to report until it moves. This is the manual nudge for the case that matters: the user is about to act on one employer and wants today's truth.

Rules worth knowing before you call it:

  • ONE employer per call. A full crawl is 20+ minutes and no client will wait; a vague word like "robot" is refused with the list of candidates rather than fanned out into eleven live crawls.

  • At most one crawl per employer per 6 hours. A throttled call returns immediately with refreshed: false, reason: "throttled" and last_refreshed_at — no network request is made. That is not an error: it means the data you already hold is that fresh.

  • An employer we know but deliberately do not index (the Feishu-hosted ones, 蔚来 NIO, the ones whose own site has no readable job list, and 毫末智行 which has ceased operations; see search_jobs) returns reason: "known_not_indexed" with the same portal, reason and employer_opt_in the other tools give, never unknown_company.

  • Do NOT call this speculatively or in a loop. Every call hits somebody else's public endpoint. Search first; refresh only when the user needs today's state of one employer.

Args: company: one employer — an id ("unitree") or any part of the name in either language ("宇树", "XPeng"). Ambiguous input is refused, not guessed.

Returns: refreshed plus last_refreshed_at / next_allowed_at; when it did run, also jobs_new / jobs_updated / jobs_delisted / jobs_unchanged, delisted (job_id and title of the rows this crawl took down, first 20), new (the rows it added, first 20), posting_dates_changed (rows whose employer- reported posting date moved since the last crawl, was / now; say so and quote both) and counts_note on what the counts mean.

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:

  • in_index: whether the employer's own system is in this index, how many postings are live, the median days open, whether the posting dates are the employer system's own or self-reported (date_source), whether the employer claimed its tenant. For an employer we know but do not index (Feishu-hosted, 蔚来, or one that has ceased operations) the item says that instead.

  • domain_age (RDAP registration date; under 180 days is reported, not judged), site_history (earliest Wayback Machine capture), icp_on_homepage (the ICP 备案号 the employer's own homepage carries, if any). These need the employer's own domain: pass domain when the employer's careers page is on a vendor host.

  • posting_red_flags: when the user pastes the posting text, the words recruitment scams rely on (境外高薪, 包机票, 不限经验, 打字员, visa provided ...) are listed if present. Absence proves nothing; presence is worth saying out loud.

  • sanctions_screen: the OFAC SDN and consolidated sanctions lists, downloaded to the user's machine at most once a day and matched against the company's name there (the name is sent nowhere). A match names the entry, list and programme; it is a reason to read the entry, not a verdict, since names collide. A live company registration proves little on its own: Prince Group's Hong Kong companies were on the register the day they were sanctioned.

  • org_events: the organisational and financing events EarlyTip (earlytip.com) has on public record for the employer (executive departures, restructurings, funding rounds, acquisitions, bankruptcies, new companies; 2026-09-20 onwards), read from its public export, downloaded whole to the user's machine once a day and matched there by the export's published company_key. Each event is a type, one date (the event's own when known, else EarlyTip's publication day: date_kind), the amount where EarlyTip's record carries one (amount, verbatim), the source domain and a plain link; never the headline, and no link where the source site's terms list linking among the prohibited acts (link_withheld). role says whether the employer is the event's subject or its counterparty; unverified carries EarlyTip's own "待核实" marker. EarlyTip checked its values against its own published headline and summary, not the source article: relay an event as "on EarlyTip's record, see the source", never as an established fact, and reason: no_record means the file holds nothing on this employer, which says nothing about it.

What costs money and is therefore pending_user unless the user set their own key:

  • registry_record: the company register (成立日期, 经营状态, 注册资本, 参保人数, 法定 代表人). The government site sits behind a captcha; 天眼查 sells it per call. With TIANYANCHA_API_KEY set on the user's machine this tool fetches it once per call, paid by the user's own 天眼查 account; the key and the lookup never reach us. Without it, pending_user[] lists the ways: set the key, use a 天眼查 MCP the assistant already has, or the free manual lookups (爱企查, 国家企业信用信息公示系统; for a Hong Kong or overseas employer the HK Companies Registry e-Services search, the HK Police Scameter for a phone number / URL / receiving account, and OpenCorporates). Offer them; do not invent the record. If the assistant already has a 天眼查 tool or connector (for example the one built into Tencent WorkBuddy), use it to fill registry_record directly instead of sending the user off to apply for a key.

An unavailable item carries reason: network means this query could not reach the source (a restricted network, a timeout) and says nothing about the employer; say that rather than "no record". no_record / http_status mean the source answered.

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. posting_text, if also given, wins. message_text: what the other side sent the user in a private message or chat (most job scams happen there, not in the posting): scanned on this machine for the usual lures (fees, loans, "add my WeChat", "install our app", screen sharing, ID photos, verification codes) and for outsourcing / labour-dispatch wording, and neither kept nor sent anywhere. Works alone, without company or domain. Before passing it, remove the user's own name, phone number and ID details from the text. Refused on the hosted endpoint (ERR_LOCAL_ONLY): a pasted chat can carry the user's personal data, which never goes through a server. Any text you pass also gets an outsourcing_words item: which outsourcing words appear, never a verdict.

Returns: checks[] (id, label, status: verified | not_verified | unavailable | not_applicable | pending_user, note, source, checked_at, plus the item's own fields), pending_user[] with how[], summary, not_a_verdict, privacy.

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 days_open where it reports one (date_signal: not_reported_by_ats otherwise), a direct apply link on the employer's own tenant, and a description excerpt that starts where the role does (company boilerplate skipped). Pass ats_job_id from a previous read to get that one posting with its whole description, when the user wants the job description itself read or summarised. What you do not get: skills, ghost_score, relist history or watches, which need repeated observation; and nothing is stored. If the employer turns out to be in the index the result says so and search_jobs is the richer source.

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, from_cache: true), with the same request headers as the weekly crawl. A board that does not answer is reported as such, never as a fact about the employer.

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 total_matching (newest posting date first, undated last). ats_job_id: one posting's id from an earlier read; returns it alone with the full text.

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 new_matches (appeared on the board since the last pull), closed_since_last (gone from the board) and total_matching; a board that does not answer comes back as board_error and nothing is marked closed on its account.

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 baseline_postings with baseline_count, new_matches is empty and is_first_pull is true; the top-level new_matches counts only increments and baseline_count the baselines. Never present a baseline to the user as "postings that appeared since last time".

Rows are compact (title, company, location, pay, dates_in_words, check_before_apply, apply link and the protocol fields; see fields): a full first pull used to run to 68,000 characters and get cut off. search_jobs with the job_id or company gives the full row.

Each result also carries total_matching and truncated: a broad watch can match hundreds of postings and only the 100 best-ranked are returned. Tell the user when truncated is true; the rest is not re-reported later.

Start your answer with the summary sentence (top level and per watch): it already says whether this is the first-pull baseline or an increment, and how many.

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 (annual_low, annual_high), with postings, employers and wide_ranges (ranges spanning 2x or more). Under 20 postings there are no quantiles, only the count. Group by the level the title names (seniority, as in search_jobs) and the years the posting asks for (years_min / years_max, from the posting's own words), never by company grade.

Quote in_words. Say these are published ranges, not offers or what anyone was paid, and never tell the user a posting "pays below market, skip it": give the numbers.

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 (closed: stop waiting on it), or has not been returned by the employer's system for 30 days (not_verified_recently). Postings recorded with authorize_application are included automatically. When the user says they applied to postings found in this index (on the employer's site or anywhere else), pass those job_ids as add_job_ids to follow them; remove_job_ids stops following ones they added. Only job_ids are stored, keyed by the anonymous fingerprint: never a name, résumé or where they applied from.

Start your answer with the summary sentence, then list closed first, then not_verified_recently, then open, quoting each row's status_in_words. Do not say an employer ignored the user or that a closed posting was filled: we see the posting, never the employer's inbox. Call it again at the start of a later session to see what changed.

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 (already_authorized: true); no second record is written.

(Formerly apply — renamed to make explicit that this only records the user's authorization to apply as themselves; it never submits anything on their behalf.)

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 resume key, a cv, a file, anything) is answered with ERR_PII_NOT_ACCEPTED and nothing is recorded. It is not silently dropped, so a client that sends one finds out.

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

NameDescription
job_checklistThe free job-search checklist: each step, what OpenHire does for you, where to do the rest, and the anti-scam steps.
job_radarFind matching postings now, then keep a standing radar on them.
track_employerEverything one employer is hiring for, and a watch on it, wherever its board lives.
vet_employerA checklist of what can be verified about an employer, never a verdict.
before_applyBefore you apply: is the posting still open, is the company real, and what to confirm. One pass, no verdict.
shortlistInstead of sending 300 applications: drop the postings not worth sending to, and say why, before you apply.
evaluate_fitHow a posting fits you: facts from the employer's system against your CV, read on your machine.
tailor_cvReword your CV for one posting on your machine: reorder and rephrase, never invent.
story_bankBuild STAR + reflection interview stories from your real experience, kept on your machine.
outreach_draftDraft a short note to a recruiter or hiring manager for one posting; you send it yourself.
radar_checkWhat is new on my radar since I last looked, and what has closed.
new_this_weekPostings first published in the last 7 days, grouped by employer.

Resources

Contextual data attached and managed by the client

NameDescription

No resources

TDQS

A4.3/5.0

Scored across 14 tools

Disambiguation4/5

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.

Naming Consistency4/5

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.

Tool Count5/5

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.

Completeness4/5

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.

Maintenance

ActivityActive
ResponsivenessOutdated data