Skip to content

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 ​

  1. Base URL
  2. Authentication
  3. Which endpoint for which question
  4. Scopes
  5. Endpoints: profiles
  6. Endpoints: runs
  7. Endpoints: portfolio (assistant answers, at most 8 KB)
  8. Endpoints: plan, steps, sketch
  9. Endpoints: decisions and step status (write)
  10. Endpoints: events feed
  11. Endpoints: logs
  12. Endpoints: artifacts
  13. Endpoint: MCP
  14. Errors and rate limits

Base URL ​

text
https://api.vulngraph.igentify.in/v1

All 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 (not 403), and POST /runs for another profile answers 403.
  • 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/latest

Which endpoint for which question ​

QuestionCallScope
How bad is the fleet right now?GET /portfolio/summaryfindings:read
What should we fix first?GET /portfolio/start-here?limit=5findings:read
How exposed is service X?GET /portfolio/service?service=svc-afindings:read
Are we affected by CVE/GHSA Y?GET /portfolio/advisory?id=CVE-2026-0001findings:read
Where do we use package Z?GET /portfolio/package?q=demo-libfindings: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=3findings:read
I am not sure how a name is spelledGET /portfolio/resolve?q=accesfindings:read
What changed since the last scan?GET /portfolio/changesfindings:read
Are we getting better over time?GET /portfolio/trendfindings: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}/planplans:read
What are the open Decide questions?GET /runs/{runId}/plan?decide=1plans:read
Details and options of step NGET /runs/{runId}/steps/{rank}plans:read
What has been decided / which PRs are open?GET /runs/{runId}/stepsplans:read
What would the PR for step N look like?GET /runs/{runId}/sketch?rank=Nsketches:read
Why did a run fail?GET /runs/{runId}/logs?from=tail&level=errorlogs:read
I need the full report / brief / plan fileGET /runs/{runId}/artifacts/report.jsonartifacts:download
Which profiles (fleets) exist?GET /profilesprofiles:read
Start a new scanPOST /runsruns:create
Record a choice on a Decide stepPOST /runs/{runId}/steps/{rank}/decisiondecisions:write
Record that a PR was opened/mergedPOST /runs/{runId}/steps/{rank}/statusstep-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 ​

ScopeGroupRiskGrants
runs:readreadlowGET /runs, GET /runs/latest, GET /runs/{id}, GET /events
findings:readreadlowGET /portfolio/{name}
profiles:readreadlowGET /profiles, GET /profiles/{name}
plans:readreadmediumGET /runs/{id}/plan, GET /runs/{id}/steps, GET /runs/{id}/steps/{rank}
sketches:readreadmediumGET /runs/{id}/sketch
logs:readreadmediumGET /runs/{id}/logs
artifacts:downloadreadmediumGET /runs/{id}/artifacts/{path}
decisions:writewritehighPOST /runs/{id}/steps/{rank}/decision
step-status:writewritemediumPOST /runs/{id}/steps/{rank}/status
runs:createwritehighPOST /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.

ParamDefaultNotes
limit50max 100
profileallonly 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" }
  • profile required; repos (default []), refs (default {}), sketchModefixture | bedrock (default bedrock).
  • 201 {"run": {...}} (status running); 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 as failed).

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.
  • next adds the live step status and decisions recorded through POST /runs/{runId}/steps/{rank}/status and /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": true is 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) ​

FieldMeaning
data_as_ofWhen the scan data was produced: the run's updatedAt, ISO 8601 UTC with Z. When you quote numbers, say "as of" this date.
served_atWhen this answer was produced (ISO 8601 UTC, Z).
run_idThe run the answer comes from.
cachedtrue when both the run pointer and the index came from the 60-second memory cache.
cache_age_sAge of the cached data in seconds (0 when cached is false).
profileThe 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"} (also id, 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",…]}.
  • 404 with found: false for unknown names (see above), an advisory that does not affect the fleet, next without a plan, and changes without 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 between data_as_of and now. running_now: a newer scan is running or queued. previous_run_at: time of the run before this one (same profile), or null.
  • counts and severity come from the report's brief. severity[].advisories counts every advisory the scan found, including a few that reach no deployed service; those are not listed by advisories (see not_on_any_service there).
  • 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.

ParamMeaning
severityComma list of CRITICAL, HIGH, MEDIUM, LOW, UNKNOWN (case-insensitive).
serviceOnly advisories on this service (forgiving match).
packageOnly advisories on this package (forgiving match).
fixabletrue = has a fixed version, false = no fix yet.
limitDefault 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_closed or skipped. pr only when a PR URL was recorded.
  • kind decide is a Decide step; options are shown until a decision is recorded, and decision holds 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 ​

ParamEffect
include=diffkeep each step's diff (dropped by default; large)
decide=1 or decide=trueonly 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.

ParamEffect
rank=None 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" }
  • option is 1-based into decide_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 get 409).

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.
  • prUrl is required for pr_opened, pr_merged, pr_closed; it must look like https://github.com/<owner>/<repo>/pull/<n> and belong to the step's repository (repo_url, else the repo name), otherwise 400.
  • {"clear": true} removes the status (signed-in users only; keys get 409).

Endpoints: events feed ​

GET /events — scope runs:read ​

Polling feed of run changes and step overlay changes, oldest first.

ParamDefaultNotes
since24 h agoISO-8601; invalid → 400 {"error":"since must be an ISO-8601 time"}
limit1001 to 200
profileall
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.

ParamDefaultNotes
fromheadhead or tail (uncursored view only)
limit500max 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 (expired answers 410).
  • complete: true: the run finished and there are no newer lines; stop polling.
  • 400 bad 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.

ParamEffect
download=1attachment download of the plain object (checked to exist first)
encoding=gzipfor .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 Authorization header 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.

VulnGraph documentation