Skip to main content
Glama
gzchenhao

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.

This server publishes no instructions, or was last inspected before Glama recorded them.

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[].

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.

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

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 | ops | other. Any other value is refused (ERR_UNKNOWN_ROLE_FAMILY) rather than ignored: "recruiting" used to pass through and return the whole index. There is NO hr / recruiting / people family — those roles are filed under ops; use title to isolate them. 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: HR, recruiting, finance, legal, a specific team name. 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. One synonym group is expanded for you: any of hr / hrbp / human resources / recruit / talent acquisition / sourcer / people ops / 人力 / 人事 / 招聘 reaches all the others, so title=["招聘"] also finds an English "Senior Technical Recruiter". Combine with company to ask "does have any HR openings?" instead of paging their whole list. 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) 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". 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.

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.

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) 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 its OWN anonymous fingerprint (e.g. "#a3f9-k2p7-x8q1"; make it 12+ random characters, a four-character tag collides with strangers) — the client generates and 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). Any other key is REFUSED (ERR_UNKNOWN_FILTER) rather than silently dropped, and a role_family outside engineering | data | product | design | marketing | sales | ops | other is refused too (ERR_UNKNOWN_ROLE_FAMILY).

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.

Returns { watch_id, status, fingerprint, existing_watches, fingerprint_notice }. existing_watches > 0 means this fingerprint was already in use; if those watches are not yours, pick a longer random fingerprint.

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 and 蔚来 NIO, 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.

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.

The FIRST pull on a watch returns everything matching it, not an increment: nothing has been reported for that watch before, so the whole standing set is new to the user. Each result says which it is via is_first_pull; do not present a first pull to the user as "postings that appeared since last time".

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.

authorize_applicationA

Record an authorized, employer-direct application. REFUSES résumés.

(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

No prompts

Resources

Contextual data attached and managed by the client

NameDescription

No resources

TDQS

A4.5/5.0

Scored across 6 tools

Disambiguation4/5

The watch pair (watch_intent / check_watches) is clearly split by register-vs-pull semantics, and refresh_index, get_company_info and authorize_application each occupy a distinct role. The only mild overlap is between search_jobs filtered by `company` and get_company_info, both of which answer employer-scoped questions, but the descriptions explicitly delineate postings vs. aggregate trust signals.

Naming Consistency5/5

All six names are snake_case, verb-first constructions (watch_intent, check_watches, get_company_info, refresh_index, authorize_application, search_jobs). No camelCase drift, no vague single-word verbs, and the noun half consistently names the resource or action target.

Tool Count5/5

Six tools for a search + standing-watch + employer-trust + apply-handoff domain is well-scoped, and each one maps to a distinct stage of the workflow rather than being a near-duplicate. Nothing appears padded, and no obvious capability is crammed into an overloaded mega-tool.

Completeness4/5

Core lifecycle is covered: search, register a watch, pull new matches, force a single-employer re-crawl, inspect employer trust signals, and record an authorized application. The notable gap is watch management — there is no way to list or delete standing watches, and no direct job-detail fetch, though search results carry full posting fields so agents can work around it.

Maintenance

ActivityActive
ResponsivenessNo issues