Skip to content

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.

  1. Identify the run by hashing the input — this becomes the namespace.
  2. Ingest the raw plan/diff — the server splits it into analysable units for you.
  3. Drain the queue — repeatedly ask for the next unanalysed unit, evaluate it, submit findings.
  4. Detect completion via the queue’s “empty” signal (HTTP 208).
  5. 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=true

Rules travel with the unit itself ($.rules on the next response) — there’s no separate rules-fetch call to remember.

Namespaces are content-addressed: the first 64 hex characters of the SHA-256 of whatever you’re about to ingest.

Terminal window
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.

Terminal window
curl -s -X POST "http://localhost:8000/terraform-plan/${NAMESPACE}/ingest" \
-H "Content-Type: text/plain" \
--data-binary @plan.txt

A 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.

Loop until the server tells you to stop.

a. Pull the next unit

Terminal window
curl -s -w "\n%{http_code}" "http://localhost:8000/${NAMESPACE}/resource/next"
  • 200 → one unit is waiting; keep going
  • 208 → the queue is empty; break out of the loop

From the 200 payload, you need:

FieldPath
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

Terminal window
curl -s -X PUT "http://localhost:8000/resource/${HASH}/analysis" \
-H "Content-Type: application/json" \
-d '{"summary": "...", "findings": [...]}'
StatusWhat it means
200accepted — go pull the next unit
422payload didn’t validate; read detail, fix, resubmit
404that hash doesn’t exist — log it and move on
{
"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", "..."]
}
]
}
FieldConstraint
summaryrequired, non-empty
findingsrequired, at least one entry
severityone of the five levels above, uppercase
confidenceone of the three levels above, uppercase
rule_idfrom the ruleset you were handed; fall back to CUSTOM-001 if nothing matches
evidenceverbatim substrings of $.value.body — never paraphrased
resource_address{type}.{name}, e.g. aws_iam_role.deploy_bot
tagslowercase, short — iam, encryption, logging, etc.

Once resource/next returns 208, three representations of the same report are available:

  • JSONGET /{NAMESPACE}/report — for programmatic consumption.
  • Full markdownGET /{NAMESPACE}/report?raw=true — every severity, meant for something like $GITHUB_STEP_SUMMARY. Requires auth.
  • Light markdownGET /{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.

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.

Terminal window
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.txt
POST /terraform-code-change/{NAMESPACE}/ingest ← diff.txt
# same loop as above, unmodified
GET /{NAMESPACE}/resource/next
PUT /resource/{hash}/analysis {...}
GET /{NAMESPACE}/report?raw=true # one merged report

Onboarding a new analyzer is just one more ingest call — the drain loop and the report call don’t need to know how many contributed.

CodeMeaningWhat to do
200okcontinue
201ingestedcontinue
208queue emptyexit the loop, fetch the report
401bad/missing API keycheck X-API-Key
404not founddouble-check the namespace or hash
409duplicate ingestharmless — ingestion is idempotent
413input too largesplit it and ingest in parts
422payload didn’t validateread detail, fix, retry
500server errorcheck server-side logs
501not implementedtry a different analyzer