Muster’s index, for scripts and agents
Everything the site shows is also published two ways: as plain JSON files you can fetch, and as an MCP server an AI assistant can search. Both are free. There are no keys, no accounts and no rate limits.
The files are static JSON on the same host as this page, served with
access-control-allow-origin: *, so a browser, a script or
an agent can read them directly. The files →
The MCP server lets Claude, Cursor or VS Code search open roles, look one up, and rank them against your résumé. Connecting →
Everything here is a snapshot. The sweep rebuilds these files
from Postgres several times a day. They hold the roles that were open
when the export ran, not what is open this minute.
manifest.json has the build time in
generated_at, and every MCP result repeats it. Read it
before you quote a number.
https://muster.sidharthjoly.com
curl -s --compressed https://muster.sidharthjoly.com/data/v1/jobs-data.json
claude mcp add --transport http muster https://muster.sidharthjoly.com/mcp
The files
Australian open roles only, under
https://muster.sidharthjoly.com/data/v1/. Sizes are what travels
over the wire with compression on.
| File | Size | What it holds |
|---|---|---|
manifest.json | ~3 KB | The build time, counts, the matching vocabularies, and the weekly open/close series behind the site’s chart. Small enough to poll. Fetch this first. |
jobs-data.json | ~170 KB | The data, analytics and ML slice: a few hundred roles. This is what the index is for, at one-twelfth the size of the full file. Start here. |
jobs.json | ~2.2 MB | Every open role in Australia, several thousand of them. Use it for the whole labour market rather than the data slice. It is about 10 MB of JSON once decompressed. |
health.json | ~6 KB | Sweep results per board and per vendor: what failed, and how many hours behind the oldest board is. |
weekly.xml | small | An RSS feed of new data roles, one item per week, at the site
root rather than under data/. See
Weekly RSS. |
Polling
Fetch manifest.json and download the rows only when
generated_at has changed. Every file carries an
ETag and cache-control: max-age=600, so a
conditional request takes one round trip and returns no body when
nothing has changed.
There is no server behind these: they are static files on a CDN, which is why there is nothing to authenticate against and nothing to rate-limit.
A row
jobs.json and jobs-data.json are both flat arrays
of these. A key whose value would be null or empty is left out
rather than written as null, so a missing key means “not published
for this role”. That is normal, not an error.
| Key | On | What it is |
|---|---|---|
ats_vendor | every row | The hiring system: greenhouse, workday,
lever, ashby, smartrecruiters,
oracle or eightfold. |
board_token | every row | The employer’s board in that system. With
external_id, it identifies the role. |
external_id | every row | The role’s own ID in the ATS. One role, one row. |
title | every row | The job title as the employer wrote it. |
company | every row | The clean employer name, or the raw board token where no clean name is known. |
apply_url | every row | The employer’s own application form. |
remote_type | every row | The employer’s remote-work field. Often unknown. |
posted_at | ~99.5% | ISO 8601. About 40 rows lack it and carry
first_seen_at instead. |
first_seen_at | ~0.5% | Fallback for posted_at. Present only when
posted_at is missing. |
location_city | ~94% | The city, as the employer gave it. |
location_raw | ~6% | Fallback for location_city, when no city could be
read. |
salary_min, salary_max,salary_currency | ~0.6% | A structured salary band. Only about 50 roles publish one, so filtering on it hides almost everything. |
kw | ~95% | The role’s indexed vocabulary: the rare words in the ad. Any word carried by more than 4% of roles is dropped, so common tools are deliberately absent. |
sk | ~35% | Skills found in the ad, from the closed list in
manifest.json. Ranking uses this, not
kw. |
Coverage figures were measured on the 18 September 2026 build, which had about 9,200 open roles and 490 in the data slice. They move a little with every sweep.
first_seen_atandlocation_raware fallbacks. Read them only whenposted_atorlocation_cityis missing, never as fields in their own right.- About 5% of rows have neither
kwnorsk, because the employer published no description. They can be found by title and employer, but cannot be ranked against a résumé. - There is no description field. The export reads each ad to build
kwandsk, then drops the text: it would be most of the file, and the site shows no summary. - When
titleandapply_urldisagree, trust the title. Workday fixes a role’s URL slug when the requisition opens and never changes it, so a retitled role keeps its old words in the link. Requisition762190WD, for example, appears on two PwC boards with different slugs.
The data slice
jobs-data.json is the rows of jobs.json whose
title passes the test beside this, using the data_terms and
analyst_exclude lists from the manifest.
The terms live in the manifest rather than on this page on purpose. The
rule is DATA_TERMS and ANALYST_EXCLUDE in
muster/search.py,
and three things already read it: the SQL filter, the site and the
export. A fourth copy typed out here would go stale.
const s = ` ${title.toLowerCase()} `;
const isDataRole = m.data_terms.some(t => s.includes(t))
|| (s.includes('analyst')
&& !m.analyst_exclude.some(x => s.includes(x)));
Versioning
data/v1/ is the published path. Build against it.
The same files also sit at data/, which is what the
site’s own pages fetch. That path is older, unversioned, and may
change without notice.
Expect rows to lose columns rather than gain them: the export gets
smaller by dropping what nothing reads. A removal that would break a
reader goes out as v2/, and v1/ keeps its
shape.
Weekly RSS
weekly.xml is an RSS feed, not JSON,
meant for a feed reader or an RSS-to-email service. It has one item per
finished week (Monday to Sunday, Sydney time) for the last eight weeks.
- Each item counts the data and AI roles that first appeared on Muster that week and were still open when the feed was built, and lists up to ten of them: the most recently posted, at most two per employer.
- Weeks go by when Muster first saw a role, not by the employer’s posting date, so a week is complete as soon as it ends.
- Each item’s
guidnames its week and never changes, so a mail service sends each week once. - The week the index started is left out, because every role was “new” that week.
The endpoint
An MCP server over the same index. It does the filtering and ranking server-side, so a model can search roles, look one up, check the index’s health and rank a résumé without holding 10 MB of JSON in its context.
Streamable HTTP transport. There is no authentication and no rate limit: everything it serves is already public in the files above, so a key would protect nothing.
A web page on any site can call it from the browser: it answers CORS
preflights with access-control-allow-origin: *, as the
files do.
https://muster.sidharthjoly.com/mcp
- TOOLsearch_roles Open roles matching words, city, employer or dates.
- TOOLget_role One role, and how many identical openings its employer has.
- TOOLindex_health How fresh the index is, what it covers, where it fails.
- TOOLmatch_resume Open roles ranked against a résumé’s text.
Connecting
One click, if your editor takes an install link. Both carry what the cards beside this write out by hand: a transport and a URL, no key, nothing to fill in.
Claude Code takes the one-line command. Claude Desktop, Cursor and most other clients take the JSON config.
Directly
It is a plain POST of JSON-RPC, and the Accept
header is required by the transport. The response comes back as one
server-sent event: strip the event: and data:
prefixes to get the JSON body.
A tool’s answer is itself a JSON document, carried as text in
result.content[0].text. The result cards on this page show
it parsed.
Every tool’s request card below can be edited and sent from this page. The answer is the live index, not the example beside it.
claude mcp add --transport http muster https://muster.sidharthjoly.com/mcp
{
"mcpServers": {
"muster": {
"type": "http",
"url": "https://muster.sidharthjoly.com/mcp"
}
}
}
curl -s -X POST https://muster.sidharthjoly.com/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
The snapshot envelope
Every tool result, error shapes included, carries a
snapshot object.
The site shows the build time in its header. A tool result has no header, and a model that cannot see the age of what it is reading will state a four-hour-old count as current, so the age travels in the payload instead.
The counts in the examples below are illustrative. Every one of them has changed since they were written.
"snapshot": {
"generated_at": "2026-09-18T08:31:37.821928+00:00",
"age_hours": 0.3,
"note": "Muster publishes a static export rebuilt about four times a day. ..."
}
search_roles
Search open roles in Australia. Defaults to the data, analytics and ML slice.
Arguments
qstringWords that must all appear in the title, the employer or the role’s vocabulary (
kw). Case-insensitive and matches parts of words. Very common words are not in the vocabulary, sopythonmatches on titles only; to search by skill, usematch_resume.scopedata | alldata(default) is the data, analytics and ML slice.allis every open role in Australia.citystringExact match on the employer’s city, e.g.
Sydney. Rows with only alocation_rawnever match.companystringAny part of the employer’s name, case-insensitive.
atsstringOne of
greenhouse,workday,lever,ashby,smartrecruiters,oracle,eightfold.remotestringExact match on the employer’s own field, e.g.
remoteorhybrid. Oftenunknown.sincedatePosted on or after this date,
YYYY-MM-DD.untildatePosted strictly before this date,
YYYY-MM-DD. With either date set, rows with no date are left out.has_salarybooleanOnly roles publishing a salary band: under 1% of them.
sortnewest | salarynewestby default.salarysorts by the top of the band, with unbanded roles last.limitinteger1 to 200. Default 25.
offsetintegerFor paging. Default 0.
totalsays how far there is to go.
totalcounts every match, not just this page.locationis the city, or the raw location where no city could be read.postedfalls back to when Muster first saw the role.salary_min,salary_maxandsalary_currencyappear only when the employer published a band. Usually they are absent.
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "search_roles",
"arguments": {
"q": "dbt", "city": "Melbourne", "limit": 1
}
}
}
{
"total": 4,
"scope": "open data/analytics/ML roles in Australia",
"offset": 0,
"results": [
{
"id": "smartrecruiters:carsales:744000149506190",
"title": "Senior Data Engineer - Data Platform & Engineering",
"company": "carsales",
"location": "Melbourne",
"remote": "hybrid",
"posted": "2026-09-15",
"apply_url": "https://jobs.smartrecruiters.com/carsales/744000149506190",
"ats": "smartrecruiters"
}
],
"snapshot": {"...": "as above"}
}
get_role
One role, by the id that search_roles returns.
Arguments
idstringrequired<ats>:<board_token>:<external_id>
Returns the same fields as a search result, plus four more:
is_data_role: whether the title passes the data-slice rule.skills: the closed vocabulary the résumé matcher scores against (sk).vocabulary: the rare words from the ad, which is whatqsearches (kw).identical_openings_at_this_employer: how many open roles that employer’s board has with exactly this title. Muster publishes this instead of a made-up match score. Eight identical openings means a team is being hired, which is worth knowing.
An unknown id is not an error. It comes back as a result that says so, and suggests searching again.
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "get_role",
"arguments": {
"id": "smartrecruiters:carsales:744000149506190"
}
}
}
{
"id": "smartrecruiters:carsales:744000149506190",
"title": "Senior Data Engineer - Data Platform & Engineering",
"...": "the search_roles fields",
"is_data_role": true,
"identical_openings_at_this_employer": 1,
"skills": ["python", "sql", "snowflake", "postgres", "dbt", "etl", "git"],
"vocabulary": ["ai-augmented", "anchor", "..."]
}
{
"error": "no open role with that id in this snapshot",
"hint": "It may have been filled and left the index, or the id may be from an older export. Search again rather than assuming it is closed."
}
index_health
No arguments. What the index holds and how fresh it is: open counts,
counts across the whole index worldwide (index_totals),
boards watched, the last sweep, the age of the oldest board, health per
ATS, and a caveats array.
caveats states the coverage limits in plain words. Use it
to answer questions about how far Muster can be trusted, rather than
guessing.
Read boards_failed and each vendor’s
incomplete together. A failed board was not fetched at all.
An incomplete one was fetched but returned fewer roles than the vendor
said it had, and an incomplete board closes nothing until it comes back
whole.
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {"name": "index_health", "arguments": {}}
}
{
"open_roles_australia": 9160,
"open_data_roles_australia": 485,
"index_totals": {
"jobs": 334121, "open": 311210, "au_open": 9160,
"closed": 22911, "boards": 911, "vendors": 7
},
"boards_watched": 925,
"last_sweep": "2026-09-18T22:19:51.092042+00:00",
"oldest_board_age_hours": 97.32841436,
"boards_failed": 11,
"by_ats": [
{
"vendor": "workday", "boards": 536, "ok": 500, "failed": 5,
"incomplete": 31, "fetched": 248068, "new": 24423, "closed": 10304,
"reopened": 176, "last_run": "2026-09-18T22:19:51.092042+00:00",
"oldest_run": "2026-09-15T02:18:06.373022+00:00"
}
],
"roles_with_no_description": 473,
"caveats": ["...three entries naming the coverage limits..."]
}
match_resume
Ranks open roles against a résumé, with the same scoring the site uses:
- a closed list of skills, each worth more the rarer it is across the index;
- a match on the role title;
- a penalty where the résumé states a level two or more steps below the role’s.
Arguments
resumestringrequiredPlain text, up to 60,000 characters.
scopedata | allDefault
data.limitinteger1 to 100. Default 25.
read_from_resumeis what the matcher took from the text, so a bad ranking can be traced rather than guessed at.levelruns from 0 (graduate) to 5 (head of, director), and isnullwhen the résumé states no level.- This ranks and never hides.
total_rankedis every role in scope andmatchedis how many scored above zero. A role with nothing in common sinks to the bottom with an emptymatched_on; it is not removed. Ties go to the newest. title_matchis the role title from the résumé found in the job’s title, ornull.relative_scorecompares each role with the best match in this response. It is not a probability, and it cannot be compared across calls. Muster has never seen an application, an interview or a hire, so it has no number for those.stretch: truemarks a role two or more levels above the résumé. It is shown rather than hidden, because a reader may want it.unrankable_roles_in_indexcounts roles with no description to score. They are in the index but cannot rank.- Text with no known skill or role title gets an error instead of a made-up order.
Sending a résumé to a server
The roles page ranks in your browser: nothing is
uploaded. This endpoint is a server, so match_resume always
receives the text. It scores it in memory for that one call and does not
store or log it, but that is a promise about the server, not something
you can check. Sending a request from this page is the same call, so
the text goes to the server there too. If the difference matters to
you, use the site, or run the ranking yourself from
jobs-data.json and the vocabulary in
manifest.json.
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "match_resume",
"arguments": {
"resume": "Analytics engineer, five years. SQL, dbt, Snowflake, Python.",
"limit": 1
}
}
}
{
"read_from_resume": {
"skills": ["python", "sql", "snowflake", "dbt"],
"roles": ["analytics engineer"],
"level": 3
},
"total_ranked": 485,
"matched": 184,
"unrankable_roles_in_index": 473,
"results": [
{
"id": "smartrecruiters:carsales:744000149506190",
"title": "Senior Data Engineer - Data Platform & Engineering",
"...": "the search_roles fields",
"matched_on": ["dbt", "snowflake", "sql", "python"],
"title_match": null,
"stretch": false,
"relative_score": 1
}
],
"note": "Ranked, never filtered. ..."
}
{
"error": "nothing rankable in that text",
"hint": "No known skill or role title was found. The vocabulary is a closed list of data/analytics tools and titles; a résumé from another field will not rank, and a made-up ordering would be worse than this error.",
"vocabulary_size": 72
}
How it runs
A Cloudflare Worker answers /mcp on this hostname, and every
other path goes to the static site. The Worker has no database: it reads
the published files above, caches them against the CDN’s ETag, and
filters in memory. Because it reads the site’s own data, it cannot
disagree with the site.
Source: mcp/
on GitHub.