openalex — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited openalex (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
Use this skill to run reliable OpenAlex API workflows from shell.
IMPORTANT: Always writecurlcommands on a single line. Multi-line\continuation breaks argument parsing in agent environments and will cause errors.
SECURITY: Never expose the actual value ofOPENALEX_API_KEYanywhere — not in text responses, not in echoed commands, not in logs. Always reference it as$OPENALEX_API_KEY. If the key appears in any output, stop immediately and do not repeat it.
A task is complete when:
Results
display_name), year, citation countProcess
curl written on a single lineapi_key included in every requestselect= used to limit returned fieldsjq used to format outputPDF download (when requested)
export OPENALEX_API_KEY='...'To verify it is set without printing the value:
[[ -n "${OPENALEX_API_KEY:-}" ]] && echo "key is set" || echo "ERROR: OPENALEX_API_KEY not set"curl -sS --get 'https://api.openalex.org/works' --data-urlencode 'search="data quality" AND "open government data"' --data-urlencode 'filter=type:article,from_publication_date:2023-01-01' --data-urlencode 'sort=relevance_score:desc' --data-urlencode 'per-page=200' --data-urlencode 'select=display_name,publication_year,cited_by_count,doi' --data-urlencode "api_key=$OPENALEX_API_KEY" | jq '.results[] | {title:.display_name, year:.publication_year, cited_by:.cited_by_count, doi}'works, authors, sources, etc.).search block with boolean logic (AND, OR, NOT, quotes, parentheses).filter constraints (type/date/language/OA/citation fields).select (root-level fields only).page or cursor=*.jq and save/transform as needed.Use this when building or debugging non-trivial queries.
per-page=5 or per-page=10) and minimal select=.display_name, year, DOI).filter=title.search:"..."search=... with same filterssearch, filter, sort, per-page, pagination mode).per-page=200, then cursor=* for deep pagination).curl -sS --get 'https://api.openalex.org/works' --data-urlencode 'search=...' --data-urlencode 'filter=...' --data-urlencode 'per-page=200' --data-urlencode 'select=display_name,doi' --data-urlencode "api_key=$OPENALEX_API_KEY" > r.json; jq -r '.results | to_entries[] | select(.value.doi=="https://doi.org/10.XXXX/YYY") | "rank \(.key+1)"' r.jsonRank far down a long result list is the failure this catches: the query did return the work, but no human reading the top of the list would have seen it. That is a triage problem, not a query problem, and the fix is a narrower filter that makes the whole set readable - not a different search string.
Avoid jumping directly from a paper/spec to a full extraction script without this short validation loop.
title.search=: searches only in the title — use this by default for focused results. Must be passed inside filter=, not as a standalone parameter: filter=title.search:"your query".search=: full-text search across the entire document — use only when title-only matching is too restrictive.search.exact=: like search= but without stemming.search.semantic=: semantic/conceptual search ($0.001/request; requires API key). See "Semantic search".corpus=: core (default, curated ~300M works) or all (adds datasets and repository records). Measured on publication_year:2024: 10.8M works with core, 28.0M with all. Never compare counts taken under different corpus values.filter=: exact/structured constraints; comma means AND.sort=: relevance_score:desc, cited_by_count:desc, publication_date:desc, etc.per-page=: 1..200. Default is 25 — always set `per-page=200` for bulk queries (8× fewer API calls).page=: page number for standard pagination.cursor=*: deep pagination beyond first 10k records.select=: reduce payload; nested paths are not allowed in select.group_by=: aggregate results by a field (e.g. group_by=publication_year, group_by=topics.id).sample=: random sample of N results (e.g. sample=20). Add seed=42 for reproducibility.search= covers title, abstract and fulltext together. To search one field, append .search to the field name inside `filter=` — these are not standalone parameters:
| Filter | Searches | Hits for heatwave, 2025 |
|---|---|---|
title.search | title only | 1.901 |
title_and_abstract.search | title and abstract | 5.102 |
abstract.search | abstract only | - |
fulltext.search | title, abstract and fulltext (same as search=) | 17.192 |
raw_author_name.search | the byline as published | - |
Title-only is the narrowest and misses any work whose subject is not in the title; fulltext.search and search= are the noisiest. title_and_abstract.search (or abstract.search) is usually the useful middle, especially combined with a structured filter such as authorships.institutions.country_code to scope by country without relying on the country name appearing in the text.
Deprecation: OpenAlex marks the whole filter=field.search: syntax as deprecated and recommends the search parameter instead. All of these still work (counts above measured 19 September 2026), and there is no search parameter equivalent for scoping to a single field, so they remain the tool for field-scoped queries - just expect them to change. default.search is a deprecated alias of fulltext.search. raw_author_name.search is the one the docs state has no replacement.
Applies to the search= parameter:
AND, OR, NOT in uppercase, with parentheses; words with no operator are treated as AND.search="fierce creatures".search="climate change"~5 matches the two words within 5 positions.machin*, wom?n. At least 3 characters before the wildcard; leading wildcards are not supported.machin~1 allows up to N character edits (N is 0, 1 or 2); at least 3 characters before the ~.possums matches possum). Use search.exact= to turn stemming off.search, search.exact or search.semantic.URL length limit. The whole request URL is capped at about 4 KB, which a long Boolean OR list can exceed - the API answers 400 with "error": "Request URL too long". Split the OR list into chunks, request each, and take the union of the IDs client-side: (X AND (a OR b OR c)) equals (X AND (a OR b)) union (X AND (c)). Each chunk is billed separately; splitting does not reduce cost and does not lose results.
Use search.semantic= to match by meaning, and above all when the input is long - an abstract, a grant aim, a paragraph. It embeds title and abstract of every work and ranks by cosine similarity.
| Constraint | Value |
|---|---|
| Max input length | 2.000 characters (longer is truncated) |
| Max results | 50 per query |
| Rate limit | 1 request per second |
Filters behave differently here. The docs say most filters work and name only last_known_institutions.country_code and cited_by_count as unsupported, but the API refuses more than that: from_publication_date is rejected with a message listing the whitelist it does accept - author.id, authorships.author.id, authorships.institutions.id, authorships.institutions.lineage, funders.id, has_abstract, has_fulltext, institution.id, institutions.id, is_oa, is_retracted, language, open_access.is_oa, primary_location.license, primary_location.source.id, publication_year, type. Read the error message rather than the docs: it is the live list. Use publication_year to bound the period.
Long queries can also return "reason": "query_timeout" - the response says you were not charged. Shorten the query and retry.
What semantic search does not do. It will not rescue a recall gap on its own. Tested against a known target - a paper on urban tree cover and heatwave mortality in Italian cities - search.semantic=heatwave mortality Italy municipal data did not return it in the top 25, with or without a year filter; it surfaced only when the query itself named "urban trees", which means already knowing the answer. Treat it as a way to find neighbours of a concept you can already phrase, not as a safety net.
Filters are comma-separated AND conditions. Within a single attribute:
| Logic | Syntax | Example | |
|---|---|---|---|
| AND (comma) | filter=a:x,b:y | filter=type:article,is_oa:true | |
| OR (pipe) | `filter=type:article\ | book` | multiple values for same field |
| NOT (exclamation) | filter=type:!journal-article | negation | |
| Greater than | filter=cited_by_count:>100 | comparison | |
| Less than | filter=publication_year:<2020 | comparison | |
| Range | filter=publication_year:2020-2023 | inclusive range |
Combine up to 50 IDs in one request using the pipe operator — avoid sequential calls:
# Batch DOI lookup (up to 50 per request)
curl -sS --get 'https://api.openalex.org/works' --data-urlencode 'filter=doi:https://doi.org/10.1/abc|https://doi.org/10.2/def' --data-urlencode 'per-page=50' --data-urlencode "api_key=$OPENALEX_API_KEY" | jq '.results[] | {title:.display_name, doi}'Names are ambiguous; always resolve to an OpenAlex ID first, then filter.
Step 1 — find the entity ID:
curl -sS --get 'https://api.openalex.org/authors' --data-urlencode 'search=Heather Piwowar' --data-urlencode 'per-page=5' --data-urlencode "api_key=$OPENALEX_API_KEY" | jq '.results[] | {id, display_name}'Step 2 — use the ID in a filter:
curl -sS --get 'https://api.openalex.org/works' --data-urlencode 'filter=authorships.author.id:A5023888391' --data-urlencode 'per-page=200' --data-urlencode 'select=id,display_name,publication_year,cited_by_count' --data-urlencode "api_key=$OPENALEX_API_KEY" | jq '.results[] | {title:.display_name, year:.publication_year}'Applies to: authors (authorships.author.id), institutions (authorships.institutions.id), sources/journals (primary_location.source.id). External IDs are also accepted: ORCID, ROR, ISSN, DOI.
For a work ID:
.content_urls.pdf.best_oa_location.pdf_url.primary_location.pdf_url.locations[].pdf_urlapi_key query parameter when source is content.openalex.org.When displaying results, always show display_name as the title — never use doi or id in its place.
Minimal jq for a results table:
| jq -r '.results[] | [.display_name, .publication_year, .cited_by_count, .doi] | @tsv'Or as structured objects:
| jq '.results[] | {title: .display_name, year: .publication_year, cited_by: .cited_by_count, doi}'To save results as a CSV file, use jq with @csv and include a header row:
curl -sS --get 'https://api.openalex.org/works' ... --data-urlencode "api_key=$OPENALEX_API_KEY" | jq -r '["title","year","cited_by","doi"], (.results[] | [.display_name, .publication_year, .cited_by_count, (.doi // "")]) | @csv' > results.csvRules:
// "" for fields that may be null (e.g. doi) — @csv fails on null values.-r (raw output) so @csv produces plain text, not JSON strings.Implement exponential backoff on 403 (rate limit) and 500 (server error):
attempt 1 → wait 1s → attempt 2 → wait 2s → attempt 3 → wait 4s → attempt 4 → wait 8sHTTP codes:
200 — success400 — invalid parameter or filter syntax; fix the query. The message field is the most reliable reference in this API: it lists the valid parameters, or the filters a given mode accepts. Read it before consulting the docs. Two specific cases: "Request URL too long" (split the Boolean query) and "reason": "query_timeout" on semantic search (not charged; shorten and retry)403 — rate limit exceeded; back off and retry404 — entity not found500 — temporary server error; retry with backoffWith the free $1/day budget:
| Request type | Cost | Daily limit |
|---|---|---|
Singleton (/works/W123) | free | unlimited |
| List / filter | $0.0001 | ~10,000 requests |
| Search (full-text or semantic) | $0.001 | ~1,000 requests |
PDF download (content.openalex.org) | $0.01 | ~100 downloads |
A .search filter is billed as a search, not as a list+filter: filter=title_and_abstract.search:... returns cost_usd 0.001, the same as search=. Only the plain structured filters cost 0.0001.
Every list response carries meta.cost_usd, what that single call actually cost. Use it instead of estimating: jq '.meta.cost_usd'.
Use select= and per-page=200 to minimize request count.
.id or .doi as the title field in jq output — .id is an OpenAlex URL, .doi is a DOI URL; always use .display_name for human-readable titles.id in select= unless you need the OpenAlex URL for follow-up lookups — it is a URL, not a title, and confuses output.relevance_score without a search query.select (example: use open_access, then parse .open_access.is_oa with jq).per-page=25 (default) for bulk extraction — always set per-page=200.search= searches full text and can return loosely related results. Use title.search= when the topic must appear in the title.curl commands on a single line — multi-line \ continuation breaks argument parsing in agent environments.title.search is NOT a valid standalone parameter — always pass it inside filter=: filter=title.search:"your query". Same for abstract.search, title_and_abstract.search and fulltext.search. Passing one standalone returns a 400 whose message lists every valid parameter: apc_sum, api-key, api_key, cited_by_count_sum, corpus, cursor, data-version, data_version, filter, format, group-by, group-bys, group_by, group_bys, include-xpac, include_xpac, mailto, page, per-page, per_page, q, sample, search, seed, select, sort, warm (search.exact and search.semantic are absent from that list but work).per_page maxes at 100, but the API accepts 200 and its own 400 message says "per-page parameter must be between 1 and 200". per-page and per_page are both accepted.title_and_abstract.search or add a structured filter.api_key=$OPENALEX_API_KEY in every request.$OPENALEX_API_KEY.[[ -n "${OPENALEX_API_KEY:-}" ]] && echo "key is set" || echo "ERROR: OPENALEX_API_KEY not set".references/query-recipes.mdscripts/openalex_query.shscripts/openalex_download_pdf.sh~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.