← SBOM Desk / API
Tokens

Drive SBOM Desk from your own code

Everything the web page does is available over HTTP. The difference from the page is where the SBOM gets read. In the browser a real CycloneDX / SPDX reader parses the document locally and derives a compact text digest; only that digest and a set of prescan flags are sent to the model. Over the API the contract is the same: you send the digest, never the whole document. The natural uses are a release gate that runs the conformance lane on every build and fails when the publish decision regresses to hold, a nightly job that re-rules the licence position after a dependency bump, and a script that drafts the customer statement straight from the gate it just ran.

The task field decides everything else

SBOM Desk is one endpoint with four lanes. task is the router: it selects the section of the system prompt that applies, the output contract, the credit hold and the progress markers. It is the first field you should decide and the first field in every example on this page. A missing or unrecognised task does not fail the run — the model picks the closest lane, names it in lane, and says which it chose in the opening sentence of exec_summary — but never rely on that. Send one of the four ids.

taskthe question it answerskeys it adds to the common envelope
conformance Is this document fit to publish, for the audience you named? Twelve named checks and the seven NTIA minimum elements, each decided in the browser first and then re-read for that audience. checks[] — exactly twelve, in a fixed order — ntia[] (exactly seven, N1..N7) and publish_gate.
licenses What is the licence position of this inventory, against your policy and the way you ship? One ruling per distinct licence expression, not one per component. rulings[], obligations[], notice_gaps[], policy_exceptions[].
triage Where should a real scanner be pointed first? Exposure reasoning from graph position, pinning and identifier coverage — never an invented CVE list. triage[], blind_spots[], monitoring[], embedded_vulnerabilities.
remediation What is the fix plan, and what goes to the customer? Ordered steps with real commands plus a sendable statement. Runs best on a lane you already have, passed in source. steps[], verification[], statement, residual[].

Every lane returns the same common envelopelane, title, posture, verdict, headline, exec_summary, findings, reconciliation, assumptions, open_questions, summary — and then adds its own keys on top. A lane never blends another lane's contract into its reply, so you can switch on lane and trust the shape.

Two lanes over the same document are two runs. They are priced separately, held separately and billed separately; the app's Idempotency-Key carries the lane id for exactly this reason. There is no combined call that returns all four.

One worked request per lane

These are the four bodies as the app itself submits them, with the digest abbreviated — the real sbom_digest runs to a few thousand characters and is capped at 26,000. The full input object is documented field by field in step 4.

conformance — a container image going to a customer's security team:

{
  "task": "conformance",
  "distribution": "container",
  "policy": "permissive",
  "audience": "customer-security",
  "context": "Generated by cdxgen in CI with default flags. We ship this as a container image.",
  "sbom_digest": "SBOM DIGEST - computed in the browser from the whole parsed document\n\n## Document\nformat: CycloneDX 1.5\n... (see step 4)",
  "prescan_facts": {
    "flags": [
      {"id": "F-NO-SUPPLIER", "severity": "high",
       "title": "13 components have no supplier",
       "detail": "NTIA minimum element 1. This is the element SBOM generators most often skip."}
    ],
    "checks": [
      {"id": "C-FORMAT", "name": "Format and specification version",
       "status": "pass", "evidence": "CycloneDX 1.5"}
    ],
    "ntia": [
      {"id": "N1", "element": "Supplier name", "status": "fail",
       "evidence": "1 of 14 components (7%)"}
    ],
    "stats": {"components": 14, "supplier_coverage": 7, "licence_coverage": 93, "edges": 9}
  }
}

licenses — the same inventory under a permissive-only policy. Note there is no checks or ntia block: those are sent only for the conformance lane.

{
  "task": "licenses",
  "distribution": "container",
  "policy": "permissive",
  "audience": "customer-security",
  "context": "Our written policy is permissive licences only.",
  "sbom_digest": "... the same digest ...",
  "prescan_facts": {
    "flags": [
      {"id": "F-COPYLEFT", "severity": "medium",
       "title": "1 component under strong copyleft terms",
       "detail": "Distributing a binary that links these obliges you to offer corresponding source."}
    ],
    "stats": {
      "components": 14, "unlicensed": 1, "deprecated_licences": 1,
      "licence_classes": {"permissive": 9, "strong-copyleft": 1, "source-available": 2,
                          "unrecognised": 1, "unknown": 1}
    }
  }
}

triage — same shape again; the lane is the only difference:

{
  "task": "triage",
  "distribution": "container",
  "policy": "permissive",
  "audience": "customer-security",
  "context": "",
  "sbom_digest": "... the same digest ...",
  "prescan_facts": {
    "flags": [{"id": "F-NO-ID", "severity": "high",
               "title": "1 component has no purl and no CPE",
               "detail": "Without a machine-readable coordinate this row can only be matched by name."}],
    "stats": {"components": 14, "identifier_coverage": 93, "edges": 9, "max_depth": 2,
              "unreachable": 5, "embedded_vulnerabilities": 0}
  }
}

remediation — with a previous lane's output carried in source. That field is optional; without it the plan is written from the prescan alone, which is a legitimate case the prompt covers.

{
  "task": "remediation",
  "distribution": "container",
  "policy": "permissive",
  "audience": "customer-security",
  "context": "cdxgen 10.4.3, default flags, GitHub Actions.",
  "sbom_digest": "... the same digest ...",
  "source": "Lane: conformance\nPosture: conditional\nVerdict: ...\n\nFindings:\n- [high] 13 components have no supplier ...",
  "prescan_facts": {
    "flags": [{"id": "F-NO-SUPPLIER", "severity": "high", "title": "...", "detail": "..."}],
    "stats": {"components": 14, "supplier_coverage": 7}
  }
}

The envelope and the error codes

Every response from https://api.skillsafe.ai/v1/app-api is one of two shapes:

{"ok": true,  "data": { ... }}
{"ok": false, "error": {"code": "...", "message": "...", "details": { ... }}}
codeHTTPwhat it means and what to do
unauthorized401 No token, a malformed token, or one that has expired. Mint a new one (step 2).
forbidden403 A guest token on a metered path. /me and /estimate work as a guest; /run and /run-stream need a personal token.
payment_required402 Balance below min_credits for this lane. Call /estimate first and compare against /me — a 402 after submit is a bug in your client, not in the service.
validation_error400 The body was not accepted. Note that /estimate performs no body validation at all, so it will happily price a malformed body — see the warning in step 4.
rate_limited429 Shared limit. Back off with jitter; never tight-loop.
not_found404 A job id that does not exist, or one belonging to a different subject.
internal500 Retry once with the same Idempotency-Key. A failed run is not billed.

1. A tiny client helper

Three things never change: the base URL, the bearer token, and reading ok before touching data. Everything after this step assumes the helper below.

# Every call is the same three things: the base URL, your bearer token,
# and a JSON body. Keep the token in an environment variable your shell
# already holds rather than pasting it into a script.
BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="YOUR_TOKEN"          # from /tokens.html, or step 2 below

call() {                     # call <path> [json-body]
  if [ -n "$2" ]; then
    curl -sS -X POST "$BASE/$1" \
      -H "Authorization: Bearer $TOKEN" \
      -H "Content-Type: application/json" \
      -d "$2"
  else
    curl -sS "$BASE/$1" -H "Authorization: Bearer $TOKEN"
  fi
}

# ok is the first thing to read; data is only meaningful when it is true.
call me | python3 -c 'import sys,json; r=json.load(sys.stdin); print(r["data"] if r["ok"] else r["error"])'

2. Get a token

The shortest path is /tokens.html — it shows the token this browser already holds for sbom-desk, with a Reveal button and a copy-as-shell-export button, so you never open the developer console. For an unattended job, mint a guest token: it is enough for /me and /estimate, which is enough to build a release gate that prices a lane and checks the balance without ever spending a credit. Running a lane needs a personal token, which comes from signing in.

# A guest token is enough for /me and /estimate.
BASE="https://api.skillsafe.ai/v1/app-api"
curl -sS -X POST "$BASE/guest" -H "Content-Type: application/json" -d '{"slug":"sbom-desk"}'
# -> {"ok":true,"data":{"token":"aut_...","subject_type":"guest"}}

# Keep it in a shell variable. Never commit it; never echo it into a log.
TOKEN=$(curl -sS -X POST "$BASE/guest" -H "Content-Type: application/json" \
  -d '{"slug":"sbom-desk"}' | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["token"])')

3. /me — who you are and what you can afford

Free, and the only place a balance comes from. The response carries exactly three fields: subject_type (user or guest), subject_id and credits. Signed in means subject_type == "user" — there is no username and no email here, so do not test for one.

{"ok": true, "data": {"subject_type": "user", "subject_id": "usr_...", "credits": 184320}}

Pair it with /estimate before every run: compare credits against the lane's min_credits and hold_credits. A 402 after submit means your client skipped this step.

BASE="https://api.skillsafe.ai/v1/app-api"
curl -sS "$BASE/me" -H "Authorization: Bearer $TOKEN"
# -> {"ok":true,"data":{"subject_type":"user","subject_id":"usr_...","credits":184320}}

# A release gate that needs a real account:
SUBJECT=$(curl -sS "$BASE/me" -H "Authorization: Bearer $TOKEN" \
  | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["subject_type"])')
[ "$SUBJECT" = "user" ] || { echo "guest token cannot run a lane"; exit 1; }

4. Price the lane — free

POST /estimate with the run input returns model (gpt-5.6-terra), model_alias (gpt-terra), markup_bps, hold_credits, min_credits and sponsor_enabled. No job is created and nothing is billed. The hold differs per lane, because the prompt sections and output caps differ, so re-estimate when you change task. The hold is a reservation priced against the full output cap; the settled figure is usually far lower.

Warning — /estimate validates nothing. A bare string, a number, null and [] all return ok: true with a well-formed estimate and a correct model binding. So a successful estimate proves the app, the token and the model binding — and says nothing whatever about whether your body is the right shape. There is no server-side signal for that, ever. Assert client-side that you are sending an object with a task string before you spend anything; the web app ships exactly that guard.

The run input, field by field

fieldtypewhat it does
taskstring, required conformance | licenses | triage | remediation. The router.
distributionstring How the software reaches its users, and the single field that changes the most answers: saas, container, on-prem, library, embedded, internal. A strong-copyleft component is a live obligation in an on-premise binary and a much narrower one in a SaaS backend; an AGPL component is a live obligation in both, because network copyleft triggers on network use.
policystring The licence allow-list to rule against: permissive, permissive-weak, osi, case-by-case.
audiencestring Who receives the document: customer-security, questionnaire, federal, cra, release-gate. Drives the publish gate.
contextstring, up to 8,000 chars Which generator produced the document and with what flags, whether the product is actually distributed, how the copyleft components are linked, what the customer asked for. Optional, and it changes the rulings.
sbom_digeststring, required, up to 26,000 chars The derived digest, never the document. Format below.
prescan_factsobject, required {flags[], checks[], ntia[], stats{}}. checks and ntia are sent only for the conformance lane. Every flag id you send must come back reconciled exactly once.
sourcestring, optional, up to 12,000 chars A previous lane's output, for the remediation lane to plan from. Omit it and the plan is written from the prescan alone.
retry_notestring, optional Set only by the app's one automatic reformat retry, which reuses the same idempotency base so a malformed first reply cannot double-bill.

sbom_digest: what to send instead of the document

The digest is plain text with named sections. Produce it however you like — the app's own reader is in /bomscan.js and BomScan.digest(BomScan.scan(text)) returns it — but keep the section names and the two rules the prompt depends on: every count in it is computed over the whole document, and the sample block ends with a sample_completeness: line reading COMPLETE or PARTIAL. That line is what tells the model whether counting over the sampled rows is legitimate. Drop it and the model will happily total a subset and present it as the inventory.

SBOM DIGEST - computed in the browser from the whole parsed document

## Document
format: CycloneDX 1.5
detected_as: cyclonedx-json
name: orders-api
identifier: urn:uuid:6f2a1c94-3b7e-4d21-9c58-0e1f4a7b3d6e
document_version: 1
created: 2026-05-04T09:12:44Z
authored_by_tools: cdxgen 10.4.3
authored_by_people: (none)
data_licence: (none)
subject: pkg:npm/orders-api@3.2.0 version 3.2.0
input_truncated: no

## Inventory (whole document)
components: 14
named: 14 (100%)
versioned: 13 (93%), of which 1 are a range or placeholder
with_purl: 14 (100%), parsing: 13, malformed: 1
with_cpe: 0
with_any_identifier: 14 (100%)
with_supplier: 1 (7%)
with_licence: 13 (93%), unlicensed: 1
with_checksum: 2 (14%), sha-256 or better: 1, weak only: 1
with_copyright: 0 (0%)
with_download_location: 0 (0%)
nested_components: 0
duplicate_coordinates: 0

## Dependency graph (whole document)
edges: 9, max_depth: 2
reachable_from_subject: 9 of 14 (64%)
unreachable: 5, isolated: 4, dangling_references: 1

## By ecosystem (whole document)
  JavaScript: 10
  Java: 1
  ...

## Licence classes (whole document)
  permissive: 9
  source-available: 2
  strong-copyleft: 1
  ...

## Licence identifiers present (whole document, top 30)
  MIT: 7
  GPL-2.0: 1
  SSPL-1.0: 1
  BUSL-1.1: 1
  MIT OR Apache-2.0: 1
  BSD: 1
  ISC: 1

## NTIA minimum elements (computed locally)
  N1 Supplier name: FAIL - 1 of 14 components (7%)
  N2 Component name: PASS - 14 of 14 components (100%)
  ... N3 through N7 ...

## Conformance checks (computed locally)
  C-FORMAT Format and specification version: PASS - CycloneDX 1.5
  ... the other eleven ...

## Prescan flags (computed locally - reconcile every one of these)
  F-NO-SUPPLIER [high] 13 components have no supplier :: express@4.19.2, body-parser@1.20.2, ...
  ... the rest ...

## Sample component rows
sample_size: 14 of 14
  - express@4.19.2 | type=library | purl=pkg:npm/express@4.19.2 | licence=MIT[permissive] | supplier=- | hash=strong
  ...
sample_completeness: COMPLETE

When the sample is a subset the block instead ends with sample_completeness: PARTIAL followed by an explicit instruction not to aggregate over those rows. Keep that sentence: it is load-bearing.

# sbom_digest is the DERIVED digest, never the document itself.
BASE="https://api.skillsafe.ai/v1/app-api"
cat > /tmp/sbom-desk-input.json <<'JSON'
{
  "task": "conformance",
  "distribution": "container",
  "policy": "permissive",
  "audience": "customer-security",
  "context": "cdxgen 10.4.3, default flags, GitHub Actions.",
  "sbom_digest": "SBOM DIGEST - computed in the browser from the whole parsed document\n\n## Document\nformat: CycloneDX 1.5\n...",
  "prescan_facts": {"flags": [], "checks": [], "ntia": [], "stats": {"components": 14}}
}
JSON

curl -sS -X POST "$BASE/estimate" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  --data-binary @/tmp/sbom-desk-input.json
# -> {"ok":true,"data":{"model":"gpt-5.6-terra","model_alias":"gpt-terra",
#                        "markup_bps":1000,"hold_credits":...,"min_credits":...}}

# The hold differs per lane. Price the one you are about to run.
for LANE in conformance licenses triage remediation; do
  python3 - "$LANE" <<'PY' > /tmp/sbom-desk-lane.json
import json, sys
body = json.load(open("/tmp/sbom-desk-input.json"))
body["task"] = sys.argv[1]
json.dump(body, open("/tmp/sbom-desk-lane.json", "w"))
PY
  printf "%-12s " "$LANE"
  curl -sS -X POST "$BASE/estimate" -H "Authorization: Bearer $TOKEN" \
    -H "Content-Type: application/json" --data-binary @/tmp/sbom-desk-lane.json \
    | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["hold_credits"])'
done

5. /run and poll

POST /run returns {"job_id": "job_..."} immediately. GET /jobs/{id} polls it to a terminal status of succeeded, failed or cancelled. The reply text is at data.output.output — a JSON string you then parse. charged_credits is the real cost and is usually far below the hold. truncated: true means the balance sat between min_credits and hold_credits, so the run executed with a reduced output cap; render what parsed and say it was cut short rather than presenting a clipped answer as complete.

Send an Idempotency-Key on every run. Include the lane in it: two lanes over one document are two distinct runs and must not collide on one key. The app uses sbom-desk:{lane}:{hash of the input}:a{attempt}, and its one automatic reformat retry reuses the same hash with the attempt bumped — so a network blip or a malformed first reply can never double-bill.

# The key carries the LANE, so two lanes over one document are two jobs, not a 409.
BASE="https://api.skillsafe.ai/v1/app-api"
LANE=conformance
HASH=$(shasum -a 256 /tmp/sbom-desk-input.json | cut -c1-16)
KEY="sbom-desk:$LANE:$HASH:a1"

JOB=$(curl -sS -X POST "$BASE/run" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  --data-binary @/tmp/sbom-desk-input.json \
  | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["job_id"])')

# Poll to a terminal state. Back off; do not tight-loop.
for i in $(seq 1 90); do
  RES=$(curl -sS "$BASE/jobs/$JOB" -H "Authorization: Bearer $TOKEN")
  STATUS=$(printf '%s' "$RES" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["status"])')
  [ "$STATUS" = "succeeded" ] && break
  [ "$STATUS" = "failed" ] && { echo "run failed"; exit 1; }
  sleep 2
done

# The reply text is a JSON string at data.output.output.
printf '%s' "$RES" \
  | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["output"]["output"])' \
  > /tmp/sbom-desk-reply.json

6. /run-stream — server-sent events

Same body, same Idempotency-Key, but the reply arrives as it is written. Each delta event carries a chunk of the reply JSON; a job event carries the terminal state. This is what the web page uses, and it is what lets the progress card advance on real signals: the app watches the delta stream for that lane's contract markers — for conformance those are "checks", "ntia", "publish_gate", "reconciliation" and "summary" — and moves a named stage forward as each one appears, rather than counting characters.

Accumulate the deltas and keep them on error. If the stream dies mid-flight you still have partial JSON; closing it with the right brackets recovers the sections that did arrive. The app does exactly that and labels the result as partial rather than discarding a run it paid for.

# Server-sent events. Each `delta` carries a chunk of the reply JSON; the final
# `job` event carries the terminal state.
BASE="https://api.skillsafe.ai/v1/app-api"
curl -sSN -X POST "$BASE/run-stream" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: sbom-desk:conformance:$HASH:a1" \
  -H "Accept: text/event-stream" \
  --data-binary @/tmp/sbom-desk-input.json \
| while IFS= read -r line; do
    case "$line" in
      "event: delta") read -r data; printf '%s\n' "${data#data: }" ;;
      "event: job")   read -r data; printf 'JOB %s\n' "${data#data: }" ;;
    esac
  done

The output contract

data.output.output is a JSON string. Parse it, then switch on lane. The app's own parser is brace-balanced rather than a regex, because a licence expression or the customer statement can legitimately contain a brace inside a string and a lazy match closes the object in the wrong place.

The common envelope — every lane

{
  "lane": "conformance",
  "title": "orders-api 3.2.0 SBOM - publish gate",
  "posture": "conditional",              // ready | conditional | blocked
  "verdict": "One sentence: the thing that decides the posture.",
  "headline": {"label": "NTIA elements passed", "value": "6 of 7"},
  "exec_summary": "2-4 sentences of plain prose.",
  "findings": [
    {"id": "SBD-001", "severity": "high", "area": "supplier",
     "title": "One line",
     "detail": "What is wrong and what it costs downstream in THIS distribution model",
     "evidence": "1 of 14 components carry a supplier (7%)",
     "action": "The specific next step, addressed to whoever must do it"}
  ],
  "reconciliation": [
    {"flag_id": "F-NO-SUPPLIER", "status": "confirmed", "note": "Specific."}
  ],
  "assumptions": ["..."],
  "open_questions": ["..."],
  "summary": "One closing paragraph."
}

severity is critical | high | medium | low; ids run SBD-001 upward in severity order. reconciliation.status is confirmed | refined | not_material | disputed, and there is exactly one entry per flag id you sent — the web page renders a table naming any the model skipped, and you should do the equivalent check in your own client. assumptions, open_questions and every task array are [] when empty, never null.

conformance adds

"checks": [
  {"id": "C-FORMAT", "name": "Format and specification version",
   "status": "pass",                     // pass | partial | fail | unknown
   "evidence": "CycloneDX 1.5",
   "comment": "What it means for THIS audience"}
],                                        // EXACTLY twelve, in this order:
                                          // C-FORMAT, C-IDENTITY, C-AUTHOR, C-TIMESTAMP,
                                          // C-SUBJECT, C-NAMES, C-VERSIONS, C-IDS,
                                          // C-SUPPLIER, C-LICENSES, C-HASHES, C-GRAPH
"ntia": [
  {"id": "N1", "element": "Supplier name", "status": "fail",
   "gap": "13 of 14 components carry no supplier",
   "fix": "Set metadata.supplier and pass --author on the generator"}
],                                        // EXACTLY seven, N1 through N7, in order
"publish_gate": {
  "decision": "publish_with_caveats",     // publish | publish_with_caveats | hold
  "caveats": ["What must be said out loud if it goes out as it is"],
  "blockers": ["What must change before it goes out at all"]
}

decision: "hold" with an empty blockers array is rejected by the app's parser and triggers its one reformat retry: telling somebody to stop without telling them what to fix is not an answer. Assert the same thing in your own client.

licenses adds

"rulings": [
  {"licence": "GPL-2.0",                  // exactly as the document states it
   "licence_class": "strong-copyleft",    // permissive | weak-copyleft | strong-copyleft |
                                          // network-copyleft | source-available | restricted |
                                          // public-domain | license-ref | unrecognised | unknown
   "components": 1,
   "examples": ["mysql-connector-java@8.0.33"],
   "ruling": "resolve",                   // allow | review | deny | resolve
   "obligation": "What shipping this way actually requires you to do",
   "rationale": "Why this ruling under this policy and this distribution model"}
],
"obligations": [
  {"obligation": "Ship a written offer of corresponding source",
   "triggered_by": "strong-copyleft, 1 component",
   "owner": "release",                    // engineering | legal | release | product
   "artifact": "A NOTICE file and a source mirror URL in the image labels"}
],
"notice_gaps": [
  {"gap": "No component carries a copyright line",
   "components": 14,
   "fix": "Read each licence file from the package itself"}
],
"policy_exceptions": [
  {"licence": "BUSL-1.1", "components": 1,
   "ask": "The exception that would have to be granted, stated as a decision",
   "alternative": "What to do instead if it is refused"}
]

One ruling per distinct licence expression, not one per component. The app cross-checks the set of rulings[].licence values against the licence expressions its own reader found and paints a row saying "present in the document but not ruled on" or "ruled on but not present in the document"; it also compares each components count against its own and prints both numbers when they disagree. And note that resolve is not a softer deny: it means the information is missing — NOASSERTION, a free-text string, a LicenseRef, or a retired identifier such as GPL-2.0 that does not say whether later versions may be used.

triage adds

"triage": [
  {"id": "T-1",
   "target": "The 1 component with no purl and no CPE",
   "exposure": "No scanner can match it, so it is not clean - it is unscanned",
   "priority": "this-sprint",             // now | this-sprint | this-quarter | accept
   "why": "Reasoning, from the digest",
   "action": "The specific next step",
   "verify": "How the user will know it is done"}
],
"blind_spots": [
  {"blind_spot": "Components with no identifier",
   "components": 1,
   "consequence": "What gets missed",
   "close_it": "What would make these components visible"}
],
"monitoring": [
  {"signal": "What to watch", "where": "Which part of the pipeline", "cadence": "How often"}
],
"embedded_vulnerabilities": {
  "present": false,
  "count": 0,
  "handling": "What to do with the records the document carries, and why they are not a current status",
  "unanalysed": 0
}

This lane has no vulnerability database and the prompt forbids inventing one. It will not tell you whether a component is affected by anything: no CVE identifiers, no CVSS scores. What it gives you is exposure reasoning from graph position, pinning and identifier coverage. The app additionally compares embedded_vulnerabilities.count against the number of vulnerabilities[] records its own reader found in the document and prints both numbers when they disagree — so a fabricated count is visible rather than plausible.

remediation adds

"steps": [
  {"id": "R-1",
   "phase": "generator",                  // generator | build | dependency | policy | document
   "step": "What to do, in the imperative",
   "why": "The finding or flag it closes",
   "command": "cdxgen --author 'Acme Release Engineering' --spec-version 1.6 -o sbom.json",
   "effort": "minutes",                   // minutes | hours | days
   "risk": "low",                         // none | low | medium | high
   "closes": ["F-NO-AUTHOR", "N6", "C-AUTHOR"]}
],
"verification": [
  {"check": "What to re-run", "expect": "The figure that proves it worked"}
],
"statement": "A complete, sendable paragraph for the customer's security team ...",
"residual": ["What will still be true after every step above is done"]

closes may only name flag ids you sent, one of the twelve check ids, or one of N1..N7. The app prints "N steps claim to close something the prescan never raised" naming each offender, and separately prints "no step closes X, which the prescan rated critical or high" for anything left neither closed nor listed in residual. A missing or empty statement is rejected by the parser.

Route on the lane

# REPLY holds data.output.output, already extracted in step 5.
python3 - /tmp/sbom-desk-reply.json <<'PY'
import json, sys
r = json.load(open(sys.argv[1]))
print(r["lane"], r["posture"], "|", r["verdict"])

if r["lane"] == "conformance":
    print("gate:", r["publish_gate"]["decision"])
    fails = [c["id"] for c in r["checks"] if c["status"] == "fail"]
    print("failing checks:", ", ".join(fails) or "none")
    # A release gate: stop the pipeline when the document is not fit to send.
    if r["publish_gate"]["decision"] == "hold":
        sys.exit(1)
elif r["lane"] == "licenses":
    for x in r["rulings"]:
        if x["ruling"] in ("deny", "resolve"):
            print(x["ruling"].upper(), x["licence"], "x", x["components"])
elif r["lane"] == "triage":
    for t in r["triage"]:
        if t["priority"] == "now":
            print("NOW", t["target"])
elif r["lane"] == "remediation":
    print(r["statement"])
PY

Rate limits, cost and the one thing to design around

What this API will not do

It will not tell you whether a component is vulnerable. There is no vulnerability database behind it, the prompt forbids inventing a CVE identifier or a CVSS score, and the triage lane is exposure reasoning rather than vulnerability reporting. It will not validate your document against the CycloneDX or SPDX schema — use the projects' own validators for that; what it does is judge whether the document is useful to the person you are sending it to. And nothing it returns is legal advice: the licence lane classifies obligations and names the decisions somebody has to make, and never states that a particular use is or is not permitted.