RSS/Atom and most niche boards land on dedicated tools unless each tenant
has a stable, roster-able feed URL and multi-company routing is worth it.
-
Spec hunt — WebSearch for an official OpenAPI/Swagger spec or
developer API docs before reverse-engineering anything (e.g.
" API openapi spec", " developer docs"). An
official spec beats a hand-derived one: fewer wrong guesses about
types, nullability, and pagination. If one exists, trim it down to
the endpoints you need rather than writing from scratch. Official or
not, the spec still gets verified against captured traffic in the
next step — vendor specs drift from what the public endpoints
actually return. No official docs does not mean no integration; it
means recon (step 2) owns the surface choice.
-
Recon + fixtures — Recon means reconnaissance: before writing a
client, discover how the site actually exposes listings, confirm the
calls work outside a browser, and pick the best surface. Do not assume
JSON REST.
Surface ranking (prefer higher when it is public, stable, and
replayable without login or a browser):
- Public JSON REST (OpenAPI + ogen path)
- Public GraphQL (hand-written client; see Indeed)
- JSON Feed / structured feed (e.g. Teamtailor
/jobs.json)
- RSS / Atom (list dump; only when the quality bar below holds)
- SSR-embedded state (
__NEXT_DATA__, var Stash = {...}, JSON
smuggled in HTML — join, jobindex, UltiPro detail)
- Pure HTML / JSON-LD scrape (LinkedIn, iCIMS)
- Browser automation (Playwright) — openings-mcp avoids this
unless a later decision explicitly allows it
When the endpoint isn't guessable or refuses direct requests, drive a
real browser with your browser-automation tool: load the careers page,
perform a search or open a posting, then read the network requests it
fired — URL, query params, and required headers (in Claude Code: the
Browser pane's navigate / computer plus read_network_requests).
Also check for <link rel="alternate" type="application/rss+xml">,
Atom, or application/feed+json if no JSON API appears. Replay the
recovered request outside the browser to confirm it works standalone.
Capture each operation as a hurl request + response pair in
internal/provider/<name>/testdata/ (happy path, filtered search when
applicable, not-found, unknown company). Fixtures are real captures:
JSON, XML (RSS/Atom), or HTML — never hand-written bodies.
make hurl-test replays them live; make hurl-fmt before committing.
RSS / Atom adoption bar (all should hold; otherwise keep looking or
treat the feed as a list-only MVP with an explicit detail gap):
- Stable per-item id (
guid, or a durable link used as id)
- Enough fields for search (at least title + link); short descriptions
need a replayable detail path (second request or linked HTML)
- Fixed or constructible feed URL, no login
- Prefer a higher-ranked surface when the same site also exposes one
RSS is a legitimate surface for niche boards. It is usually a full
dump + client-side filter shape, not server-side search. Do not choose
RSS to save time when a stable public JSON API already exists.
-
Client — first classify the chosen surface's search shape (this
decides adapter behavior regardless of transport):
- Server-side search: list/search with query params and pagination,
plus detail-by-id (workday, smartrecruiters).
- Full dump: one response returns the whole board; search happens
in our code via
searchDump (internal/ats/filter.go) for ATS
adapters (greenhouse, lever, ashby). Dump-style boards/feeds use the
same idea locally. A separate detail endpoint may still exist, or the
dump may already carry full descriptions.
Then build the client for the surface:
- JSON REST — minimal
internal/provider/<name>/openapi.yaml
covering only the endpoints you use (trimmed official spec or written
from captures). Mark fields nullable per real responses (see
docs/superpowers/plans/2026-07-11-provider-schema-nullable-sweep.md).
Add gen.go with the ogen go:generate line, run
go generate ./internal/provider/<name>, add the spec to
OPENAPI_SPECS in the Makefile, run make validate-openapi.
- GraphQL, HTML, SSR blob, RSS/Atom, JSON Feed — hand-written
client (
client.go / parse.go). Document the surface and quirks in
doc.go and, when reverse-engineering is non-obvious, API.md.
For RSS/Atom, default to
github.com/mmcdole/gofeed (one
API for RSS and Atom, including common real-world feed variations).
Reserve direct encoding/xml for unsupported vendor extensions or
provider-specific fields gofeed does not surface. Use goquery for
HTML / __NEXT_DATA__. Skip ogen and OPENAPI_SPECS unless you
later gain a true REST OpenAPI surface.
-
Provider package — mocksrv.go replays the testdata fixtures;
client_test.go exercises the client against it. Roster-based
providers add companies.yaml + companies.go (embedded via
go:embed, validated at init, sorted by name). Seed the initial
roster with 3–5 companies: WebSearch for well-known companies hosted
on this ATS (e.g. "site: ..." or " customers"),
then confirm each against the live API before adding it — a real
request must return 200 with jobs present and a matching company name
(the smartrecruiters roster documents this bar). Bulk expansion comes
later in step 7; the seed roster just has to prove the pipeline
end-to-end.
-
Debug CLI — cmd/<name>/main.go using ff/v4 with search,
detail, and companies subcommands for live manual checks. Validate
pagination flags and reject stray positional args (mirror
cmd/smartrecruiters). Do not add cmd/<name>/doc.go; package-level
documentation belongs only in internal/provider/<name>/doc.go.
-
MCP surface
- ATS adapter:
internal/ats/<name>.go implementing Adapter
(Name, Roster, ParseCareersURL, Search, Filters, Detail) + tests.
Register it in newATSRegistry (cmd/openings-mcp/main.go), add its
careers-URL host pattern to careersHostPatternsByAdapter
(internal/ats/registry.go), and add it to providerOrder
(cmd/verify-companies/main.go).
- Dedicated tools:
internal/openingsmcp/<name>.go with
Register<Name> + tests; wire the client in newServer.
Finish the stage with a live smoke test through the real MCP path:
sample 3–5 companies from the provider's companies.yaml and send
actual MCP requests into the server — every sampled company must
return live listings via search_jobs_by_company, and
get_job_detail_by_company must work on at least one returned
job_id. Over stdio that is initialize → notifications/initialized →
tools/call, keeping stdin open (trailing sleep) so the server
doesn't EOF before answering:
(echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"smoke","version":"0"}}}'
echo '{"jsonrpc":"2.0","method":"notifications/initialized"}'
echo '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"search_jobs_by_company","arguments":{"company":"<roster company>"}}}'
sleep 15) | go run ./cmd/openings-mcp
Dedicated-tool providers have no roster: run the same requests
against the new <name>_search_jobs / <name>_get_job_detail
tools with a few real queries instead.
-
Roster curation — bulk-discovered candidates go in
unverified/<name>.yaml; verify entries with cmd/verify-companies
(runs the real adapter path) before promoting them into the curated
companies.yaml. Follow the roster commit convention in CLAUDE.md.
-
Docs — update the README provider list and, if tool-selection
guidance changes, the server instructions in cmd/openings-mcp.