Operating CRAne from an AI agent
This page is written to be read by a coding agent — Copilot, Claude, or anything else that can run a command — and it is complete enough to drive CRAne without reading the rest of this site.
- Pointing an agent at this
- Which surface to use
- The rules that matter
- Exit codes
- Which commands speak JSON
- The normal loop
- Adding to what already exists
- Modelling mistakes an agent will make
- Decisions are governed acts
- Reading state without changing anything
- Reporting
- Over HTTP instead
- Do not
- Where the rest is
Pointing an agent at this
Three ways, in order of preference:
- Fetch
/CRAne/llms-full.txt— this entire documentation set flattened into one plain-text file, meant to be read in a single request./CRAne/llms.txtis the short routing index if you would rather fetch pages selectively. - Read it out of the image, when there is no network:
docker run --rm --entrypoint cat ghcr.io/oscarvalenzuelab/crane:latest /app/AGENTS.md - Ask the running system.
crane import spec --jsonand/api/openapi.jsonare generated from the code, so they cannot be out of date. When they disagree with this page, they are right.
For a project that uses CRAne regularly, paste the rules and the
exit codes into .github/copilot-instructions.md and link to this page for
the rest.
Which surface to use
Prefer the crane command. It needs no port and no token, and a few lifecycle things
(init, migrate, job prune, reading local files) exist only there.
There is also an HTTP API, served by crane serve and documented at /api/docs, with its
schema at /api/openapi.json. It mirrors the working loop end to end — upload, import,
validate, run the worker, rescore, read the report — and every write calls the same
function the command does. Reach for it when you have a port and a token and no shell.
Never write to the SQLite file directly. Every write should carry provenance, and a
manual INSERT carries none.
Invoking it in a container
Everything below is written as crane <command>. Against the published image that is:
docker run --rm -v "$PWD/data:/data" --user "$(id -u):$(id -g)" \
ghcr.io/oscarvalenzuelab/crane:latest <command>
Define it once and forget it:
crane() {
docker run --rm -i -v "$PWD/data:/data" -v "$PWD/exports:/exports" \
--user "$(id -u):$(id -g)" ghcr.io/oscarvalenzuelab/crane:latest "$@"
}
Every path you pass is resolved inside the container. Write input files into a mounted directory and refer to them by their container path (
/data/batch.json), never the host path.
The rules that matter
- Discover the format, do not guess it.
crane import spec --jsonprints every dataset, column, accepted value and rule. It is generated from the code. - Pass
--jsonto anything you intend to parse. See the table below for exactly which commands accept it. - Check the exit code. They distinguish “your data is wrong” from “the setup is wrong”, and those need different responses.
- Dry run first.
--dry-runon both importers validates and writes nothing. - Never mark anything approved unless a human actually approved it. Loading data
records observations.
source_kind: approvedin a scope row writes a decision with a named authority, and inventing one corrupts the audit trail this exists to keep. - Do not invent values. A field you do not know is left empty. Empty means nobody has looked, which is a state the system reports honestly. A guess becomes a fact nobody can distinguish from a real one.
Rules 5 and 6 are the ones that matter most when the operator is a model. Everything else here is recoverable; a fabricated approval is not.
Exit codes
| Code | Meaning | What to do |
|---|---|---|
0 |
Worked | Continue. Check findings anyway — see below |
1 |
Setup problem: missing database, pending migrations, bad path | Fix the environment. Do not retry blindly |
2 |
The command is specified but not built, or its adapter is not installed | Do not work around it. Report it |
3 |
Loaded, but something did not resolve, and you asked to be told | Read unresolved[]. Only import adapter --fail-on-unresolved returns this |
4 |
Input read fine but did not validate | Read errors[], fix the rows, retry |
5 |
A worker job failed | Read crane job list --status failed --json |
0 does not mean “nothing to look at”. A successful import can carry findings and
conflicts:
| Field | Exit | Means |
|---|---|---|
errors[] |
4 |
The batch was rejected. "written": {} — nothing was written |
findings{} |
0 |
Loaded, and something is worth a person’s attention |
conflicts |
0 |
Loaded, and it disagrees with what another source already said |
A value that is not on the accepted list for its column is usually a finding, not an error. Structural problems — an unknown column, an unresolvable reference — are errors and stop the whole batch.
Which commands speak JSON
--json |
Commands |
|---|---|
| Yes | lens list, policy list, import spec, import data, import adapter, validate sbom, validate profiles, assess, worker run, job list, job show, job prune, scope list, scope set, report list, report templates, demo |
| No | init, migrate, status, import template, job cancel |
The ones without it report on the database rather than on the data in it, and print for a person. Check their exit code instead.
crane report <kind> --format json is a different flag from --json, and it is the one
that controls the rendered report body.
The normal loop
# 1. What does the system accept?
crane import spec --json
# 2. Write what you collected as a JSON bundle: keys are dataset names,
# values are arrays of row objects.
cat > data/batch.json <<'JSON'
{
"products": [{"slug": "gateway", "canonical_name": "Aurora Gateway", "lifecycle": "released"}],
"components": [{"slug": "crypto-lib", "canonical_name": "Crypto Library"}],
"product_components": [{"product_slug": "gateway", "component_slug": "crypto-lib"}]
}
JSON
# 3. Validate without writing. Fix anything reported, then load.
crane import data /data/batch.json --dry-run --json
crane import data /data/batch.json --json
# 4. Queue SBOM validation. Returns immediately; it does not run the tool.
crane validate sbom --requested-by agent --json
# 5. Do the queued work.
crane worker run --once --json
# 6. Score, and read the explanation rather than just the number.
crane assess cra --json
Steps 3 to 6 are idempotent. Re-running with unchanged input changes nothing.
Adding to what already exists
Load only the datasets you have. References resolve against rows already in the database, so this needs no other file:
echo '{"distribution": [{"subject_type": "product", "subject_slug": "gateway",
"direction": "outbound", "channel_type": "nuget",
"channel_locator": "pkg:nuget/Aurora.Gateway@2.4.0", "visibility": "public"}]}' \
> data/dist.json
crane import data /data/dist.json --json
Modelling mistakes an agent will make
Three questions, three answers. Where code lives, where the build runs, and where
output goes are separate. A component built by a vendor from their own source and shipped
back as binaries has code_hosting: vendor, build_location: vendor_external, and an
outbound release_asset distribution row. Do not collapse them.
Direction matters. outbound means we make it available, which is what puts a product
on the market. inbound means we consume it. A third party library has an inbound row and
often no build at all, which is normal and not a gap.
Record “not distributed” explicitly. For something used only internally, write an
outbound row with channel_type: not_distributed. An absent row means nobody looked, and
the report has to tell those apart.
Leave is_signed empty when unknown. Empty means not established. false means known
to be unsigned. The first is a coverage gap, the second is a finding.
Attach ownership at the most specific level that is true. A product owned by a team reports under that team’s organization and division automatically. Do not repeat the hierarchy on every row.
One scope row per lens. The same product can be in scope under cra.regulatory and
undecided under commercial. Both are kept. Do not reconcile them yourself.
Register evidence at the path the worker will see. If the worker runs in a container, a
host path will not resolve there. crane job show <id> prints the path it tried.
Decisions are governed acts
Scope is the common case, and it is not a field update:
crane scope set product gateway cra.regulatory in_scope \
--by "Regulatory counsel" \
--rationale "Made available on the Union market in the course of a commercial activity"
Both flags are required. --rationale must be at least eight characters, because it is the
sentence somebody reads in a year. Status is one of in_scope, out_of_scope, undecided,
not_applicable, disputed.
Do not call this on your own initiative. Write the decision a human made, with their name in
--by. If no human has decided, the correct state is no scope row at all, which CRAne reports as undecided and counts as neither passing nor failing.
Reading state without changing anything
crane status # database location, migrations, row counts
crane policy list --json # obligations, their checks and weights
crane lens list --json # the definitions of "product" in play
crane scope list --json # scope decisions per lens
crane validate profiles --json # which validator profiles exist, and which policy needs
crane job list --status failed --json
crane job show <job-id> --json # parameters, events, artifacts, run directory
crane assess cra --subject gateway --json
For anything not covered by a command, query the SQLite file read only. Use the v_
prefixed views rather than the tables when counting relationships: edge tables store one
row per assertion, so two sources agreeing produce two rows for one relationship.
Reporting
crane report list --json # what exists
crane report leadership --format json # for a steering committee
crane report audit --format html --out /exports/audit.html
crane report worklist --format json # what to do next, by owner
crane report package --out /exports/handover # hashed, for handing over
crane report templates --json
crane report render division-readiness --param division="Platform" --format json
Reports never write to the database, so running one is always safe. Each carries the policy hash that produced its numbers and a content hash that is stable when the data has not changed, which is how you tell “nothing moved” from “nobody looked”.
If asked for a report that does not exist, write a template rather than post-processing one that does — a Markdown file with TOML front matter in the templates directory. That way it can be run again next month.
Over HTTP instead
export T="$CRANE_API_TOKEN"
curl -H "Authorization: Bearer $T" http://host:8000/api/v1/status
curl -H "Authorization: Bearer $T" http://host:8000/api/openapi.json # the full schema
The loop maps onto it directly:
| Step | Endpoint |
|---|---|
| Load data | POST /api/v1/import/data |
| Upload an SBOM | POST /api/v1/import/sbom |
| Queue validation | POST /api/v1/validate/sbom |
| Queue a rescore | POST /api/v1/assess |
| Watch a job | GET /api/v1/jobs/{job_run_id} |
| Read the score | GET /api/v1/overview |
| Read a report | GET /api/v1/reports/{kind} |
| Settle a conflict | POST /api/v1/conflicts/{conflict_id} |
Every write the interface offers exists over HTTP too — this is the surface a migration drives. The full list with request shapes is the OpenAPI document above; the map:
| Change | Endpoint |
|---|---|
| Create / edit a product, component, pipeline or repository | POST /api/v1/subjects, POST /api/v1/subjects/{subject_type}/{subject_id} |
| Wire the graph: contains, builds, source, relates | POST /api/v1/connect |
| Turn a scanned repository into a component | POST /api/v1/assets/adopt |
| Record a fact a check reads (support end, security contact) | POST /api/v1/claims, end it with POST /api/v1/claims/{claim_id}/end |
| Assign or remove an owner | POST /api/v1/owners, POST /api/v1/owners/{responsibility_id}/remove |
| People, teams, and the org tree | POST /api/v1/parties/{party_id}, /api/v1/parties/{keep_id}/merge, /api/v1/settings/org-units |
| Distribution, dependencies, aliases | POST /api/v1/distribution, /api/v1/dependencies, /api/v1/aliases |
| Identity mappings to other systems | POST /api/v1/identities, /api/v1/identities/{identity_id}/remove |
| Evidence standing | POST /api/v1/evidence/{evidence_id}/state |
| Decisions: scope, exceptions, findings, conflicts | POST /api/v1/scope, /api/v1/exceptions, /api/v1/findings/{finding_id}, /api/v1/conflicts/{conflict_id} |
| Jobs | POST /api/v1/jobs/{id}/cancel, /api/v1/jobs/{id}/retry, /api/v1/worker/run |
When you must prove what you loaded
POST /api/v1/import/data validates and loads in one request, and its dry_run flag is
enough for most feeds. It needs no filesystem path and fetches nothing. Use the staged flow
when the two steps have to be tied together — an audited feed, or an integration that records
the digest of what it shipped:
| Step | Endpoint |
|---|---|
| Read the contract and its fingerprint | GET /api/v1/import/spec |
| Validate and stage, writing nothing | POST /api/v1/imports |
| Apply the bundle that id names | POST /api/v1/imports/{import_id}/apply |
| Read the receipt, whenever | GET /api/v1/imports/{import_id} |
The apply step takes no bundle. The id names the bundle that passed validation, so there is nothing you could send that differs from it. Rules that will otherwise surprise you:
ok: falsestill returns 201. Received and refused;errors[]lists every row to fix. Do not retry the same bundle — fix it and stage again.- Only a validated import can be applied. A rejected one cannot.
- Applying twice returns the first receipt, not a second load.
- Send
contract_fingerprintfrom step 1. It is checked again at apply, so a contract that moved in between refuses the apply rather than reinterpreting your rows. - Send an
Idempotency-Key. A retried upload is then one import; a different bundle under a used key is refused, which is how a bug in your feed gets caught. 202means the worker has it. Poll the receipt forstatus: applied.
crane import spec prints the same fingerprint, and both surfaces run the same importer.
Two migration rules worth restating here. Bulk data belongs in POST /api/v1/import/data
— one validated, idempotent, all-or-nothing batch in the import spec shape — not in a
thousand single-row POSTs; the single-row operations are for corrections and decisions.
And the write operations share dedupe keys with the importer, so a row recorded by
hand and the same row arriving later in a batch land as one fact, never twins.
Long running work returns a job id and 202. Poll GET /api/v1/jobs/{id}.
A write that breaks a rule returns 422 with a sentence explaining it, and writes nothing
at all — including the decision that would have authorised it. Treat 422 as “fix the
request”, not “retry”.
Do not
- Write to the database directly. A manual
INSERTcarries no provenance. - Retry a code
4without changing the input. It will fail identically. - Retry a code
1at all until the environment is fixed. - Fill a required field with a placeholder to get past validation. Leave the row out.
- Approve, decide scope, or grant an exception on your own authority.
- Treat a passing SBOM profile as a compliant product. It is one check out of several.
- Assume
ok: truemeans there is nothing to report. Readfindingsandconflicts.
Where the rest is
| Question | Page |
|---|---|
| Every column of every dataset | Data contract, or crane import spec --json |
| Every command, flag and route | Interface contract, or /api/openapi.json |
| The database, with worked queries | Data model |
| Why it is shaped this way | Design decisions |