Read-only JSON API and RSS feed behind the board. No keys, no rate plans, no tracking.
/api/v1. All bodies are JSON (camelCase); timestamps are ISO-8601 UTC with a Z suffix.Cache-Control: public, max-age=300, s-maxage=300). The cache key is the route plus its validated parameters, so unrecognised parameters are ignored and do not produce a separate cache entry, and parameters that mean the same thing (limit=5 and limit=05, kev_first=1 and kev_first=true, the same instant written in two time zones) share one. /api/v1/status is cached for 60 seconds and /api/v1/health for 30 seconds. The digest archive (/api/v1/digests, /api/v1/digests/{id}) is cached for the same five minutes as the other read routes, so a digest its operator withdraws stops being served within that window. The admin trigger is never cached.ETag derived from the data it reflects, not from the bytes. Send it back as If-None-Match and an unchanged resource answers 304 with no body — worth doing if you poll /rss.xml. The tag is weak because generatedAt is not part of it, so it stays valid across cache generations while the underlying data is unchanged.GET, HEAD and OPTIONS from any origin.http(s) URLs.sources.json fill Incidents & Advisories. Together these are the Secure It half of the board.builder, carries defense-first news for people building AI systems — guidance and standards (badge GUIDANCE), security tooling releases (TOOLING), framework releases (RELEASE) and builder news (NEWS). It is filled by the RSS/Atom feeds in sources.json marked for Build It, each with its own filter: the AI keyword filter, none, or — for framework release feeds — only releases whose full release note names a security fix, in which case the item's summary is those lines joined by “; ” (at most 280 characters). Per feed, pre-releases, patch releases, entries whose title matches an exclusion pattern and entries outside a list of categories can be dropped too. Build It items are not triaged (triageStatus: "skipped"). The column appears in /api/v1/latest as columns.builder and has its own column route; a client written before it existed can ignore the key.REJECTED) are hidden everywhere unless a column route is asked for them.The board view: the newest items of each column. Threats are ordered with the CVEs CISA lists as actively exploited (badge KEV) first, newest first, followed by the remaining CVEs newest first — limit and days apply to the whole list, so a busy CNA day never hides an exploited vulnerability. Research is limited to items that finished LLM triage with a relevance at or above the board threshold (minRelevance in the response; 7 by default). Incidents are hidden only once triage scored them below that threshold (items not scored yet are shown), and a story carried by several outlets appears once: the other reports are listed in extra.alsoReportedBy and their sources added to sources. Build It items (builder) are listed newest first with no relevance gate.
| Parameter | Type | Default | Meaning |
|---|---|---|---|
limit | integer 1–500 | 20 | items per column |
days | integer 0–3650 | 30 | only items published within the last N days; 0 disables the window |
{ "generatedAt": "…", "lastUpdated": "…" | null, "minRelevance": 7,
"columns": { "threats": [Item], "research": [Item], "incidents": [Item], "builder": [Item] },
"threatGroups": [{ "product": "…", "count": 3, "worstSeverity": "CRITICAL" | null, "worstScore": 9.8 | null, "kev": false,
"fixedVersions": ["1.2.3"], "unfixedCount": 1, "latestAt": "…", "items": [Item] }] }
threatGroups groups every threat of the window by the product its description names (at most 40 groups, known-exploited groups first, then by worst CVSS score, then newest; each lists up to 5 of its CVEs, known-exploited first, then most severe). A CVE whose description names no product is a group of its own, named by its CVE id. product is at most 120 characters. count is the number of CVEs in the window, fixedVersions every version a description says fixes a flaw (“fixed in”, “patched in”, “upgrade to”, else “before” / “prior to”; every release line named), in version order, unfixedCount the CVEs whose description names no fixed version. Every threat item carries the same facts in extra.product (a name, or null), extra.fix (fixed in A, fixed in A and B or fixed in A, B and C — every fixed version named —, no fix named (affected through X) — X the highest version still affected — or no version stated) and extra.flaw (the description's first one or two sentences without the product blurb, at most 220 characters, or null); all are read from the description by fixed rules, not by a model. For a CVE CISA listed before NVD described it, the fix status comes from NVD's description whenever NVD has one (kept in extra.nvdFix), never from CISA's short text. When those optional reads fail, the board is sent without threatGroups (and without the edge cache) rather than failing. Research items carry extra.stance — attack, defense or measurement — when the keyword classifier can tell.
One column: threats, research, incidents or builder. Unknown columns are a 404.
| Parameter | Type | Default | Meaning |
|---|---|---|---|
limit | integer 1–500 | 50 | maximum items |
days | integer 0–3650 | 30 | as above; 0 = no window |
min_score | integer 0–10 | 0 (research); the board threshold (incidents) | Research: 0 returns every row in the window, including items not yet scored (relevance: null); any value above 0 keeps items that have a score at or above it — check triageStatus: an item queued for re-triage keeps its previous score while pending. Incidents: hides items scored below it (unscored items stay), default the board threshold, 0 shows everything; incidents are collapsed per story as on the board. Ignored for threats and builder. minRelevance echoes it for research only (null for every other column). |
since | ISO-8601 date-time with zone | — | only items published at or after this instant, e.g. 2026-09-01T00:00:00Z — a full date-time with a zone is required; a bare date is a 400 (combine with days=0 for an open-ended range). The instant is rounded down to the whole UTC hour (…T10:42:17Z is read as …T10:00:00Z), so items published up to an hour earlier can appear. |
include | abstract | — | adds abstract, triageModel, promptVersion and triagedAt to every item (offline evaluation). With it, limit is capped at 100 (a larger value is treated as 100). |
include_rejected | 1 | true | — | also return rows carrying the REJECTED badge |
kev_first | 1 | true | — | threats only: the board's order — KEV-badged rows first (newest first), then the rest (newest first). The default is pure publication-date order. |
{ "column": "research", "generatedAt": "…", "lastUpdated": "…" | null,
"count": 12, "minRelevance": 0 | n | null, "items": [Item] }
Board versus API relevance. The board (and /latest) shows research at or above the threshold only. The column route defaults to min_score=0 so the whole scored history is available, including pending items; pass min_score=7 to reproduce the board's selection.
Offline evaluation. GET /api/v1/research?min_score=0&include=abstract&limit=100&days=90 returns the newest 100 abstracts of the last quarter with the model and prompt version that scored them; narrow days or raise min_score when a window holds more. worker/scripts/export-scored.sh in the repository turns that into JSON Lines.
Operational status: the last run per source and row counts. runs lists the newest run of every ingestion module plus the newest triage pass (source: "triage", triaged = items scored). lastUpdated is the finish time of the newest run that stored data — an ingestion run with status ok or partial (an error run stored nothing), or a triage pass that scored at least one item; it is the same value the board footer shows. counts.pendingTriage is the number of research and incidents items the next triage pass will pick up: pending ones plus failed ones still under the retry cap. Cached for 60 seconds.
Freshness. freshness has one entry per ingestion source (kev, nvd, arxiv, hf, rss, in that order; treat source as a free string, more may be added). lastOkAt is, for nvd, the NVD watermark: NVD changes up to about 6 hours before this time have been read (a run that reads only part of a backlog does not move it); for every other source, the finish time of its newest ok or partial run; null when there is none. stale is true when that time (or, with none, the oldest recorded run's start) is more than staleAfterHours old — 24 for KEV and NVD, 36 for arXiv and the news feeds, 48 for Hugging Face, each at least twice the source's schedule. A source that has never run is not stale. The field is optional: an older deployment omits it.
{ "generatedAt": "…", "lastUpdated": "…" | null,
"runs": [{ "source": "arxiv", "startedAt": "…", "finishedAt": "…" | null,
"status": "ok" | "partial" | "error", "fetched": 3, "inserted": 1, "updated": 0, "triaged": 0, "error": null }],
"counts": { "threats": 0, "research": 0, "incidents": 0, "builder": 0, "pendingTriage": 0 },
"freshness": [{ "source": "nvd", "lastOkAt": "…" | null, "staleAfterHours": 24, "stale": false }] }
For uptime monitors. 200 with status: "ok" while ingestion is alive; 503 with status: "stale" when no ingestion run has stored data (status ok or partial) in the last 12 hours — twice the 6-hour interval of the CISA KEV / NVD runs, so one missed run is tolerated — when none ever has, or when the latest run of every source that ran in the last 7 days failed. Triage and digest runs never count. reason is a constant phrase naming the cause. A 200 is cached for 30 seconds; a 503 is sent with Cache-Control: no-store.
{ "status": "ok" | "stale", "generatedAt": "…", "lastSuccessfulIngest": "…" | null,
"staleAfterHours": 12, "reason": null | "…" }
The newest weekly digest — a short Markdown briefing on the last seven days, written by the configured digest model every Monday — or a 404 while none has been written yet. The model is asked for four sections, headed ## Bottom line, ## Secure It (threats, attack research, incidents and advisories), ## Build It (defensive research, guidance, tooling and releases) and ## Do this week, for at most 320 words and at most three bullets in each section after the first. Those are targets, not guarantees: a digest that misses them is still stored (a missing digest is worse than a long one), and the Worker records the miss in the digest run's notes. Abbreviations are spelled out at their first use in the prose (glossary-backed); link labels are names and are left as written. The model cites items by reference and the Worker turns each reference into a link to that item's own URL; the digest only ever discusses items that are on the board.
What the Worker guarantees about the stored Markdown: no HTML tags; one line per paragraph; links only to the digested items' URLs — every inline link [label](url) points exactly to the URL of an item the digest was written from (any other link becomes its label, an image its alt text); no http://, https://, other scheme://, //host or www. address anywhere else in the text, labels included (such bare URLs are removed, so GFM autolinking finds none); and ]( or ]: outside those links is broken with a space, so a CommonMark renderer cannot pair leftover brackets into a link or a link reference definition. Not covered: bare domain names without a scheme or www. and e-mail addresses stay as text, so render the digest with fuzzy linkification off, as plain text, or through your own link allow-list. The digest is skipped (and the board unaffected) when no digest provider is configured. Cached for five minutes.
{ "generatedAt": "…",
"digest": { "id": 1, "periodStart": "…", "periodEnd": "…", "model": "…", "promptVersion": "…", "content": "# …", "createdAt": "…" } }
The digest archive: a page of past digests, newest first, each the same full shape as /api/v1/digest/latest serves (Markdown included — a digest is a few kilobytes). Pages are cut by id, not by offset: nextBefore is the id of the last digest on the page — pass it as before to get the next, older page — or null when the page reaches the oldest digest. Ids are assigned in order of creation, so this is also newest-first by createdAt, and a digest written between two page reads never shifts an older page. An empty archive is a 200 with an empty list, not a 404. Cached for five minutes.
| Parameter | Type | Default | Meaning |
|---|---|---|---|
limit | integer 1–50 | 20 | digests per page |
before | integer ≥ 1 | — | only digests with an id below this one (the previous page's nextBefore); a value past the oldest digest gives an empty page |
{ "generatedAt": "…",
"digests": [{ "id": 8, "periodStart": "…", "periodEnd": "…", "model": "…", "promptVersion": "…", "content": "## …", "createdAt": "…" }, …],
"nextBefore": 5 | null }
One archived digest by id, in the shape of /api/v1/digest/latest; a 404 when there is no such digest, and for anything that is not a plain positive integer (0, 007, abc; the id is read after URL decoding, so %37 is 7). Cached for five minutes like the other read routes — a digest its operator withdraws stops being served within that window — and the ETag changes only with the row.
RSS 2.0, the newest 50 items across all columns together with every CVE CISA added to KEV in the last 30 days, capped at 50 and ordered by publication date (KEV items keep their real date and are kept in preference to older non-KEV items when the cap applies). Research appears only once triaged at or above the board threshold. Item titles are prefixed with the column ([Threat], [Research], [Advisory], [Incident], [News], [Build It]); a threat listed in CISA KEV is prefixed [KEV] instead; <category> carries the column, every badge and, for research, the triage tags; <guid> is the stable item id (not a permalink), with :kev appended once the CVE is in KEV so the addition surfaces once as a new item; the description is the one-line summary followed by the source and, where applicable, the relevance score or CVSS score and severity. Cached for five minutes; Content-Type: application/rss+xml.
Runs an ingestion module synchronously and returns its outcome — for populating the board after a deploy or re-running a source without waiting for its cron. source is one of kev, nvd, arxiv, hf, rss, triage, digest or all (every source, then triage). Every source run ends with a triage pass over pending research.
Authorization: Bearer <ADMIN_TOKEN>. The token is a deployment secret; the route answers 404 when no token is configured and 401 for a missing or wrong one.all runs every module and a triage pass inside the request and can take several minutes: keep the connection open until it answers (e.g. curl --max-time 600) — a client that disconnects early can cut the run short. Start one all at a time.?triage_limit= (integer 1–200) caps the triage pass for this run.Cache-Control: no-store. GET on this path is a 405.triage result: status: "error" with an error starting provider failing means the model provider was not serving (the circuit breaker stopped the pass) and nothing was scored; partial means some items failed and the rest were scored; the notes carry the counts.{ "generatedAt": "…", "requested": "arxiv",
"results": [{ "source": "arxiv", "status": "ok" | "partial" | "error", "fetched": 3, "matched": 3, "inserted": 1, "updated": 0,
"triaged": 0, "durationMs": 812, "error": null, "notes": [] },
{ "source": "triage", … }] }
| Field | Type | Meaning |
|---|---|---|
id | string | stable 32-hex id; sources that describe the same thing (e.g. CISA KEV and NVD for one CVE) share it |
column | threats | research | incidents | builder | board column (builder = Build It) |
source, sourceLabel | string | primary source id (nvd, kev, arxiv, hf, a feed id) and its display label |
sources | string[] | every source that contributed to the row |
externalId | string | CVE id, arXiv id, HF paper id or feed entry id |
title, url | string | plain-text title (≤ 500 chars) and canonical http(s) link |
summary | string | null | one line for the board (the LLM summary for research; otherwise the first sentence of the source text) |
publishedAt, firstSeenAt, updatedAt | ISO-8601 | source date (for CVEs: the later of NVD publication and KEV addition), first stored, last changed |
relevance | integer 0–10 | null | LLM triage score (research); null until triaged |
tags | string[] | triage tags from a fixed vocabulary (e.g. prompt-injection, agents, supply-chain) |
severity, cvssScore, cvssVersion | CRITICAL…NONE | null, number | null, string | null | CVSS data for threats, from NVD: the NIST (Primary) metric of the highest CVSS version when there is one, otherwise the CNA's own (Secondary) metric; null until NVD has scored the CVE (CISA KEV carries no score — every KEV run looks up a few unscored KEV rows on NVD, so a KEV-only row gains its score within a few runs — and, with the lookups left over, KEV rows NVD has not yet given a fix status). When the record's severity label is missing or not one of the five, the severity is derived from the score and extra.severityDerived is true. |
badges | string[] | KEV, RANSOMWARE, ADVISORY, INCIDENT, NEWS, REJECTED; Build It items: GUIDANCE, TOOLING, RELEASE, NEWS |
triageStatus | pending | done | failed | skipped | skipped = not a triage target, or given up after repeated failures |
extra | object | source-specific data: NVD vulnStatus, lastModified, cpes, references, cvssSource, cvssType, matchedKeywords, severityDerived; KEV dueDate, requiredAction, vendorProject, product, vulnerabilityName, notes, cwes, nvdLookup (the last NVD lookup of a KEV row: { at, outcome, factsVersion }); threats product, fix, flaw (see threatGroups above), nvdFix and nvdFixVersion (NVD's own fix status on a row CISA listed first, and its rules version; it wins over fix) and, when they were stored at ingest, factsVersion (the rules version they were computed with); feed items feedId, feedName, kind, categories, matchedKeywords; arXiv categories and authors … |
With include=abstract: abstract (plain text, ≤ 5000 chars), triageModel, promptVersion and triagedAt (all nullable).
Every non-2xx response is { "error": { "code": "…", "message": "…" } } with a stable code: bad_request (an invalid query parameter — the message names the parameter, never its value), unauthorized, not_found, method_not_allowed (with an Allow header), rate_limited (too many failed admin authentications from one address) and internal_error. Error responses are not cached.