API & MCP

open data · no keys · no accounts
data/v1MCP · Streamable HTTP

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.

Base URL
https://muster.sidharthjoly.com
SHELLthe data slice
curl -s --compressed https://muster.sidharthjoly.com/data/v1/jobs-data.json
SHELLadd to Claude Code
claude mcp add --transport http muster https://muster.sidharthjoly.com/mcp
Data files

The files

Australian open roles only, under https://muster.sidharthjoly.com/data/v1/. Sizes are what travels over the wire with compression on.

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

KeyOnWhat it is
ats_vendorevery row The hiring system: greenhouse, workday, lever, ashby, smartrecruiters, oracle or eightfold.
board_tokenevery row The employer’s board in that system. With external_id, it identifies the role.
external_idevery row The role’s own ID in the ATS. One role, one row.
titleevery row The job title as the employer wrote it.
companyevery row The clean employer name, or the raw board token where no clean name is known.
apply_urlevery row The employer’s own application form.
remote_typeevery 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_at and location_raw are fallbacks. Read them only when posted_at or location_city is missing, never as fields in their own right.
  • About 5% of rows have neither kw nor sk, 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 kw and sk, then drops the text: it would be most of the file, and the site shows no summary.
  • When title and apply_url disagree, 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. Requisition 762190WD, 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.

JSisDataRole
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 guid names 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.
MCP server

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.

POST/mcp
https://muster.sidharthjoly.com/mcp
Tools
  • 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.

SHELLClaude Code
claude mcp add --transport http muster https://muster.sidharthjoly.com/mcp
JSONclient config
{
  "mcpServers": {
    "muster": {
      "type": "http",
      "url": "https://muster.sidharthjoly.com/mcp"
    }
  }
}
SHELLlist the tools
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.

JSONin every result
"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
  • qstring

    Words 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, so python matches on titles only; to search by skill, use match_resume.

  • scopedata | all

    data (default) is the data, analytics and ML slice. all is every open role in Australia.

  • citystring

    Exact match on the employer’s city, e.g. Sydney. Rows with only a location_raw never match.

  • companystring

    Any part of the employer’s name, case-insensitive.

  • atsstring

    One of greenhouse, workday, lever, ashby, smartrecruiters, oracle, eightfold.

  • remotestring

    Exact match on the employer’s own field, e.g. remote or hybrid. Often unknown.

  • sincedate

    Posted on or after this date, YYYY-MM-DD.

  • untildate

    Posted strictly before this date, YYYY-MM-DD. With either date set, rows with no date are left out.

  • has_salaryboolean

    Only roles publishing a salary band: under 1% of them.

  • sortnewest | salary

    newest by default. salary sorts by the top of the band, with unbanded roles last.

  • limitinteger

    1 to 200. Default 25.

  • offsetinteger

    For paging. Default 0. total says how far there is to go.

  • total counts every match, not just this page.
  • location is the city, or the raw location where no city could be read. posted falls back to when Muster first saw the role.
  • salary_min, salary_max and salary_currency appear only when the employer published a band. Usually they are absent.
POST/mcp · tools/call
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "search_roles",
    "arguments": {
      "q": "dbt", "city": "Melbourne", "limit": 1
    }
  }
}
RESULTcontent[0].text
{
  "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 what q searches (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.

POST/mcp · tools/call
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "get_role",
    "arguments": {
      "id": "smartrecruiters:carsales:744000149506190"
    }
  }
}
RESULTfound
{
  "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", "..."]
}
RESULTunknown id
{
  "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.

POST/mcp · tools/call
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {"name": "index_health", "arguments": {}}
}
RESULTcontent[0].text
{
  "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
  • resumestringrequired

    Plain text, up to 60,000 characters.

  • scopedata | all

    Default data.

  • limitinteger

    1 to 100. Default 25.

  • read_from_resume is what the matcher took from the text, so a bad ranking can be traced rather than guessed at. level runs from 0 (graduate) to 5 (head of, director), and is null when the résumé states no level.
  • This ranks and never hides. total_ranked is every role in scope and matched is how many scored above zero. A role with nothing in common sinks to the bottom with an empty matched_on; it is not removed. Ties go to the newest.
  • title_match is the role title from the résumé found in the job’s title, or null.
  • relative_score compares 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: true marks 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_index counts 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.

POST/mcp · tools/call
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "match_resume",
    "arguments": {
      "resume": "Analytics engineer, five years. SQL, dbt, Snowflake, Python.",
      "limit": 1
    }
  }
}
RESULTranked
{
  "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. ..."
}
RESULTnothing rankable
{
  "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.