200, which an assistant otherwise gets wrong.
The prompts call the API over raw HTTPS, so your code gains no dependency. The 4.0.0 client libraries cover
search_description, the date filters, and include_fields. They lack sort, the occupation filters, and GET /api/occupations (full list).Pick your prompt
| Your situation | Use |
|---|---|
| No search yet: add job search to your app | Add job search |
| You already call the search API: check it for silent failures | Audit an existing integration |
| You’re on a client library older than 4.0.0 | The upgrade prompt, which covers the search changes too |
1
Paste a prompt into your assistant at the root of your repo
Claude Code, Cursor, Copilot, or any assistant that can read and edit your codebase. The copy button on each prompt copies all of it.
2
Answer its questions, then review the diff
Both prompts make the assistant report on your codebase before it changes any code.
Add job search
The assistant builds three pieces in your existing stack: a search service, a server-side route that keeps the API key out of the browser, and a results view with facet-driven filters and pagination. It skips the results view if your app has no frontend.Add-job-search prompt
# Task: add Jobo job search to this application
Wire the Jobo search API into this app: a search service, a thin
route/endpoint of our own, and — if this repo has a frontend — a results view
with filters and pagination.
## First: inspect this repo and confirm the plan
Before writing any code, examine the codebase and report back:
- Language(s), framework, and (if present) the frontend stack
- The HTTP client already in use
- Any caching layer (Redis, in-memory, HTTP cache)
- How config and secrets are handled
Propose where the new code will live, following this repo's conventions, and
wait for my confirmation. Use the existing stack; ask me before adding any
dependency.
## Hard rules
1. Raw HTTP. Build this against the endpoints below rather than installing a
client library — we want no third-party dependency in this path. (Official
clients exist, but 4.0.0 lacks several parameters below, such as `sort` and
the occupation filters; we are choosing not to add one here.)
2. Read the API key from the `JOBO_API_KEY` environment variable (or this
repo's secret mechanism) — server-side only. Never expose the key to a
browser; our own route proxies the search.
3. Use only the endpoints, parameters, and values in this prompt. Do not invent
parameters: the API ignores unknown parameters (flagging them only in
`warnings`) and silently returns zero results for unknown filter values, so
a guess will look like it worked while filtering nothing.
## API basics
- Base URL: `https://connect.jobo.world`. HTTPS only.
- Auth: `X-Api-Key: <key>` header. No `Authorization: Bearer` form, no
query-parameter auth. All JSON is snake_case in both directions.
- `Content-Type: application/json` on POST.
- A live key (`jbe_live_…`) returns real jobs and is billed. A sandbox key
(`jbe_test_…`) returns about 200 fictional jobs in the same shapes, free —
use it for development and tests. The sandbox ignores `sort` and the
occupation filters (POST flags them in an `unknown_request_fields` warning),
its jobs carry no `occupation`, and `GET /api/occupations` returns
`404 not_available_in_sandbox`. Test those against a live key.
## The two search endpoints (same index, same response shape)
### GET /api/jobs — query-string search
Use for simple queries and anything URL-shareable. Parameters:
- `q` — free-text query. Matches title, company, popular skills, and summary
with typo tolerance. Wrap in double quotes (`q="Quantitative Developer"`)
for an exact contiguous phrase match against the title only.
- `search_description` — boolean, default `true`. Set `false` to restrict
matching to titles and curated alternative titles: faster, more precise.
- `location` — ONE location string (city, region, or country).
- `sources`, `work_model`, `employment_type`, `experience_level`, `skills`,
`industries` — comma-separated lists.
- `job_categories`, `job_subcategories`, `occupation_ids` — comma-separated
occupation filters, include only (exclude needs POST). Values come from
`GET /api/occupations` — see "Occupation filters" below.
- `company_categories` — comma-separated company business models: `b2b`,
`b2c`, `b2b2c`, `saas`, `marketplace`, `service-provider`.
- `language` — comma-separated ISO 639-1 codes of the description language,
e.g. `en,de`.
- `posted_after`, `posted_before` — ISO 8601; employer posting date.
- `discovered_after`, `discovered_before` — ISO 8601; first-indexed time.
- `min_salary_usd`, `max_salary_usd` — integers, annual USD.
- `sort` — `relevance` (default), `discovered_asc`, or `discovered_desc`. See
"Sort order" below.
- `page` — 1-indexed, default 1.
- `page_size` — default 25, range 1–100. Out of range IS a real `400`.
- `description_format` — `markdown` (default) or `html`.
- `include_fields`, `include_facets` — see field selection below.
### POST /api/jobs/search — typed JSON body
Use for filter UIs, multiple queries, and include/exclude logic. Body fields:
```json
{
"queries": ["backend engineer", "\"Platform Engineer\"", "-crypto"],
"locations": ["Berlin", "Remote"],
"sources": ["greenhouse", "lever"],
"work_models": ["remote", "hybrid"],
"employment_types": ["full-time", "contract"],
"experience_levels": ["senior", "lead"],
"skills": { "include": ["python"], "exclude": ["php"] },
"industries": { "include": ["fintech"], "exclude": [] },
"companies": { "include": ["Fidelity Investments"], "exclude": [] },
"job_categories": { "include": ["Technology"], "exclude": [] },
"salary_usd": { "min": 120000, "max": 250000 },
"posted_after": "2026-07-01T00:00:00Z",
"search_description": true,
"include_fields": ["summary"],
"include_facets": ["work_model", "sources"],
"page": 1,
"page_size": 25
}
```
- `queries`: entries are OR'd. A double-quoted entry is an exact contiguous
title phrase. A `-` prefix excludes. Max 10 positive entries — exceeding
that IS a real `400` (exclusions don't count).
- Array values are NOT comma-split: `["full-time,contract"]` is one literal
value that matches nothing. Write `["full-time", "contract"]`.
- Also accepted: `languages` (array of ISO 639-1 codes), `company_categories`
and `job_subcategories` / `occupation_ids` as `{include, exclude}`,
`discovered_after`, `discovered_before`, `posted_before`, `sort`, and
`description_format` — same meaning as on GET.
- `companies`: each entry is a Jobo company id, a website domain
(`fedex.com`), or a WHOLE company name, matched case-insensitively. For
names, accents, punctuation and trailing legal suffixes (Inc, Ltd, GmbH, …)
are normalized away, but it is NOT a partial match: `Glenmark` does not find
`Glenmark Pharmaceuticals`. Prefer an id or domain when you have one.
**Never guess a company name.** Resolve it first by searching the name as
free text and reading `company.name` off a result. Unmatched entries come
back in `filters.companies.unmatched` with a `companies_unmatched` warning
and a 200, not an error — an empty result is not evidence that a company has
no openings.
### Canonical filter values (both endpoints)
- work model: `remote` | `hybrid` | `onsite`
- employment type: `full-time` | `part-time` | `contract` | `internship` |
`freelance` | `temporary`
- experience level: `intern` | `entry` | `mid` | `senior` | `lead` | `executive`
- `sources`: ATS ids (`greenhouse`, `lever`, `workday`, …) — an open set of
200+; treat as opaque strings.
### Occupation filters (both endpoints)
Every classified job carries `occupation`:
`{id, name, category, subcategory, isco_code, onet_soc_codes[]}`, e.g.
`{"id": "esco:8d3e8aaa-791b-4c75-a465-f3f827028f50", "name": "Registered Nurse",
"category": "Healthcare", "subcategory": "Nursing", ...}`. On unclassified jobs
it is `null` (the key is always present).
- `job_categories` takes category names (`Healthcare`), `job_subcategories`
subcategory names (`Nursing`), `occupation_ids` occupation ids
(`esco:<uuid>` or `jobo:<slug>`). Exact match, case ignored.
- Get every value from `GET /api/occupations` (FREE, rate-limited):
`{version, categories: [{name, subcategories: [{name, occupations: [{id,
name, isco_code, onet_soc_codes}]}]}]}`. Cache it and refetch it daily;
`version` changes when occupations or the hierarchy change, but display
names can improve without it.
- Filter occupations by `id`, NEVER by name: names can change between taxonomy
releases, ids don't. An occupation name passed to `occupation_ids` matches
nothing (`200`, `total: 0`).
- Four subcategory names contain a comma (`Film, TV & Stage Production`,
`Libraries, Archives & Museums`, `Sports, Fitness & Recreation`,
`Environmental, Health & Safety`). GET comma-splits them into values that
match nothing; send them in the POST array instead.
- Free-text `q`/`queries` does NOT match occupation names. Use the filters.
Job objects return DISPLAY values (`Remote`, `Full-time`, `Senior`,
`Entry Level`) for these same concepts — never feed a display value back in as
a filter. The filter is `work_model`/`work_models`; the field on the job is
`workplace_type`. There is no `is_remote` parameter or field.
## Response envelope
```json
{
"jobs": [ { "...": "job objects" } ],
"total": 12847,
"page": 1,
"page_size": 25,
"total_pages": 514,
"facets": {
"work_model": [ { "key": "remote", "count": 5230 } ],
"sources": [ { "key": "greenhouse", "count": 3120 } ]
}
}
```
Mirrored in headers: `X-Total-Count`, `X-Total-Pages`, `X-Page`, `X-Page-Size`.
Two optional keys can also appear: `warnings` (absent when there is nothing to
report; codes `unknown_request_fields`, `companies_unmatched`,
`companies_exclude_unmatched`, `companies_outside_sources`, mirrored
comma-separated in the `X-Jobo-Warnings` header) and `filters` (only when a
`companies` filter was sent). Log every warning.
Facet keys are canonical filter values — feed them straight back in as filters
to build the filter UI from live counts. Useful job fields for a results list:
`id`, `title`, `company.name`, `company.logo_url`, `locations`, `compensation`,
`workplace_type`, `employment_type`, `experience_level`, `date_posted`,
`listing_url`, `apply_url`. `description` is Markdown by default (line breaks
preserved, light Markdown such as bullets, bold, links); pass
`description_format=html` to receive rendered HTML instead. Sanitize (e.g.
DOMPurify) any HTML you render.
## Three failure modes that still return 200 — design around them
1. An unknown PARAMETER is ignored, not rejected. `?is_remote=true` returns
plausible-looking UNFILTERED results. Search does flag it: the response
carries `warnings[{"code": "unknown_request_fields"}]` and the
`X-Jobo-Warnings` header. Only use the parameter names in this prompt,
spelled exactly, and treat that warning as a bug.
2. An unknown filter VALUE matches nothing. `experience_level=senior level`
returns `200` with `"total": 0`. Send canonical values only; when a filter
is active and `total` is 0, treat it as a possible value bug, not just an
empty result.
3. Deep pagination is CLAMPED, not rejected. Results are capped at a depth of
100,000 (`page × page_size`); requests past it silently return the last
reachable page (the response's `page` reports the clamped page, while
`total_pages` is not capped). Cap our pagination UI at that depth. Bulk
export must not page through search at all — that's what
`POST /api/jobs/feed` is for (guide:
https://jobo.world/docs/guides/sync-a-database). Flag it if we need one;
don't build it here.
## Sort order
`sort` takes exactly three values; anything else is a `400`.
- `relevance` (default): match quality with a text query (ties broken by
posting date); most recently updated first without one. It shifts as jobs
update, so paging through a large result set can skip or repeat a job.
- `discovered_asc` / `discovered_desc`: by first-indexed time (`created_at`),
which never changes, so pages stay stable across a fixed
`discovered_after` / `discovered_before` window.
There is no sort by salary, title, or posting date — do not promise those
sortable columns in the UI.
## Field selection — keep responses small
`include_fields` controls the heavy, non-core fields: `description`, `summary`,
`qualifications`, `responsibilities`, `benefits`.
- Omit the parameter → full job objects (every field).
- Empty value (`?include_fields=` / `"include_fields": []`) → core fields only.
- A subset → core fields plus just those.
`external_id` is a core field containing the collected ATS or job-board job
identifier. It is omitted when unavailable. Keep it as a string, including
leading zeros; it is not necessarily an employer requisition number or globally
unique. Use Jobo's `id` for single-job lookups and upserts.
For a results list, request core fields only — `description` dominates payload
size. Fetch the full job for a detail view with `GET /api/jobs/{id}`, which is
FREE and returns the same job shape. Excluded fields come back empty, not
absent: `description` as `""`, `summary` as `null`, list fields as `[]`
(`qualifications` with empty buckets). The same three-state rule applies to
`include_facets` (allowed: `work_model`, `experience_level`,
`employment_type`, `sources`, `industries`, `skills`, `countries`,
`company_categories`, `job_categories`, `job_subcategories`, `occupations`;
omit for the default low-cardinality set).
Unknown names in either parameter are silently dropped — spell them exactly.
The three occupation facets are opt-in. Their keys are lowercased names
(`healthcare`, `nursing`) or occupation ids, valid as filter values as-is, and
each bucket also carries `name` (display name) and `parent` (the key one level
up: a subcategory's category, an occupation's subcategory). Nest them by
`parent` for a category › subcategory › occupation filter tree. At most 8
buckets per facet — use `GET /api/occupations` for the full tree.
## Errors, retries, and cost
- Retry ONLY 429, 500, 503. On 429/503 wait at least the `Retry-After` header
(seconds); on 500, or a 503 without `Retry-After`, back off exponentially
(1s, doubling to a 30s cap, ±25% jitter); cap at 3–5 attempts. Everything
else: fix the request.
- `401` key problem; `402` means the remaining included jobs plus wallet
cannot cover the request — stop and surface, don't retry without funding or a
smaller page. Branch on status code + body fields, not Content-Type.
- Log the `x-correlation-id` response header on failures.
- Budget: search shares one account-wide budget across all your keys
(`X-RateLimit-Group: JobSearch`) — 20 requests/min without a subscription,
up to 180/min on Scale or Unlimited. Slow down when `X-RateLimit-Remaining`
drops below ~15% of `X-RateLimit-Limit`. Metering is per DELIVERED
job, so keep `page_size` at what the UI actually shows (10–25), debounce
user input, and cache identical queries server-side for a few minutes if
this repo has a cache.
## Deliverables
1. A search service module wrapping the two endpoints with the retry rules,
typed to this repo's conventions.
2. Our own route/endpoint that proxies search (key stays server-side),
validating `page_size` ≤ 100 and clamping page depth to 100,000.
3. If this repo has a frontend: a results view with pagination, facet-driven
filters (checkboxes fed from `facets`), and empty-state handling that
distinguishes "no matches" from "a filter value matched nothing".
4. Tests with mocked HTTP: canonical-value mapping (UI label → canonical key),
the 10-positive-queries limit surfaced as a user error, retry honoring
`Retry-After`, and the deep-pagination clamp.
## Acceptance checklist
- [ ] No `jobo-enterprise` / `Jobo.Enterprise.Client` dependency.
- [ ] Every filter value sent is a canonical lowercase key from this prompt.
- [ ] No invented parameters — no `order`, no `is_remote` — and `sort` only
with one of its three values.
- [ ] Results list uses `include_fields` to skip `description`; detail views
use free `GET /api/jobs/{id}`.
- [ ] Pagination UI capped at 100,000 depth; `page_size` ≤ 100.
- [ ] API key never reaches the browser.
Audit an existing integration
A search integration can be wrong while every request returns200. This prompt checks 14 items. The 5 critical ones are invented parameters, display values sent as filters, a client library below 4.0.0, comma-joined arrays, and deep-pagination loops. The assistant reports each finding with its file and line before changing any code.
Audit-a-search-integration prompt
# Task: audit our Jobo search integration for silent failures
Our app calls the Jobo search API. The API has failure modes that
return `200` while doing the wrong thing, so bugs here don't throw — they
quietly misfilter. Find our integration, audit it against the contract below,
and report findings before changing any code.
## Locate the integration
Search the codebase for: `connect.jobo.world`, `jobs-api.jobo.world`,
`/api/jobs`, `X-Api-Key`, `jobo_enterprise`, `jobo-enterprise`,
`Jobo.Enterprise.Client`. Map every call site before auditing.
## Contract reference (current, condensed)
- Base `https://connect.jobo.world` (HTTPS only), header `X-Api-Key`, JSON
snake_case. Endpoints: `GET /api/jobs` (query-string),
`POST /api/jobs/search` (JSON body), `GET /api/jobs/{id}` (free),
`GET /api/occupations` (free; the occupation taxonomy).
- Canonical filter values — the ONLY values that match:
work model `remote|hybrid|onsite`; employment type
`full-time|part-time|contract|internship|freelance|temporary`; experience
level `intern|entry|mid|senior|lead|executive`. Jobs return display values
(`Remote`, `Full-time`, `Senior`) which do NOT work as filters.
- GET params: `q`, `search_description`, `location` (single), `sources`,
`work_model`, `employment_type`, `experience_level`, `skills`, `industries`,
`company_categories`, `language`, `job_categories`, `job_subcategories`,
`occupation_ids` (comma-separated), `posted_after/-before`,
`discovered_after/-before`, `min_salary_usd`, `max_salary_usd`, `sort`
(`relevance` default | `discovered_asc` | `discovered_desc`; any other value
is a `400`), `page`, `page_size` (1–100), `description_format`
(`markdown` default | `html`), `include_fields`, `include_facets`. POST
body: same concepts as arrays (`work_models`, `employment_types`,
`experience_levels`, `languages`), plus `queries[]` (OR'd; quoted = exact
title phrase; `-` excludes; max 10 positive), `locations[]`,
`companies`/`skills`/`industries`/`company_categories`/`job_categories`/
`job_subcategories`/`occupation_ids` as `{include, exclude}`, `salary_usd`
`{min, max}`.
- Unknown parameter names are ignored, not rejected. Search flags them in
`warnings` (`code: unknown_request_fields`) and the `X-Jobo-Warnings`
header, but the results are unfiltered either way.
- Occupations: jobs carry an `occupation` object (null when
unclassified). Filter it by `occupation.id` via `occupation_ids`, never by
name — names change. Values come from the free `GET /api/occupations`.
`q`/`queries` does not match occupation names.
- Response: `{jobs, total, page, page_size, total_pages, facets, warnings?,
filters?}`; facet keys are canonical filter values. Opt-in facets
`job_categories`, `job_subcategories`, `occupations` also carry `name` and
`parent`. `description` is Markdown by default (HTML with
`description_format=html`).
- Retryable: exactly 429/500/503. On 429/503 wait at least `Retry-After`
(seconds); on 500, or a 503 without it, back off exponentially (1s doubling
to 30s, ±25% jitter), 3–5 attempts.
- Rate limits: `JobSearch` group, 20/min with no subscription, 30–180/min by
Job Search plan (180/min on Scale and Unlimited), plus hourly and daily
windows (see https://jobo.world/docs/rate-limits); metered per delivered job.
## Checklist — verify each item, citing file and line
### Critical — silent wrong results
1. **Invented parameters.** Grep our call sites for anything not in the
contract above — `order`, `is_remote`, `remote=true`, `seniority`,
`job_type`, etc. Any hit means that "filter" has never done anything and
users saw unfiltered results. Also check whether we read `warnings` at all.
2. **Display values sent as filters.** `work_model=Remote` or
`experience_level=Senior` returns `200` with `total: 0`. Check every place
a filter value originates (UI labels, DB columns, config) and confirm the
mapping to canonical lowercase keys.
3. **Client library below 4.0.0.** Those versions don't expose
`search_description`, `posted_before`, `discovered_after`/`_before` or
`include_fields`, and their enums are missing `freelance` and `intern`, so
any call site needing those is either hand-rolling query strings or simply
not using them. Report the pinned version of `jobo-enterprise` or
`Jobo.Enterprise.Client`; 4.0.0 exposes all of them, and the upgrade is the
smallest fix available.
4. **Comma-joined POST arrays.** On `POST /api/jobs/search`, array values are
not comma-split: `["full-time,contract"]` is one literal value matching
nothing. Check how we build arrays from multi-select UI state.
5. **Deep pagination assumptions.** Depth is clamped at 100,000
(`page × page_size`) — silently. If we page through search to export or
mirror jobs, that loop cannot see the whole corpus and burns credits;
flag it for migration to `POST /api/jobs/feed` (cursor-based and stable for bulk sync; same job pricing).
### Important
6. Zero-result handling: when a filter is active and `total` is 0, do we
distinguish "no matches" from "the filter value matched nothing"? Add
logging/telemetry on filtered zero-result queries if absent.
7. `page_size` within 1–100 (out of range is a real `400`); `queries` capped
at 10 positive entries (a real `400`).
8. Cost: results lists should use `include_fields` to exclude `description`;
detail views should use free `GET /api/jobs/{id}` instead of re-searching;
identical queries cached if this repo has a cache; input debounced.
9. Retry set exactly {429, 500, 503} with `Retry-After` honored; no retries on
4xx validation errors.
10. `description` rendered as Markdown or plain text (it is Markdown by
default; HTML only with `description_format=html`), with any HTML
rendered from it passed through a sanitizer in a web UI.
11. API key server-side only — never shipped to a browser bundle; loaded from
env/secret store, not hardcoded.
### Minor
12. Base URL is `connect.jobo.world`. Any call site still on the retired
`jobs-api.jobo.world` is failing outright (the hostname no longer
resolves) — report it as critical.
13. `x-correlation-id` logged on failures; pacing reads
`X-RateLimit-Remaining` instead of reacting to 429s.
14. Facets: unknown names in `include_facets` are silently dropped — check
spelling (`work_model`, `experience_level`, `employment_type`, `sources`,
`industries`, `skills`, `countries`, `company_categories`,
`job_categories`, `job_subcategories`, `occupations`).
## Report format
A table — severity, finding, file:line, user-visible impact, proposed fix —
followed by a short paragraph per critical finding. For findings 1–3, estimate
how long the bug has been live (git blame) and what users experienced. Then
STOP and wait for my approval before implementing fixes.
After the assistant is done
Whichever prompt you used, check these by hand:- Every filter value sent is a canonical lowercase key, and no parameter appears that isn’t in the docs.
- Detail views use
GET /api/jobs/{id}(free), not a repeated search. - Nothing installed
jobo-enterpriseorJobo.Enterprise.Client.
jbe_test_…): search is free there and returns about 200 fictional jobs in the live response shape. The sandbox ignores sort and the occupation filters, so test those with a live key.
Search recipes
The same contract in prose: working filter combinations, relevance, facets, and field selection.
The job object
Every field, its type, nullability, and the enum send/receive table.

