Infra Planner — API

Describe the workload, get an Azure architecture plan and an IaC starter.

API tokens Open the app

Plan Azure infrastructure from your own scripts

Send a workload description — what you are building, who uses it, where it must run, what it must comply with — and get back one JSON object: the Azure resource topology, the network and identity design, an honest check against all five pillars of the Well-Architected Framework, the risks worth arguing about, and a ready-to-adapt Bicep or Terraform starter. Everything this app does goes through the SkillSafe App API — plain JSON over HTTPS — so you can wire planning into an intake form, a design-review bot or a scaffolding step that drops main.bicep into a new repo. Every code step below is shown in cURL, Python, JavaScript, Go, Java, Ruby, PHP and C#; pick a language once and the whole page follows.

Basics

Base URL: https://api.skillsafe.ai/v1/app-api, app slug infra-planner. Every request sends Authorization: Bearer <token> and JSON bodies with Content-Type: application/json. Responses are wrapped in an envelope: {"data": …} on success, {"error": {"code", "message"}} on failure. The plan itself is produced by the model this app is bound to — currently the gpt-terra alias. Do not hardcode that: the authoritative values are the model and model_alias fields on the /estimate response (step 3), and an alias repoints as new models ship. Estimates are free; runs are metered against your credit balance. There is a single run task — one description in, one plan out, no follow-up calls and no session state to carry.

StatusMeaning
401Missing or expired token — create a new session.
402Not enough credits — top up at skillsafe.ai/account/credits.
403The token isn't allowed to do this (e.g. a guest planning from a very large requirements doc).
404Unknown job or record id.
5xxTransient platform error — retry with backoff.

Browsers enforce CORS for this API, so run these examples from a server, script or terminal — not from another website's frontend.

Step 0 — A tiny client

Every task below is a single HTTP call, so start with a short helper that adds the auth header, sends JSON and unwraps the data envelope. The later steps reuse it.

export API="https://api.skillsafe.ai/v1/app-api"
export TOKEN="YOUR_TOKEN"      # see step 1

# every call looks like:
#   curl -s "$API/..." -H "Authorization: Bearer $TOKEN" [-d '{json}']
# jq is used below to pull fields out of the {"data": ...} envelope
import json, requests

API = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN"  # see step 1 — read it from your shell environment in real code

def api(method, path, body=None, **headers):
    res = requests.request(method, API + path, json=body,
                           headers={"Authorization": f"Bearer {TOKEN}", **headers})
    payload = res.json()
    if not res.ok:
        raise RuntimeError(payload.get("error", {}).get("message", res.reason))
    return payload["data"]
// Node 18+ (built-in fetch)
const API = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN"; // see step 1 — read it from your shell environment in real code

async function api(method, path, body, extraHeaders = {}) {
  const res = await fetch(API + path, {
    method,
    headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json", ...extraHeaders },
    body: body === undefined ? undefined : JSON.stringify(body),
  });
  const json = await res.json();
  if (!res.ok) throw new Error(json.error?.message ?? res.statusText);
  return json.data;
}
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"net/http"
	"os"
)

const API = "https://api.skillsafe.ai/v1/app-api"

var token = os.Getenv("SKILLSAFE_TOKEN") // see step 1

func call(method, path string, body, out any) error {
	var buf bytes.Buffer
	if body != nil {
		json.NewEncoder(&buf).Encode(body)
	}
	req, _ := http.NewRequest(method, API+path, &buf)
	req.Header.Set("Authorization", "Bearer "+token)
	req.Header.Set("Content-Type", "application/json")
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		return err
	}
	defer res.Body.Close()
	var env struct {
		Data  json.RawMessage `json:"data"`
		Error *struct{ Message string `json:"message"` } `json:"error"`
	}
	json.NewDecoder(res.Body).Decode(&env)
	if res.StatusCode >= 400 {
		return fmt.Errorf("api %s %s: %s", method, path, env.Error.Message)
	}
	if out == nil {
		return nil
	}
	return json.Unmarshal(env.Data, out)
}
// Java 17+, no dependencies. Pair with your JSON library (Jackson, Gson…)
// to read fields out of the returned envelope.
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

public class SkillSafe {
    static final String API = "https://api.skillsafe.ai/v1/app-api";
    static final String TOKEN = System.getenv("SKILLSAFE_TOKEN"); // see step 1
    static final HttpClient HTTP = HttpClient.newHttpClient();

    static String api(String method, String path, String jsonBody) throws Exception {
        var req = HttpRequest.newBuilder(URI.create(API + path))
            .header("Authorization", "Bearer " + TOKEN)
            .header("Content-Type", "application/json")
            .method(method, jsonBody == null
                ? HttpRequest.BodyPublishers.noBody()
                : HttpRequest.BodyPublishers.ofString(jsonBody))
            .build();
        var res = HTTP.send(req, HttpResponse.BodyHandlers.ofString());
        if (res.statusCode() >= 400) throw new RuntimeException(res.body());
        return res.body(); // envelope: {"data": …}
    }
}
require "net/http"
require "json"

API = "https://api.skillsafe.ai/v1/app-api"
TOKEN = ENV.fetch("SKILLSAFE_TOKEN") # see step 1

def api(method, path, body = nil)
  uri = URI(API + path)
  req = Net::HTTP.const_get(method.capitalize).new(uri)
  req["Authorization"] = "Bearer #{TOKEN}"
  req["Content-Type"] = "application/json"
  req.body = body.to_json if body
  res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
  payload = JSON.parse(res.body)
  raise (payload.dig("error", "message") || res.message) unless res.is_a?(Net::HTTPSuccess)
  payload["data"]
end
<?php
const API = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = getenv("SKILLSAFE_TOKEN"); // see step 1

function api(string $method, string $path, ?array $body = null): mixed {
    global $TOKEN;
    $ch = curl_init(API . $path);
    curl_setopt_array($ch, [
        CURLOPT_CUSTOMREQUEST  => $method,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER     => [
            "Authorization: Bearer $TOKEN",
            "Content-Type: application/json",
        ],
        CURLOPT_POSTFIELDS     => $body === null ? null : json_encode($body),
    ]);
    $payload = json_decode(curl_exec($ch), true);
    $status  = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    curl_close($ch);
    if ($status >= 400) {
        throw new Exception($payload["error"]["message"] ?? "HTTP $status");
    }
    return $payload["data"];
}
// .NET 8+
using System.Net.Http.Json;
using System.Text.Json;

static class SkillSafe
{
    const string Api = "https://api.skillsafe.ai/v1/app-api";
    static readonly HttpClient Http = new();

    static SkillSafe() =>
        Http.DefaultRequestHeaders.Authorization =
            new("Bearer", Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN")); // see step 1

    public static async Task<JsonElement> ApiAsync(HttpMethod method, string path, object? body = null)
    {
        var req = new HttpRequestMessage(method, Api + path);
        if (body != null) req.Content = JsonContent.Create(body);
        var res = await Http.SendAsync(req);
        var json = await res.Content.ReadFromJsonAsync<JsonElement>();
        if (!res.IsSuccessStatusCode)
            throw new Exception(json.GetProperty("error").GetProperty("message").GetString());
        return json.GetProperty("data");
    }
}

Step 1 — Get a token

POST /guest

A guest token lets you check balances and estimate costs for free. For metered planning runs billed to your own account, use your personal token: open the token page, sign in with SkillSafe, and press Copy shell export — it puts export SKILLSAFE_TOKEN="…" on your clipboard, which every example below reads. Treat the token like a password: it can spend your credits. For fully headless scripts, POST /guest mints a guest token with no browser involved.

curl -s -X POST "$API/guest" \
  -H "Content-Type: application/json" \
  -d '{"slug":"infra-planner"}' | jq -r '.data.token'
token = api("POST", "/guest", {"slug": "infra-planner"})["token"]
const { token } = await api("POST", "/guest", { slug: "infra-planner" });
var guest struct{ Token string `json:"token"` }
err := call("POST", "/guest", map[string]string{"slug": "infra-planner"}, &guest)
String envelope = api("POST", "/guest", """
    {"slug":"infra-planner"}""");
// token is at data.token in the returned JSON
token = api("POST", "/guest", { slug: "infra-planner" })["token"]
$token = api("POST", "/guest", ["slug" => "infra-planner"])["token"];
var guest = await SkillSafe.ApiAsync(HttpMethod.Post, "/guest",
    new { slug = "infra-planner" });
var token = guest.GetProperty("token").GetString();

The app stores this browser's token under the localStorage key skillsafe_app_token:infra-planner, on the app's own origin. The token page reads and manages it for you — you never need to open developer tools.

Step 2 — Check who you are and your balance

GET /me

Returns subject_type ("user" or "guest"), subject_id and your credits balance. Check this before planning from a long requirements document.

curl -s "$API/me" -H "Authorization: Bearer $TOKEN" | jq '.data'
me = api("GET", "/me")
print(me["subject_type"], me["credits"])
const me = await api("GET", "/me");
console.log(me.subject_type, me.credits);
var me struct {
	SubjectType string `json:"subject_type"`
	Credits     int64  `json:"credits"`
}
err := call("GET", "/me", nil, &me)
String envelope = api("GET", "/me", null);
// data.subject_type, data.credits
me = api("GET", "/me")
puts "#{me["subject_type"]}: #{me["credits"]} credits"
$me = api("GET", "/me");
echo "{$me['subject_type']}: {$me['credits']} credits\n";
var me = await SkillSafe.ApiAsync(HttpMethod.Get, "/me");
Console.WriteLine($"{me.GetProperty("subject_type")}: {me.GetProperty("credits")} credits");
What the output is, and what it is not. A plan is a design proposal generated from a description. It is not an architecture review, a security assessment, a compliance audit, or an approval to deploy, and no person reviews it. Costs are never computed: any statement in the returned JSON about spend, budget fit or run rate is the model's estimate from published behaviour at training time, not a quote — price resources against Azure's current list prices for your regions and agreement, which govern. SKU availability, regional availability and subscription quotas are not verified. The iac.code starter is never compiled, validated or deployed. A waf[].status of "aligned" is the model's own self-assessment, not a verified finding, and an empty risks array means no risks were recorded, not that none exist. If you surface this output in your own product, carry this statement with it.

Step 3 — Estimate the cost

POST /estimate

Send exactly the input you would send to /run; the response's hold_credits is the worst-case cost. Nothing is charged and no job is created, so estimating is free — useful when you are feeding in a long requirements document and want a ceiling before spending credits.

Input fieldTypeNotes
workloadstring, requiredThe workload description: what you are building, who uses it, data sensitivity, scale, budget. Prose, bullets or a pasted requirements doc all work. Very long documents may be clipped middle-out, with a [... clipped ...] marker showing where.
iacstringbicep | terraform — which starter file to generate.
environmentstringprod | nonprod. prod gets zone redundancy, private endpoints and WAF-fronted ingress where exposed; nonprod gets cheaper SKUs and relaxed redundancy, and the plan says so.
regionsstring, optionalYour region preference, verbatim, e.g. "westeurope" or "EU only, DR in a paired region". Left out, the planner picks a conservative default and records it as a risk.
compliancestring, optionalCompliance obligations, verbatim, e.g. "GDPR; EU data residency". These must show up concretely in resource choices, the Security pillar note and coverage_check.
notesstring, optionalExtra constraints: budget, existing estate, team skills, deadlines.
prescan_factsobject, optionalWhat a client-side prescan mechanically detected in the workload text: {"services": [], "regions": [], "compliance": [], "signals": []}. Each fact carries an id such as svc:aks or comp:hipaa, and every one comes back in coverage_check. API callers with no prescan of their own may omit the field or send the four empty arrays.
retry_notestring, optionalOnly set by the app's automatic reformat retry when a first reply was not valid JSON. Leave it out.
cat > workload.txt <<'TXT'
Internal invoice-processing API for the finance team (about 40 users). A .NET API on App
Service takes supplier invoices, stores the records in Azure SQL and the PDFs in Blob
Storage. The database and storage must not be reachable from the public internet - private
endpoints only. Data stays in the EU. westeurope, nonprod first; prod follows next quarter.
TXT

jq -n --rawfile w workload.txt \
  '{workload: $w, iac: "bicep", environment: "nonprod", regions: "westeurope",
    compliance: "GDPR; EU data residency",
    notes: "Small platform team, no Kubernetes experience; budget under 500 EUR/month.",
    prescan_facts: {services: [], regions: [], compliance: [], signals: []}}' > input.json

curl -s -X POST "$API/estimate" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d @input.json | jq '.data.hold_credits'
WORKLOAD = """Internal invoice-processing API for the finance team (about 40 users). A .NET API on App
Service takes supplier invoices, stores the records in Azure SQL and the PDFs in Blob
Storage. The database and storage must not be reachable from the public internet - private
endpoints only. Data stays in the EU. westeurope, nonprod first; prod follows next quarter."""

payload = {
    "workload": WORKLOAD,
    "iac": "bicep",
    "environment": "nonprod",
    "regions": "westeurope",
    "compliance": "GDPR; EU data residency",
    "notes": "Small platform team, no Kubernetes experience; budget under 500 EUR/month.",
    "prescan_facts": {"services": [], "regions": [], "compliance": [], "signals": []},
}

est = api("POST", "/estimate", payload)
print("worst case:", est.get("hold_credits", est.get("credits")), "credits")
const workload = `Internal invoice-processing API for the finance team (about 40 users). A .NET API on App
Service takes supplier invoices, stores the records in Azure SQL and the PDFs in Blob
Storage. The database and storage must not be reachable from the public internet - private
endpoints only. Data stays in the EU. westeurope, nonprod first; prod follows next quarter.`;

const payload = {
  workload,
  iac: "bicep",
  environment: "nonprod",
  regions: "westeurope",
  compliance: "GDPR; EU data residency",
  notes: "Small platform team, no Kubernetes experience; budget under 500 EUR/month.",
  prescan_facts: { services: [], regions: [], compliance: [], signals: [] },
};

const est = await api("POST", "/estimate", payload);
console.log("worst case:", est.hold_credits ?? est.credits, "credits");
const workload = `Internal invoice-processing API for the finance team (about 40 users). A .NET API on App
Service takes supplier invoices, stores the records in Azure SQL and the PDFs in Blob
Storage. The database and storage must not be reachable from the public internet - private
endpoints only. Data stays in the EU. westeurope, nonprod first; prod follows next quarter.`

payload := map[string]any{
	"workload":    workload,
	"iac":         "bicep",
	"environment": "nonprod",
	"regions":     "westeurope",
	"compliance":  "GDPR; EU data residency",
	"notes":       "Small platform team, no Kubernetes experience; budget under 500 EUR/month.",
	"prescan_facts": map[string]any{
		"services": []any{}, "regions": []any{}, "compliance": []any{}, "signals": []any{},
	},
}

var est struct{ HoldCredits int64 `json:"hold_credits"` }
err := call("POST", "/estimate", payload, &est)
String workload = """
    Internal invoice-processing API for the finance team (about 40 users). A .NET API on App
    Service takes supplier invoices, stores the records in Azure SQL and the PDFs in Blob
    Storage. The database and storage must not be reachable from the public internet - private
    endpoints only. Data stays in the EU. westeurope, nonprod first; prod follows next quarter.""";

String jsonPayload = """
    {"workload": %s, "iac": "bicep", "environment": "nonprod",
     "regions": "westeurope", "compliance": "GDPR; EU data residency",
     "notes": "Small platform team, no Kubernetes experience; budget under 500 EUR/month.",
     "prescan_facts": {"services": [], "regions": [], "compliance": [], "signals": []}}
    """.formatted(toJsonString(workload));

String envelope = api("POST", "/estimate", jsonPayload);
// worst-case cost is at data.hold_credits
WORKLOAD = <<~TXT
  Internal invoice-processing API for the finance team (about 40 users). A .NET API on App
  Service takes supplier invoices, stores the records in Azure SQL and the PDFs in Blob
  Storage. The database and storage must not be reachable from the public internet - private
  endpoints only. Data stays in the EU. westeurope, nonprod first; prod follows next quarter.
TXT

payload = { workload: WORKLOAD, iac: "bicep", environment: "nonprod",
            regions: "westeurope", compliance: "GDPR; EU data residency",
            notes: "Small platform team, no Kubernetes experience; budget under 500 EUR/month.",
            prescan_facts: { services: [], regions: [], compliance: [], signals: [] } }

est = api("POST", "/estimate", payload)
puts "worst case: #{est["hold_credits"] || est["credits"]} credits"
$workload = <<<'TXT'
Internal invoice-processing API for the finance team (about 40 users). A .NET API on App
Service takes supplier invoices, stores the records in Azure SQL and the PDFs in Blob
Storage. The database and storage must not be reachable from the public internet - private
endpoints only. Data stays in the EU. westeurope, nonprod first; prod follows next quarter.
TXT;

$payload = [
    "workload"      => $workload,
    "iac"           => "bicep",
    "environment"   => "nonprod",
    "regions"       => "westeurope",
    "compliance"    => "GDPR; EU data residency",
    "notes"         => "Small platform team, no Kubernetes experience; budget under 500 EUR/month.",
    "prescan_facts" => ["services" => [], "regions" => [], "compliance" => [], "signals" => []],
];

$est = api("POST", "/estimate", $payload);
echo "worst case: " . ($est["hold_credits"] ?? $est["credits"]) . " credits\n";
var workload = """
    Internal invoice-processing API for the finance team (about 40 users). A .NET API on App
    Service takes supplier invoices, stores the records in Azure SQL and the PDFs in Blob
    Storage. The database and storage must not be reachable from the public internet - private
    endpoints only. Data stays in the EU. westeurope, nonprod first; prod follows next quarter.
    """;

var payload = new {
    workload,
    iac = "bicep",
    environment = "nonprod",
    regions = "westeurope",
    compliance = "GDPR; EU data residency",
    notes = "Small platform team, no Kubernetes experience; budget under 500 EUR/month.",
    prescan_facts = new {
        services = Array.Empty<object>(), regions = Array.Empty<object>(),
        compliance = Array.Empty<object>(), signals = Array.Empty<object>(),
    },
};

var est = await SkillSafe.ApiAsync(HttpMethod.Post, "/estimate", payload);
Console.WriteLine($"worst case: {est.GetProperty("hold_credits")} credits");

Step 4 — Run the plan and wait for the result

POST /run
GET /jobs/{job_id}

/run takes the same input as /estimate, places a credit hold and returns a job_id. Poll /jobs/{job_id} every 1–2 seconds until status is succeeded or failed (a run typically takes 30–90 s, since the IaC starter is written out in full). Always send an Idempotency-Key header so a network retry can't start a second, double-charged run. The plan is in output — usually nested as output.output, and as a JSON string, so parse defensively. The samples below print the plan name and verdict, the resource table and the five WAF pillar statuses, then write iac.code to main.bicep.

JOB_ID=$(curl -s -X POST "$API/run" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: plan-$(date +%s)" \
  -d @input.json | jq -r '.data.job_id')

while :; do
  JOB=$(curl -s "$API/jobs/$JOB_ID" -H "Authorization: Bearer $TOKEN")
  STATUS=$(echo "$JOB" | jq -r '.data.status')
  [ "$STATUS" = "succeeded" ] || [ "$STATUS" = "failed" ] && break
  sleep 2
done

# unwrap the plan once, then read it
echo "$JOB" | jq -r '.data.output.output' > plan.json

jq -r '
  "\(.plan_name): \(.verdict)",
  "",
  "RESOURCES (\(.network.topology))",
  (.resources[] | "  \(.name)  |  \(.type)  |  \(.sku)  |  \(.region)  |  \(.purpose)"),
  "",
  "WAF PILLARS",
  (.waf[] | "  [\(.status)] \(.pillar) - \(.note)"),
  "",
  "RISKS",
  (.risks[] | "  (\(.severity)) \(.title)")' plan.json

# and drop the starter straight into the repo
jq -r '.iac.code' plan.json > "$(jq -r '.iac.filename' plan.json)"   # main.bicep
import time

job_id = api("POST", "/run", payload,
             **{"Idempotency-Key": "plan-001"})["job_id"]

while True:
    job = api("GET", f"/jobs/{job_id}")
    if job["status"] in ("succeeded", "failed"):
        break
    time.sleep(1.5)

if job["status"] == "failed":
    raise RuntimeError(job.get("error", "run failed"))

raw = job["output"]
if isinstance(raw, dict) and "output" in raw:
    raw = raw["output"]
plan = json.loads(raw) if isinstance(raw, str) else raw

print(f'{plan["plan_name"]}: {plan["verdict"]}')
print(f'network: {plan["network"]["topology"]} - {len(plan["resources"])} resources')
for r in plan["resources"]:
    print(f'  {r["name"]:<26} {r["type"]:<24} {r["sku"]:<20} {r["region"]:<14} {r["purpose"]}')
for pillar in plan["waf"]:
    print(f'  [{pillar["status"]:>7}] {pillar["pillar"]}: {pillar["note"]}')
for risk in plan["risks"]:
    print(f'  ({risk["severity"]}) {risk["title"]}')

with open(plan["iac"]["filename"], "w", encoding="utf-8") as fh:   # main.bicep
    fh.write(plan["iac"]["code"])
import { writeFileSync } from "node:fs";

const { job_id } = await api("POST", "/run", payload,
  { "Idempotency-Key": crypto.randomUUID() });

let job;
do {
  await new Promise((r) => setTimeout(r, 1500));
  job = await api("GET", `/jobs/${job_id}`);
} while (job.status !== "succeeded" && job.status !== "failed");

if (job.status === "failed") throw new Error(job.error ?? "run failed");

const raw = job.output?.output ?? job.output;
const plan = typeof raw === "string" ? JSON.parse(raw) : raw;

console.log(`${plan.plan_name}: ${plan.verdict}`);
console.log(`network: ${plan.network.topology} - ${plan.resources.length} resources`);
for (const r of plan.resources) {
  console.log(`  ${r.name} | ${r.type} | ${r.sku} | ${r.region} | ${r.purpose}`);
}
for (const pillar of plan.waf) {
  console.log(`  [${pillar.status}] ${pillar.pillar}: ${pillar.note}`);
}
for (const risk of plan.risks) console.log(`  (${risk.severity}) ${risk.title}`);

writeFileSync(plan.iac.filename, plan.iac.code);   // main.bicep
var started struct{ JobID string `json:"job_id"` }
if err := call("POST", "/run", payload, &started); err != nil {
	log.Fatal(err)
}

var job struct {
	Status string          `json:"status"`
	Error  string          `json:"error"`
	Output json.RawMessage `json:"output"`
}
for {
	if err := call("GET", "/jobs/"+started.JobID, nil, &job); err != nil {
		log.Fatal(err)
	}
	if job.Status == "succeeded" || job.Status == "failed" {
		break
	}
	time.Sleep(1500 * time.Millisecond)
}

// job.Output is {"output": "<json string>"} — unwrap, unquote, then unmarshal:
type Plan struct {
	PlanName  string `json:"plan_name"`
	Verdict   string `json:"verdict"`
	Resources []struct {
		Name, Type, SKU, Region, Purpose string
	} `json:"resources"`
	WAF []struct {
		Pillar, Status, Note string
	} `json:"waf"`
	IaC struct {
		Flavor, Filename, Code string
	} `json:"iac"`
}
var wrapper struct{ Output string `json:"output"` }
json.Unmarshal(job.Output, &wrapper)
var plan Plan
json.Unmarshal([]byte(wrapper.Output), &plan)

fmt.Printf("%s: %s\n", plan.PlanName, plan.Verdict)
for _, r := range plan.Resources {
	fmt.Printf("  %s | %s | %s | %s | %s\n", r.Name, r.Type, r.SKU, r.Region, r.Purpose)
}
for _, p := range plan.WAF {
	fmt.Printf("  [%s] %s: %s\n", p.Status, p.Pillar, p.Note)
}
os.WriteFile(plan.IaC.Filename, []byte(plan.IaC.Code), 0o644) // main.bicep
String envelope = api("POST", "/run", jsonPayload);
String jobId = /* data.job_id via your JSON library */;

while (true) {
    String job = api("GET", "/jobs/" + jobId, null);
    String status = /* data.status */;
    if (status.equals("succeeded") || status.equals("failed")) break;
    Thread.sleep(1500);
}
// The plan is at data.output.output as a JSON string — parse it again, then read
// plan_name, verdict, architecture, resources[] (name/type/sku/region/purpose),
// network{topology, description, segments[]}, identity[], waf[] (five pillars with
// pillar/status/note), risks[], coverage_check[], next_steps[] and summary.
// Finally write the starter to disk:
//   Files.writeString(Path.of(iacFilename), iacCode);   // main.bicep
started = api("POST", "/run", payload)

job = nil
loop do
  job = api("GET", "/jobs/#{started["job_id"]}")
  break if %w[succeeded failed].include?(job["status"])
  sleep 1.5
end
raise (job["error"] || "run failed") if job["status"] == "failed"

raw = job["output"].is_a?(Hash) ? job["output"].fetch("output", job["output"]) : job["output"]
plan = raw.is_a?(String) ? JSON.parse(raw) : raw

puts "#{plan["plan_name"]}: #{plan["verdict"]}"
puts "network: #{plan["network"]["topology"]} - #{plan["resources"].size} resources"
plan["resources"].each do |r|
  puts "  #{r["name"]} | #{r["type"]} | #{r["sku"]} | #{r["region"]} | #{r["purpose"]}"
end
plan["waf"].each { |p| puts "  [#{p["status"]}] #{p["pillar"]}: #{p["note"]}" }
plan["risks"].each { |r| puts "  (#{r["severity"]}) #{r["title"]}" }

File.write(plan["iac"]["filename"], plan["iac"]["code"])   # main.bicep
$started = api("POST", "/run", $payload);

do {
    sleep(2);
    $job = api("GET", "/jobs/" . $started["job_id"]);
} while (!in_array($job["status"], ["succeeded", "failed"]));

if ($job["status"] === "failed") {
    throw new Exception($job["error"] ?? "run failed");
}

$raw = is_array($job["output"]) ? ($job["output"]["output"] ?? $job["output"]) : $job["output"];
$plan = is_string($raw) ? json_decode($raw, true) : $raw;

echo "{$plan['plan_name']}: {$plan['verdict']}\n";
echo "network: {$plan['network']['topology']} - " . count($plan["resources"]) . " resources\n";
foreach ($plan["resources"] as $r) {
    echo "  {$r['name']} | {$r['type']} | {$r['sku']} | {$r['region']} | {$r['purpose']}\n";
}
foreach ($plan["waf"] as $p) {
    echo "  [{$p['status']}] {$p['pillar']}: {$p['note']}\n";
}
foreach ($plan["risks"] as $r) {
    echo "  ({$r['severity']}) {$r['title']}\n";
}

file_put_contents($plan["iac"]["filename"], $plan["iac"]["code"]);   // main.bicep
var started = await SkillSafe.ApiAsync(HttpMethod.Post, "/run", payload);
var jobId = started.GetProperty("job_id").GetString();

JsonElement job;
while (true)
{
    job = await SkillSafe.ApiAsync(HttpMethod.Get, $"/jobs/{jobId}");
    var status = job.GetProperty("status").GetString();
    if (status is "succeeded" or "failed") break;
    await Task.Delay(1500);
}

var rawText = job.GetProperty("output").GetProperty("output").GetString();
using var doc = JsonDocument.Parse(rawText!);
var plan = doc.RootElement;

Console.WriteLine($"{plan.GetProperty("plan_name")}: {plan.GetProperty("verdict")}");
foreach (var r in plan.GetProperty("resources").EnumerateArray())
{
    Console.WriteLine($"  {r.GetProperty("name")} | {r.GetProperty("type")} | " +
                      $"{r.GetProperty("sku")} | {r.GetProperty("region")}");
}
foreach (var p in plan.GetProperty("waf").EnumerateArray())
{
    Console.WriteLine($"  [{p.GetProperty("status")}] {p.GetProperty("pillar")}");
}

var iac = plan.GetProperty("iac");
await File.WriteAllTextAsync(iac.GetProperty("filename").GetString()!,   // main.bicep
                             iac.GetProperty("code").GetString()!);

The model is asked for one JSON object and nothing else, but a stray code fence or preamble is always possible. Strip a leading ```json fence, take the text between the first { and the last }, and only then parse — that is what the app does before it falls back to a retry_note reformat run.

The plan object — output schema

One JSON object, always the same shape. Every array is present (empty only if genuinely nothing applies); resources always has at least one entry, waf always has exactly the five pillars, and iac.code is never empty. If the description was too thin to plan responsibly, you still get this object: a minimal defensible core, a verdict that says the description is thin, and the missing information written up as high-severity risks and next_steps.

FieldTypeMeaning
plan_namestringA short name for the plan, taken from the workload's own naming.
verdictstringOne or two sentences: the architecture posture and the single biggest open decision.
architecturestringOne or two paragraphs on the overall design and why it fits this workload.
resourcesarray{name, type, sku, region, purpose}name is the identifier used in the IaC starter, type a human label ("AKS cluster"), sku the tier or size, region a real Azure region, and purpose the stated need it serves. Nothing is in the topology without a named need.
networkobject{topology, description, segments[]}. topology is hub-spoke, single-vnet, multi-region or whatever fits; description covers ingress, east-west and egress traffic plus private endpoints; each segment is {name, cidr, purpose}.
identitystring[]Bullets on identity, RBAC and managed-identity decisions, one per line.
wafarray of 5{pillar, status, note} — the five Well-Architected pillars listed below, each exactly once. status is aligned (the plan handles it), risk (handled with caveats) or gap (the description or budget prevents handling it). At least one note references a concrete resource decision.
risksarray{severity, title, detail}; severity is high | medium | low. This is also where every assumption lands — anything a real architect had to decide because the description was silent on it.
coverage_checkarray{id, addressed, note} — one entry per prescan_facts item you sent (svc:aks, comp:hipaa, …), saying where the plan covers it or why it was set aside. Nothing you flagged is silently dropped.
iacobject{flavor, filename, code}flavor matches the iac you asked for, filename is main.bicep or main.tf, and code is the full starter file: parameters up top, resources with real Azure types, outputs at the end, extension points marked with comments. Names, SKUs and regions match the resources table.
next_stepsstring[]Ordered and concrete: validate quotas, review the CIDR plan with networking, run az deployment what-if, and so on.
summarystring3–5 sentences a platform lead could paste into a design review.

The five WAF pillars, in order, spelled exactly like this:

pillarWhat its note covers
ReliabilityRedundancy, failure modes, RTO/RPO, DR posture.
SecurityNetwork exposure, identity, secrets, encryption, and any stated compliance obligation.
Cost OptimizationSKU right-sizing for the environment, and what the plan deliberately does not buy.
Operational ExcellenceDeployment, observability, and what the team can actually run.
Performance EfficiencyScaling model, data path, and where the plan expects to be stressed.

The IaC starter is a starter, not a landing zone: it covers the core topology (network, identity wiring, the main workload resources) and marks extension points with comments. It is written to be syntactically plausible and self-consistent with the resource table, but it is AI-generated — run az bicep build or terraform validate and a what-if before anyone deploys it.

Step 5 — Stream the plan as it is written

POST /run-stream

/run-stream takes exactly the same body as /run but answers with server-sent events, so you can show progress instead of a spinner — useful here because the IaC starter makes for a long reply. This app's own progress panel is this endpoint. Events are separated by a blank line; each has an event: line and a data: line carrying JSON.

EventPayloadMeaning
job{job_id, status}Sent once, when the job is accepted — show "starting".
delta{text}A chunk of the reply, in order. Append it; the accumulated length is your only progress signal (the total is not known in advance).
done{job_id, status, charged_credits, output}The final, authoritative result — read the plan from output.output rather than trusting concatenated deltas, and the settled price from charged_credits.
error{code, message}Replaces done when the run fails.
# -N disables buffering so events print as they arrive
curl -N -s -X POST "$API/run-stream" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: plan-$(date +%s)" \
  -d @input.json

# event: job
# data: {"job_id":"job_...","status":"running"}
#
# event: delta
# data: {"text":"{\"plan_name\":\"Invoice"}
# ...
# event: done
# data: {"job_id":"job_...","status":"succeeded","charged_credits":734,"output":{"output":"{...}"}}
import json, requests

result = None
with requests.post(
    API + "/run-stream",
    headers={"Authorization": f"Bearer {TOKEN}",
             "Idempotency-Key": "plan-001"},
    json=payload,
    stream=True,
) as r:
    r.raise_for_status()
    event = None
    for line in r.iter_lines(decode_unicode=True):
        if not line:
            continue
        if line.startswith("event:"):
            event = line[len("event:"):].strip()
        elif line.startswith("data:"):
            data = json.loads(line[len("data:"):].strip())
            if event == "delta":
                print(".", end="", flush=True)          # live progress
            elif event == "done":
                result = data
            elif event == "error":
                raise RuntimeError(data.get("message", "run failed"))

plan = json.loads(result["output"]["output"])           # authoritative
print("charged:", result["charged_credits"], "-", plan["plan_name"])
for pillar in plan["waf"]:
    print(f'  [{pillar["status"]}] {pillar["pillar"]}')
open(plan["iac"]["filename"], "w", encoding="utf-8").write(plan["iac"]["code"])
const res = await fetch(API + "/run-stream", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${TOKEN}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify(payload),
});

const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "", done = null;

for (;;) {
  const chunk = await reader.read();
  if (chunk.done) break;
  buf += decoder.decode(chunk.value, { stream: true });
  const frames = buf.split("\n\n");
  buf = frames.pop();
  for (const frame of frames) {
    const name = /^event:\s*(.+)$/m.exec(frame)?.[1];
    const body = /^data:\s*(.+)$/m.exec(frame)?.[1];
    if (!name || !body) continue;
    const data = JSON.parse(body);
    if (name === "delta") process.stdout.write(".");   // live progress
    if (name === "done") done = data;
    if (name === "error") throw new Error(data.message ?? "run failed");
  }
}

const plan = JSON.parse(done.output.output);
console.log(`\n${done.charged_credits} credits - ${plan.plan_name}`);
for (const pillar of plan.waf) console.log(`  [${pillar.status}] ${pillar.pillar}`);
writeFileSync(plan.iac.filename, plan.iac.code);       // main.bicep
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", API+"/run-stream", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "plan-001")

res, err := http.DefaultClient.Do(req)
if err != nil {
	log.Fatal(err)
}
defer res.Body.Close()

var event string
var final map[string]any
sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
for sc.Scan() {
	line := sc.Text()
	switch {
	case strings.HasPrefix(line, "event:"):
		event = strings.TrimSpace(strings.TrimPrefix(line, "event:"))
	case strings.HasPrefix(line, "data:"):
		var data map[string]any
		json.Unmarshal([]byte(strings.TrimPrefix(line, "data:")), &data)
		switch event {
		case "delta":
			fmt.Print(".") // live progress
		case "done":
			final = data
		case "error":
			log.Fatal(data["message"])
		}
	}
}
// final["output"].(map[string]any)["output"].(string) is the plan JSON —
// unmarshal it into the Plan struct from step 4, then write plan.IaC.Code to disk.
// Java 17+ — read the stream line by line instead of buffering the body.
var req = HttpRequest.newBuilder(URI.create(API + "/run-stream"))
    .header("Authorization", "Bearer " + TOKEN)
    .header("Content-Type", "application/json")
    .header("Idempotency-Key", "plan-001")
    .POST(HttpRequest.BodyPublishers.ofString(jsonPayload))
    .build();

var res = HTTP.send(req, HttpResponse.BodyHandlers.ofLines());
String event = null, done = null;
for (String line : (Iterable<String>) res.body()::iterator) {
    if (line.startsWith("event:")) {
        event = line.substring(6).trim();
    } else if (line.startsWith("data:")) {
        String data = line.substring(5).trim();
        if ("delta".equals(event)) System.out.print(".");   // live progress
        else if ("done".equals(event)) done = data;
        else if ("error".equals(event)) throw new RuntimeException(data);
    }
}
// parse `done`, then parse data.output.output again — it is a JSON string holding
// plan_name, resources[], waf[], iac{flavor, filename, code} and the rest.
require "net/http"
require "json"

uri = URI(API + "/run-stream")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = "plan-001"
req.body = payload.to_json

event = nil
done = nil
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
  http.request(req) do |res|
    res.read_body do |chunk|
      chunk.each_line do |line|
        line = line.strip
        if line.start_with?("event:")
          event = line.delete_prefix("event:").strip
        elsif line.start_with?("data:")
          data = JSON.parse(line.delete_prefix("data:").strip)
          case event
          when "delta" then print "."           # live progress
          when "done"  then done = data
          when "error" then raise (data["message"] || "run failed")
          end
        end
      end
    end
  end
end

plan = JSON.parse(done["output"]["output"])
puts "\n#{done["charged_credits"]} credits - #{plan["plan_name"]}"
plan["waf"].each { |p| puts "  [#{p["status"]}] #{p["pillar"]}" }
File.write(plan["iac"]["filename"], plan["iac"]["code"])   # main.bicep
$event = null;
$done  = null;

$ch = curl_init(API . "/run-stream");
curl_setopt_array($ch, [
    CURLOPT_POST       => true,
    CURLOPT_HTTPHEADER => [
        "Authorization: Bearer $TOKEN",
        "Content-Type: application/json",
        "Idempotency-Key: plan-001",
    ],
    CURLOPT_POSTFIELDS => json_encode($payload),
    CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$event, &$done) {
        foreach (explode("\n", $chunk) as $line) {
            $line = trim($line);
            if (str_starts_with($line, "event:")) {
                $event = trim(substr($line, 6));
            } elseif (str_starts_with($line, "data:")) {
                $data = json_decode(trim(substr($line, 5)), true);
                if ($event === "delta") { echo "."; }        // live progress
                elseif ($event === "done") { $done = $data; }
                elseif ($event === "error") { throw new Exception($data["message"] ?? "run failed"); }
            }
        }
        return strlen($chunk);
    },
]);
curl_exec($ch);
curl_close($ch);

$plan = json_decode($done["output"]["output"], true);
echo "\n{$done['charged_credits']} credits - {$plan['plan_name']}\n";
foreach ($plan["waf"] as $p) { echo "  [{$p['status']}] {$p['pillar']}\n"; }
file_put_contents($plan["iac"]["filename"], $plan["iac"]["code"]);   // main.bicep
var req = new HttpRequestMessage(HttpMethod.Post, Api + "/run-stream") {
    Content = JsonContent.Create(payload),
};
req.Headers.Add("Idempotency-Key", "plan-001");

using var res = await Http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());

string? evt = null, done = null;
while (await reader.ReadLineAsync() is { } line)
{
    if (line.StartsWith("event:")) evt = line[6..].Trim();
    else if (line.StartsWith("data:"))
    {
        var data = line[5..].Trim();
        if (evt == "delta") Console.Write(".");            // live progress
        else if (evt == "done") done = data;
        else if (evt == "error") throw new Exception(data);
    }
}

using var final = JsonDocument.Parse(done!);
var text = final.RootElement.GetProperty("output").GetProperty("output").GetString();
using var planDoc = JsonDocument.Parse(text!);
var plan = planDoc.RootElement;
Console.WriteLine(plan.GetProperty("plan_name"));
foreach (var p in plan.GetProperty("waf").EnumerateArray())
    Console.WriteLine($"  [{p.GetProperty("status")}] {p.GetProperty("pillar")}");
var iac = plan.GetProperty("iac");
await File.WriteAllTextAsync(iac.GetProperty("filename").GetString()!,   // main.bicep
                             iac.GetProperty("code").GetString()!);

In a browser, the native EventSource only speaks GET, and this endpoint is a POST — read the fetch response body incrementally, as the JavaScript sample above does. On an idempotent replay the server may answer with a plain JSON envelope instead of an event stream; check the Content-Type before you start parsing frames.