Inventory

Documentation / API reference

Inventory API (v1)

Base URL: https://inventory.metastic.ch/api/v1 (locally http://localhost:8080/api/v1).

Endpoints

Method and path Scopes Purpose
GET /me all Token name, scope, expiry (expires_at, null = never) and organization (with plan)
GET /apps read, write, agent List applications (with open_todos)
POST /apps write Create an application
GET /apps/{id} read, write, agent One application with its deployments
PATCH /apps/{id} write Update an application
POST /apps/{id}/deployments write Assign the app to a server in an environment
GET /apps/{id}/todos read, write, agent Todos of one application (filters below)
POST /apps/{id}/todos write, agent Create a todo (optionally with a first note)
GET /servers read, write, agent List servers
POST /servers write Create a server
GET /servers/{id} read, write, agent One server with the apps deployed on it
PATCH /servers/{id} write Update a server
GET /todos read, write, agent Todos across all applications (filters below)
GET /todos/{id} read, write, agent One todo with all notes and events (notes)
PATCH /todos/{id} write, agent Update title, status, priority, assignee, link; optional note
PUT /apps/{id}/manifests write, deploy Upload lockfiles/manifests (see Dependencies)
GET /apps/{id}/manifests read, write, agent Uploaded manifests of an app
DELETE /apps/{id}/manifests/{manifest_id} write Remove a manifest and its packages (204)
GET /apps/{id}/dependencies read, write, agent Packages with latest version, distance, vulnerabilities; runtimes with EOL status
GET /dependencies/summary read, write, agent Per-app counts (outdated, vulnerable, worst runtime EOL) and totals
GET /dependencies/search?q= read, write, agent Which apps use a package (name contains q; exact=1 for an exact name)
POST /apps/{id}/todos/from-finding write, agent Todo from a dependency finding or a forge issue, idempotent (see Findings to todos)
GET /forges read, write, agent Forge connections (never the token, only token_hint)
GET /apps/{id}/repos read, write, agent Repositories linked to an app, with sync state
POST /apps/{id}/repos write Link a repository (repo = owner/repo or a URL, optional forge, path, label)
DELETE /apps/{id}/repos/{repo_id} write Unlink (204)
POST /apps/{id}/forge/refresh write Sync the app's repositories now (≤ 25 s, 6/min per org) and return repos
GET /apps/{id}/pulls read, write, agent Open PRs/MRs of an app (see Forge)
GET /apps/{id}/issues read, write, agent Open issues of an app
GET /pulls read, write, agent Open PRs across all apps (deduplicated; stale=1)
GET /issues read, write, agent Open issues across all apps (deduplicated)
POST /todos/{id}/claim write, agent Atomically take an open todo: in_progress + assignee (default: the org's first agent assignee, drover unless changed under Settings → Agent assignees)
POST /todos/{id}/notes write, agent Add a markdown note
PUT /todos/{id}/link write, agent Set the PR/issue URL ("" clears it)
POST /deploys write, deploy Report a deploy from CI: version, commit, status (see Deploys)
GET /overview read, write, agent Server overview: servers with their apps, versions, last deploys and red/amber/green/grey status

Todo list filters (GET /todos, GET /apps/{id}/todos): status = comma-separated list of open, in_progress, done, cancelled, or all (default open,in_progress); assignee = a member's email or free text such as drover (case-insensitive); app = app id (/todos only); limit (default 500, max 1000). Order: in progress first, then open, then by priority (urgent → low), newest first.

Token scopes

Pick the smallest scope that does the job. The matrix is enforced per route and tested route by route (internal/web/quotas_test.go fails when a new route is not in it).

Endpoint class read write agent deploy
GET /me ✓ ✓ ✓ ✓
All other GET endpoints (apps, servers, todos, dependencies, forges, PRs/issues, overview) ✓ ✓ ✓ –
Todo work: POST /apps/{id}/todos, PATCH /todos/{id}, POST /todos/{id}/claim, POST /todos/{id}/notes, PUT /todos/{id}/link, POST /apps/{id}/todos/from-finding – ✓ ✓ –
CI: POST /deploys (its answer is the deploy result), PUT /apps/{id}/manifests – ✓ – ✓
Everything else that changes data: apps, servers, deployments, DELETE manifests, repository links, forge refresh – ✓ – –

Objects

Application

{
  "id": "app_dxbh589k4kzshxdq", "name": "Cronjay",
  "description": "Cron job and heartbeat monitoring (markdown).",
  "repo_url": "https://forgejo.example/metastic/passive-income", "app_url": "https://cronjay.metastic.ch",
  "tags": ["go", "htmx", "sqlite"], "open_todos": 4,
  "url": "https://inventory.metastic.ch/apps/app_dxbh589k4kzshxdq",
  "created_at": "2026-11-06T20:05:01Z", "updated_at": "2026-11-06T20:05:02Z",
  "deployments": [{"id": "dep_…", "environment": "production", "app": "app_…", "app_name": "Cronjay",
                   "server": "srv_…", "server_name": "app1", "hostname": "2.31.173.42"}]
}

Writable: name (required, unique per org), description, repo_url, app_url (absolute http(s) URLs), tags (array of strings; normalized to lowercase, sorted, deduplicated, max 20).

Server: id, name (required, unique per org), hostname (host name or IP), provider, location, os, notes (markdown), url, created_at, updated_at, and deployments on GET /servers/{id}. Deployments also carry the last reported deploy: version, commit, deployed_at (null if never reported) and deploy_status.

Deployment body: {"server": "srv_…", "environment": "production"} (environment defaults to production; letters, digits, -, _). Answers with the application (201).

Todo

{
  "id": "todo_2j0d15be3znbzxym", "app": "app_…", "app_name": "Cronjay",
  "title": "Rotate the Stripe webhook secret", "status": "in_progress", "priority": "urgent",
  "assignee": "drover", "link_url": "https://forgejo.example/metastic/passive-income/pulls/43",
  "created_by": "ada@example.com", "note_count": 2,
  "url": "https://inventory.metastic.ch/todos/todo_2j0d15be3znbzxym",
  "created_at": "…", "updated_at": "…", "closed_at": null,
  "notes": [
    {"id": "note_…", "kind": "event", "body": "Status: Open → In progress", "author": "API token Drover", "created_at": "…"},
    {"id": "note_…", "kind": "note", "body": "Rotated in Stripe; updated `secrets.env`.", "author": "API token Drover", "created_at": "…"}
  ]
}

notes (the timeline) is included on single-todo responses. kind: event entries are written by the server for status, assignee, priority and link changes; kind: note entries are markdown written by people or tokens.

Writable: title (required on create, max 200), status, priority (low, normal (default), high, urgent), assignee (a member's email links the user; anything else is kept as text, e.g. drover; "" unassigns), link_url (http(s)), and note (markdown, added as a note in the same call; create and PATCH). Setting done or cancelled sets closed_at; reopening clears it.

Examples

BASE=https://inventory.metastic.ch/api/v1
AUTH="Authorization: Bearer $INVENTORY_TOKEN"

curl -s -H "$AUTH" $BASE/me
curl -s -H "$AUTH" "$BASE/apps"
curl -s -H "$AUTH" -X POST $BASE/servers -H 'Content-Type: application/json' \
  -d '{"name":"app1","hostname":"2.31.173.42","provider":"Hetzner","location":"fsn1","os":"Debian 13"}'
curl -s -H "$AUTH" -X POST $BASE/apps -H 'Content-Type: application/json' \
  -d '{"name":"Runbook","repo_url":"https://forgejo.example/metastic/passive-income","app_url":"https://runbook.metastic.ch","tags":["go","sqlite","htmx"]}'
curl -s -H "$AUTH" -X POST $BASE/apps/app_…/deployments -H 'Content-Type: application/json' \
  -d '{"server":"srv_…","environment":"production"}'

Drover agent usage

Drover agents work on todos that a human assigned to drover (see DESIGN.md §5 for why Drover pulls instead of Inventory pushing). The token lives in the Drover vars file as INVENTORY_API_TOKEN (scope agent, one token per goal, named after the goal; write works too, but agent cannot touch apps, servers or repository links). Never write the token into a note, a commit or a log.

Connect your own Drover: /docs/drover walks through the setup (agent token, CLI download with sha256, vars file) and generates a ready-to-paste goal for GitHub, GitLab or Forgejo/Gitea.

Helper CLI. scripts/inv wraps the calls below (POSIX sh + curl + jq). Download it from GET /cli/inv (public, the exact file of this repository, embedded in the binary; the response header X-Inv-Sha256 and /docs/drover show its sha256; inv version prints its version). It reads INVENTORY_API_BASE and INVENTORY_API_TOKEN from the environment, else from ~/.drover/secrets/passive-income.vars.env (INV_VARS_FILE overrides the path). It passes the token to curl through a private temp file, never prints it, and refuses to send a note that contains it. On failure it prints inv: <request>: HTTP <code>: <API error> and exits 1; a lost claim exits 2.

inv next                              # open todos assigned to drover, urgent first: id, priority, status, app, title
inv show todo_…                       # todo + notes + app context (repo URL, monorepo path, deployments)
inv claim todo_… -m "Picked up by Drover task #57"   # exit 2: someone else holds it, do not work on it
inv note todo_… notes.md              # or: echo "…" | inv note todo_… -
inv pr todo_… https://…/pulls/43
inv done todo_… -m "Merged in #43, deployed, verified."
inv release todo_… -m "Blocked: …" [--to human@example.com]   # back to open; unassigned unless --to
inv mine                              # in-progress drover todos (with PR links), e.g. to check for merges
inv from-finding app_… dep:npm/lodash # idempotent finding → todo, assigned to drover

INV_ASSIGNEE changes the assignee used by next, mine, claim and from-finding (default drover). The test internal/web/cli_script_test.go runs every command against the test server.

The raw calls:

1. Find work (primary agent, every wake-up):

curl -s -H "$AUTH" "$BASE/todos?assignee=drover&status=open" \
  | jq -r '.todos[] | "\(.id)\t\(.priority)\t\(.app_name)\t\(.title)"'

Create one Drover sub task per todo and put the todo id and URL into the task description. Read the details (description of the app, repo URL, earlier notes):

curl -s -H "$AUTH" $BASE/todos/todo_2j0d15be3znbzxym | jq '{title, app, notes: [.notes[].body]}'
curl -s -H "$AUTH" $BASE/apps/app_dxbh589k4kzshxdq | jq '{name, repo_url, description, deployments}'

2. Claim it (sub agent, first action). Exactly one claimer wins; the others get 409 and must stop:

curl -s -o /tmp/claim.json -w '%{http_code}\n' -H "$AUTH" -X POST $BASE/todos/todo_…/claim \
  -H 'Content-Type: application/json' -d '{"note":"Picked up by Drover task #57. Plan: …"}'
# 200 → yours (status in_progress, assignee drover); 409 → someone else has it, do not work on it

3. Report progress (markdown; keep notes short and factual):

curl -s -H "$AUTH" -X POST $BASE/todos/todo_…/notes -H 'Content-Type: application/json' \
  -d "$(jq -n --arg b "Implemented the export in \`internal/web/export.go\`; 12 tests added, all green." '{body: $b}')"

4. Attach the PR:

curl -s -H "$AUTH" -X PUT $BASE/todos/todo_…/link -H 'Content-Type: application/json' \
  -d '{"url":"https://forgejo.example/metastic/passive-income/pulls/43"}'

5. Finish (done with a summary note), or hand it back if blocked:

curl -s -H "$AUTH" -X PATCH $BASE/todos/todo_… -H 'Content-Type: application/json' \
  -d '{"status":"done","note":"Merged in #43 and deployed; verified on production."}'

# blocked: give it back to a human with the reason
curl -s -H "$AUTH" -X PATCH $BASE/todos/todo_… -H 'Content-Type: application/json' \
  -d '{"status":"open","assignee":"hello@metastic.ch","note":"Blocked: needs the Stripe live key rotated by a human."}'

6. Report findings as new todos (e.g. something noticed while working):

curl -s -H "$AUTH" -X POST $BASE/apps/app_…/todos -H 'Content-Type: application/json' \
  -d '{"title":"N+1 query in OrdersController#index","priority":"high","note":"Seen while profiling for #43."}'

Everything the agent does shows up on the todo page as notes and events attributed to "API token ", so the human can follow and correct it.

Dependencies

Upload lockfiles from CI (or the app page in the UI); Inventory parses them, then a background job looks up the latest stable version of every package (rubygems.org, the npm registry, proxy.golang.org, PyPI), known vulnerabilities (OSV) and runtime end-of-life dates (endoflife.date). Request handlers never call those upstreams, so right after an upload latest_checked / vulns_checked are false for a minute or two.

Supported files (detected by the base name of path): Gemfile.lock, package-lock.json (v2/v3) and npm-shrinkwrap.json, yarn.lock (v1 and berry), pnpm-lock.yaml (v5/v6/v9), go.mod, requirements*.txt (pinned == lines; pip-compile # via annotations mark direct packages), poetry.lock, and the runtime files .ruby-version, .nvmrc, .node-version, .python-version, .go-version, .tool-versions.

Direct vs transitive: Gemfile.lock (DEPENDENCIES), package-lock (root/workspace dependencies and devDependencies), pnpm (importers), go.mod (// indirect) and pip-compile output record it; yarn.lock and poetry.lock do not, so their packages are all reported as direct: false.

Runtimes: go.mod toolchain (else go), Gemfile.lock RUBY VERSION, a pinned engines.node (20, ^20.11; open ranges like >=18 are ignored), runtime files, and the rails / django package versions.

Private packages (git/path sources, a non-public registry, go.mod replace with a local directory) are stored with private: true and are never sent to a registry or OSV.

PUT /apps/{id}/manifests

Two forms. The path is a label (your repo path); uploading the same path again replaces that manifest's packages. An identical file is a no-op (unchanged: true). Limits: 5 MB per file, 20 files per request, 50 manifests per app, 20,000 packages per file.

# one raw file
curl -fsS -X PUT -H "Authorization: Bearer $TOKEN" --data-binary @apps/web/Gemfile.lock \
  "$BASE/apps/app_…/manifests?path=apps/web/Gemfile.lock"

# several files as JSON
curl -fsS -X PUT -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"manifests": [{"path": "go.mod", "content": "module …"}, {"path": "web/package-lock.json", "content": "{…}"}]}' \
  "$BASE/apps/app_…/manifests"

Response 200:

{"app_id": "app_…", "manifests": [
  {"id": "man_…", "path": "apps/web/Gemfile.lock", "format": "Gemfile.lock", "packages": 142, "direct": 31,
   "runtimes": [{"product": "ruby", "version": "3.3.4"}, {"product": "rails", "version": "7.1.3"}], "unchanged": false}]}

Errors: 400 (no ?path=, bad JSON, too many files), 413 (file > 5 MB), 422 (unsupported file name, a file that does not parse, too many manifests). A request with one bad file stores nothing if the problem is in a path or a size; parse errors are reported for the first file that fails (files before it are stored).

GET /apps/{id}/dependencies

Filters (all optional, combinable): outdated=1, vulnerable=1, direct=1, dev=0 (hide dev dependencies), ecosystem=npm|rubygems|go|pypi, q=<name substring>.

{"app_id": "app_…", "total": 142, "count": 3,
 "dependencies": [
  {"ecosystem": "rubygems", "name": "nokogiri", "version": "1.16.0", "latest": "1.19.7", "behind": "minor",
   "direct": true, "dev": false, "private": false, "manifest": "apps/web/Gemfile.lock",
   "latest_checked": true, "vulns_checked": true,
   "vulnerabilities": [{"id": "GHSA-xc9x-jj77-9p9j", "summary": "…", "severity": "high", "aliases": ["CVE-…"],
                        "fixed_in": ["1.16.5"], "url": "https://osv.dev/vulnerability/GHSA-xc9x-jj77-9p9j"}]}],
 "runtimes": [{"product": "go", "name": "Go", "version": "1.27.3", "manifest": "go.mod", "cycle": "1.27",
               "latest": "1.27.4", "eol": "2027-08-11", "status": "supported", "days_left": 278}]}

GET /dependencies/summary

{"totals": {"apps": 3, "packages": 212, "outdated": 40, "outdated_major": 4, "vulnerable": 1, "vulnerabilities": 2,
            "runtimes_eol": 0, "runtimes_eol_soon": 0, "pending_lookups": 0},
 "apps": [{"app_id": "app_…", "app_name": "Runbook", "manifests": 2, "packages": 71, "direct": 9, "outdated": 12,
           "outdated_major": 1, "vulnerable": 0, "vulnerabilities": 0, "pending_lookups": 0,
           "last_upload": "2026-11-06T21:00:00Z", "runtimes": […], "worst_runtime": {…}}]}

Only apps with at least one manifest are listed.

Drover: findings to todos

A reporting goal turns findings into todos: GET /dependencies/summary, then for each app with vulnerable > 0 GET /apps/{id}/dependencies?vulnerable=1 and, per package, POST /apps/{id}/todos/from-finding with {"source": "dep:<ecosystem>/<name>"} (see Findings to todos). The endpoint is idempotent, so the goal does not need to search existing todos.

Forge: pull requests and issues

Connections are added in the UI (Settings → Forges). For organizations on a self-service plan the base URL must be https on port 443 and resolve to a public address that is not one of the operator's own servers; this is checked again on every connection (DNS rebinding), redirects are never followed, and every connection failure (blocked, refused, timeout, DNS, TLS) reads the same: could not reach the forge. Each organization may send 2,000 forge requests per hour, run 10 connection tests per hour and add 5 connections per day. Background syncing pauses for organizations with no sign-in and no API call for 14 days and resumes with the next one.

Admins connect Forgejo/Gitea, GitHub (github.com or Enterprise) or GitLab (gitlab.com or self-hosted) under Settings → Forges with a read-only token. Tokens can only be entered in the UI. They are stored encrypted (AES-256-GCM) and never returned by the API. Each application links one or more repositories, and several apps can share a repository (a monorepo). A background job fetches the open PRs/MRs and issues every 15 minutes. It sends conditional requests (ETag, so an unchanged repository costs one cheap 304 per list), and it looks up CI status and changed files only when a PR's head commit changes.

Monorepo filters per link: path (only PRs that change files under it) and label (only issues with that label; PRs with the label are also shown). Items outside the filters are kept with in_scope: false and are returned only with ?all=1.

POST /apps/{id}/repos

curl -fsS -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"repo": "https://projects.metikular.ch/metastic/platform", "path": "apps/inventory"}' "$BASE/apps/app_…/repos"

repo is owner/repo (GitLab: group/subgroup/project) together with forge (a connection id), or a repository URL that is matched to a connection by host. Response 201: the link (below). 422 if no connection matches, the repository is already linked, or the app has 10 links. The first sync runs within a minute (or call forge/refresh).

{"id": "repo_…", "app": "app_…", "app_name": "Inventory", "forge": "fc_…", "forge_name": "metastic Forgejo", "kind": "forgejo",
 "repo": "metastic/platform", "url": "https://projects.metikular.ch/metastic/platform", "path": "apps/inventory", "label": "",
 "synced_at": "2026-11-06T21:00:00Z", "last_ok_at": "2026-11-06T21:00:00Z", "last_error": "", "truncated": false,
 "next_sync_at": "2026-11-06T21:15:00Z"}

GET /apps/{id}/pulls, GET /apps/{id}/issues, GET /pulls, GET /issues

Filters: all=1 (include items outside the path/label filters), stale=1 (PRs without an update for 14 days). The cross-app lists contain each PR/issue once, with every app that links it in apps. Newest update first.

{"pulls": [
  {"kind": "pr", "repo": "metastic/platform", "number": 42, "ref": "metastic/platform#42", "title": "Inventory: forge sync",
   "author": "ada", "url": "https://projects.metikular.ch/metastic/platform/pulls/42", "draft": true, "ci_status": "success",
   "labels": ["inventory"], "assignees": [], "created_at": "…", "updated_at": "…", "stale": false, "in_scope": true, "apps": ["app_…"]}]}

ci_status is success, failure or pending, and is omitted when unknown (no CI, or the token may not read it). GitLab MRs have ref group/project!12. Issues also have source_ref (for from-finding) and todo, the id of the open todo created from the issue, when there is one.

GET /forges returns {"forges": [{"id": "fc_…", "kind", "name", "base_url", "token_hint": "••••abcd", "login", "status": "ok|error|untested", "last_error", "checked_at", "linked_repos", "needs_new_token"}]}. needs_new_token: true means the server's encryption key changed: an admin must enter the token again.

Findings to todos

POST /apps/{id}/todos/from-finding (write) creates a todo from a finding of this app. The server builds the title, the priority, the link and a first markdown note from its own data, so the finding must exist in the app:

source Finding Todo
dep:<ecosystem>/<name>, e.g. dep:npm/lodash The package is outdated or vulnerable in one of the app's manifests "Fix 2 vulnerabilities in lodash 4.17.15" (priority from the worst severity: critical → urgent, high → high) or "Upgrade lodash 4.17.15 → 4.17.21"; the note lists the manifests, the latest version and the advisories with links and fixed versions
issue:<issue URL> (the source_ref of an issue) A synced open issue of a linked repository " (owner/repo#58)", link_url = the issue; the note links the issue and lists its labels

Optional title, priority and assignee (e.g. "drover") replace the generated values.

Idempotent: while a todo for the same app and source is open or in progress, the call returns that todo with 200. Otherwise it creates one and returns 201 with a Location header. After the todo is done or cancelled, a new call creates a new todo, and reopening the old one then fails with 422. Errors: 404 (no such package in the app, or the issue is not synced), 422 (unknown source format, or the package is up to date and has no known vulnerabilities), 429 (more than 120 per hour and org). Todos carry their source_ref in every todo response.

curl -fsS -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"source": "dep:npm/lodash", "assignee": "drover"}' "$BASE/apps/app_…/todos/from-finding"

Deploys and the server overview

CI reports every deploy, so the server overview (/servers in the UI, GET /overview here) shows real versions and deploy times. A deployment is one app on one server in one environment.

POST /deploys

{"app": "app_…" | "Runbook", "server": "srv_…" | "app1", "environment": "production",
 "version": "v1.42", "commit": "3f9c2a1…", "status": "ok"}
curl -fsS -X POST -H "Authorization: Bearer $INVENTORY_API_TOKEN" -H "Content-Type: application/json" \
  -d '{"app": "Runbook", "server": "app1", "version": "v1.42", "commit": "'"$GIT_SHA"'", "status": "ok"}' \
  "$INVENTORY_API_BASE/deploys"

Forgejo Actions / GitHub Actions (the last step of the deploy job; if: always() also reports failed deploys, and || true keeps an Inventory outage from failing your deploy):

      - name: Report deploy to Inventory
        if: always()
        env:
          INVENTORY_API_TOKEN: ${{ secrets.INVENTORY_API_TOKEN }}
          INVENTORY_API_BASE: ${{ secrets.INVENTORY_API_BASE }}   # e.g. https://inventory.metastic.ch/api/v1
          STATUS: ${{ job.status }}                               # success | failure | cancelled
        run: |
          [ -n "$INVENTORY_API_TOKEN" ] || { echo "no INVENTORY_API_TOKEN, skipped"; exit 0; }
          curl -fsS -m 15 --retry 3 --retry-all-errors -X POST \
            -H "Authorization: Bearer $INVENTORY_API_TOKEN" -H "Content-Type: application/json" \
            -d "{\"app\":\"my-app\",\"server\":\"app1\",\"version\":\"${GITHUB_REF_NAME}\",\"commit\":\"${GITHUB_SHA}\",\"status\":\"${STATUS}\"}" \
            "${INVENTORY_API_BASE:-https://inventory.metastic.ch/api/v1}/deploys" || echo "::warning::deploy report failed"

GitLab CI:

report-deploy:
  stage: .post
  when: always
  script:
    - 'curl -fsS -m 15 -X POST -H "Authorization: Bearer $INVENTORY_API_TOKEN" -H "Content-Type: application/json"
       -d "{\"app\":\"my-app\",\"server\":\"app1\",\"version\":\"$CI_COMMIT_REF_NAME\",\"commit\":\"$CI_COMMIT_SHA\",\"status\":\"$CI_JOB_STATUS\"}"
       "$INVENTORY_API_BASE/deploys" || true'

GET /overview

Optional filters: env, tag (an app tag), problems=1 (only red and amber). Servers come red first, then amber, then by name. Apps that run on no server are in not_deployed.

{
  "servers": [{
    "id": "srv_…", "name": "app1", "hostname": "2.31.173.42", "provider": "Hetzner", "location": "fsn1",
    "os": "Debian 13", "os_eol": {"product": "debian", "cycle": "13", "eol": "2030-06-30", "status": "ok"},
    "status": "amber", "app_count": 3, "open_todos": 4, "url": "…",
    "apps": [{"deployment": "dep_…", "app": "app_…", "app_name": "Runbook", "environment": "production",
              "version": "v1.42", "commit": "3f9c…", "deployed_at": "2026-11-07T10:00:00Z", "deploy_status": "ok",
              "deployed_by": "API token ci", "status": "amber", "reasons": ["1 high vulnerability"],
              "vulns_critical": 0, "vulns_high": 1, "vulns_total": 2, "outdated_direct": 3,
              "runtime": null, "open_prs": 2, "failing_prs": 0, "url": "…"}]
  }],
  "not_deployed": [{"app": "app_…", "app_name": "Marketing site", "status": "grey", "reasons": [], …}],
  "generated_at": "…"
}

Status rule (per app row; a server takes the worst of its rows and of its OS):

os_eol is set for Debian and Ubuntu (parsed from the server's os field, e.g. "Debian 13", "Ubuntu 24.04 LTS", "Debian bookworm"); status is ok, soon, eol or unknown. An EOL OS makes the server red, one less than 90 days from EOL makes it amber.