Job alerts for AI agents: describe a role once, get only new postings from 1,500+ tech job boards.
One address for any MCP client. No sign-in needed on this server.
3 read1 act2 other
Grouped by tool name.
Odel charges nothing per call. The publisher may charge for its own service.
Tech job monitoring for AI agents. Say what you are looking for once and get only the new postings that match, from the job boards of tech companies and startups, as structured JSON.
Agents are bad at waiting for a job to be posted. A session lasts minutes, careers pages are heavy to re-read, and "has anyone posted an iOS role in Austin yet?" turns into re-running the same search every day. With Watchtower the agent creates a watch once, in plain language:
{ "query": "iOS jobs in Austin making at least 150k a year with a maximum of 6 years of experience" }
Watchtower checks the job boards of tech companies and startups on a schedule and remembers which jobs were open. get_changes then returns only the new postings that match, and a webhook can wake the agent when one arrives. An agent can also watch one company's board by URL and get JOB_ADDED, JOB_REMOVED and JOB_UPDATED events.
query covers every board Watchtower monitors: a built-in directory of tech company and startup boards plus every board anyone has watched by URL.salary and experience_years when the posting states them, and watches filter on min_salary and max_experience_years.jobs.apple.com), Google (careers.google.com), Amazon (amazon.jobs) and Microsoft (careers.microsoft.com), which run their own careers sites. Any other careers page works if it publishes schema.org JobPosting markup.get_changes only returns what matters: keywords, all_keywords, exclude_keywords, locations, seniority, remote_only, min_salary and max_experience_years. Every job carries derived remote and seniority fields.urls instead of url.Watchtower runs at watchtower.lat, free, with no sign-up. MCP endpoint: https://watchtower.lat/mcp.
claude mcp add --transport http watchtower https://watchtower.lat/mcp, or install the plugin, which adds a skill that tells Claude when to use it:
/plugin marketplace add connorlagana/watchtower
/plugin install watchtower@watchtower
https://watchtower.lat/mcp.{ "mcpServers": { "watchtower": { "type": "http", "url": "https://watchtower.lat/mcp" } } }.Listed in the MCP Registry as lat.watchtower/watchtower (server.json).
cp .env.example .env
docker compose up --build # app on http://localhost:3000, Postgres alongside
docker compose --profile demo run --rm demo # the end-to-end demo below
Without Docker (Node 22 and a running Postgres):
npm install
export DATABASE_URL=postgres://postgres:postgres@localhost:5432/watchtower
npm run migrate
npm run dev # or: npm run build && npm start
npm run demo (with DATABASE_URL set) runs the core loop end to end against a local fixture careers page:
ios (takes the initial snapshot) → 3. a second agent watches the same board and reuses the same resource → 4. a third agent names no board and creates a search watch from "remote iOS jobs paying at least 150k with a maximum of 6 years of experience" → 5. a re-check where only the page around the jobs changed (session token, "N minutes ago") reports no change → 6. the source adds an iOS job and an Android job → 7. Watchtower checks and detects the change → 8. an MCP client calls get_changes:{
"changes": [
{ "type": "JOB_ADDED",
"summary": "New job: Senior iOS Engineer (Remote - US)",
"data": { "job": { "title": "Senior iOS Engineer", "location": "Remote - US", "company": "Acme Robotics", "url": "…/careers/ios-303" } } }
],
"cursor": 2, "has_more": false
}
The Android job is filtered out by the keyword. 9. A second get_changes call returns []. 10. The second agent, with no keyword, sees both new roles. 11. The third agent gets the iOS role with the salary and experience read from the posting.
The demo runs its fixture on 127.0.0.1, so it turns on ALLOW_PRIVATE_NETWORKS for its own process only.
{ "mcpServers": { "watchtower": { "type": "http", "url": "http://localhost:3000/mcp" } } }
| Tool | What it does |
|---|---|
watch_jobs | With query and no URL: watch every monitored board for new postings that match a plain-language request. The result shows the reading (interpreted), the matching jobs open now (current_jobs) and the boards covered (coverage). With url, or urls (up to 25; returns watches and per-URL errors): watch specific boards. Greenhouse, Lever, Ashby, Workable, SmartRecruiters, Recruitee, Workday and iCIMS use their own endpoints; other careers pages use schema.org JobPosting. A careers page that only links to a supported board is watched through that board (resolved_from), and a page with neither is rejected with NO_JOB_DATA. Board watches emit JOB_ADDED / JOB_REMOVED / JOB_UPDATED; search watches emit JOB_ADDED. Explicit filters override the query: keywords, all_keywords, exclude_keywords, locations, seniority, remote_only, min_salary, salary_currency, max_experience_years, include_unknown. |
search_jobs | Read-only, one-off: the jobs open right now that match a query and/or the same filters, across every monitored board, newest first. Returns interpreted, total, jobs and next_offset (limit up to 100, offset). Needs no token and creates no watch. REST: POST /v1/jobs/search. Limited to SEARCH_PER_MINUTE (20) calls per address. |
list_companies | Read-only: the companies in the directory (what every search covers), by name, with board_url, platform and open_jobs. query checks for one company; pages with limit/offset. Needs no token. REST: GET /v1/companies?q=. People can browse the same list at /companies. |
get_changes | Changes since your last call (the cursor advances). peek, since (replay), watch_id, limit. |
ack_changes | Acknowledge a cursor after get_changes(peek=true), for at-least-once processing. |
list_watches | Your watches, with health, expiry and pending-change counts. |
get_watch | One watch plus the jobs currently open that match its filters (across all boards for a search watch). |
delete_watch | Stop monitoring and free a slot. |
watch_jobs also accepts webhook_url for push delivery (see below).
The tool descriptions and server instructions tell agents to prefer Watchtower over re-running job searches or re-checking careers pages.
A watch with no url is a search watch. It is its filters, and it reads the new postings of every monitored board.
curl -s -X POST localhost:3000/v1/watches -H "Authorization: Bearer $TOKEN" -H "content-type: application/json" \
-d '{"query":"iOS jobs in Austin making at least 150k a year with a maximum of 6 years of experience"}'
{
"scope": "all_boards",
"interpreted": { "filters": { "keywords": ["ios"], "locations": ["austin"], "min_salary": 150000, "max_experience_years": 6, "include_unknown": true }, "notes": [] },
"coverage": { "boards": 1705 },
"matching_jobs_count": 3,
"current_jobs": [ { "title": "Senior iOS Engineer", "company": "Acme", "location": "Austin, TX", "salary": { "min": 165000, "max": 210000, "currency": "USD", "period": "year", "annual_min": 165000, "annual_max": 210000 }, "experience_years": 5, "url": "…" } ]
}
interpreted, with notes for anything it could not use, so the calling agent can check it. Explicit filters always win over the query.
keywords (any of, for "iOS or Android") or all_keywords (all of, for "data scientist"). Generic words such as "engineer" and "developer" are dropped when a more specific word is present.locations. A bare "remote" becomes remote_only.min_salary, converted to a yearly figure.max_experience_years.seniority. "no managers" becomes exclude_keywords.src/search/boards.ts) lists 1,705 company boards on Greenhouse, Lever, Ashby, Workable, SmartRecruiters, Workday, iCIMS, Oracle Recruiting, Eightfold and SuccessFactors, plus the careers sites of Apple, Google, Amazon and Microsoft, from seed-stage startups to large employers in every industry that hire developers. Each was confirmed against its platform's API with open jobs. Every role those companies post is covered, not only engineering. The live list is published at /companies and through list_companies. On top of that, every board any client watches by URL is covered, and stays covered (see Growing the directory).min_salary when the top of its range reaches it, and max_experience_years when it asks for no more than that.salary or experience_years field, because many postings state no pay. Pass include_unknown: false to report only postings that state a qualifying value.JOB_ADDED), and only those that appear after the watch was created. current_jobs on creation and on get_watch is the baseline of what is open now.salary or experience_years. Apple and Oracle Recruiting listings carry only a short summary, which rarely states either.locations never matches them.The directory grows in three ways.
INDEX_MAX_PROMOTED (5,000) boards are kept this way, and one that fails eight checks in a row is dropped. Arbitrary careers pages are never kept; only boards on the platform APIs.watch_jobs with https://acme.com/careers usually finds no job markup there, because the page only links to or embeds the company's board. Watchtower then reads the page once more, finds the link to a supported board, and watches that board instead. The response says so in resolved_from.npm run discover -- companies.json boards.txt takes [{ "name", "website" }] and, for each company, reads its careers page for a board link, then tries boards named after the company on Ashby, Greenhouse and Lever. Every candidate is confirmed against the platform's listing API and kept only if it has open jobs. boards.txt is in the format INDEX_BOARDS_FILE reads. The built-in list was produced this way from the public Y Combinator company directory. A board found by name rather than from the company's own page can belong to a different company with the same name; the .json output records how each board was found.Auth: the MCP connection should carry the identity, so a token never has to pass through a chat (careful assistants refuse to copy secrets from a transcript into tool calls, especially into delete_watch).
/.well-known/oauth-protected-resource/mcp points at the built-in authorization server (/.well-known/oauth-authorization-server): dynamic client registration at /oauth/register, /oauth/authorize with PKCE (S256), /oauth/token. There are no accounts: the consent page creates an anonymous client in one click, or connects an existing one if the user pastes its token there, which keeps its watches. New clients draw on the same per-address budget as POST /v1/clients.search_jobs and list_companies still work, and the other watch tools (and watch_jobs once provisioning is off) return UNAUTHORIZED with the challenge in _meta["mcp/www_authenticate"] (ChatGPT's mixed-auth signal; tools also declare securitySchemes). An unknown token gets an HTTP 401 with WWW-Authenticate, as does any request to /mcp?auth=required without one, for clients that only start OAuth on a 401 (Claude Code, Cursor).Authorization: Bearer <token> with a token from POST /v1/clients.client_token, and while MCP_ANONYMOUS_PROVISIONING is on (the default) a watch_jobs call with no token at all creates an anonymous client and returns its token, as before. When a call carries both an OAuth connection and a client_token, and the connection's own client has no watches, the connection is moved to the client_token's client, so a user who connects the app keeps their watches. Turn provisioning off once the OAuth version of the ChatGPT app is live; tokenless watch_jobs calls then get the OAuth challenge and the watch tools declare OAuth as required.All endpoints except POST /v1/clients need Authorization: Bearer <token>.
| Method & path | |
|---|---|
POST /v1/clients | Create an anonymous client. Returns token (shown once). Rate-limited per address. |
POST /v1/watches | { "query"?, "url"? | "urls"?, "keywords"?, "all_keywords"?, "exclude_keywords"?, "locations"?, "seniority"?, "remote_only"?, "min_salary"?, "salary_currency"?, "max_experience_years"?, "include_unknown"?, "interval_minutes"?, "label"?, "webhook_url"? }. With neither url nor urls it creates a search watch across all monitored boards and needs at least one filter (QUERY_TOO_BROAD otherwise). With urls the response is { watches, errors }. "type": "jobs" is accepted for older clients. |
GET /v1/watches | List watches. |
GET /v1/watches/:id | Watch detail and current state. |
DELETE /v1/watches/:id | Delete. |
POST /v1/watches/:id/check | Force a check. Refused if the resource was checked within MIN_CHECK_INTERVAL_SECONDS, and for search watches (NOT_SUPPORTED). |
GET /v1/changes | ?watch_id=&since=&limit=&peek=. Returns { changes, cursor, has_more }. |
POST /v1/changes/ack | { "cursor", "watch_id"? }. Acknowledges changes read with peek=true. |
Also served: / (homepage/docs), /llms.txt, /.well-known/watchtower.json, /health, and /metrics (Prometheus; set METRICS_TOKEN to require a bearer token).
Errors look like { "error": "WATCH_LIMIT", "message": "…" }.
NO_JOB_DATA (no supported platform and no JobPosting markup), ROBOTS_DISALLOWED, BOT_CHALLENGE, ACCESS_DENIED, SSRF_BLOCKED, UNSUPPORTED_CONTENT_TYPE, BODY_TOO_LARGE.WATCH_LIMIT and HOST_WATCH_LIMIT (409), HOST_CAPACITY (429), CAPACITY (503).resource.last_error and are retried with exponential backoff.Polling. get_changes returns what's new and advances the cursor. For at-least-once processing, call get_changes(peek=true), process the changes, then ack_changes(cursor). If you crash before the ack, the same changes come back.
Webhooks. Pass webhook_url when creating a watch. The creation response includes a webhook_secret, shown once. Each batch of matching changes is POSTed as JSON with these headers:
x-watchtower-timestampx-watchtower-signature: sha256=<hex HMAC-SHA256 of "<timestamp>.<body>">x-watchtower-deliveryDeliveries come from an outbox and are retried with exponential backoff (8 attempts). Webhook URLs go through the same SSRF checks as monitored URLs, and redirects are not followed.
A watch stays alive as long as someone uses it: get_changes, get_watch, list_watches or a successful webhook delivery renews it. Watches nobody touches for WATCH_TTL_DAYS (30) expire and stop costing fetches; expires_at is shown on every watch.
watches (per client) ──many-to-one──▶ resources (fetch url + adapter)
│ scheduler: due? one per host, host lease held
▼
fetch ─▶ job-board API adapter, or JobPosting JSON-LD
│ diff the job set vs the previous snapshot
▼
snapshot + typed job events, one transaction
│ read through each watch's keyword filter
▼
get_changes (per-watch cursor) · webhook outbox → signed POST
A search watch has no resource of its own. It reads the same change events, from every monitored resource, through its filters.
The board directory. Boards in the directory are ordinary resources flagged indexed. The scheduler checks them every INDEX_CHECK_INTERVAL_SECONDS (default four hours) whether or not anyone watches them, and the listed part of the directory is synced from src/search/boards.ts and INDEX_BOARDS_FILE at startup. One request per platform host is in flight at a time, so a platform's boards are checked one after another: with the default 5 s scheduler tick that is about 700 boards per platform per hour, or 2,900 per four-hour cycle. A directory larger than that is not an error; boards are checked as fast as politeness allows, and watchtower_directory_overdue_boards shows how far behind it is. A board with a watch on it is still checked at that watch's interval.
Listing requests carry the posting text. Greenhouse (content=true), Lever, Ashby (includeCompensation=true), Workable (details=true) and Recruitee return each posting's description and pay in the listing response, so salary and experience cost no extra requests. Only the derived fields are stored, never the description. These responses are large, so platform APIs get their own body cap (MAX_API_BODY_BYTES, 64 MB); a board larger than that is read as a plain listing, without pay or experience.
Board URLs map to platform endpoints. boards.greenhouse.io/acme, jobs.lever.co/acme, jobs.ashbyhq.com/acme, apply.workable.com/acme, jobs.smartrecruiters.com/Acme and acme.recruitee.com (plus their EU and embed variants) are fetched from each platform's public job-board API in one request. Other URLs are fetched as HTML and read through schema.org JobPosting JSON-LD (including @graph and ItemList). Only the job list is compared, so page churn around it (session tokens, "rendered N minutes ago", banners) never registers as a change.
acme.wd5.myworkdayjobs.com/Careers, wd3.myworkdaysite.com/recruiting/acme/External): the site's own search endpoint, a JSON POST that returns postings newest first, 20 per page. A check reads the newest 200 (10 requests). Boards with more postings are marked complete: false on the snapshot and never emit JOB_REMOVED, because a job leaving the window isn't a removal. Job links point at the public site; the relative "Posted 3 Days Ago" is not kept.careers-acme.icims.com): the portal's sitemap.xml lists every open job in one request (id plus a slug of the title), and the first page of /jobs/search gives the newest ~50 their real title, location and posting date. A check reads both. Jobs seen only in the sitemap are partial: true (title from the slug, no location); details learned earlier are carried forward, and a partial entry becoming a full one is not reported as an update.jobs.apple.com URL): the site's own search endpoint (/api/v1/search), a JSON POST that needs no session and returns roles newest first, 20 per page, with title, team, every location and posting date. A check reads the newest 300 (15 requests) and is marked complete: false, as for Workday. About 80 evergreen retail roles are stamped with the request time, so they always sort first and use part of that window; that timestamp is not kept as posted_at. Apple posts one role per location, so a role open in three cities is three jobs.careers.google.com, www.google.com/about/careers/applications): Google's robots.txt disallows the job search and job pages, so a check reads only the jobs sitemap, which lists every open role in one request. Every job is partial: true: the title comes from the URL slug, which stops at the first comma of the real title ("Senior Software Engineer, Infrastructure" reads as "Senior Software Engineer"), and there is no location, team or date.amazon.jobs): the site's search.json, newest first, 100 postings per request with the full posting text (so experience_years is usually known; pay is not in the listing). A check reads the newest 300 (3 requests) and is always complete: false, since the hit count is capped at 10,000. Hourly warehouse roles on hiring.amazon.com are not covered.acme.fa.us2.oraclecloud.com/hcmUI/CandidateExperience/en/sites/CX_1): the site's own requisition search (/hcmRestApi/resources/latest/recruitingCEJobRequisitions, the REST finder the candidate site calls), newest first, 100 per request, with every location, job family, posting date and a short summary. A check reads the newest 300 (3 requests) and is marked complete: false when the site lists more, as for Workday.acme.eightfold.ai/careers?domain=acme.com): the search endpoint Eightfold's robots.txt allows (/api/pcsx/search), newest first, 10 per request. Some Eightfold sites answer quick successive requests with HTTP 429, so a check reads the newest 50, 3 s apart, and when a later page is refused keeps what it read and marks the snapshot complete: false. Large employers post far more than 50 roles between checks, so an Eightfold board is a window onto its newest postings rather than its whole list. Some tenants have not turned that endpoint on (it answers 403) and serve the older /api/apply/v2/jobs instead, with each posting's text (so salary and experience_years when stated) but in the site's own, roughly newest-first order; the tenants listed in src/extract/eightfold.ts as LEGACY are read through it.careers.paramount.com): there is no public JSON API, so a check reads the site's search page sorted by date, 25 jobs per page with title, location and date, up to the newest 200 (8 requests), and is marked complete: false when the site lists more. Tile and table themes are both read. Themes that render results with JavaScript leave the search page empty (their JSON endpoint is under /services/, which robots.txt disallows), so those sites are read from sitemap.xml, which lists every open job in one request: as an RSS feed with each job's title, location, function and text on some sites, and as bare job URLs on others, whose jobs are partial: true (title from the URL slug, which also carries the location words; no location or date).careers.microsoft.com, jobs.careers.microsoft.com, apply.careers.microsoft.com): an Eightfold site, read as above.Derived fields. Every job gets remote (title or location says remote, and not hybrid/on-site) and seniority (intern, entry, mid, senior, staff, principal, manager, director, from the title; management words win over IC levels, and "mid" means the title carries no level). These are heuristics over the text, which is why they are exposed on the job rather than hidden inside the filter.
Resources vs. watches. Watches are per-client intents. Resources are what actually gets fetched. Any number of watches on the same board (across clients) share one resource, one fetch per interval, and one set of snapshots. URLs are canonicalized before sharing: tracking parameters (utm_*, fbclid, gclid, …) are dropped and the query is sorted. The resource is checked at the shortest interval any of its active watches asks for, but never more often than MIN_CHECK_INTERVAL_SECONDS (default 5 minutes).
Job identity. Jobs are keyed by the platform's job id, or for JSON-LD by identifier, then url, then title and location. A job whose title, location, department or url changed becomes JOB_UPDATED with changed_fields. A JSON-LD job without a stable id whose location changed is paired into one JOB_UPDATED instead of a remove plus an add.
Change events are written once per resource. Each watch reads them through its filters, evaluated in SQL against the change's job data (and against the current job list, for get_watch): keywords (any, against title, location, department and company), all_keywords (every one), exclude_keywords, locations (against the job's location and other_locations), seniority, remote_only, min_salary and max_experience_years. Terms match whole words, case-insensitively and allowing a plural: "ios" matches "Senior iOS Engineer" but not "Game Studios", and "java" does not match "JavaScript". A watch never sees changes from before it was created. Change ids double as cursors, so change-writing transactions are serialized. That keeps ids visible in order, so a slow concurrent check can't commit an id that a reader has already moved past.
Politeness. At most one fetch is in flight per website across all replicas, using a Postgres host lease with HOST_MIN_SPACING_MS between requests; a multi-request check (Workday, iCIMS, Oracle Recruiting, Eightfold, SuccessFactors, Apple, Amazon, Microsoft) holds the lease for all of its requests and pauses briefly between them (3 s for Eightfold sites such as Microsoft, which rate-limit quick requests). Each scheduler tick claims at most one resource per host. Watchtower also:
If-None-Match / If-Modified-Since (a 304 means no work);Retry-After on 429.Scheduling and maintenance. Due resources are claimed with a lease, so you can run several replicas; set RUN_SCHEDULER=false on API-only replicas. The same loop delivers webhooks and runs maintenance under an advisory lock. Maintenance:
CHANGE_RETENTION_DAYS and non-current snapshots after SNAPSHOT_RETENTION_DAYS;localhost, *.local, *.internal, single-label and numeric hosts are rejected.MAX_RESOURCES_PER_HOST distinct careers-page URLs per website across all clients; job-board platform APIs are exempt, and watching an already-monitored board is always allowed.MAX_ACTIVE_RESOURCES.host is refusing us, and counts them in watchtower_blocked_resources_1h.GET /metrics exposes Prometheus metrics:
watchtower_mcp_initialize_total by the MCP client name an agent reports (claude-code, cursor, ...), and watchtower_clients_created_7d by the ?ref= tag on the URL a client was created through. Each listing and install path hands out its own tag (/mcp?ref=registry, ?ref=claude-plugin, ?ref=cursor, ...); clients.source and clients.user_agent keep it per client./stats shows users, daily active users, new users, tool calls, page views and where users come from, as charts and tables. Log in with any username and STATS_TOKEN (or METRICS_TOKEN) as the password; with neither set the page is off. A user is one anonymous client token. The history lives in usage_daily (one row per client, UTC day, tool and interface) and counts_daily (page views by people, AI assistants and other bots, and MCP connections by app name), kept for 400 days. Nothing per visitor is stored for page views.
server.json is the registry entry. The lat.watchtower/* namespace is proven by HTTP: set MCP_REGISTRY_AUTH to the public-key record and the app serves it at /.well-known/mcp-registry-auth. Then, with the matching private key:
mcp-publisher login http --domain watchtower.lat --private-key "$PRIVATE_KEY_HEX"
mcp-publisher publish # bump "version" in server.json for each new publish
Robots, the MCP server card (/.well-known/mcp.json, also at /.well-known/mcp-server-card while the path is still a draft) and the install links on the homepage are generated from PUBLIC_BASE_URL.
ops/alerts.yml has example alert rules: sites refusing us, high error rate, checks stalled, webhook backlog, slow fetches, and the directory falling behind its check interval.
See .env.example. The most important settings:
DATABASE_URL and PUBLIC_BASE_URL.MIN_CHECK_INTERVAL_SECONDS and HOST_MIN_SPACING_MS.INDEX_ENABLED, INDEX_CHECK_INTERVAL_SECONDS, INDEX_BOARDS_FILE and INDEX_MAX_PROMOTED: the board directory that search watches are matched against. With INDEX_ENABLED=false Watchtower fetches only boards someone watches by URL.MAX_WATCHES_PER_CLIENT, MAX_RESOURCES_PER_HOST and MAX_ACTIVE_RESOURCES.WATCH_TTL_DAYS and the retention settings.TRUST_PROXY: set it behind a load balancer so rate limits see real client IPs.ALLOW_PRIVATE_NETWORKS turns SSRF protection off and exists only for tests and the demo.npm run typecheck
npm test # unit tests; integration tests need a database:
TEST_DATABASE_URL=postgres://postgres:postgres@localhost:5432/watchtower_test npm test
The integration suite drops and recreates the public schema of TEST_DATABASE_URL, so point it at a throwaway database.
The suite has three parts:
NO_JOB_DATA, host leases, concurrent checks, caps, expiry and retention, webhooks, rate limits and metrics.CI (.github/workflows/ci.yml) runs typecheck, all tests against a Postgres service, the build and the demo. It also builds the Docker image and smoke-tests it: migrations, /health, /metrics, the homepage, and the non-root user.
src/
server.ts, app.ts entrypoint; Fastify app (site, REST, MCP)
config.ts, db.ts env config; pg pool + migration runner
security/ssrf.ts URL validation + connect-time DNS guard
fetch/ safeFetch (redirects, limits, decompression), robots.txt
extract/ job-board adapters (incl. Workday, iCIMS, Oracle, Eightfold, SuccessFactors, Apple, Google, Amazon, Microsoft), JobPosting JSON-LD, remote/seniority
classifier, pay/experience parsing, term matching, job diff (identity, pairing)
search/ plain-language query reader; the built-in board directory; board discovery
(careers-page links, name guesses)
services/ clients, watches (board and search), checker, scheduler (+ maintenance), board
directory sync, host leases, rate limits, webhooks, metrics
mcp/server.ts MCP tools
web/site.ts homepage, llms.txt, well-known metadata
migrations/ SQL migrations
ops/alerts.yml example Prometheus alert rules
scripts/demo.ts end-to-end demo
scripts/discover-boards.ts grow the directory from a list of companies
JobPosting JSON-LD.003_jobs_only.sql retires existing page and event watches).src/extract/successfactors.ts are read as SuccessFactors; any other one is read through its JobPosting markup, if it has any.