Appearance
API reference
The VulnGraph HTTP API for agents, scripts and people: method, path, scope, parameters, response, errors and when to use each endpoint. Agents can use the same operations as MCP tools.
Examples use generic names (acme, svc-a, demo-lib@1.0.0, CVE-2026-0001); ids and timestamps are illustrative.
Contents
- Base URL
- Authentication
- Which endpoint for which question
- Scopes
- Endpoints: profiles
- Endpoints: runs
- Endpoints: portfolio (assistant answers, at most 8 KB)
- Endpoints: plan, steps, sketch
- Endpoints: decisions and step status (write)
- Endpoints: events feed
- Endpoints: logs
- Endpoints: artifacts
- Endpoint: MCP
- Errors and rate limits
Base URL
text
https://api.vulngraph.igentify.in/v1All paths below are relative to /v1. All responses are JSON (content-type: application/json). Request bodies (POST) are JSON; a body that is not valid JSON answers 400 {"error":"body must be JSON"}.
Authentication
Every /v1/... request needs one header:
http
Authorization: Bearer vg_live_<id>_<secret>- Key format:
vg_live_+ 10-character id +_+ 40-character secret (letters and digits). The full key is shown once, when it is created. See API keys. - The same header also accepts a web-app session token (a signed-in person). Signed-in users may call every route; keys only what their scopes allow.
- A key may be limited to a list of profiles. Runs, plans and portfolio answers of other profiles then answer
404(not403), andPOST /runsfor another profile answers403. - Responses never contain credentials: URL userinfo, GitHub tokens and
vg_live_keys are redacted.
bash
curl -s -H "Authorization: Bearer $VULNGRAPH_API_KEY" \
https://api.vulngraph.igentify.in/v1/runs/latestWhich endpoint for which question
| Question | Call | Scope |
|---|---|---|
| How bad is the fleet right now? | GET /portfolio/summary | findings:read |
| What should we fix first? | GET /portfolio/start-here?limit=5 | findings:read |
| How exposed is service X? | GET /portfolio/service?service=svc-a | findings:read |
| Are we affected by CVE/GHSA Y? | GET /portfolio/advisory?id=CVE-2026-0001 | findings:read |
| Where do we use package Z? | GET /portfolio/package?q=demo-lib | findings:read |
| List our critical CVEs / which have no fix? | GET /portfolio/advisories?severity=critical (&fixable=false) | findings:read |
| What is the next plan step / what is blocked? | GET /portfolio/next?limit=3 | findings:read |
| I am not sure how a name is spelled | GET /portfolio/resolve?q=acces | findings:read |
| What changed since the last scan? | GET /portfolio/changes | findings:read |
| Are we getting better over time? | GET /portfolio/trend | findings:read |
| Which run is the newest / did the scan finish? | GET /runs/latest (?status=any to include running/failed) | runs:read |
| Is run R still running? | GET /runs/{runId} | runs:read |
| What changed since I last looked? | GET /events?since=<next> | runs:read |
| What is the remediation plan? | GET /runs/{runId}/plan | plans:read |
| What are the open Decide questions? | GET /runs/{runId}/plan?decide=1 | plans:read |
| Details and options of step N | GET /runs/{runId}/steps/{rank} | plans:read |
| What has been decided / which PRs are open? | GET /runs/{runId}/steps | plans:read |
| What would the PR for step N look like? | GET /runs/{runId}/sketch?rank=N | sketches:read |
| Why did a run fail? | GET /runs/{runId}/logs?from=tail&level=error | logs:read |
| I need the full report / brief / plan file | GET /runs/{runId}/artifacts/report.json | artifacts:download |
| Which profiles (fleets) exist? | GET /profiles | profiles:read |
| Start a new scan | POST /runs | runs:create |
| Record a choice on a Decide step | POST /runs/{runId}/steps/{rank}/decision | decisions:write |
| Record that a PR was opened/merged | POST /runs/{runId}/steps/{rank}/status | step-status:write |
| Create, list, rename or revoke API keys | /keys... | signed-in users only |
Rule of thumb for assistants: start with /portfolio/* (small, pre-shaped answers), go to /runs/{id}/plan and /steps for plan work, and download artifacts only when a full file is really needed.
Scopes
| Scope | Group | Risk | Grants |
|---|---|---|---|
runs:read | read | low | GET /runs, GET /runs/latest, GET /runs/{id}, GET /events |
findings:read | read | low | GET /portfolio/{name} |
profiles:read | read | low | GET /profiles, GET /profiles/{name} |
plans:read | read | medium | GET /runs/{id}/plan, GET /runs/{id}/steps, GET /runs/{id}/steps/{rank} |
sketches:read | read | medium | GET /runs/{id}/sketch |
logs:read | read | medium | GET /runs/{id}/logs |
artifacts:download | read | medium | GET /runs/{id}/artifacts/{path} |
decisions:write | write | high | POST /runs/{id}/steps/{rank}/decision |
step-status:write | write | medium | POST /runs/{id}/steps/{rank}/status |
runs:create | write | high | POST /runs |
There is no scope for /keys: keys can never manage keys (403). A route not in this table answers 404 {"error":"not found"}. {rank} is 1 to 5 digits.
Endpoints: profiles
GET /profiles — scope profiles:read
Profiles (fleets) the caller may see.
json
{ "profiles": [ { "name": "acme", "description": "Acme fleet", "default_ref": "main" } ] }Use this when you need valid profile values for other calls.
GET /profiles/{name} — scope profiles:read
One profile with its repository list and refs. 404 {"error":"profile not found"} if unknown or outside the key's profile limit.
json
{ "profile": { "name": "acme", "description": "Acme fleet", "default_ref": "main",
"repos": ["svc-a", "svc-b"], "refs": { "svc-a": "release" } } }Endpoints: runs
A run record (run) has: runId, status (queued | running | succeeded | failed), profile, repos, refs, sketchMode, createdAt, updatedAt and error. Other fields may appear; ignore them.
GET /runs — scope runs:read
Newest runs first.
| Param | Default | Notes |
|---|---|---|
limit | 50 | max 100 |
profile | all | only runs of this profile |
json
{ "runs": [ { "runId": "0b6c…", "status": "succeeded", "profile": "acme",
"createdAt": "2026-10-24T01:20:00Z", "updatedAt": "2026-10-24T01:57:01Z", "…": "…" } ] }Use this when listing history. For "the current state" prefer /runs/latest.
GET /runs/latest — scope runs:read
The newest succeeded run (?status=any: newest run of any status). Optional ?profile=. 404 {"error":"no matching run"} when none.
json
{ "run": { "runId": "0b6c…", "status": "succeeded", "profile": "acme", "…": "…" } }Use this when you need the run id the portfolio answers come from, or to check whether a scan is in progress (?status=any).
GET /runs/{runId} — scope runs:read
One run. The status of a running run is refreshed in the answer. 404 {"error":"not found"}.
json
{ "run": { "runId": "0b6c…", "status": "running", "…": "…" } }Use this when polling a run you started.
POST /runs — scope runs:create (write; 5 per key per day)
Starts a scan job.
json
{ "profile": "acme", "repos": ["svc-a"], "refs": { "svc-a": "main" }, "sketchMode": "bedrock" }profilerequired;repos(default[]),refs(default{}),sketchModefixture|bedrock(defaultbedrock).201 {"run": {...}}(statusrunning);400 {"error":"profile is required"};400 {"error":"unknown profile","allowed":[...]};403 {"error":"this key is limited to other profiles"};502 {"run": {...}, "error": "…"}when the job could not start (the run is stored asfailed).
Use this only when someone explicitly asks for a fresh scan; scans take time and cost money.
Endpoints: portfolio (assistant answers, at most 8 KB)
GET /portfolio/{name} — scope findings:read for every name. Ten names: summary, start-here, service, advisory, package, advisories, next, resolve, changes, trend. These are made for voice and chat assistants: short field names that read well aloud, and small answers.
How answers are made:
- Answers come from the latest succeeded run the key may read (optional
?profile=). They are read from a compact index of that run's report (the source of truth), brief and plan. Nothing is estimated; a field the run does not carry is left out. nextadds the live step status and decisions recorded throughPOST /runs/{runId}/steps/{rank}/statusand/decision(read on every call, not cached).- Every answer is at most 8000 bytes. When an answer would be larger, the longest arrays are shortened and
"truncated": trueis added. - The latest-run pointer and the loaded index are kept in memory for 60 seconds, so a new run shows up within a minute of finishing.
Freshness fields (every portfolio answer)
| Field | Meaning |
|---|---|
data_as_of | When the scan data was produced: the run's updatedAt, ISO 8601 UTC with Z. When you quote numbers, say "as of" this date. |
served_at | When this answer was produced (ISO 8601 UTC, Z). |
run_id | The run the answer comes from. |
cached | true when both the run pointer and the index came from the 60-second memory cache. |
cache_age_s | Age of the cached data in seconds (0 when cached is false). |
profile | The run's profile, when it has one. |
Names, typos and did_you_mean
Service and package names are matched forgivingly: case, spaces, _ versus -, a Maven group:artifact tail and small typos. When the name used is not the exact one, resolved_from holds what was asked and the answer uses the real name. A miss answers 404 with "found": false, did_you_mean (closest names, may be empty) and a hint ("retry with one of did_you_mean" or "call resolve to find the service name"). Advisory ids are normalised too (cve 2026 0001, cve2026-0001 → CVE-2026-0001), and aliases (GHSA ids) are accepted.
Errors (portfolio)
400 {"error":"service is required"}(alsoid,q) for a missing required parameter;400 {"error":"severity must be one of CRITICAL, HIGH, MEDIUM, LOW, UNKNOWN"}.404 {"error":"no succeeded run yet"}(or… for profile X);404 {"error":"the latest run has no report.json","run_id":…}.- Unknown
{name}→404 {"error":"not found","allowed":["summary","start-here",…]}. 404withfound: falsefor unknown names (see above), an advisory that does not affect the fleet,nextwithout a plan, andchangeswithout an earlier run.
GET /portfolio/summary
Fleet headline numbers and how fresh they are.
json
{ "data_as_of": "2026-10-24T01:57:01Z", "served_at": "2026-10-24T21:17:27Z",
"run_id": "0b6c…", "cached": true, "cache_age_s": 12, "profile": "acme",
"scanned_at": "2026-10-24T01:57:01Z", "age_hours": 19.3, "running_now": false,
"previous_run_at": "2026-10-23T14:39:49Z",
"counts": { "root_repo_count": 40, "unique_cve_count": 120, "exposure_count": 900,
"service_count": 35, "critical_count": 4, "high_count": 30,
"services_with_critical": 3, "services_with_high_or_critical": 12,
"fixable_exposure_count": 700, "unfixed_exposure_count": 200 },
"severity": [ { "severity": "CRITICAL", "advisories": 4, "exposures": 20 } ],
"top_services": [ { "service": "svc-a", "max_severity": "CRITICAL", "critical_count": 2,
"high_count": 5, "exposure_count": 60 } ],
"top_packages": [ { "package": "demo-lib", "version": "1.0.0", "severity": "CRITICAL", "service_count": 9 } ],
"start_here_count": 25, "decide_count": 3 }age_hours: hours betweendata_as_ofand now.running_now: a newer scan is running or queued.previous_run_at: time of the run before this one (same profile), ornull.countsandseveritycome from the report's brief.severity[].advisoriescounts every advisory the scan found, including a few that reach no deployed service; those are not listed byadvisories(seenot_on_any_servicethere).- Top lists hold at most 5. Use this for "how are we doing?" or a daily digest.
GET /portfolio/start-here?limit=&service=
The ranked fix list (the report's start_here rows). limit default 5, 1 to 20. Optional service keeps only rows that touch that service (forgiving match).
json
{ "data_as_of": "…", "served_at": "…", "run_id": "0b6c…", "cached": true, "cache_age_s": 3,
"profile": "acme", "total": 25,
"items": [ { "rank": 1, "package": "demo-lib", "version": "1.0.0", "max_severity": "CRITICAL",
"service_count": 9, "exposure_count": 30, "decide": false, "services": ["svc-a", "svc-b"],
"fix": { "repo": "svc-a", "file": "package.json", "kind": "npm" },
"why": "9 service(s) …" } ] }services holds at most 6 names, why at most 220 characters and starts with the number of services the row reaches. fix is present only when the report has an edit site. Use this for "what should we fix first?".
GET /portfolio/service?service=
One service. service is required (400).
json
{ "…freshness": "…", "service": "svc-a", "found": true,
"artifact_count": 2, "root_cause_count": 7, "exposure_count": 60, "advisory_count": 12,
"exposures_by_severity": [ { "severity": "CRITICAL", "count": 2 } ],
"top_packages": [ { "package": "demo-lib", "version": "1.0.0", "max_severity": "CRITICAL",
"advisories": 3, "top_advisories": ["CVE-2026-0001"] } ],
"start_here": [ { "rank": 1, "package": "demo-lib", "max_severity": "CRITICAL", "decide": false } ] }top_packages at most 10 (3 advisory ids each), start_here at most 8. Misses: see Names, typos and did_you_mean.
GET /portfolio/advisory?id=
One advisory by CVE/GHSA id or alias. id is required (400).
json
{ "…freshness": "…", "found": true, "id": "CVE-2026-0001",
"aliases": ["GHSA-xxxx-xxxx-xxxx"], "severity": "HIGH", "fixed_version": "1.0.1",
"title": "…", "service_count": 4, "exposure_count": 6,
"packages": ["demo-lib@1.0.0"], "services": ["svc-a", "svc-b"],
"start_here": [ { "rank": 1, "package": "demo-lib", "version": "1.0.0", "decide": false } ] }resolved_from is added when the id was normalised or matched through an alias. Not found → 404 {"found": false, "id": …, "did_you_mean": [...], "hint": "this advisory does not affect the scanned fleet, or the id is mistyped; use advisories to list ids"}. title at most 240 characters. Use this for "are we affected by CVE-…?".
GET /portfolio/package?q=
Vulnerable packages matching q (forgiving match; name@version narrows to one version), grouped by name and version; exact name first, then by severity and service count. q is required (400).
json
{ "…freshness": "…", "q": "demo-lib", "match_count": 2,
"matches": [ { "package": "demo-lib", "version": "1.0.0", "max_severity": "CRITICAL",
"fixed_version": "1.0.2", "service_count": 9, "advisory_count": 3,
"top_advisories": ["CVE-2026-0001"], "services": ["svc-a"], "start_here_ranks": [1] } ] }fixed_version is the lowest version that fixes every advisory of that package version (null when there is no such version). At most 10 matches, 6 services each. No match → 404 with found: false and a hint. Use this for "where do we use X?" or "which version of X do we run?".
GET /portfolio/advisories?severity=&service=&package=&fixable=&limit=
Advisories that reach at least one service, most severe first, then by service count.
| Param | Meaning |
|---|---|
severity | Comma list of CRITICAL, HIGH, MEDIUM, LOW, UNKNOWN (case-insensitive). |
service | Only advisories on this service (forgiving match). |
package | Only advisories on this package (forgiving match). |
fixable | true = has a fixed version, false = no fix yet. |
limit | Default 10, 1 to 25. |
json
{ "…freshness": "…", "filters": { "severity": "CRITICAL" }, "total": 17,
"not_on_any_service": 1,
"note": "1 more advisory(ies) matching these filters were found in scans but reach no deployed service, so they are not listed.",
"items": [ { "id": "CVE-2026-0001", "severity": "CRITICAL", "package": "demo-lib",
"version": "1.0.0", "other_versions": 1, "fixed_version": "1.0.1",
"service_count": 9, "title": "…" } ] }total counts listed advisories. not_on_any_service and note appear only without a service or package filter and only when such advisories exist, so total plus not_on_any_service matches the summary's severity row. Use this for "list our critical CVEs" or "which advisories have no fix yet?".
GET /portfolio/next?service=&limit=&include_done=
The next remediation plan steps, in plan order, with live status. limit default 3, 1 to 10. service keeps steps that touch that service. Steps whose status is pr_merged or skipped are left out unless include_done=true.
json
{ "…freshness": "…", "total_steps": 40, "open_steps": 38,
"items": [ { "rank": 1, "repo": "svc-a", "package": "demo-lib", "from": "1.0.0", "to": "1.0.2",
"file_line": "package.json:12", "kind": "bump", "edit_kind": "direct", "also_clears": 2,
"severity": "CRITICAL", "status": "open", "pr": "https://…", "decision": null,
"options": ["…"], "blocked_by": [], "service_count": 3 } ] }status:open(nothing recorded),in_progress,pr_opened,pr_merged,pr_closedorskipped.pronly when a PR URL was recorded.kinddecideis a Decide step;optionsare shown until a decision is recorded, anddecisionholds the recorded choice (at most 120 characters).blocked_by: ranks of earlier steps this one waits for that are not merged or skipped.also_clears: how many other packages the same edit fixes.- No plan in the run →
404 {"found": false, "error": "the latest run has no remediation plan"}.
Use this for "what is the next step?" or "what is blocked?".
GET /portfolio/resolve?q=
Spelling help: up to 5 candidate names each for services, packages and advisories (ids only when q contains 3 or more digits). q is required (400). Always 200.
json
{ "…freshness": "…", "q": "acces", "services": ["access-ui", "access-service"],
"packages": [], "advisories": [] }GET /portfolio/changes
What changed against the previous succeeded run of the same profile.
json
{ "…freshness": "…", "previous_run_id": "9a31…", "previous_data_as_of": "2026-10-23T14:39:49Z",
"advisories_new": 12, "advisories_resolved": 4,
"new_top": [ { "id": "CVE-2026-0002", "severity": "HIGH", "package": "lib-y", "service_count": 3 } ],
"resolved_top": [ { "id": "CVE-2025-0009", "severity": "HIGH", "package": "lib-q", "service_count": 1 } ],
"exposures_before": 880, "exposures_now": 900,
"exposures_by_severity": [ { "severity": "CRITICAL", "before": 3, "now": 4 } ],
"services_before": 34, "services_now": 35,
"services_changed": [ { "service": "svc-a", "exposures_before": 50, "exposures_now": 60 } ],
"services_added": ["svc-c"], "services_removed": [] }An exposure is one advisory on one service. new_top and resolved_top hold at most 5 advisories, most severe first; services_changed at most 8, biggest change first. When services_before and services_now differ a lot, the two scans covered different fleets, so say that before quoting the new and resolved counts.
No earlier run → 404 {"found": false, "error": "no earlier succeeded run of this profile to compare with"}. Use this for "what changed since yesterday?".
GET /portfolio/trend
Headline numbers of the last runs of this profile (up to 10), oldest first.
json
{ "…freshness": "…",
"points": [ { "run_id": "9a31…", "data_as_of": "2026-10-23T14:39:49Z", "advisories": 560,
"critical_advisories": 17, "high_advisories": 270, "exposures": 4600,
"critical_exposures": 110, "high_exposures": 2500, "services": 41 } ] }Advisory counts match the summary's severity[].advisories; exposure counts are advisory × service pairs. services is the number of services with at least one exposure.
Use this for "are we getting better?".
Endpoints: plan, steps, sketch
All read the run's plan (never rewritten) plus the recorded decisions and step status. 404 {"error":"run not found"}; 404 {"error":"this run has no plan yet","runId":…}.
A step view is the plan step as written (rank, phase, repo, repo_url, how, action_kind, depends_on, max_severity, and other plan fields) plus: decide (boolean), decide_options ([{option, text}], parsed from the numbered list under the Options…: line of how), decision and status (the overlay, or null).
GET /runs/{runId}/plan — scope plans:read
| Param | Effect |
|---|---|
include=diff | keep each step's diff (dropped by default; large) |
decide=1 or decide=true | only Decide steps |
json
{ "runId": "0b6c…", "profile": "acme", "summary": { "…": "…" }, "unresolved_count": 0,
"phases": [ { "id": "base", "steps": [ { "rank": 1, "repo": "svc-a", "how": "…",
"decide": false, "decide_options": [], "decision": null, "status": null } ] } ] }Use this when you need the whole plan or the open Decide questions. Can be large for big fleets; use /portfolio/start-here for a short list.
GET /runs/{runId}/steps — scope plans:read
Only steps that have something recorded, sorted by rank.
json
{ "runId": "0b6c…", "steps": [ { "rank": 3, "decision": null,
"status": { "state": "pr_opened", "prUrl": "https://github.com/acme/svc-a/pull/12",
"by": { "kind": "key", "label": "agent", "keyId": "…" }, "at": "…" } } ] }Use this when asked "what has been decided / which PRs are in flight?".
GET /runs/{runId}/steps/{rank} — scope plans:read
One step view. Unknown rank → 404 {"error":"no step N in this plan","steps":<count>}.
json
{ "runId": "0b6c…", "step": { "rank": 4, "decide": true,
"decide_options": [ { "option": 1, "text": "Upgrade the base image" },
{ "option": 2, "text": "Accept the risk" } ],
"decision": null, "status": null, "…": "…" } }Use this before recording a decision (to read the numbered options).
GET /runs/{runId}/sketch — scope sketches:read
PR sketches for the plan steps.
| Param | Effect |
|---|---|
rank=N | one step only: {"runId", "file", "step"}; 404 {"error":"no sketch step N"} |
min_severity=HIGH (or DECIDE) | the sketch file of that severity queue from plan.summary.severity_queues; 404 if there is none |
Whole file: {"runId", "file": "sketch.json", "steps": [...], …}. 404 {"error":"this run has no sketch","file":…}. Use rank= whenever you can; the whole file can be large.
Endpoints: decisions and step status (write)
Both answer 200 {"runId", "rank", "decision", "status"} and append to the step's history (last 20 entries kept). Text fields are at most 1000 characters. The caller (user, or key name and id) is recorded with every change.
POST /runs/{runId}/steps/{rank}/decision — scope decisions:write
json
{ "option": 2, "rationale": "Base image owner confirmed the upgrade window" }optionis 1-based intodecide_options; out of range →400.- Not a Decide step →
409. A key cannot overwrite a decision a person recorded →409. {"clear": true}removes the decision (signed-in users only; keys get409).
POST /runs/{runId}/steps/{rank}/status — scope step-status:write
json
{ "state": "pr_opened", "prUrl": "https://github.com/acme/svc-a/pull/12", "note": "optional" }state:in_progress,pr_opened,pr_merged,pr_closed,skipped.prUrlis required forpr_opened,pr_merged,pr_closed; it must look likehttps://github.com/<owner>/<repo>/pull/<n>and belong to the step's repository (repo_url, else the repo name), otherwise400.{"clear": true}removes the status (signed-in users only; keys get409).
Endpoints: events feed
GET /events — scope runs:read
Polling feed of run changes and step overlay changes, oldest first.
| Param | Default | Notes |
|---|---|---|
since | 24 h ago | ISO-8601; invalid → 400 {"error":"since must be an ISO-8601 time"} |
limit | 100 | 1 to 200 |
profile | all |
json
{ "since": "2026-10-23T20:00:00.000Z", "next": "2026-10-24T01:57:01Z",
"events": [
{ "type": "run", "at": "2026-10-24T01:57:01Z", "runId": "0b6c…", "profile": "acme", "status": "succeeded" },
{ "type": "step", "at": "…", "runId": "0b6c…", "profile": "acme", "rank": 3, "decision": null, "status": { "…": "…" } } ] }Use this when an agent polls for news: pass the previous answer's next as since.
Endpoints: logs
GET /runs/{runId}/logs — scope logs:read
The scan job's log, parsed into lines.
| Param | Default | Notes |
|---|---|---|
from | head | head or tail (uncursored view only) |
limit | 500 | max 1000 |
level | — | info, warn, error |
stage | — | scan, plan, brief, sketch, upload, job |
q | — | text search, at most 200 characters |
cursor | — | nextCursor / prevCursor from a previous answer (tied to the filters used) |
json
{ "state": "ok", "lines": [ { "id": "…", "ts": "…", "level": "error", "stage": "scan",
"msg": "…", "repo": "svc-a" } ],
"nextCursor": "…", "prevCursor": null, "complete": true, "filtered": true, "scanned": 1200,
"retentionDays": 30 }state:ok,not-started,no-stream,expired(expiredanswers410).complete: true: the run finished and there are no newer lines; stop polling.400bad query or a cursor from other filters;502 {"error":"could not read logs"}.
Use this when asked why a run failed: ?from=tail&level=error.
Endpoints: artifacts
GET /runs/{runId}/artifacts/{path} — scope artifacts:download
Returns a short-lived download URL for the run file <path>, not the file itself.
| Param | Effect |
|---|---|
download=1 | attachment download of the plain object (checked to exist first) |
encoding=gzip | for .json files, prefer the pre-compressed .gz sibling (encoding: "gzip") |
json
{ "url": "https://…", "key": "runs/0b6c…/report.json", "encoding": "identity", "expiresIn": 300 }- URLs live 300 s for API keys, 3600 s for signed-in users. Fetch the URL right away (send no
Authorizationheader with the download). - Known paths:
report.json(full report, source of truth),brief.json,plan/plan.json,plan/sketch.json. 404 {"error":"run not found"};404 {"error":"artifact not found","key":…}.
Use this when a full file is needed (offline analysis, archival). Files can be many megabytes; prefer the portfolio and plan endpoints for questions.
Endpoint: MCP
POST /mcp — any scope
The Model Context Protocol endpoint for AI agents (Streamable HTTP, JSON answers, stateless). It uses the same Authorization header. Every tool maps to one endpoint above and needs that endpoint's scope; tools/list shows only the tools your key may call. GET /mcp answers 405. Full contract: MCP.
Errors and rate limits
Error statuses and body shapes are listed in Errors and limits.
Rate limits per API key: 60 reads per minute, 20 writes per minute, 5 new runs per day. On 429, wait retryAfter seconds. Signed-in people are not limited. API key management (/keys) is described in API keys.