Threat Lens — API

Paste the system description, get a structured AppSec threat model.

API tokens Open the app

Threat-model your systems from your own scripts

Send a description of a system — an architecture doc, a design proposal, a README architecture section, a service run-book — and get back one JSON object: a posture call, the components and trust boundaries, the assets worth protecting, an explicit attacker model, entry points, multi-step abuse paths, a prioritized threat table and the areas to review first. Everything this app does goes through the SkillSafe App API — plain JSON over HTTPS — so you can hang a threat model off a design-doc pipeline, a docs/architecture.md change, or a pre-launch review checklist. 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 threat-lens. 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 threat model itself is produced by the gpt-terra model. Estimates are free; runs are metered against your credit balance. There is a single run task — one description in, one threat model 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 modelling a very large description).
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 threat-model 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":"threat-lens"}' | jq -r '.data.token'
token = api("POST", "/guest", {"slug": "threat-lens"})["token"]
const { token } = await api("POST", "/guest", { slug: "threat-lens" });
var guest struct{ Token string `json:"token"` }
err := call("POST", "/guest", map[string]string{"slug": "threat-lens"}, &guest)
String envelope = api("POST", "/guest", """
    {"slug":"threat-lens"}""");
// token is at data.token in the returned JSON
token = api("POST", "/guest", { slug: "threat-lens" })["token"]
$token = api("POST", "/guest", ["slug" => "threat-lens"])["token"];
var guest = await SkillSafe.ApiAsync(HttpMethod.Post, "/guest",
    new { slug = "threat-lens" });
var token = guest.GetProperty("token").GetString();

The app stores this browser's token under the localStorage key skillsafe_app_token:threat-lens, 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 modelling a long description.

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");

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 whole design doc and want a ceiling before spending credits.

Input fieldTypeNotes
descriptionstring, requiredThe system description: prose, bullets, an architecture-doc excerpt, a run-book, or a mix with code and config fragments. This is the model's only evidence — nothing is inferred from a repository. Very long descriptions may be clipped middle-out, with a [... clipped ...] marker showing where.
deploymentstringinternet (internet-facing service) | internal (internal-only service) | desktop-cli | library | mobile-backend | other — calibrates the attacker model, so an offline CLI is not scored like a public API.
data_sensitivitystringpublic | internal | pii | regulated — drives asset objectives and how hard a confidentiality loss lands in severity.
authstring, optionalAuthentication and authorization expectations as you state them — who may call what, and how identity is established. Left empty, the model records the gap as an assumption instead.
notesstring, optionalExtra context, including anything explicitly out of scope (a managed dependency, a payment processor you do not own).
prescan_factsobject, optionalWhat a client-side prescan mechanically matched in the description: {"surfaces": [], "assets": [], "exposure": []}. Each entry is {id, label} — attack-surface signals (surface:upload, surface:webhook), sensitive-asset mentions (asset:credentials, asset:payment) and exposure hints (exposure:internet, exposure:multi-tenant). Every id you send comes back in coverage_check. The web UI fills this from its own scan; API callers may omit the field or send the three empty arrays.
retry_notestring, optionalOnly set by the app's automatic reformat retry when a first reply was not valid JSON. Leave it out.
jq -n \
  '{description: "Internet-facing REST API; Node/Express; Postgres; S3 file uploads; JWT auth",
    deployment: "internet",
    data_sensitivity: "pii",
    auth: "JWT bearer tokens; tenant scoping expected on every query",
    notes: "Billing runs through a third party and is out of scope.",
    prescan_facts: {surfaces: [], assets: [], exposure: []}}' > input.json

curl -s -X POST "$API/estimate" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d @input.json | jq '.data.hold_credits'
DESCRIPTION = (
    "Internet-facing REST API; Node/Express; Postgres; S3 file uploads; JWT auth"
)

payload = {
    "description": DESCRIPTION,
    "deployment": "internet",
    "data_sensitivity": "pii",
    "auth": "JWT bearer tokens; tenant scoping expected on every query",
    "notes": "Billing runs through a third party and is out of scope.",
    "prescan_facts": {"surfaces": [], "assets": [], "exposure": []},
}

est = api("POST", "/estimate", payload)
print("worst case:", est.get("hold_credits", est.get("credits")), "credits")
const description =
  "Internet-facing REST API; Node/Express; Postgres; S3 file uploads; JWT auth";

const payload = {
  description,
  deployment: "internet",
  data_sensitivity: "pii",
  auth: "JWT bearer tokens; tenant scoping expected on every query",
  notes: "Billing runs through a third party and is out of scope.",
  prescan_facts: { surfaces: [], assets: [], exposure: [] },
};

const est = await api("POST", "/estimate", payload);
console.log("worst case:", est.hold_credits ?? est.credits, "credits");
const description = "Internet-facing REST API; Node/Express; Postgres; S3 file uploads; JWT auth"

payload := map[string]any{
	"description":      description,
	"deployment":       "internet",
	"data_sensitivity": "pii",
	"auth":             "JWT bearer tokens; tenant scoping expected on every query",
	"notes":            "Billing runs through a third party and is out of scope.",
	"prescan_facts": map[string]any{
		"surfaces": []any{}, "assets": []any{}, "exposure": []any{},
	},
}

var est struct{ HoldCredits int64 `json:"hold_credits"` }
err := call("POST", "/estimate", payload, &est)
String description =
    "Internet-facing REST API; Node/Express; Postgres; S3 file uploads; JWT auth";

String jsonPayload = """
    {"description": %s,
     "deployment": "internet",
     "data_sensitivity": "pii",
     "auth": "JWT bearer tokens; tenant scoping expected on every query",
     "notes": "Billing runs through a third party and is out of scope.",
     "prescan_facts": {"surfaces": [], "assets": [], "exposure": []}}
    """.formatted(toJsonString(description));

String envelope = api("POST", "/estimate", jsonPayload);
// worst-case cost is at data.hold_credits
DESCRIPTION = "Internet-facing REST API; Node/Express; Postgres; S3 file uploads; JWT auth"

payload = { description: DESCRIPTION,
            deployment: "internet",
            data_sensitivity: "pii",
            auth: "JWT bearer tokens; tenant scoping expected on every query",
            notes: "Billing runs through a third party and is out of scope.",
            prescan_facts: { surfaces: [], assets: [], exposure: [] } }

est = api("POST", "/estimate", payload)
puts "worst case: #{est["hold_credits"] || est["credits"]} credits"
$description = "Internet-facing REST API; Node/Express; Postgres; S3 file uploads; JWT auth";

$payload = [
    "description"      => $description,
    "deployment"       => "internet",
    "data_sensitivity" => "pii",
    "auth"             => "JWT bearer tokens; tenant scoping expected on every query",
    "notes"            => "Billing runs through a third party and is out of scope.",
    "prescan_facts"    => ["surfaces" => [], "assets" => [], "exposure" => []],
];

$est = api("POST", "/estimate", $payload);
echo "worst case: " . ($est["hold_credits"] ?? $est["credits"]) . " credits\n";
var description =
    "Internet-facing REST API; Node/Express; Postgres; S3 file uploads; JWT auth";

var payload = new {
    description,
    deployment = "internet",
    data_sensitivity = "pii",
    auth = "JWT bearer tokens; tenant scoping expected on every query",
    notes = "Billing runs through a third party and is out of scope.",
    prescan_facts = new {
        surfaces = Array.Empty<object>(), assets = Array.Empty<object>(),
        exposure = Array.Empty<object>(),
    },
};

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

prescan_facts is how you make the threat model answer for things you already know about. Send {"surfaces": [{"id": "surface:upload", "label": "file upload"}], "assets": [{"id": "asset:pii", "label": "customer records"}], "exposure": [{"id": "exposure:multi-tenant", "label": "multi-tenant"}]} and every one of those ids comes back in coverage_check — addressed by a threat or entry point, or set aside with the reason. Nothing you flag is silently dropped.

Step 4 — Run the threat model 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 full threat table is written out). Always send an Idempotency-Key header so a network retry can't start a second, double-charged run. The threat model is in output — usually nested as output.output, and as a JSON string, so parse defensively. The samples below print the model name and posture, the entry points, the prioritized threats and the focus areas, then save the whole object to threat-model.json.

JOB_ID=$(curl -s -X POST "$API/run" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: tm-$(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 model once, then read it
echo "$JOB" | jq -r '.data.output.output' > threat-model.json

jq -r '
  "\(.model_name) [\(.posture)]: \(.verdict)",
  "",
  "ENTRY POINTS",
  (.entry_points[] | "  \(.surface) via \(.reached_via) [\(.boundary)]"),
  "",
  "THREATS",
  (.threats[] | "  [\(.priority)] \(.id) \(.action) (L:\(.likelihood)/S:\(.severity))"),
  "",
  "FOCUS AREAS",
  (.focus_areas[] | "  \(.area) - \(.why)"),
  "",
  "COVERAGE",
  (.coverage_check[] | "  \(.id): \(if .addressed then "ok" else "SET ASIDE" end) - \(.note)")' \
  threat-model.json
import time

job_id = api("POST", "/run", payload,
             **{"Idempotency-Key": "tm-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"]
model = json.loads(raw) if isinstance(raw, str) else raw

print(f'{model["model_name"]} [{model["posture"]}]: {model["verdict"]}')
for b in model["boundaries"]:
    print(f'  boundary {b["path"]:<36} {b["controls"]}')
for e in model["entry_points"]:
    print(f'  entry    {e["surface"]:<36} {e["reached_via"]}')
for t in model["threats"]:
    print(f'  [{t["priority"]:>8}] {t["id"]} {t["action"]}')
    print(f'      L:{t["likelihood"]}/S:{t["severity"]} gaps: {t["gaps"]}')
    print(f'      fix: {t["mitigations"]}')
for a in model["focus_areas"]:
    print(f'  focus {a["area"]} {a["threat_ids"]} - {a["why"]}')
for c in model["coverage_check"]:
    print(f'  {c["id"]}: {"ok" if c["addressed"] else "SET ASIDE"} - {c["note"]}')

with open("threat-model.json", "w", encoding="utf-8") as fh:
    json.dump(model, fh, indent=2)
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 model = typeof raw === "string" ? JSON.parse(raw) : raw;

console.log(`${model.model_name} [${model.posture}]: ${model.verdict}`);
for (const b of model.boundaries) {
  console.log(`  boundary ${b.path}: ${b.controls}`);
}
for (const e of model.entry_points) {
  console.log(`  entry ${e.surface} via ${e.reached_via} [${e.boundary}]`);
}
for (const t of model.threats) {
  console.log(`  [${t.priority}] ${t.id} ${t.action}`);
  console.log(`      L:${t.likelihood}/S:${t.severity} - ${t.mitigations}`);
}
for (const a of model.focus_areas) {
  console.log(`  focus ${a.area} (${a.threat_ids.join(", ")}): ${a.why}`);
}
for (const c of model.coverage_check) {
  console.log(`  ${c.id}: ${c.addressed ? "ok" : "SET ASIDE"} - ${c.note}`);
}

writeFileSync("threat-model.json", JSON.stringify(model, null, 2));
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, then unmarshal:
type ThreatModel struct {
	ModelName string `json:"model_name"`
	Posture   string `json:"posture"`
	Verdict   string `json:"verdict"`
	Boundaries []struct {
		Path, Data, Channel, Controls string
	} `json:"boundaries"`
	EntryPoints []struct {
		Surface, Boundary, Note string
		ReachedVia              string `json:"reached_via"`
	} `json:"entry_points"`
	Threats []struct {
		ID, Source, Action, Impact, Gaps, Mitigations string
		Likelihood, Severity, Priority                string
	} `json:"threats"`
	FocusAreas []struct {
		Area, Why string
		ThreatIDs []string `json:"threat_ids"`
	} `json:"focus_areas"`
	CoverageCheck []struct {
		ID, Note  string
		Addressed bool
	} `json:"coverage_check"`
	Summary string `json:"summary"`
}
var wrapper struct{ Output string `json:"output"` }
json.Unmarshal(job.Output, &wrapper)
var model ThreatModel
json.Unmarshal([]byte(wrapper.Output), &model)

fmt.Printf("%s [%s]: %s\n", model.ModelName, model.Posture, model.Verdict)
for _, e := range model.EntryPoints {
	fmt.Printf("  entry %s via %s [%s]\n", e.Surface, e.ReachedVia, e.Boundary)
}
for _, t := range model.Threats {
	fmt.Printf("  [%s] %s %s (L:%s/S:%s)\n", t.Priority, t.ID, t.Action, t.Likelihood, t.Severity)
}
for _, a := range model.FocusAreas {
	fmt.Printf("  focus %s %v: %s\n", a.Area, a.ThreatIDs, a.Why)
}
os.WriteFile("threat-model.json", []byte(wrapper.Output), 0o644)
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 threat model is at data.output.output as a JSON string — parse it again, then read
// model_name, posture, verdict, exec_summary, assumptions[], open_questions[],
// components[] (name/role), boundaries[] (path/data/channel/controls), assets[] (asset/why/objective),
// attacker_capabilities[], attacker_non_capabilities[], entry_points[] (surface/reached_via/boundary/note),
// abuse_paths[] (goal/steps[]/impact), threats[] (id/source/prerequisites/action/impact/assets[]/
// existing_controls/gaps/mitigations/detection/likelihood/severity/priority),
// coverage_check[] (id/addressed/note), focus_areas[] (area/why/threat_ids[]) and summary.
// Finally keep the model on disk:
//   Files.writeString(Path.of("threat-model.json"), modelJson);
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"]
model = raw.is_a?(String) ? JSON.parse(raw) : raw

puts "#{model["model_name"]} [#{model["posture"]}]: #{model["verdict"]}"
model["boundaries"].each { |b| puts "  boundary #{b["path"]}: #{b["controls"]}" }
model["entry_points"].each { |e| puts "  entry #{e["surface"]} via #{e["reached_via"]}" }
model["threats"].each do |t|
  puts "  [#{t["priority"]}] #{t["id"]} #{t["action"]}"
  puts "      L:#{t["likelihood"]}/S:#{t["severity"]} - #{t["mitigations"]}"
end
model["focus_areas"].each { |a| puts "  focus #{a["area"]} #{a["threat_ids"].join(", ")}" }
model["coverage_check"].each { |c| puts "  #{c["id"]}: #{c["addressed"] ? "ok" : "SET ASIDE"}" }

File.write("threat-model.json", JSON.pretty_generate(model))
$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"];
$model = is_string($raw) ? json_decode($raw, true) : $raw;

echo "{$model['model_name']} [{$model['posture']}]: {$model['verdict']}\n";
foreach ($model["boundaries"] as $b) {
    echo "  boundary {$b['path']}: {$b['controls']}\n";
}
foreach ($model["entry_points"] as $e) {
    echo "  entry {$e['surface']} via {$e['reached_via']}\n";
}
foreach ($model["threats"] as $t) {
    echo "  [{$t['priority']}] {$t['id']} {$t['action']}\n";
    echo "      L:{$t['likelihood']}/S:{$t['severity']} - {$t['mitigations']}\n";
}
foreach ($model["focus_areas"] as $a) {
    echo "  focus {$a['area']}: " . implode(", ", $a["threat_ids"]) . "\n";
}
foreach ($model["coverage_check"] as $c) {
    echo "  {$c['id']}: " . ($c["addressed"] ? "ok" : "SET ASIDE") . "\n";
}

file_put_contents("threat-model.json", json_encode($model, JSON_PRETTY_PRINT));
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 model = doc.RootElement;

Console.WriteLine($"{model.GetProperty("model_name")} " +
                  $"[{model.GetProperty("posture")}]: {model.GetProperty("verdict")}");
foreach (var b in model.GetProperty("boundaries").EnumerateArray())
{
    Console.WriteLine($"  boundary {b.GetProperty("path")}: {b.GetProperty("controls")}");
}
foreach (var e in model.GetProperty("entry_points").EnumerateArray())
{
    Console.WriteLine($"  entry {e.GetProperty("surface")} via {e.GetProperty("reached_via")}");
}
foreach (var t in model.GetProperty("threats").EnumerateArray())
{
    Console.WriteLine($"  [{t.GetProperty("priority")}] {t.GetProperty("id")} " +
                      $"{t.GetProperty("action")} (L:{t.GetProperty("likelihood")}/" +
                      $"S:{t.GetProperty("severity")})");
}
foreach (var a in model.GetProperty("focus_areas").EnumerateArray())
{
    Console.WriteLine($"  focus {a.GetProperty("area")}: {a.GetProperty("why")}");
}

await File.WriteAllTextAsync("threat-model.json", rawText!);

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 threat model object — output schema

One JSON object, always the same shape. Every array is present, and the model is grounded in the pasted description alone — components, flows and controls that the text does not state or strongly imply are not invented; where the description is silent on something that matters, you get an entry in assumptions and, if it would change the ranking, in open_questions. Expect 5–12 system-specific threats rather than a generic checklist. If the description is too thin to model, you still get the object: exec_summary says so, threats holds only the one to three things that can be said honestly, and what would be needed lands in open_questions.

FieldTypeMeaning
model_namestringA short title naming the system, taken from the description's own naming — e.g. Acme file-share API — threat model.
posturestringsound-baseline | hardening-recommended | needs-design-changes. See the table below.
verdictstringOne or two sentences justifying the posture and naming the single most important change.
exec_summarystringA short paragraph on the top risk themes and the highest-risk areas.
assumptionsstring[]Explicit assumptions filling gaps the description left open. Read these first — a wrong assumption invalidates the threats built on it.
open_questionsstring[]Questions whose answers would materially change the risk ranking.
componentsarray{name, role} — the parts of the system the description names, and what each does and why it matters.
boundariesarray{path, data, channel, controls} — trust boundaries as directed crossings (Internet -> API gateway), what data crosses, over what protocol, and the controls the description actually states. Where none are stated, controls says none stated rather than assuming a default.
assetsarray{asset, why, objective} — what is worth protecting, why it drives risk, and the security objective at stake: C, I, A or a combination such as C/I.
attacker_capabilitiesstring[]What an attacker can plausibly do, calibrated to deployment and data_sensitivity.
attacker_non_capabilitiesstring[]What the attacker explicitly cannot do. This is what keeps severities honest — a threat needing control that does not exist for this system is downgraded, not inflated.
entry_pointsarray{surface, reached_via, boundary, note} — a concrete surface (POST /upload), how an attacker reaches it, which boundary it sits on, and its validation/authorization situation.
abuse_pathsarray{goal, steps, impact} — multi-step attacker stories, not one-liners: the goal, the ordered steps, and the concrete impact if the path completes.
threatsarrayThe prioritized threat table — typically 5–12 entries, ids TM-001, TM-002, … in sequence. Columns are listed below.
coverage_checkarray{id, addressed, note} — one entry per prescan_facts id you sent, each appearing exactly once. See the semantics below.
focus_areasarray{area, why, threat_ids} — the component or flow to review first, one sentence tied to the model, and the threat ids that motivate it. Every id in threat_ids exists in threats.
summarystringClosing paragraph: what to fix first, and what residual risk remains after that.

The three posture values:

postureWhat it means
sound-baselineThe design as described holds up. Threats exist, but the stated controls meet them and nothing calls for structural change. Genuinely low-risk systems — an offline CLI with no sensitive data — land here rather than having severity manufactured for them.
hardening-recommendedThe shape of the design is right, but named gaps should be closed — missing rate limits, unscoped tokens, absent validation at a boundary. Fixes are additive, not architectural.
needs-design-changesAt least one risk cannot be closed without changing the design itself: a trust boundary in the wrong place, a shared secret that cannot be scoped, an authorization model that cannot express the rule it needs.

Each entry in threats:

ColumnMeaning
idSequential TM-001, TM-002, … — the stable handle referenced from focus_areas[].threat_ids.
sourceWho or what attacks: an unauthenticated internet caller, a logged-in tenant, a compromised dependency, a malicious insider.
prerequisitesOne or two sentences on what has to be true first for this to be reachable.
actionWhat the attacker actually does.
impactWhat happens if it works.
assetsstring[] — the impacted asset names, matching entries in assets.
existing_controlsThe controls the description states for this threat, or none stated.
gapsWhat is missing between those controls and the threat.
mitigationsA concrete recommendation — code, config or process, not "add validation".
detectionA logging, metric or alerting idea that would surface this happening.
likelihoodlow | medium | high.
severitylow | medium | high.
prioritycritical | high | medium | low — likelihood by severity, adjusted down for controls that already exist. critical is reserved for a realistic pre-auth or cross-tenant path, so sort on this field and work top-down.

coverage_check semantics:

CaseWhat you get
Every id you sentEach prescan_facts id — across surfaces, assets and exposure — appears in coverage_check exactly once. Nothing you flagged is silently dropped, which makes this the field to assert on in a CI check.
addressed: trueThe fact is covered by the model; note names the threat id or entry point that covers it.
addressed: falseThe fact was deliberately set aside; note gives the reason — a keyword match that turned out not to be security-relevant for this system, or a surface that the description places out of scope.
Nothing sentOmit prescan_facts, or send the three empty arrays, and coverage_check comes back empty. The rest of the model is unaffected.

A small, realistic result for the internet-facing API described above, trimmed for length:

{
  "model_name": "Orders API — threat model",
  "posture": "hardening-recommended",
  "verdict": "The boundaries are in sensible places, but nothing in the description scopes a
              JWT to a tenant, so the first fix is authorization on every record read.",
  "exec_summary": "Three themes dominate: per-record authorization on a multi-tenant store,
                   the S3 upload path as an unvalidated ingress, and token handling at the
                   internet boundary. Nothing here needs the architecture redrawn.",
  "assumptions": [
    "JWTs are signed with an asymmetric key and verified on every request; the description
     does not say which algorithm or where the key lives.",
    "Postgres is reachable only from the API subnet, not from the internet."
  ],
  "open_questions": [
    "Are uploads served back to users from the same bucket, and if so through what URL scheme?",
    "Is there a tenant id in the JWT claims, or is tenancy derived from the record?"
  ],
  "components": [
    { "name": "Express API", "role": "Sole request handler; terminates JWT auth and owns all
                                     database and object-store access." },
    { "name": "Postgres", "role": "System of record for customer and order data, including PII." },
    { "name": "S3 bucket", "role": "Stores user-supplied file uploads." }
  ],
  "boundaries": [
    { "path": "Internet -> Express API", "data": "credentials, order data, uploaded files",
      "channel": "HTTPS/JSON", "controls": "JWT bearer auth; no rate limit stated" },
    { "path": "Express API -> Postgres", "data": "customer records, order rows",
      "channel": "TLS to the database subnet", "controls": "none stated" },
    { "path": "Express API -> S3", "data": "uploaded file bytes and object keys",
      "channel": "AWS SDK over HTTPS", "controls": "none stated" }
  ],
  "assets": [
    { "asset": "customer records", "why": "PII under the stated data_sensitivity",
      "objective": "C/I" },
    { "asset": "JWT signing key", "why": "Forging a token collapses the entire auth boundary",
      "objective": "C" }
  ],
  "attacker_capabilities": [
    "Send arbitrary authenticated and unauthenticated HTTP requests to every route",
    "Register a legitimate tenant account and use it as a foothold",
    "Upload files of attacker-chosen name, type and size"
  ],
  "attacker_non_capabilities": [
    "Cannot reach Postgres directly — no public listener is described",
    "Cannot read the signing key without first achieving code execution on the API host"
  ],
  "entry_points": [
    { "surface": "POST /upload", "reached_via": "any authenticated caller over the internet",
      "boundary": "Internet -> Express API",
      "note": "No content-type, size or extension validation is described." },
    { "surface": "GET /orders/:id", "reached_via": "any authenticated caller",
      "boundary": "Internet -> Express API",
      "note": "No statement that the order is scoped to the caller's tenant." }
  ],
  "abuse_paths": [
    { "goal": "Read another tenant's orders",
      "steps": [
        "Register a tenant account and obtain a valid JWT",
        "Enumerate sequential order ids against GET /orders/:id",
        "Collect the responses that are not rejected"
      ],
      "impact": "Bulk disclosure of customer PII across tenants from a single legitimate account." }
  ],
  "threats": [
    { "id": "TM-001",
      "source": "Authenticated tenant user",
      "prerequisites": "A valid JWT and knowledge that order ids are guessable.",
      "action": "Requests order ids belonging to other tenants.",
      "impact": "Cross-tenant disclosure of customer PII.",
      "assets": ["customer records"],
      "existing_controls": "JWT authentication establishes who the caller is.",
      "gaps": "Authentication is not authorization — nothing ties the record to the tenant.",
      "mitigations": "Scope every read to the tenant claim: filter on tenant_id in the query
                      itself, and enforce it in a repository layer rather than per route.",
      "detection": "Alert on a single subject reading order ids across more than one tenant_id.",
      "likelihood": "high", "severity": "high", "priority": "critical" },
    { "id": "TM-002",
      "source": "Any authenticated caller",
      "prerequisites": "Reachable upload route with no described type or size checks.",
      "action": "Uploads oversized or executable content, or an object key with path traversal.",
      "impact": "Storage cost abuse, and stored content that is dangerous if later served back.",
      "assets": ["uploaded files"],
      "existing_controls": "none stated",
      "gaps": "No size cap, no content-type allowlist, no server-generated object key.",
      "mitigations": "Cap the body size at the proxy, allowlist content types, and generate
                      object keys server-side instead of accepting a client-supplied name.",
      "detection": "Track upload size distribution and rejected content types per subject.",
      "likelihood": "medium", "severity": "medium", "priority": "high" }
  ],
  "coverage_check": [
    { "id": "surface:upload", "addressed": true,
      "note": "Covered by TM-002 and the POST /upload entry point." },
    { "id": "asset:pii", "addressed": true,
      "note": "Modelled as the customer records asset; drives TM-001." },
    { "id": "exposure:internet", "addressed": false,
      "note": "Folded into the attacker model rather than tracked as its own threat." }
  ],
  "focus_areas": [
    { "area": "Order read path", "why": "The only place a single legitimate account can reach
                                        other tenants' data.", "threat_ids": ["TM-001"] },
    { "area": "Upload ingress", "why": "Unvalidated ingress that also becomes an egress if the
                                       bucket is ever served back.", "threat_ids": ["TM-002"] }
  ],
  "summary": "Fix per-record tenant scoping first; harden the upload path next. …"
}

This is AI-generated modelling from a text description, not a security sign-off: it sees only what you pasted, and a threat model is only as good as the description behind it. Check assumptions and open_questions before you act on the rankings, and keep a human reviewer in the loop.

Step 5 — Stream the threat model 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 a full threat table 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 threat model 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: tm-$(date +%s)" \
  -d @input.json

# event: job
# data: {"job_id":"job_...","status":"running"}
#
# event: delta
# data: {"text":"{\"model_name\":\"Orders API"}
# ...
# event: done
# data: {"job_id":"job_...","status":"succeeded","charged_credits":612,"output":{"output":"{...}"}}
import json, requests

result = None
with requests.post(
    API + "/run-stream",
    headers={"Authorization": f"Bearer {TOKEN}",
             "Idempotency-Key": "tm-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"))

model = json.loads(result["output"]["output"])          # authoritative
print("charged:", result["charged_credits"], "-", model["model_name"])
print("posture:", model["posture"])
for t in model["threats"]:
    print(f'  [{t["priority"]}] {t["id"]} {t["action"]}')
with open("threat-model.json", "w", encoding="utf-8") as fh:
    json.dump(model, fh, indent=2)
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 model = JSON.parse(done.output.output);
console.log(`\n${done.charged_credits} credits - ${model.model_name} [${model.posture}]`);
for (const t of model.threats) console.log(`  [${t.priority}] ${t.id} ${t.action}`);
writeFileSync("threat-model.json", JSON.stringify(model, null, 2));
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", "tm-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 threat model JSON —
// unmarshal it into the ThreatModel struct from step 4, then write it to threat-model.json.
// 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", "tm-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
// model_name, posture, verdict, boundaries[], entry_points[], abuse_paths[], threats[],
// coverage_check[], focus_areas[] 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"] = "tm-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

model = JSON.parse(done["output"]["output"])
puts "\n#{done["charged_credits"]} credits - #{model["model_name"]} [#{model["posture"]}]"
model["threats"].each { |t| puts "  [#{t["priority"]}] #{t["id"]} #{t["action"]}" }
File.write("threat-model.json", JSON.pretty_generate(model))
$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: tm-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);

$model = json_decode($done["output"]["output"], true);
echo "\n{$done['charged_credits']} credits - {$model['model_name']} [{$model['posture']}]\n";
foreach ($model["threats"] as $t) {
    echo "  [{$t['priority']}] {$t['id']} {$t['action']}\n";
}
file_put_contents("threat-model.json", json_encode($model, JSON_PRETTY_PRINT));
var req = new HttpRequestMessage(HttpMethod.Post, Api + "/run-stream") {
    Content = JsonContent.Create(payload),
};
req.Headers.Add("Idempotency-Key", "tm-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 modelDoc = JsonDocument.Parse(text!);
var model = modelDoc.RootElement;
Console.WriteLine($"{model.GetProperty("model_name")} [{model.GetProperty("posture")}]");
foreach (var t in model.GetProperty("threats").EnumerateArray())
    Console.WriteLine($"  [{t.GetProperty("priority")}] {t.GetProperty("id")} {t.GetProperty("action")}");
await File.WriteAllTextAsync("threat-model.json", text!);

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.