Documentation

The setup, step by step

Every snippet here is ready to paste. The numbers — quotas, limits, defaults, field names — come from the running code, so this page and the server agree.

Getting started

Get your key

Everything here except the RSS feeds uses one API key. Open Your account, section Developer API, and copy it. The Free plan needs no card.

The key carries your plan, and your plan carries your daily volumes. Two counters run side by side: one for the REST API, one for the MCP server. An editor asking a few questions never eats into your API budget.

Regenerating the key from your account revokes the old one immediately. Anything still using it stops working — update your integrations first.

Which one do I need?

You want to…Use
Ask questions from your editorMCP server
Query the data from your own codeREST API
Be told when something changesWebhooks
Wire it into n8n, Zapier or MakeAutomations
Check dependencies on every pull requestGitHub Action
Follow along in a reader, no keyRSS feeds

MCP server

What the MCP server is

MCP (Model Context Protocol) is a standard way for an AI assistant to call an outside service. You paste one address into your client, and the assistant gains five tools it can use while you work. Those tools query the olud.ai catalogue: open-source AI projects, AI models with their prices, and open-source alternatives to commercial products.

FactValue
Endpointhttps://olud.ai/mcp.php
MethodPOST, JSON-RPC 2.0. GET returns 405 (see below)
TransportStreamable HTTP, one endpoint, no SSE stream
Protocol version2025-06-18. If the client asks for 2025-03-26 or 2024-11-05, the server answers in that version
Server version1.0.0
SessionNone. No Mcp-Session-Id to keep, nothing to expire, nothing to reconnect
Capabilitiestools only. resources/list, resources/templates/list and prompts/list answer with empty lists instead of an error
AuthX-Api-Key request header. Optional
BatchingNot supported. A JSON-RPC array is rejected with HTTP 400

The server reads. The only thing it writes is your daily call counter.

Every tool answer carries the URL of the matching page on olud.ai, plus an attribution line: scores and indices are computed by olud.ai, contextual facts come from GitHub, Hugging Face, OpenRouter, PyPI, NPM and Docker Hub.

Install it, client by client

Same URL for every client: https://olud.ai/mcp.php. The key travels in the X-Api-Key header. Without a key the server still answers, with a smaller allowance (25 calls a day per IP, 5 results per call). Your key and a ready-to-paste block are in Your account, section MCP server.

Claude Code, one command:

claude mcp add --transport http opensourceai https://olud.ai/mcp.php \
  --header "X-Api-Key: your-key"

# without a key:
claude mcp add --transport http opensourceai https://olud.ai/mcp.php

# check what got registered:
claude mcp list

Claude Desktop, claude_desktop_config.json:

{
  "mcpServers": {
    "opensourceai": {
      "type": "http",
      "url": "https://olud.ai/mcp.php",
      "headers": { "X-Api-Key": "your-key" }
    }
  }
}

That file lives at ~/Library/Application Support/Claude/claude_desktop_config.json on macOS, and %APPDATA%\Claude\claude_desktop_config.json on Windows. Quit and reopen Claude Desktop after editing it; it reads the file at startup.

If Claude Desktop shows the server as failed or does not list it at all, your build only accepts local (stdio) servers. Bridge it with mcp-remote, which needs Node installed:

{
  "mcpServers": {
    "opensourceai": {
      "command": "npx",
      "args": ["-y", "mcp-remote", "https://olud.ai/mcp.php",
               "--header", "X-Api-Key:${OSAI_KEY}"],
      "env": { "OSAI_KEY": "your-key" }
    }
  }
}
In that bridge block, write X-Api-Key:${OSAI_KEY} with no space after the colon and put the value in env. mcp-remote splits a header argument on spaces, and a header written inline loses its value.

Cursor, .cursor/mcp.json in the project (or ~/.cursor/mcp.json for every project):

{
  "mcpServers": {
    "opensourceai": {
      "url": "https://olud.ai/mcp.php",
      "headers": { "X-Api-Key": "your-key" }
    }
  }
}

VS Code, .vscode/mcp.json in the workspace:

{
  "servers": {
    "opensourceai": {
      "type": "http",
      "url": "https://olud.ai/mcp.php",
      "headers": { "X-Api-Key": "${input:osaiKey}" }
    }
  },
  "inputs": [
    { "id": "osaiKey", "type": "promptString",
      "description": "olud.ai API key", "password": true }
  ]
}
VS Code names the top-level object servers, not mcpServers. Copying the Claude or Cursor block as-is is the usual reason the server never appears. The inputs prompt asks for the key on first use, so the file stays safe to commit.

A key in the URL as ?key=your-key also works, because the server reads it. Prefer the header: query strings end up in browser history, proxy logs and shell history.

Check it works with curl

When a client says a server is unavailable, test the server itself first. This call lists the tools and costs nothing: only tools/call decrements your quota, so initialize, tools/list and ping are free. Restarting your editor never eats into the day.

curl -s -X POST https://olud.ai/mcp.php \
  -H "Content-Type: application/json" \
  -H "X-Api-Key: your-key" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

A healthy answer is HTTP 200 with {"jsonrpc":"2.0","id":1,"result":{"tools":[ ... ]}} and the five names inside. Next, spend one call:

curl -s -X POST https://olud.ai/mcp.php \
  -H "Content-Type: application/json" \
  -H "X-Api-Key: your-key" \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/call",
       "params":{"name":"search_open_source_ai",
                 "arguments":{"query":"text to speech","limit":5}}}'

A successful tools/call carries two response headers worth reading: X-RateLimit-Limit is your daily allowance, X-RateLimit-Remaining is what is left after this call. They are set on tools/call only.

Shortest possible liveness check. It answers {"jsonrpc":"2.0","id":0,"result":{}} and needs no key:

curl -s -X POST https://olud.ai/mcp.php \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":0,"method":"ping"}'

The five tools

You do not call these by name. You ask a question, the client picks the tool. The names matter when you read a log or write the call yourself, and they are matched exactly: search_open_source_ai works, Search_Open_Source_AI does not.

ToolParametersBounds and defaultsReturns
search_open_source_aiquery (string, required), limit (integer)limit clamped to 1 … your results-per-call ceiling. Default 10, or your ceiling if it is lowermatches (total found, not truncated), then id, name, summary, stars, language, license, github, page, health score and label, recent trend
get_projectproject (string, required)Accepts a slug (ollama-ollama), owner/repo, a full github.com URL, or the exact project nameEverything above plus owner, forks, created, pushed, topics, the full health breakdown, stars gained, and up to 5 recent releases
find_alternativesproduct (string, required)Name of the commercial product. Matched on its slug, then on its exact nameproduct, page, and the alternatives list, itself capped by your results-per-call ceiling
list_modelsprovider (string), max_price_out (number), min_context (integer), free_only (boolean), limit (integer)provider is a case-insensitive substring. max_price_out is dollars per million output tokens. min_context is in tokens. limit clamped to 1 … your ceiling, default 15 or your ceiling if lowermatches, then id, name, provider, context, price_per_million (input and output), modality, tool_calling, and the leaderboard URL
trending_projectskind (string), limit (integer)kind is trending, emerging or top_health. Anything else falls back to trending without an error. Default trending. limit clamped to 1 … your ceiling, default 10 or your ceiling if lowerkind, the project cards, and the generation time of the index

Questions that reach each tool

  • search_open_source_ai — "Find me an open-source OCR library I can run locally."
  • get_project — "How healthy is ollama/ollama right now, and when did it last ship?"
  • find_alternatives — "What open-source tool could replace Notion for us?"
  • list_models — "Which models have at least 128k context and cost under $1 per million output tokens?"
  • trending_projects — "What open-source AI projects are gaining stars fastest this week?" Ask for emerging instead to get healthy projects that are still little-known, or top_health for the best-maintained ones.
Search matches text, not meaning. It scores an exact name highest, then a name that starts with your words, then a name that contains them, then a description that contains them, and adds points for an exact topic tag. A long sentence finds nothing; "speech synthesis" finds nothing that "text to speech" does not. When a search comes back empty, shorten the query to one or two words.

Filters drop what they cannot judge. With max_price_out set, a model whose output price we have no figure for is left out rather than guessed. With min_context set, a model with no recorded context window counts as zero and is left out. free_only keeps models whose tier is exactly free.

A limit above your ceiling is not an error: it is clamped down silently. The published schema says maximum 100 because that is the highest any plan allows, not what your key allows. A limit that is not a number falls back to the default.

For a few products, we have a comparison page written by hand and no structured list in the machine-readable index. find_alternatives then answers as a success with an empty alternatives array, says the structured list is not in the index for this one, and gives the page URL. An empty array there does not mean no alternative exists.

Volumes by plan

No keyFreeDevProOrg
Calls per day252002,00020,000200,000
Results per call5102550100

Two ceilings, because they stop two different things: calls per day bound the load, results per call bound how much of the catalogue leaves in one answer.

  • MCP calls are counted apart from REST API calls. Same key, two counters. An editor asking a few questions never touches your API budget.
  • Only tools/call is counted. initialize, tools/list and ping cost nothing.
  • Both counters reset at 00:00 UTC.
  • Without a key, the count is per IP address. Everyone behind one office IP shares the 25.
  • On Free and with no key, each answer ends with a note line stating your two ceilings. Dev, Pro and Org answers carry no such line.
  • Org is the plan named business inside the system. Error messages print the internal name.

When it does not work

A failing tool returns HTTP 200 with isError set to true and the reason as plain text. That is deliberate: the model reads the reason and can try something else. It also means a 200 in your proxy log is no proof the call worked. Read the body.

Key typed wrong, or revoked. Every question fails with:

That API key is unknown or has been revoked. Remove it to use the free tier,
or get a new one at https://olud.ai/account.html

A bad key does not quietly fall back to the anonymous allowance. Fix the key or remove the X-Api-Key header entirely, then restart the client so it re-reads the config.

Daily ceiling reached. With a key, then without:

Daily MCP quota reached (200 calls/day on the "free" plan). Resets at 00:00 UTC.
Higher tiers: https://olud.ai/plans.html

Anonymous limit reached (25 calls/day per IP). Resets at 00:00 UTC.
A free API key raises it to 200/day — https://olud.ai/account.html

Nothing is broken and nothing is charged. Wait for 00:00 UTC, or move up. If you are on no key, a free key takes you from 25 to 200 calls and from 5 to 10 results per call.

Tool name not one of the five:

Unknown tool "list_projects". Call tools/list to see what is available.

A required argument is missing. Each message shows the shape it wants:

Missing "query". Example: {"query": "text to speech"}
Missing "project". Example: {"project": "ollama/ollama"}
Missing "product". Example: {"product": "Midjourney"}

Nothing found. Both messages tell you where to go next:

No project found for "ollamaa". Try search_open_source_ai first to get its exact id.

We do not track "photoshop" yet. Closest matches: <up to 8 names>.
Full list: https://olud.ai/alternatives-hub.html

The catalogue is mid-rebuild. Wait a minute and ask again; these are the four wordings, one per data set:

The catalogue is being rebuilt. Try again in a minute.
That list is being rebuilt. Try again in a minute.
The model catalogue is unavailable right now.
The alternatives index is unavailable right now.

Opening https://olud.ai/mcp.php in a browser returns HTTP 405. That is the correct answer: the endpoint speaks JSON-RPC over POST, and the specification asks for 405 when a server offers no GET stream. The body tells you what to do, and carries the configuration to copy. It arrives on one line; it is spaced out here to be read.

{
  "server": "olud.ai MCP",
  "version": "1.0.0",
  "protocol": "2025-06-18",
  "usage": "This endpoint speaks MCP over JSON-RPC 2.0. POST to it from an MCP client.",
  "config": { "mcpServers": { "opensourceai": { "url": "https://olud.ai/mcp.php" } } },
  "tools": ["search_open_source_ai", "get_project", "find_alternatives",
            "list_models", "trending_projects"],
  "docs": "https://olud.ai/api.html"
}

Transport-level refusals. These four carry a JSON-RPC error code, and the first three carry an HTTP error code too:

405  -32600  Use POST with a JSON-RPC 2.0 body.          (PUT, DELETE, HEAD…)
400  -32700  Parse error: the body is not valid JSON.
400  -32600  JSON-RPC batching is not supported (removed in MCP 2025-06-18).
200  -32601  Unknown method "tools/execute".
200  -32602  Missing tool name in params.

If your client complains about a missing session id, ignore it and check the transport type in your config. This server keeps no session, and there is no Mcp-Session-Id to send back. A client configured for stdio or for SSE against this URL will fail before the first call; the type is http.

REST API

Authentication and keys

Base URL: https://olud.ai/api/v1. Every endpoint is a GET. The CORS header advertises POST, but v1 has no POST route: parameters are always read from the query string.

Send your key in the X-Api-Key header. A ?key= query parameter works too, for a browser tab or a quick test. If both are present, the header wins. Only /meta works without a key.

Both forms are accepted:

curl -s -H "X-Api-Key: osk_YOUR_KEY" \
  "https://olud.ai/api/v1/projects?limit=3"

curl -s "https://olud.ai/api/v1/projects?limit=3&key=osk_YOUR_KEY"

To get a key: sign in on olud.ai and open your account page, section Developer API. The key is created on your first visit, on the free plan, and looks like osk_ followed by 40 hex characters. Rotating it from the same page deletes the old key immediately — the old value then returns 403 invalid_key.

HeaderValueSent on
X-RateLimit-LimitYour daily ceiling, as an integerEvery request that passed the key check
X-RateLimit-RemainingRequests left today, floored at 0Every request that passed the key check, including the 429
ETagQuoted md5 of the projects graph build timeEvery endpoint except /meta
Access-Control-Allow-Origin*Every response, including OPTIONS (which answers 204)
CORS is open and X-Api-Key is an allowed header, so a browser can call the API directly. Anyone who reads your page source then has your key and spends your quota. Call the API from your server and pass results on.
The key check runs before routing. A misspelled path with no key returns 401 missing_key, not 404. Add the key before you go looking for a typo in the path.

Response envelope

A success is always HTTP 200 with three top-level keys: ok is true, data holds the payload, meta holds context. data is an object on single-record endpoints and an array on list endpoints.

GET /api/v1/meta, verbatim:

{"ok":true,"data":{"name":"olud.ai API","version":"v1","generated":"2026-07-27T07:45:15+00:00","counts":{"projects":10142},"endpoints":["/project/{id}","/projects","/emerging","/alternatives/{id}","/search?q=","/models"],"docs":"https://olud.ai/api/"},"meta":{"attribution":"Scores & indices computed by olud.ai (olud.ai). Contextual facts from public sources: GitHub, Hugging Face, OpenRouter, Artificial Analysis, PyPI, NPM, Docker Hub."}}

meta.attribution is set on every success response, on every endpoint, and cannot be turned off. The rest of meta varies by endpoint: generated, total, limit, offset, sort, freshness, points. Read the endpoint sections for which fields you get.

An error carries ok false, an HTTP status other than 200, and error.code plus error.message. There is no meta object on an error, so no attribution field. Test ok before you read data — do not assume the success shape.

GET /api/v1/projects with no key, verbatim:

{"ok":false,"error":{"code":"missing_key","message":"Provide your API key via the X-Api-Key header (or ?key=). Get one at https://olud.ai/api/"}}

Send the ETag back as If-None-Match and you get 304 with an empty body when the graph has not been rebuilt. The graph is rebuilt once each morning, so polling more often than that returns 304 all day.

Two things to know about the ETag. It is computed from the projects graph only, so the same value is returned by /models, /hf and /history — send an ETag back to the endpoint you got it from, or you will get a 304 while newer data sits behind it. And the quota is counted before the ETag is compared, so a 304 still costs one request.

Endpoints: meta and projects

GET /meta

Graph status. The only endpoint without a key, and the only one that does not count against your quota. Returns the API name and version, generated (UTC timestamp of the last graph build), counts.projects, a list of endpoints and the docs URL. No parameters. If the graph files are unreadable, counts is null and the call still returns 200 — use it as a health check and to decide whether a re-pull is worth it. It sends no ETag and no rate-limit headers.

curl -s https://olud.ai/api/v1/meta

GET /project/{id}

One project, full record. This is the merged view: GitHub facts, our maintenance score, star velocity, releases, adoption pulse.

  • Identity: id, name, owner, url, page (path of the project page on the site), desc.
  • GitHub facts as of the last build: stars, forks, lang, license, topics, created, pushed.
  • health: score out of 100, label, why, plus the four components it is made of — activity (max 30), momentum (max 20), community (max 30), maintenance (max 20) — weights v2 since 29 July 2026 — and checked. Labels follow the score: 85+ Thriving, 70+ Healthy, 50+ Maintained, 30+ Slowing down, below 30 At risk. A project with no commit in four weeks has its score capped at 45, so the components can add up to more than the score. Not every project is scored; test for the field.
  • velocity: stars_1d and stars_7d, from our own daily star history.
  • releases: tag, date, level, url — most recent first.
  • tool: slug, cat, pulse, docker_pulls, npm_month, pip_month, hn_hits, bsky_week, compare_pages. Present only for projects matched to a tracked tool.
  • rank, trend (steady, quiet or accelerating) and signals (stars_accel, has_page).
ParameterTypeDefaultBehaviour
idpath segment, requirednoneLowercased. Resolved in three steps: exact slug, then the owner/name index, then owner-name. No segment at all returns 400 missing_id; no match returns 404 not_found with a pointer to /search.
curl -s -H "X-Api-Key: osk_YOUR_KEY" \
  https://olud.ai/api/v1/project/ollama-ollama
Use the dashed slug. A literal slash in the path starts a new path segment, so /v1/project/ollama/ollama looks up "ollama" and returns 404. The ids returned by /projects and /search are always safe to pass back.

GET /projects

Filter, sort and page the catalogue. Filters apply first, then the sort, then offset and limit.

ParameterType / boundsDefaultBehaviour
langstringno filterExact match on the project language, case-insensitive. lang=rust keeps Rust only.
licensestringno filterSubstring match on the licence id, case-insensitive. license=gpl keeps GPL-2.0 and AGPL-3.0.
verticalstringno filterExact match, case-insensitive. Values present in the graph: robotics, security, finance, science, healthcare, education, legal. Most projects have none, and they all disappear when you set this.
health_minintegerno filterKeeps projects whose health.score is greater than or equal to the value. Unscored projects count as 0 and drop out. A non-numeric value becomes 0, which filters nothing.
sortstars, health, momentum or recentstarsAlways descending. momentum reads velocity.stars_7d, recent reads the last push date. An unknown value sorts by stars but is echoed back verbatim in meta.sort — check meta.sort if the order surprises you.
limitinteger 1-10025Out-of-range values are clamped to the bounds, non-numeric falls back to 25. No error is raised, so limit=500 silently gives you 100.
offsetinteger 0-1000000Clamped the same way.
curl -s -H "X-Api-Key: osk_YOUR_KEY" \
  "https://olud.ai/api/v1/projects?lang=python&license=apache&sort=health&health_min=70&limit=10"

data is an array of compact cards: id, name, url, desc, stars, lang, license, health (the score alone), momentum (stars_7d), trend, vertical. Fields with no value are present and null. For the components, releases and adoption figures, call /project/{id}. meta gives total (matches after filtering, before paging), limit, offset, sort and generated.

Endpoints: search, emerging, alternatives

GET /search

Searches projects by name, description and topics. It does not search models or Hugging Face rows — use /models and /hf for those.

ParameterType / boundsDefaultBehaviour
qstring, requirednoneTrimmed and lowercased. Empty or whitespace-only returns 400 missing_query.
limitinteger 1-5015Clamped to the bounds; non-numeric falls back to 15.
curl -s -H "X-Api-Key: osk_YOUR_KEY" \
  "https://olud.ai/api/v1/search?q=ocr&limit=5"

Scoring, so you can predict the order: exact name match 100, name starts with q 60, name contains q 40, description contains q 15, and +20 when q equals one of the project's topics exactly. Ties break on stars. Anything scoring 0 is dropped. Matching is plain substring — no stemming, no typo tolerance. Cards are the same shape as /projects. meta gives total (all matches, not the page) and q as normalised.

GET /emerging

The daily discovery index: young repositories with sustained growth and, where we can score them, good health. Ranked by our Discovery Score. Cards carry two extra fields: signals and detected, the date the project first entered the index (null when unknown).

ParameterType / boundsDefaultBehaviour
limitinteger 1-10025Clamped. You can receive fewer rows than you asked for: ids that are no longer in the projects graph are skipped.
curl -s -H "X-Api-Key: osk_YOUR_KEY" \
  "https://olud.ai/api/v1/emerging?limit=10"

Freshness depends on the plan, and meta says which one you got. Pro and Business read this morning's index and get meta.freshness "realtime". Free and Dev read yesterday's snapshot and get "previous-day" plus a meta.note. Two consequences worth knowing: on Free and Dev, meta.criteria is null, because the criteria text is stored only with the current index; and if yesterday's snapshot file is missing, Free and Dev are served the current index while meta.freshness still reads "previous-day".

GET /alternatives/{product}

Open-source alternatives to a commercial product. data returns name, domain, desc, page, cat and an alternatives array whose entries hold name, repo, site, license and desc. repo can be an empty string when the project has no GitHub repository. meta gives generated.

ParameterTypeDefaultBehaviour
productpath segment, requirednoneLowercased. Exact product key first; failing that, the first product whose key or name contains your string, in file order. No segment returns 400 missing_id; no match returns 404 not_found. Pass the exact slug — the one in the /alternatives/<slug>.html page URL — when you need a specific product, because loose matching takes the first hit, not the best.
curl -s -H "X-Api-Key: osk_YOUR_KEY" \
  https://olud.ai/api/v1/alternatives/notion

Endpoints: models, hf, history

GET /models

Model catalogue built from OpenRouter. Each row: id, name, provider, tier, ctx (context window in tokens), price_in and price_out (US dollars per million tokens, rounded to two decimals, 0 when the source reports no price), modality (for example text->text or text+image+file->text), tools (boolean) and rank. Fields with no value are dropped from the row, so check for presence rather than assuming price_in exists. meta gives total, limit, offset, generated and source.

ParameterType / boundsDefaultBehaviour
providerstringno filterSubstring match, case-insensitive. provider=mistral matches "Mistral AI".
tierfree or paidno filterExact match, case-insensitive on your input.
limitinteger 1-20050Clamped to the bounds; non-numeric falls back to 50.
offsetinteger 0-1000000Applied to the merged list. Open-weight rows come first, then proprietary ones, each block in rank order.
curl -s -H "X-Api-Key: osk_YOUR_KEY" \
  "https://olud.ai/api/v1/models?tier=free&provider=mistral&limit=20"
tier is not a price flag. free means the weights are published (open-weight), paid means proprietary. An open-weight model can have a non-zero price_in, because the price is what an API provider charges to run it. Also, rank restarts at 1 in each tier, so sorting the merged list by rank mixes two scales.

GET /hf

Hugging Face: most downloaded and trending. No parameters. data has three arrays — top_llm (text-generation models by 30-day downloads), top (all tasks, capped at 30 rows by this endpoint) and trending (today's risers). Each row: id, org, name, task, downloads (rolling 30 days), likes, license, gated, created, updated, trend (rise in likes on trending rows, null elsewhere). meta gives generated and source.

curl -s -H "X-Api-Key: osk_YOUR_KEY" \
  https://olud.ai/api/v1/hf
This endpoint depends on a morning scan of the Hugging Face Hub. Before that file exists, the call returns 503 not_ready. Nothing on your side is wrong; retry later in the day.

GET /history/{slug}

Daily series for one project: stars, health and 7-day velocity. Pro and Business keys only — the Business plan is the one sold as Org. Any other plan gets 403 pro_required. data returns slug, days (dates as YYYY-MM-DD, oldest first) and series with three arrays, stars, health and velocity_7d, aligned index by index with days. Individual entries can be null when a day's value was missing. meta gives points and window.

ParameterTypeDefaultBehaviour
slugpath segment, requirednoneLowercased, and must match ^[a-z0-9][a-z0-9._-]*$ — the same id as /project/{id}. Anything else returns 400 bad_slug. A valid slug with nothing recorded yet returns 404 not_found.
curl -s -H "X-Api-Key: osk_YOUR_KEY" \
  https://olud.ai/api/v1/history/ollama-ollama
meta.window always reads "90 days rolling" — that is the cap kept at build time, not a promise of 90 points. Recording starts when a project enters the graph, so short series are normal. Read meta.points for what you actually received, and size your chart from days, never from a hard-coded 90.

Daily quotas

Requests are counted per key, per UTC day. The counter resets at 00:00 UTC. There is no per-second or per-minute limit in the code.

Plan on the keyRequests / dayPublic nameWhat it changes
free500FreeEverything except /history. /emerging serves the previous day's index.
dev5000DevSame access as Free, higher ceiling.
pro50000Pro/history opens. /emerging serves this morning's index.
business500000OrgSame access as Pro, higher ceiling.

What counts: every endpoint except /meta, one unit per request. The counter is incremented right after the key is checked and before anything else, so a 304, a 404 not_found, a 400 missing_query and a 503 graph_unavailable all cost one unit. Only 401 missing_key and 403 invalid_key cost nothing, since there is no valid key to charge.

A quota field set on your key overrides the plan default. X-RateLimit-Limit is the authority on your ceiling, not the table above.

Past the ceiling, every request returns 429 quota_exceeded until the reset. The refused request is not counted, nothing is queued and nothing is charged. No Retry-After header is sent — the reset is 00:00 UTC.

What a refused request looks like on a free key:

HTTP/2 429
X-RateLimit-Limit: 500
X-RateLimit-Remaining: 0

{"ok":false,"error":{"code":"quota_exceeded","message":"Daily quota reached (500 requests/day on the \"free\" plan). Resets at 00:00 UTC."}}
The MCP server at /mcp.php has its own counter: 25 calls/day without a key (counted per IP), 200 on Free, 2000 on Dev, 20000 on Pro, 200000 on Business. Questions your editor asks over MCP do not eat the REST quota, and vice versa.
One failure mode to recognise: if the counter file cannot be opened, requests are let through and X-RateLimit-Remaining reads 0. A 200 alongside Remaining: 0 means the counter was unavailable, not that you are out of quota.

Error codes

Every error returns error.code as a stable string. Branch on that, not on the message text, which contains slugs and plan names and changes with the request.

HTTPcodeTriggerWhat to do
401missing_keyNo X-Api-Key header and no ?key=. Also what you get for an unknown path when no key is sent, because the key check runs before routing.Send the key. A 401 on a path you are sure exists is a missing header, not a wrong path.
403invalid_keyThe key is not in the store, or its active flag is false. Rotating your key deletes the previous one.Read the current key from your account page and update the caller. Rotation breaks every deployed copy of the old key at once.
403pro_required/history called with a free or dev key.Read today's values from /project/{id} (health, velocity), or move to Pro.
429quota_exceededThe daily counter reached your ceiling.Stop calling until 00:00 UTC, or raise the plan. Read X-RateLimit-Limit to confirm your real ceiling.
400missing_id/project or /alternatives called with no path segment.Add the segment. /v1/project alone is not a listing — use /v1/projects.
400missing_query/search with q empty or whitespace only.Send a non-empty q. Note the request was still counted.
400bad_slug/history slug empty, or containing a character outside ^[a-z0-9][a-z0-9._-]*$.Pass the id exactly as returned by /projects or /search.
404not_foundUnknown project id, no alternatives tracked for that product, or no history stored for that slug.For a project, retry through /search?q=. For history, the project may have entered tracking too recently to have points.
404unknown_endpointValid key, path not in the router.Check the spelling. /meta lists six endpoints and leaves out /hf and /history, which both exist.
503graph_unavailableA graph file is missing or unreadable, which happens while the morning build writes it.Retry in a minute. Your key is fine; do not rotate it.
503not_ready/hf before the morning Hugging Face scan has produced its file.Retry later in the day. Other endpoints are unaffected.
304no bodyIf-None-Match matched the current ETag.Serve your cached copy. Remember it consumed one request from your quota.

Retry policy that matches the code: retry on 503 after a minute, and on 429 only after the UTC reset. Never retry a 400, 403 or 404 unchanged — the answer will not change and each attempt costs a request.

Validate parameters on your side before sending. Out-of-range limit and offset values are clamped in silence rather than rejected, so a bad value costs you a request and returns a page you did not ask for. Compare meta.limit and meta.offset with what you sent when a result looks short.

Webhooks

Creating a webhook

A webhook is one signed POST to your endpoint per event. You pick the events, the repositories and the shape of the body. Webhooks need a paid plan: on a free key, creation answers 403 paid_feature and the account page shows a locked panel instead of the form.

Plan reported by the APIWebhooks allowed
free0
dev3
pro10
business50

A plan name we do not recognise falls back to 1. Once you are at your count, creation answers 429 limit_reached — delete one or change plan.

From your account

  • Sign in and open /account.html. The Webhooks card appears once your API key has loaded, and stays hidden until then.
  • Paste your endpoint in the URL field. The page refuses anything that does not start with https://.
  • Tick the events. release and health are ticked for you; license and new_project are not.
  • Leave the repos field empty to receive everything, or type owner/name entries separated by commas.
  • Pick the destination in the select: your own endpoint (raw signed JSON), Slack, or Discord.
  • Press Create. The secret appears once, in a green box. Copy it before you leave the page — nothing shows it again.
  • Each webhook row then carries a Send test ping button and a Delete link. Deleting stops deliveries at once.

From the API — create. Your API key is the one in your account, sent as X-Api-Key (or ?key=).

curl -sS -X POST https://olud.ai/api/webhooks.php \
  -H "X-Api-Key: $OSAI_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "url": "https://your-app.example/osai-hook",
        "events": ["release", "health", "license"],
        "repos": ["acme/inference-server", "acme/tiny-router"],
        "format": "json"
      }'

The data block of the answer. Every response also carries a meta block with our attribution line. The secret is here and nowhere else.

{
  "id": "whk_9b41c7e2f0a5d3861c4e",
  "url": "https://your-app.example/osai-hook",
  "events": ["release", "health", "license"],
  "repos": ["acme/inference-server", "acme/tiny-router"],
  "secret": "whs_7d2a19f4c6b03e58a1d7f492c0b6e35847ac91d2e6f0b8a3",
  "note": "Store this secret now — it is shown only once. Verify each delivery: X-OSAI-Signature == \"sha256=\" + HMAC_SHA256(raw_body, secret). Test it: POST {\"ping\":\"whk_9b41c7e2f0a5d3861c4e\"}."
}

What the URL must satisfy

  • Scheme https. http is rejected.
  • A host must be present.
  • If you write a port, it has to be 443. https://your-app.example:8443/hook is rejected.
  • The host cannot be localhost, and cannot end in .local or .internal.
  • We resolve the host. If any address it returns sits in a private or reserved range, the URL is rejected.
  • We do not call your endpoint during creation. A host that fails to resolve passes this check and fails later, at delivery. Send a test ping to find out.
  • A bad URL answers 400 bad_url.

What repos accepts

  • The default is ["*"] — every repository we track.
  • Entries are trimmed and lowercased, so matching is case-insensitive.
  • An entry must look like owner/name: owner starts with a letter or digit, then letters, digits, dot, underscore, hyphen.
  • Entries that do not match are dropped without a word. If nothing survives, you get 400 bad_repos.
  • "*" anywhere in the list replaces the whole list. ["acme/one", "*"] is stored as ["*"].
  • More than 100 entries answers 400 too_many_repos. Use "*" past that point.

The four events

Each event carries a repository. One event is one POST — events are never batched together. The name sits in the X-OSAI-Event header and in the event field of the body.

eventFires whenKeys inside data
healththe health label of a repository changesfrom, to, stars, page
releasea new release tag appears for a repositorytag, name, url
licensethe license we hold for a repository changesfrom, to
new_projecta repository enters the directorystars, page

health

{
  "webhook_id": "whk_9b41c7e2f0a5d3861c4e",
  "event": "health",
  "repo": "acme/inference-server",
  "data": {
    "from": "Thriving",
    "to": "Slowing down",
    "stars": 58120,
    "page": "https://olud.ai/project/acme-inference-server.html"
  },
  "sent_at": "2026-07-27T05:41:12+00:00"
}

from and to are labels, not numbers. The six labels we publish: Thriving, Healthy, Maintained, Slowing down, At risk, Archived. stars is the star count we hold for the repository. No event fires the first time a repository gets a label — a change needs a previous value to compare against.

release

{
  "webhook_id": "whk_9b41c7e2f0a5d3861c4e",
  "event": "release",
  "repo": "acme/inference-server",
  "data": {
    "tag": "v0.6.2",
    "name": "Acme Inference Server",
    "url": "https://github.com/acme/inference-server/releases"
  },
  "sent_at": "2026-07-27T05:41:12+00:00"
}

tag is the git tag. name is the project's display name in our directory, not the title of the release. url always points at the repository's releases page, never at one specific release — build the tag URL yourself if you need it.

license

from and to are the license strings we hold. This event has no page key.

{
  "webhook_id": "whk_9b41c7e2f0a5d3861c4e",
  "event": "license",
  "repo": "acme/inference-server",
  "data": {
    "from": "Apache-2.0",
    "to": "BSL-1.1"
  },
  "sent_at": "2026-07-27T05:41:12+00:00"
}

new_project

{
  "webhook_id": "whk_9b41c7e2f0a5d3861c4e",
  "event": "new_project",
  "repo": "acme/tiny-router",
  "data": {
    "stars": 1840,
    "page": "https://olud.ai/project/acme-tiny-router.html"
  },
  "sent_at": "2026-07-27T05:41:12+00:00"
}

At most 25 new_project events per run, highest star counts first. On a first run, or when more than 200 repositories appear at once, they are recorded and none are sent — that guard keeps a re-import from flooding you.

Body and headers

With format json, the body has five keys, in this order. Nothing else is added.

{
  "webhook_id": "whk_9b41c7e2f0a5d3861c4e",
  "event": "release",
  "repo": "acme/inference-server",
  "data": { },
  "sent_at": "2026-07-27T05:41:12+00:00"
}

webhook_id is the id you created: whk_ plus 20 hex characters. repo is the lowercase owner/name, and is null only on a test ping. data is an object, empty for an event that carries no fields. sent_at is UTC, ISO 8601 with an offset.

HeaderValue
Content-Typeapplication/json
User-Agentolud.ai-Webhooks/1.0
X-OSAI-Eventhealth, release, license, new_project or ping
X-OSAI-Delivery16 hex characters, fresh for every POST
X-OSAI-Signaturesha256= followed by the HMAC-SHA256 of the body

Those five are the whole set. X-OSAI-Delivery is not stored on our side — use it to spot a duplicate in your own log.

A test ping, sent by POST {"ping":"whk_…"}. Same headers, same signature, X-OSAI-Event: ping.

{
  "webhook_id": "whk_9b41c7e2f0a5d3861c4e",
  "event": "ping",
  "repo": null,
  "data": {
    "message": "It works. Real events arrive after each morning scan."
  },
  "sent_at": "2026-07-27T05:41:12+00:00"
}
Answer 2xx, and answer fast. We wait 6 seconds, then count a failure. Verify the signature, push the event onto a queue, return 200, and do the work afterwards.

Verifying the signature

Recompute the HMAC-SHA256 of the raw body with your secret, prefix it with sha256=, and compare it to X-OSAI-Signature. The secret is the whs_ string from the creation response: whs_ plus 48 hex characters.

PHP

<?php
$raw    = file_get_contents('php://input');   // raw bytes, before any parsing
$sent   = $_SERVER['HTTP_X_OSAI_SIGNATURE'] ?? '';
$expect = 'sha256=' . hash_hmac('sha256', $raw, $SECRET);

if (!hash_equals($expect, $sent)) {           // constant time
    http_response_code(401);
    exit;
}

$event = json_decode($raw, true);             // parse only after the check
http_response_code(200);

Node, with Express

const crypto  = require('crypto');
const express = require('express');
const app = express();

// express.raw keeps the bytes. express.json() would destroy them.
app.post('/osai-hook', express.raw({ type: 'application/json' }), (req, res) => {
  const sent   = req.get('X-OSAI-Signature') || '';
  const expect = 'sha256=' + crypto.createHmac('sha256', SECRET)
                                   .update(req.body).digest('hex');

  // timingSafeEqual throws a RangeError on buffers of different length,
  // so check the length first, then compare in constant time.
  const a = Buffer.from(sent), b = Buffer.from(expect);
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
    return res.sendStatus(401);
  }

  const event = JSON.parse(req.body.toString('utf8'));
  res.sendStatus(200);
});

Python, with Flask

import hmac, hashlib, json
from flask import Flask, request, abort

app = Flask(__name__)

@app.post("/osai-hook")
def osai_hook():
    raw    = request.get_data()               # bytes, before any parsing
    expect = "sha256=" + hmac.new(SECRET.encode(), raw, hashlib.sha256).hexdigest()
    sent   = request.headers.get("X-OSAI-Signature", "")

    if not hmac.compare_digest(expect, sent):  # constant time
        abort(401)

    event = json.loads(raw)
    return "", 200

Compare with hash_equals, crypto.timingSafeEqual or hmac.compare_digest. Never with == or ===. A plain string comparison stops at the first byte that differs, so the time it takes tells an attacker how many leading bytes they got right. Repeat the measurement and they recover the signature byte by byte, then post you forged events. The three functions above read every byte, whatever the input.

  • Hash the raw bytes, before any parsing. A body a framework parsed and re-serialised hashes to something else, and every delivery will look invalid.
  • The json format escapes non-ASCII characters as \uXXXX. Re-serialising loses that too.
  • A missing header arrives as an empty string. All three examples reject it instead of crashing.
  • The secret is shown once, at creation. If you lose it, delete the webhook and create another — you get a new id and a new secret.
  • Keep one secret per webhook. Two webhooks never share one.
Slack and Discord ignore X-OSAI-Signature. We send it anyway, and it still covers what they receive: the signature is computed over the bytes actually posted, whatever the format.

json, slack, discord

format is chosen at creation, and defaults to json. There is no update endpoint: to change it, delete the webhook and create another. GET reports the format of each webhook; the creation response does not echo it. A value outside the three answers 400 bad_format.

formatWhat we postURL to paste
jsonthe payload above, unchangedyour own endpoint
slacktext, plus one mrkdwn section blocka Slack incoming-webhook URL
discordone embed: title, url, description, color, footera Discord channel webhook URL

slack

A release event, posted to Slack

{
  "text": "🚀 acme/inference-server released v0.6.2 — Acme Inference Server",
  "blocks": [
    {
      "type": "section",
      "text": {
        "type": "mrkdwn",
        "text": "🚀 *<https://github.com/acme/inference-server/releases|acme/inference-server released v0.6.2>*\nAcme Inference Server"
      }
    }
  ]
}

Get the URL from Slack: add an app to the workspace, turn on Incoming Webhooks, add one for the channel you want, copy the https://hooks.slack.com/services/… URL, and paste it in the url field. text is always filled — it is what Slack shows in the notification and in clients that do not render blocks.

discord

A license event, posted to Discord

{
  "embeds": [
    {
      "title": "🔑 acme/inference-server changed licence",
      "url": "https://github.com/acme/inference-server",
      "description": "Apache-2.0 → BSL-1.1",
      "color": 14427686,
      "footer": { "text": "olud.ai" }
    }
  ]
}

Get the URL from Discord: channel settings, Integrations, Webhooks, New Webhook, Copy Webhook URL — https://discord.com/api/webhooks/… — and paste it in the url field. color is a decimal integer: 14427686 (#DC2626) for license, 6514417 (#6366F1) for release, new_project and ping.

Both hosts pass the URL check. When a license event carries no page, the link falls back to https://github.com/owner/name; health and new_project link to the project page on olud.ai; release links to the repository's releases page.

Known defect, health on slack and discord only. The message builder reads from and to as numbers, but health carries labels. The title comes out as "acme/inference-server health 0 → 0", the body always reads "Maintenance is improving.", and the colour is always green — including when the score drops. The json format is not affected: it forwards the two labels as they are. Use json if you need the health labels.

Delivery, failure, disabling

Deliveries ride on the alert pass, send-alerts.php — the same detection that produces the member emails. The dispatch runs right after detection, in the same process. There is no separate schedule and no queue. The API describes the cadence as "daily, after the morning scan".

  • One POST per event, per matching webhook. A webhook receives an event when the event name is in its events list, and when the repository is in its repos list or repos is ["*"].
  • A success is HTTP 200 to 299, within the 6-second timeout. A 4xx, a 5xx, a timeout, a DNS or TLS failure all count as failures.
  • A success sets fails back to 0 and adds 1 to delivered.
  • A failure adds 1 to fails. There is no retry. The event is not sent again — the next delivery is the next change we detect.
  • At 10 consecutive failures the webhook is disabled, stamped with the UTC time of that moment, and the remaining events of that run are skipped for it.
  • A disabled webhook stays in your list, marked disabled. There is no call to re-enable it: delete it and create another, with a new id and a new secret.

Reading the state

curl -sS https://olud.ai/api/webhooks.php -H "X-Api-Key: $OSAI_KEY"

The answer, meta included

{
  "ok": true,
  "data": [
    {
      "id": "whk_9b41c7e2f0a5d3861c4e",
      "url": "https://your-app.example/osai-hook",
      "events": ["release", "health"],
      "repos": ["*"],
      "format": "json",
      "created": "2026-07-20T09:14:02+00:00",
      "delivered": 37,
      "fails": 0,
      "disabled": null
    }
  ],
  "meta": {
    "plan": "dev",
    "used": 1,
    "limit": 3,
    "events": ["health", "release", "license", "new_project"],
    "formats": ["json", "slack", "discord"],
    "delivery": "daily, after the morning scan",
    "signature": "X-OSAI-Signature: sha256=HMAC_SHA256(body, secret)"
  }
}

delivered is the lifetime count of successful POSTs. fails is the current consecutive streak, so it drops back to 0 on the next success. disabled is null, or the timestamp at which we stopped. Secrets are never returned by GET.

Four ways deliveries stop

  • Your endpoint keeps failing. Ten failures in a row and the webhook is disabled. Fix the endpoint, delete the webhook, create a new one, ping it.
  • Your plan goes back to free — a cancellation. The webhook is kept and skipped at every dispatch, silently. Changing plan again wakes it up, same id, same secret.
  • You rotate your API key in your account. The old key leaves the key store while the webhook still points at it: it drops out of your list, it can no longer be pinged or deleted, and every dispatch skips it like a free key. Delete your webhooks before rotating the key, then create them again with the new one.
  • You delete it. Deliveries stop at once.

Testing without waiting

curl -sS -X POST https://olud.ai/api/webhooks.php \
  -H "X-Api-Key: $OSAI_KEY" -H "Content-Type: application/json" \
  -d '{"ping":"whk_9b41c7e2f0a5d3861c4e"}'

Both outcomes

HTTP/1.1 200
{"ok":true,"data":{"ping":"delivered","http":200}}

HTTP/1.1 502
{"ok":false,"error":{"code":"ping_failed",
  "message":"Your endpoint answered HTTP 500 — a 2xx is expected."}}

The ping goes out in the format of the webhook, signed the same way, with event ping and repo null. It moves neither delivered nor fails, so a failed ping never pushes you toward the disable threshold. It also works on a free plan when the webhook already exists, while scheduled deliveries do not.

HTTP 0 in a ping_failed message means we got no answer at all: the host did not resolve, TLS did not complete, or the 6 seconds ran out.

Error codes

Every failure comes back in the same shape, with a matching HTTP status.

{
  "ok": false,
  "error": {
    "code": "bad_url",
    "message": "URL must be https, publicly reachable, standard port (no localhost or private ranges)."
  }
}
HTTPcodeWhen
400bad_jsonThe body is not a JSON object.
400bad_urlThe URL failed the check: not https, a port other than 443, localhost, .local, .internal, or an address in a private or reserved range.
400bad_eventsAfter filtering, events held none of health, release, license, new_project.
400bad_reposNo entry was "*" or a valid owner/name.
400too_many_reposMore than 100 repositories on one webhook.
400bad_formatformat was not json, slack or discord.
401missing_keyNo X-Api-Key header and no ?key= parameter.
403bad_keyThe key is not in our key store. A rotated or revoked key lands here.
403paid_featureYour plan allows 0 webhooks.
404not_foundThe id in ping or in ?id= is not attached to your key.
405method_not_allowedA method other than GET, POST or DELETE.
429limit_reachedYou already hold as many webhooks as your plan allows.
500store_write_failedWe could not write the change to disk. Retry.
502ping_failedYour endpoint answered the test ping with something other than a 2xx.

Two practical points. Managing webhooks does not consume your daily API request quota — this endpoint never touches the counter and sends no X-RateLimit headers. And DELETE is absent from the CORS allow-methods list, so a cross-origin call from a browser fails at preflight; delete server-side, or from the account page, which is same-origin.

Automations

n8n: read and receive

Two nodes cover both directions. An HTTP Request node reads our data. A Webhook node receives our events. Nothing to install from the community list.

Read the catalogue

Every endpoint answers GET at https://olud.ai/api/v1, with your key in the X-Api-Key header. /meta is the one endpoint that answers without a key and without touching your quota — use it as the first node while you test, because it proves the URL and the network path before you spend a call.

Receive the events

Add a Webhook node, method POST, and copy its production URL. Register that URL in your account (Webhooks section), or POST it to https://olud.ai/api/webhooks.php with your key. Four events to choose from: release, health, license, new_project. Deliveries go out once a day, in the same pass that sends the email alerts — not the second something happens.

A delivery body, with format json (the default):

What sits in data depends on the event:

  • release — tag, name, url (the releases page of the repository)
  • health — from, to, stars, page
  • license — from, to
  • new_project — stars, page
  • ping — message (test deliveries only)

On a health event, from and to are labels, not numbers: Thriving (score 85 and up), Healthy (70+), Maintained (50+), Slowing down (30+), At risk (below 30). Compare text in your IF node, not integers.

Every delivery carries three headers: X-OSAI-Event, X-OSAI-Delivery (a unique id per delivery, use it to drop duplicates) and X-OSAI-Signature, of the form sha256=<HMAC-SHA256 of the body with your webhook secret>. The HMAC covers the exact bytes we sent, so if you want to verify it, turn on the raw-body option of the Webhook node. A body that has been parsed and re-serialized never matches.

Paste this on the canvas. The two nodes are left unconnected on purpose — each one is its own starting point:

Then put your key in the header field of the HTTP node, and activate the workflow — the production URL of a Webhook node listens only while the workflow is active.

When nothing arrives

  • Registering the URL answers 400 bad_url: the endpoint must be https, on port 443, on a publicly resolvable host. A self-hosted n8n on http, on localhost, on a *.local name or on a private IP range is refused.
  • Registering answers 403 paid_feature: webhooks start at the Dev plan — 3 endpoints on Dev, 10 on Pro, 50 on Org. A Free key cannot create one.
  • Registering answers 429 limit_reached: you already have as many webhooks as your plan allows. Delete one first.
  • Do not wait for tomorrow to find out: POST {"ping":"whk_…"} to /api/webhooks.php and the test event leaves immediately, in the same shape as a real one. If your endpoint does not answer 2xx you get 502 ping_failed with the code it returned.
  • Ten consecutive non-2xx replies disable the webhook, silently. One success resets the counter. A disabled webhook comes back only by recreating it. The delivery timeout is 6 seconds, so a node that answers slowly counts as a failure.
  • The webhook was created but nothing is delivered and nothing errors: check the plan on the key that owns it. If it fell back to Free, deliveries are skipped while the endpoint stays registered.
  • The secret is shown once, at creation. There is no way to read it back — delete and recreate.

Zapier and Make

Same two directions, same key, no app to find in their directories.

Receive: catch the hook

In Zapier: Webhooks by Zapier, trigger Catch Hook. In Make: the Webhooks module, Custom webhook. Both hand you an https URL on their own domain, which passes our check. Paste it into your account as a webhook endpoint, pick your events, then send yourself a ping and confirm the Zap or scenario receives it before you build the steps behind it.

If you plan to verify X-OSAI-Signature, you need the raw body and the headers. In Zapier that means Catch Raw Hook rather than Catch Hook. In Make, switch on the webhook setting that keeps request headers. Our signature is an HMAC over the exact bytes of the body, so a step that parses the JSON before you see it makes the comparison impossible.

Read: one HTTP step

Zapier's Webhooks GET action, or Make's HTTP module. One step, one header:

A success is {"ok":true,"data":[…],"meta":{…}}. An error is {"ok":false,"error":{"code":"…","message":"…"}} with a matching HTTP status. Test ok before you map fields: an error body has no data at all, and a mapping that reads data[0] on an error silently produces empty values downstream.

  • 401 missing_key — the header did not arrive. Check the step sends it on every call, not only the first.
  • 403 invalid_key — key unknown or revoked.
  • 429 quota_exceeded — daily quota reached, resets at 00:00 UTC. Successful answers carry X-RateLimit-Limit and X-RateLimit-Remaining, so you can see it coming.
  • 503 graph_unavailable — the graph is being rebuilt. Retry in a minute; this is the one error worth an automatic retry.
  • 403 pro_required — /history/{slug} is Pro and Org only.
  • 404 not_found — no such project or product. The message carries a /search suggestion you can follow.
Do not schedule this every minute. The graph is rebuilt once every morning, so hourly is already more often than the data changes. Answers carry an ETag derived from the build date and a repeat request gets 304 Not Modified — but the key is counted before that comparison, so a 304 still costs one call against your daily quota.

GitHub Action: dependency watch

The action reads the dependency manifests of the checked-out repository, asks us the licence and the maintenance record of each dependency, and posts one comment on the pull request listing what needs a decision. It rewrites that same comment on every run — it finds it again through a hidden marker — so a long pull request does not fill up with duplicates.

The whole workflow, with every input at its default value. Only api-key is required:

actions/checkout has to come first: the action reads files from the working directory, and at the root only unless you set paths.

InputDefaultWhat it does
api-keyrequiredSent as X-Api-Key. One lookup per distinct dependency. A free key works.
github-token${{ github.token }}Posts the comment. Needs pull-requests: write on the job.
health-floor45Flag a dependency scoring below this out of 100. 0 turns the health check off and keeps only licences. Read the known limit below.
licencesAGPL-3.0,BSL-1.1,SSPL-1.0,Elastic-2.0,BUSL-1.1,NOASSERTIONComma-separated, matched without case against the licence we hold. Setting it replaces the list, it does not add to it.
fail-on-findingfalseFail the check instead of only commenting.
pathsemptyComma-separated manifests to read. Empty means: look for the four known names at the root.

NOASSERTION is in the default list on purpose: it is what we hold when the repository carries no licence file a machine can recognise, which is a decision to make rather than a detail. The action exposes one output, findings — the number of flagged dependencies, written even when it is 0.

What it reads

  • package.json — the keys of dependencies and devDependencies. peerDependencies and optionalDependencies are not read.
  • requirements.txt — one name per line, comments stripped, lines starting with - dropped. So -r other-requirements.txt is not followed and -e . is ignored.
  • pyproject.toml — only keys whose value starts with a quote or a brace, which is Poetry style: fastapi = "^0.110". A PEP 621 block, dependencies = ["fastapi>=0.110"], yields nothing at all.
  • go.mod — indented lines of the form name vX.

paths may point anywhere in the tree, for instance apps/api/requirements.txt, but the base name of the file must be one of those four. A file called requirements-dev.txt has no parser: it is skipped without a message.

It does not fail your check by default. fail-on-finding is false, so a finding is a comment, not a red build — a licence change is a decision, and a pipeline that turns red for something nobody can fix in five minutes teaches the team to click through it. Two things do fail the job: a missing api-key, logged as ::error::api-key is required, and fail-on-finding: true with at least one finding.

How it fails

  • No manifest at the root, or no checkout step: "No dependency manifest found — nothing to check." findings is 0 and the job is green. A green run does not mean a clean tree — read that line.
  • The event has no pull request, for instance on push: "No pull request in context — comment skipped." The findings are still in the log.
  • pull-requests: write missing: "Could not post the comment (HTTP 403)." The job stays green and the comment never appears. Look at the log, not at the pull request.
  • Daily API quota reached mid-run: ::warning::Daily API quota reached — partial run. It stops looking things up and comments on what it managed to resolve. One call per distinct dependency, so 300 dependencies spend 300 of the 500 daily calls of a Free key.
  • A manifest that will not parse: "⚠ <file> unreadable (…) — skipped". The other manifests still run.
  • The composite step runs node from the PATH and uses native fetch. GitHub-hosted runners already carry Node 20. A self-hosted runner needs actions/setup-node with Node 20 or later.
  • licences: '' with health-floor: '0' produces zero findings for ever. That combination turns both checks off.

What the Action misses

A package name is not a repository name. Each name goes through /api/v1/search?q=<name>&limit=5 and the action keeps a result only when the project name is equal to the package name, ignoring case. Anything else is dropped in silence — no comment line, no warning. Guessing at the closest result would raise alarms about the wrong project, which is worse than staying quiet, but it does mean the check covers less than your dependency tree.

  • Scoped npm packages lose their scope before the lookup: @types/node is searched as node, which can match an unrelated repository of that name. This is the one case where the wrong project can end up in the comment.
  • Go modules keep their path: github.com/gin-gonic/gin becomes gin-gonic/gin, which never equals a repository name (gin). go.mod dependencies do not resolve.
  • A package whose repository is named differently — the common case in Python — never resolves.
  • Only the top five results are examined and only the first exact match is used. When two repositories share a name, the one with more stars wins.

The line to read in the log is: Resolved N of M dependencies · K flagged. If N is far below M, the check looked at a fraction of your tree. Nothing in the pull-request comment says so.

Known limit on health-floor. The action reads the maintenance score as an object field, while /api/v1/search returns health as a plain number. With today's API response the health check cannot fire, whatever floor you set: the licence list is what produces findings. The full score with its four components is served by /api/v1/project/{id} if you need it in the meantime.

OpenAPI specification

One file describes the read API: https://olud.ai/openapi.json. OpenAPI 3.0.3, one server (https://olud.ai/api/v1), nine GET operations, one security scheme — an API key in the X-Api-Key header. You do not write an integration; you paste an address.

  • /meta — build date and project count. Declared without security: it is the health check.
  • /search — q required, limit 1 to 50, default 15.
  • /projects — lang, license, vertical, health_min 0 to 100, sort in stars|health|momentum|recent (default stars), limit 1 to 100 (default 25), offset 0 to 100000 (default 0).
  • /project/{id} — the full merged record, including health with its components.
  • /emerging — limit 1 to 100, default 25.
  • /alternatives/{product} — open-source alternatives to a commercial product.
  • /models — provider, tier, limit 1 to 200 (default 50), offset.
  • /hf — most downloaded and trending on Hugging Face.
  • /history/{slug} — 90 days of stars, health and momentum. Pro and Org.

Import it

  • Custom GPT — Configure, then Actions, then Import from URL. Authentication: API Key, custom header, name X-Api-Key.
  • Dify, Flowise, Open WebUI — add a tool from an OpenAPI schema, paste the URL, pick the operations you want to expose, add the same header.
  • Postman, Insomnia — Import, then Link. You get the nine requests, documented. Set X-Api-Key once at collection level so every request inherits it.
  • Code generators — any OpenAPI generator produces a typed client, TypeScript, Python or Go, from this file alone.

Whatever the tool, there are only two things to set: the URL of the file, and the key as a header named X-Api-Key. If an import shows nine operations but every call answers 401, the header is not being sent — that is the first thing to check.

Quotas are per key and per day, reset at 00:00 UTC: 500 requests on Free, 5,000 on Dev, 50,000 on Pro, 500,000 on Org. They are counted separately from the MCP server, so an assistant asking questions in your editor never eats into this budget.

Webhook management is not in this file. It lives at https://olud.ai/api/webhooks.php with the same key: GET lists your endpoints with their delivery and failure counts, POST creates one or sends a ping, DELETE removes one. A tool that imports the spec cannot create a webhook for you.

Four RSS feeds

No key, no signup, no account. feed.php accepts exactly four types: news, blog, releases, emerging.

URLWhat it carriesWhere it comes from
/feed.phpUp to 30 new projects (★stars · owner/name), the new models of the day (New model: name (provider), with the context window in the description), new Hugging Face Spaces (New Space: name by author, with the like count), and the paper of the day.today-data.json, rebuilt hourly
/feed.php?type=releasesUp to 60 versions shipped by the projects we follow: title project + tag, link to the release, guid owner/name@tag.releases-data.json, rebuilt hourly
/feed.php?type=emergingUp to 40 emerging projects — healthy, accelerating, still little known. Description: ★stars · health N/100 · +N stars this week.the graph, rebuilt every morning
/feed.php?type=blogArticles under /blog/<slug>/, /blog/alternatives/ and /reports/. Title and description are the page's own title and meta description; the date is the file's modification time.read from disk, so a new article appears on its own

All four answer RSS 2.0 as application/rss+xml, in English, newest item first, with Cache-Control: public, max-age=1800. That is 30 minutes: a reader that polls more often gets the cached copy, which is the same answer served faster.

Guids are stable and not always the link, which is what you want when de-duplicating in an automation: releases use owner/name@tag, emerging projects use emerging:<id>, the paper of the day uses its URL plus the date, everything else uses its link.

The trap: an unknown type is not an error. ?type=release in the singular, or a typo, returns the news feed with HTTP 200. If two of your feeds look suspiciously identical, check the spelling of type. Values are trimmed and lower-cased, so ?type=Releases is fine.

Point Slack, Teams or Feedly at them to read, or use the RSS trigger in n8n, Zapier and Make when a schedule suits you better than a webhook — that is also the way to get releases without a paid plan. If a source file has not been rebuilt, the feed still answers 200 with an empty channel rather than an error, so an automation that suddenly receives nothing is not necessarily broken.