Skip to content

Server-Side Analysis

The LLM Agent Workflow has the client drive the ingest → loop → submit cycle, calling the LLM itself between each step. POST /{analyzer}/{namespace}/analyze moves that entire cycle onto the server: hand it a raw input, an LLM backend, and a model, and it ingests, analyses every unit, and hands back a report — one call in, a report out. The GitHub App integration uses this same path internally.

Use this when you don’t need fine-grained control over the LLM call itself (custom prompting, a non-tool-calling client, an internal script) and just want the report.


POST /{analyzer}/{namespace}/analyze
body: {llm_backend, model, api_key?, raw_input, rule_type?}
small input (<= server.sync_analysis_max_units units)
-> 200 {job_id, report_markdown, resource_count, quota_exceeded}
-> recorded as a mode:"sync" job; GET /jobs/{job_id} resolves it like any other
larger input
-> 202 {job_id, status: "queued"}
-> poll GET /jobs/{job_id} until status is "done" or "failed"
-> then GET /{namespace}/report as usual

Internally this reuses exactly the same ingest()/get_rules() calls the client-led path uses, so a namespace partially analysed via PUT /resource/{hash}/analysis and then finished via /analyze (or vice versa) works correctly — already-analysed units are skipped either way.


Every call needs an LLM provider API key. Two options, resolved in this order:

  1. Inline — pass api_key in the request body. Never stored.
  2. Stored — omit api_key; the server looks up a credential saved for the org via POST /orgs/{org_id}/llm-credentials.
Terminal window
# Store a credential once (org-admin)
curl -X POST http://localhost:8000/orgs/default/llm-credentials \
-H "X-API-Key: <admin-key>" -H "Content-Type: application/json" \
-d '{"provider": "anthropic", "api_key": "sk-ant-..."}'

Stored credentials are encrypted at rest (Fernet) and never returned by the API once saved — only GET /orgs/{org_id}/llm-credentials lists which providers are configured, never the key itself.


Terminal window
jq -n \
--arg raw_input "$(cat terraform_plan.txt)" \
'{llm_backend: "anthropic", model: "claude-sonnet-5", raw_input: $raw_input, rule_type: "aws"}' \
| curl -s -X POST http://localhost:8000/terraform-plan/prod-deploy-42/analyze \
-H "X-API-Key: <your-key>" -H "Content-Type: application/json" -d @-
FieldRequiredDescription
llm_backendYesanthropic, mistral, or openai
modelYesProvider-specific model name, e.g. claude-sonnet-5
api_keyNoInline credential — omit to use a stored one
raw_inputYesSame raw text you’d send to .../ingest
rule_typeNoRule sub-category filter, e.g. aws

Step 3a — Small input: synchronous response

Section titled “Step 3a — Small input: synchronous response”

At or below server.sync_analysis_max_units (default 5) ingested units, the call blocks until analysis finishes and returns the report directly:

{
"job_id": "b7e2c1a4-...",
"report_markdown": "# 🤖 Analysis Report\n...",
"resource_count": 3,
"quota_exceeded": false
}

The run is recorded as a job (mode: "sync") exactly like a background one — same document, same GET /jobs/{job_id}, same Jobs page — so a small analysis isn’t a run you can only observe by holding onto its HTTP response. That matters most when the response is an error: the 4xx/502 bodies carry the same job_id, and the job holds the provider’s own error message and can be resumed with POST /jobs/{job_id}/retry. The one thing an inline job can’t do is be cancelled — it finishes or fails with the request that started it, so there is nothing left to stop.

Above the threshold, the same call returns immediately with a job to poll:

{"job_id": "b7e2c1a4-...", "status": "queued"}
Terminal window
curl -s http://localhost:8000/jobs/b7e2c1a4-... -H "X-API-Key: <your-key>"
{
"job_id": "b7e2c1a4-...",
"status": "done",
"mode": "async",
"trigger": "api",
"resource_count": 14,
"quota_exceeded": false,
"error": null
}

status transitions queued → running → done | failed. Once done, fetch the report the normal way:

Terminal window
curl "http://localhost:8000/prod-deploy-42/report?raw=true"

If the org has a monthly LLM spend cap set (PATCH /orgs/{org_id}/quota), it’s enforced two ways:

  • Pre-flight — if the cap is already met/exceeded, the call is rejected outright with 402, before any LLM call is made.
  • Mid-run — a single large /analyze call that would blow through the cap partway through stops early instead of failing outright: the response/job comes back with quota_exceeded: true and a partial report covering whatever was analysed before the budget ran out.
Terminal window
curl -X PATCH http://localhost:8000/orgs/default/quota \
-H "X-API-Key: <admin-key>" -H "Content-Type: application/json" \
-d '{"monthly_budget_usd": 50.0}'
curl http://localhost:8000/orgs/default/usage -H "X-API-Key: <admin-key>"
# {"period": "2026-07", "total_cost_usd": 12.34, "budget_usd": 50.0, "remaining_usd": 37.66, "event_count": 84}

See Billing / quota endpoints.


StatusMeaning
200Analysis complete (sync) — full report
202Job queued (async) — poll GET /jobs/{job_id}
400No api_key given and none stored for this org/provider
402Org’s monthly LLM budget already exceeded — checked before any work starts
404Unknown analyzer, or analyzer disabled for this org
413Input exceeds server.max_input_size_mb
501Analyzer doesn’t support ingestion (e.g. a placeholder analyzer)
502LLM provider returned output that couldn’t be parsed, or stopped at analysis.llm_max_output_tokens before finishing. Sync path only — an async failure lands on the job doc instead, which is now also where the sync failure is recorded. Body carries job_id

Server-side /analyzeClient-led loop
Calls needed1 (+ optional job poll)1 per unit, plus ingest/report
Who calls the LLMThe serverYour client/agent
Prompt controlFixed system prompt (rules + generic instructions)Full control
Best forScripts, internal tooling, the GitHub AppCustom agents, MCP clients, non-standard LLM providers

Both paths write to the exact same data model and are fully interoperable within one namespace — see Architecture — Server-side analysis.