LLM Agent Workflow
The API is built around a simple contract: send it raw input, it hands back bite-sized units to analyse one at a time, you post findings back for each, and it tells you when there’s nothing left. This page walks through wiring an LLM (or any automation) to that contract end to end.
The five-step lifecycle
Section titled “The five-step lifecycle”- Identify the run by hashing the input — this becomes the namespace.
- Ingest the raw plan/diff — the server splits it into analysable units for you.
- Drain the queue — repeatedly ask for the next unanalysed unit, evaluate it, submit findings.
- Detect completion via the queue’s “empty” signal (HTTP 208).
- Fetch the report and hand its link back to whoever’s waiting on the result.
NAMESPACE = sha256(input)[:64]
POST /{analyzer}/{NAMESPACE}/ingest → 201 Created
repeat: GET /{NAMESPACE}/resource/next → 208 done, stop → 200 { hash, value: {body, rules, ...} } <analyse value.body against value.rules> PUT /resource/{hash}/analysis {summary, findings} → 200 ok / 422 fix & retry
GET /{NAMESPACE}/report?format=markdown_light&raw=trueRules travel with the unit itself ($.rules on the next response) — there’s
no separate rules-fetch call to remember.
Step 1: derive the namespace
Section titled “Step 1: derive the namespace”Namespaces are content-addressed: the first 64 hex characters of the SHA-256 of whatever you’re about to ingest.
NAMESPACE=$(sha256sum plan.txt | cut -c1-64)Because the namespace is derived from content rather than assigned by the caller, submitting the same input twice always lands on the same run — ingestion becomes naturally idempotent.
Step 2: ingest the raw input
Section titled “Step 2: ingest the raw input”curl -s -X POST "http://localhost:8000/terraform-plan/${NAMESPACE}/ingest" \ -H "Content-Type: text/plain" \ --data-binary @plan.txtA 201 comes back with every unit the server split the input into. Each one
carries a hash (its stable identifier for the analysis step later) and a
body (the actual content to evaluate):
[ { "hash": "9c2a7e1f4d8b...", "type": "aws_iam_role", "name": "deploy_bot", "action": "update", "body": " # aws_iam_role.deploy_bot will be updated in-place\n ~ resource \"aws_iam_role\" \"deploy_bot\" {\n ~ assume_role_policy = jsonencode({...})\n }", "metadata": {"generated_at_utc": "2026-01-01T00:00:00Z", "source_path": null}, "analysis": null }]Re-posting identical content is a safe no-op — units that already exist for that namespace won’t be duplicated.
Step 3: drain the analysis queue
Section titled “Step 3: drain the analysis queue”Loop until the server tells you to stop.
a. Pull the next unit
curl -s -w "\n%{http_code}" "http://localhost:8000/${NAMESPACE}/resource/next"200→ one unit is waiting; keep going208→ the queue is empty; break out of the loop
From the 200 payload, you need:
| Field | Path |
|---|---|
| Hash (for the submit step) | $.hash |
| Content to analyse | $.value.body |
Address (type.name) | $.value.type + . + $.value.name |
| Planned change | $.value.action |
| Applicable ruleset | $.rules |
Add ?rule_type=aws to the request if you want the cloud-specific ruleset
instead of the provider-neutral default.
b. Evaluate it
Run every rule in $.rules against $.value.body, looking for
misconfigurations, risky changes, or policy violations. A clean unit still
needs exactly one finding, at severity: "INFO" — never submit an empty
findings list.
c. Submit the verdict
curl -s -X PUT "http://localhost:8000/resource/${HASH}/analysis" \ -H "Content-Type: application/json" \ -d '{"summary": "...", "findings": [...]}'| Status | What it means |
|---|---|
200 | accepted — go pull the next unit |
422 | payload didn’t validate; read detail, fix, resubmit |
404 | that hash doesn’t exist — log it and move on |
The analysis payload
Section titled “The analysis payload”{ "summary": "one to three sentences: the overall verdict for this unit", "findings": [ { "title": "short finding title", "severity": "CRITICAL | HIGH | MEDIUM | LOW | INFO", "confidence": "HIGH | MEDIUM | LOW", "rule_id": "id from the fetched ruleset, e.g. TFSEC-020", "risk": "what breaks if this ships as-is", "why_it_matters": "the business or compliance angle", "suggested_next_step": "one actionable fix, imperative voice", "evidence": ["verbatim line from body", "..."], "resource_address": "type.name", "tags": ["category", "..."] } ]}| Field | Constraint |
|---|---|
summary | required, non-empty |
findings | required, at least one entry |
severity | one of the five levels above, uppercase |
confidence | one of the three levels above, uppercase |
rule_id | from the ruleset you were handed; fall back to CUSTOM-001 if nothing matches |
evidence | verbatim substrings of $.value.body — never paraphrased |
resource_address | {type}.{name}, e.g. aws_iam_role.deploy_bot |
tags | lowercase, short — iam, encryption, logging, etc. |
Step 4: hand back the report
Section titled “Step 4: hand back the report”Once resource/next returns 208, three representations of the same
report are available:
- JSON —
GET /{NAMESPACE}/report— for programmatic consumption. - Full markdown —
GET /{NAMESPACE}/report?raw=true— every severity, meant for something like$GITHUB_STEP_SUMMARY. Requires auth. - Light markdown —
GET /{NAMESPACE}/report?format=markdown_light&raw=true— CRITICAL/HIGH only, this is what the agent should return as its final message. It embeds a link to the human-facing HTML view.
The HTML view itself has no fixed URL: each light-report render mints a
fresh, high-entropy GET /reports/{token} link scoped to the org’s
report_link_ttl_hours (24h by default, tunable via
PATCH /orgs/{org_id}/report-link-ttl — see Endpoints).
Read that link out of the light report’s own text rather than constructing
or caching it yourself — it’s public and unauthenticated, but expires.
The report header’s analyzer label(s) come from each unit’s own
metadata.analyzer — nothing extra to pass.
Authentication
Section titled “Authentication”When the deployment has auth.api_key turned on, every call needs a key
except two: GET /reports/{token} and GET /health stay public always.
curl -H "X-API-Key: <key>" ...A member-scoped key covers the whole workflow above (rules, ingest, loop,
report). Anything touching org settings — key management under
/auth/keys, LLM credentials, quota, the GitHub App wiring — needs an
admin or owner key instead.
Running more than one analyzer against the same run
Section titled “Running more than one analyzer against the same run”Point multiple analyzers at one namespace and the loop doesn’t change —
resource/next already resolves the right ruleset per unit, regardless of
which analyzer produced it:
NAMESPACE = "release-4821"
POST /terraform-plan/{NAMESPACE}/ingest ← plan.txtPOST /terraform-code-change/{NAMESPACE}/ingest ← diff.txt
# same loop as above, unmodifiedGET /{NAMESPACE}/resource/nextPUT /resource/{hash}/analysis {...}
GET /{NAMESPACE}/report?raw=true # one merged reportOnboarding a new analyzer is just one more ingest call — the drain loop
and the report call don’t need to know how many contributed.
Status codes at a glance
Section titled “Status codes at a glance”| Code | Meaning | What to do |
|---|---|---|
| 200 | ok | continue |
| 201 | ingested | continue |
| 208 | queue empty | exit the loop, fetch the report |
| 401 | bad/missing API key | check X-API-Key |
| 404 | not found | double-check the namespace or hash |
| 409 | duplicate ingest | harmless — ingestion is idempotent |
| 413 | input too large | split it and ingest in parts |
| 422 | payload didn’t validate | read detail, fix, retry |
| 500 | server error | check server-side logs |
| 501 | not implemented | try a different analyzer |