Flow Forge — API

Describe the system, get the diagram.

API tokens Open the app

Draw the diagram from your own tools

Send a prose description of a system or a process — a checkout flow, a service topology, a message exchange, a schema, a class hierarchy, a network — and get back one JSON object: a typed list of nodes with a kind and a layer hint, a list of edges that reference those nodes by id, a legend, a per-entity check against what your own text mentioned, and a diagram-ready / assumptions-made / needs-detail position on the description itself. Nothing is rendered server-side: the spec is a graph, and the browser lays it out, paints an SVG and serialises a draw.io (.drawio mxGraph XML) file from it — which means your own client can do exactly the same, and the last step on this page shows how. Everything this app does goes through the SkillSafe App API — plain JSON over HTTPS — so you can wire it into a docs pipeline, regenerate architecture diagrams from a README on every commit, or batch-convert a folder of runbooks. 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 flow-forge. 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 spec is produced by the gpt-terra model at a 10% publisher markup. Estimates are free; runs are metered against your credit balance. There is a single run task — one description in, one diagram spec out.

POST /guest GET /me POST /estimate POST /run GET /jobs/{id} POST /run-stream
StatusMeaning
400Malformed body — a missing description, or the app slug sent as an X-App-Slug header instead of in the JSON body of POST /guest.
401Missing or expired token — create a new session.
402Not enough credits — top up at skillsafe.ai/account/billing.
403The token isn't allowed to do this (e.g. a guest submitting a very large description).
404Unknown job id.
429Rate limited — back off and retry.
5xxTransient platform error — retry with backoff, reusing the same idempotency key.

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;

class Api {
    static final String BASE = "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 call(String method, String path, String jsonBody) throws Exception {
        var body = jsonBody == null
                ? HttpRequest.BodyPublishers.noBody()
                : HttpRequest.BodyPublishers.ofString(jsonBody);
        var req = HttpRequest.newBuilder(URI.create(BASE + path))
                .header("Authorization", "Bearer " + TOKEN)
                .header("Content-Type", "application/json")
                .method(method, body)
                .build();
        var res = HTTP.send(req, HttpResponse.BodyHandlers.ofString());
        if (res.statusCode() >= 400) throw new RuntimeException(res.body());
        return res.body();   // {"data": ...}
    }
}
require "json"
require "net/http"
require "uri"

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

def api(method, path, body = nil, extra = {})
  uri = URI(API + path)
  klass = { "GET" => Net::HTTP::Get, "POST" => Net::HTTP::Post,
            "DELETE" => Net::HTTP::Delete }.fetch(method)
  req = klass.new(uri)
  req["Authorization"] = "Bearer #{TOKEN}"
  req["Content-Type"]  = "application/json"
  extra.each { |k, v| req[k] = v }
  req.body = JSON.dump(body) if body
  res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
  payload = JSON.parse(res.body)
  raise payload.dig("error", "message").to_s 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, array $extra = []): array {
    global $TOKEN;
    $headers = ["Authorization: Bearer $TOKEN", "Content-Type: application/json"];
    foreach ($extra as $k => $v) { $headers[] = "$k: $v"; }
    $ch = curl_init(API . $path);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_CUSTOMREQUEST  => $method,
        CURLOPT_HTTPHEADER     => $headers,
    ]);
    if ($body !== null) {
        curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
    }
    $raw    = curl_exec($ch);
    $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);
    $payload = json_decode($raw, true);
    if ($status >= 400) { throw new RuntimeException($payload["error"]["message"] ?? "request failed"); }
    return $payload["data"];
}
// .NET 6+
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;

static class Api {
    const string Base = "https://api.skillsafe.ai/v1/app-api";
    static readonly string Token = Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN")!; // step 1
    static readonly HttpClient Http = new();

    public static async Task<JsonElement> Call(HttpMethod method, string path, object? body = null,
                                               (string, string)? extraHeader = null) {
        var req = new HttpRequestMessage(method, Base + path);
        req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token);
        if (extraHeader is var (hk, hv) && hk is not null) req.Headers.Add(hk, hv);
        if (body is not null)
            req.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
        var res = await Http.SendAsync(req);
        var json = JsonDocument.Parse(await res.Content.ReadAsStringAsync()).RootElement;
        if (!res.IsSuccessStatusCode)
            throw new Exception(json.GetProperty("error").GetProperty("message").GetString());
        return json.GetProperty("data");
    }
}

Step 1 — Get a token

Two kinds. A guest token needs no account and is enough for /me and the free /estimate. A personal token bills metered runs to your own balance — get one from the token page, which shows the token this browser already holds, lets you sign in for a personal one, and copies a ready-made export SKILLSAFE_TOKEN="…" line. You never need the DevTools console.

One detail costs people an afternoon: on POST /v1/app-api/guest the app slug goes in the JSON body — {"slug":"flow-forge"}. Sending it as an X-App-Slug header instead returns 400, because the body is then empty and the endpoint has no idea which app you mean.

# A scripted guest token — no browser, no account. Good for /me and /estimate.
# The slug goes in the JSON body, not in a header: an X-App-Slug header gives 400.
curl -s -X POST "$API/guest" \
  -H "Content-Type: application/json" \
  -d '{"slug":"flow-forge"}' | jq -r .data.token

# For a personal token (metered runs bill your account), open
#   https://flow-forge.skillsafe.ai/tokens.html
# sign in, and press "Copy shell export".
# A scripted guest token — no browser, no account. Good for /me and /estimate.
# The slug goes in the JSON body, not in a header: an X-App-Slug header gives 400.
guest = requests.post(API + "/guest", json={"slug": "flow-forge"}).json()["data"]
TOKEN = guest["token"]          # aut_...
guest_id = guest["guest_id"]    # gst_... — keep it if you later migrate the wallet on sign-in

# For a personal token (metered runs bill your account), open
#   https://flow-forge.skillsafe.ai/tokens.html
# sign in, and press "Copy shell export".
// A scripted guest token — no browser, no account. Good for /me and /estimate.
// The slug goes in the JSON body, not in a header: an X-App-Slug header gives 400.
const guest = await (await fetch(API + "/guest", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ slug: "flow-forge" }),
})).json();
const token = guest.data.token;

// For a personal token (metered runs bill your account), open
//   https://flow-forge.skillsafe.ai/tokens.html
// sign in, and press "Copy shell export".
// A scripted guest token — no browser, no account. Good for /me and /estimate.
// The slug goes in the JSON body, not in a header: an X-App-Slug header gives 400.
guestBody, _ := json.Marshal(map[string]string{"slug": "flow-forge"})
req, _ := http.NewRequest("POST", API+"/guest", bytes.NewReader(guestBody))
req.Header.Set("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()

var env struct {
	Data struct {
		Token string `json:"token"`
	} `json:"data"`
}
json.NewDecoder(res.Body).Decode(&env)
token = env.Data.Token

// For a personal token, open https://flow-forge.skillsafe.ai/tokens.html
// A scripted guest token — no browser, no account. Good for /me and /estimate.
// The slug goes in the JSON body, not in a header: an X-App-Slug header gives 400.
var req = HttpRequest.newBuilder(URI.create(Api.BASE + "/guest"))
        .header("Content-Type", "application/json")
        .POST(HttpRequest.BodyPublishers.ofString("{\"slug\":\"flow-forge\"}"))
        .build();
var res = Api.HTTP.send(req, HttpResponse.BodyHandlers.ofString());
// res.body() is {"data":{"token":"aut_...","guest_id":"gst_...","expires_at":"..."}}
// — read data.token with your JSON library.

// For a personal token, open https://flow-forge.skillsafe.ai/tokens.html
# A scripted guest token — no browser, no account. Good for /me and /estimate.
# The slug goes in the JSON body, not in a header: an X-App-Slug header gives 400.
uri = URI(API + "/guest")
req = Net::HTTP::Post.new(uri)
req["Content-Type"] = "application/json"
req.body = JSON.dump({ "slug" => "flow-forge" })
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
token = JSON.parse(res.body).dig("data", "token")

# For a personal token, open https://flow-forge.skillsafe.ai/tokens.html
<?php
// A scripted guest token — no browser, no account. Good for /me and /estimate.
// The slug goes in the JSON body, not in a header: an X-App-Slug header gives 400.
$ch = curl_init(API . "/guest");
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST           => true,
    CURLOPT_HTTPHEADER     => ["Content-Type: application/json"],
    CURLOPT_POSTFIELDS     => json_encode(["slug" => "flow-forge"]),
]);
$guest  = json_decode(curl_exec($ch), true);
curl_close($ch);
$TOKEN = $guest["data"]["token"];

// For a personal token, open https://flow-forge.skillsafe.ai/tokens.html
// A scripted guest token — no browser, no account. Good for /me and /estimate.
// The slug goes in the JSON body, not in a header: an X-App-Slug header gives 400.
var guestReq = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/guest") {
    Content = new StringContent("{\"slug\":\"flow-forge\"}", Encoding.UTF8, "application/json"),
};
var guestRes = await new HttpClient().SendAsync(guestReq);
var guest = JsonDocument.Parse(await guestRes.Content.ReadAsStringAsync()).RootElement;
var token = guest.GetProperty("data").GetProperty("token").GetString();

// For a personal token, open https://flow-forge.skillsafe.ai/tokens.html

Treat the token like a password: anyone holding it can spend its credits through this app. Keep it in your shell environment rather than in source control.

Step 2 — Check the session and the balance

GET /me is free and tells you whether the token is a guest or a real user, and how many credits it can spend. The app calls this before enabling its run button, and so should you — a 402 after submitting is avoidable.

curl -s "$API/me" -H "Authorization: Bearer $TOKEN" | jq .data
# { "subject_type": "user", "subject_id": "...", "credits": 184220 }
# subject_type is "guest" for a POST /guest token, "user" for a personal one.
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"`
}
if err := call("GET", "/me", nil, &me); err != nil {
	panic(err)
}
fmt.Println(me.SubjectType, me.Credits)
String me = Api.call("GET", "/me", null);
System.out.println(me);   // {"data":{"subject_type":"user","credits":184220}}
me = api("GET", "/me")
puts me["subject_type"], me["credits"]
<?php
$me = api("GET", "/me");
echo $me["subject_type"], " ", $me["credits"], "\n";
var me = await Api.Call(HttpMethod.Get, "/me");
Console.WriteLine($"{me.GetProperty("subject_type")} {me.GetProperty("credits")}");

Step 3 — Estimate (free, no job created)

POST /estimate takes the exact body you would send to /run and returns the model binding and the price envelope without creating a job or charging anything. hold_credits is a worst-case reservation, priced against the full output cap; the settled charged_credits is usually much lower. If the balance sits between min_credits and hold_credits, the run still executes with a reduced cap and comes back "truncated": true — for a diagram that usually means a smaller graph, so check the node count before you trust it.

Input fields

FieldTypeMeaning
descriptionstringRequired. The system or process to diagram, prose or bullets. Explicit arrows written as A -> B are honoured as edges rather than re-inferred.
diagram_typestringauto, or one of flowchart, architecture, sequence, er, uml-class, network. auto defers to the prescan's detected type.
directionstringauto, TB (top–bottom) or LR (left–right). Echoed back resolved, and the client layout obeys the echo.
detailstringoverview (fewest nodes), standard, detailed.
titlestringOptional. A title for the diagram; if omitted the model writes one into diagram_title.
constraintsstringOptional. Naming conventions, what to leave out, house style.
current_datetimestringThe caller's local timestamp.
prescanobjectWhat the client-side scan found before the call: detected_type and detected_type_label, type_scores (e.g. ["flowchart:12","architecture:4"]), type_ambiguous, entity_count, explicit_edges, sentences, words, specificity_score, and description_clipped when the middle of a long description was dropped.
prescan_entitiesarray{"id": "E1", "label": "Checkout service"} entries — the nouns the scan believes are parts of the system. The output must return exactly one entity_check entry per id.
prescan_gapsarray{"id": "G1", "label": "no direction is stated between the parts"} entries — what the description leaves unsaid. Each one should be answered in assumptions or open_questions.
node_kindsarrayThe closed vocabulary a node's kind may use: start, end, process, decision, data, store, actor, service, external, table, class, note. The client picks a shape per kind.
edge_kindsarrayThe closed vocabulary for an edge's kind: flow, data, call, relation, inherits. Drives the line style.
retry_notestringOptional. Sent only on the app's one automatic reformat retry, when the first reply did not parse as a single JSON object.
curl -s -X POST "$API/estimate" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"description":"A shopper submits an order. Checkout service validates the cart -> Payment gateway authorises the card -> Order service writes the order to Postgres and publishes to the fulfilment queue. The warehouse picks and ships.","diagram_type":"auto","direction":"LR","detail":"standard","prescan_entities":[{"id":"E1","label":"Checkout service"}],"prescan_gaps":[{"id":"G1","label":"no failure path is stated"}]}' | jq .data

# { "model": "gpt-5.6-terra", "model_alias": "gpt-terra", "markup_bps": 1000,
#   "hold_credits": 2180, "min_credits": 310, "sponsor_enabled": false }
payload = {
    "description": (
        "A shopper submits an order. Checkout service validates the cart -> "
        "Payment gateway authorises the card -> Order service writes the order to "
        "Postgres and publishes to the fulfilment queue. The warehouse picks and ships."
    ),
    "diagram_type": "auto",          # or flowchart | architecture | sequence | er | uml-class | network
    "direction": "LR",               # auto | TB | LR
    "detail": "standard",            # overview | standard | detailed
    "title": "Order fulfilment",
    "constraints": "Use the team's service names. Leave out monitoring.",
    "current_datetime": "2026-08-06T09:00:00+02:00 (Thursday)",
    "prescan": {
        "detected_type": "flowchart",
        "detected_type_label": "Flowchart",
        "type_scores": ["flowchart:12", "architecture:4"],
        "type_ambiguous": False,
        "entity_count": 9,
        "explicit_edges": 4,
        "sentences": 12,
        "words": 210,
        "specificity_score": 62,
        "description_clipped": "",
    },
    "prescan_entities": [{"id": "E1", "label": "Checkout service"}],
    "prescan_gaps": [{"id": "G1", "label": "no direction is stated between the parts"}],
    "node_kinds": ["start", "end", "process", "decision", "data", "store",
                   "actor", "service", "external", "table", "class", "note"],
    "edge_kinds": ["flow", "data", "call", "relation", "inherits"],
}
est = api("POST", "/estimate", payload)
print(est["model"], est["model_alias"], est["hold_credits"], est["min_credits"])
const payload = {
  description:
    "A shopper submits an order. Checkout service validates the cart -> " +
    "Payment gateway authorises the card -> Order service writes the order to " +
    "Postgres and publishes to the fulfilment queue. The warehouse picks and ships.",
  diagram_type: "auto",       // or flowchart | architecture | sequence | er | uml-class | network
  direction: "LR",            // auto | TB | LR
  detail: "standard",         // overview | standard | detailed
  title: "Order fulfilment",
  constraints: "Use the team's service names. Leave out monitoring.",
  current_datetime: "2026-08-06T09:00:00+02:00 (Thursday)",
  prescan: {
    detected_type: "flowchart",
    detected_type_label: "Flowchart",
    type_scores: ["flowchart:12", "architecture:4"],
    type_ambiguous: false,
    entity_count: 9,
    explicit_edges: 4,
    sentences: 12,
    words: 210,
    specificity_score: 62,
    description_clipped: "",
  },
  prescan_entities: [{ id: "E1", label: "Checkout service" }],
  prescan_gaps: [{ id: "G1", label: "no direction is stated between the parts" }],
  node_kinds: ["start", "end", "process", "decision", "data", "store",
               "actor", "service", "external", "table", "class", "note"],
  edge_kinds: ["flow", "data", "call", "relation", "inherits"],
};
const est = await api("POST", "/estimate", payload);
console.log(est.model, est.hold_credits, est.min_credits);
payload := map[string]any{
	"description":      description,
	"diagram_type":     "auto",
	"direction":        "LR",
	"detail":           "standard",
	"title":            "Order fulfilment",
	"current_datetime": "2026-08-06T09:00:00+02:00 (Thursday)",
	"prescan_entities": []map[string]string{{"id": "E1", "label": "Checkout service"}},
	"node_kinds": []string{"start", "end", "process", "decision", "data", "store",
		"actor", "service", "external", "table", "class", "note"},
	"edge_kinds": []string{"flow", "data", "call", "relation", "inherits"},
}

var est struct {
	Model       string `json:"model"`
	ModelAlias  string `json:"model_alias"`
	MarkupBps   int    `json:"markup_bps"`
	HoldCredits int64  `json:"hold_credits"`
	MinCredits  int64  `json:"min_credits"`
}
if err := call("POST", "/estimate", payload, &est); err != nil {
	panic(err)
}
fmt.Println(est.Model, est.ModelAlias, est.MarkupBps, est.HoldCredits, est.MinCredits)
String payload = """
    {"description":"A shopper submits an order. Checkout service validates the cart -> Payment gateway authorises the card -> Order service writes the order to Postgres.",
     "diagram_type":"auto","direction":"LR","detail":"standard","title":"Order fulfilment",
     "current_datetime":"2026-08-06T09:00:00+02:00 (Thursday)",
     "prescan_entities":[{"id":"E1","label":"Checkout service"}],
     "edge_kinds":["flow","data","call","relation","inherits"]}
    """;
String est = Api.call("POST", "/estimate", payload);
System.out.println(est);   // model, model_alias, markup_bps, hold_credits, min_credits
payload = {
  "description"      => description,
  "diagram_type"     => "auto",
  "direction"        => "LR",
  "detail"           => "standard",
  "title"            => "Order fulfilment",
  "current_datetime" => "2026-08-06T09:00:00+02:00 (Thursday)",
  "prescan_entities" => [{ "id" => "E1", "label" => "Checkout service" }],
  "prescan_gaps"     => [{ "id" => "G1", "label" => "no direction is stated between the parts" }],
  "node_kinds"       => %w[start end process decision data store actor service external table class note],
  "edge_kinds"       => %w[flow data call relation inherits],
}
est = api("POST", "/estimate", payload)
puts est["model"], est["model_alias"], est["hold_credits"], est["min_credits"]
<?php
$payload = [
    "description"      => $description,
    "diagram_type"     => "auto",
    "direction"        => "LR",
    "detail"           => "standard",
    "title"            => "Order fulfilment",
    "current_datetime" => "2026-08-06T09:00:00+02:00 (Thursday)",
    "prescan_entities" => [["id" => "E1", "label" => "Checkout service"]],
    "node_kinds"       => ["start", "end", "process", "decision", "data", "store",
                           "actor", "service", "external", "table", "class", "note"],
    "edge_kinds"       => ["flow", "data", "call", "relation", "inherits"],
];
$est = api("POST", "/estimate", $payload);
echo $est["model"], " ", $est["hold_credits"], " ", $est["min_credits"], "\n";
var payload = new {
    description,
    diagram_type = "auto",
    direction = "LR",
    detail = "standard",
    title = "Order fulfilment",
    current_datetime = "2026-08-06T09:00:00+02:00 (Thursday)",
    prescan_entities = new[] { new { id = "E1", label = "Checkout service" } },
    node_kinds = new[] { "start", "end", "process", "decision", "data", "store",
                         "actor", "service", "external", "table", "class", "note" },
    edge_kinds = new[] { "flow", "data", "call", "relation", "inherits" },
};
var est = await Api.Call(HttpMethod.Post, "/estimate", payload);
Console.WriteLine($"{est.GetProperty("model")} {est.GetProperty("hold_credits")}");

The three assertions worth making in a test: model is gpt-5.6-terra, model_alias is gpt-terra, and markup_bps is 1000. Together they prove the app is bound to the right model at the right markup, and they cost nothing to check. sponsor_enabled tells you whether a sponsor is currently covering runs for this app.

Step 4 — Run and poll

POST /run creates a job; GET /jobs/{job_id} polls it to a terminal state (succeeded or failed). Always send an Idempotency-Key header — a idempotency_key field in the body works the same way — derived from the input content plus an attempt counter. A retried POST that reuses the same key returns the original job instead of billing a second one, so a network blip cannot double-charge a diagram; a retry that invents a fresh key will. The completed job's output.output is the diagram spec as a JSON string.

# The Idempotency-Key makes a retried POST return the original job instead of
# billing a second one. Derive it from the input, not from a random value —
# and reuse the SAME key on every retry of that attempt.
KEY="flow-forge:$(printf %s "$DESCRIPTION" | shasum -a 256 | cut -c1-16):a1"

JOB=$(curl -s -X POST "$API/run" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  -d @payload.json | jq -r .data.job_id)

# Poll until terminal.
while :; do
  J=$(curl -s "$API/jobs/$JOB" -H "Authorization: Bearer $TOKEN")
  S=$(echo "$J" | jq -r .data.status)
  [ "$S" = "succeeded" ] || [ "$S" = "failed" ] && break
  sleep 2
done
echo "$J" | jq -r .data.output.output | jq '{diagram_type, direction, verdict,
  nodes: (.nodes | length), edges: (.edges | length)}'
import hashlib, time

# Same input + same attempt => same key. Reuse it when retrying.
key = "flow-forge:" + hashlib.sha256(payload["description"].encode()).hexdigest()[:16] + ":a1"
job = api("POST", "/run", payload, **{"Idempotency-Key": key})

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

spec = json.loads(j["output"]["output"])
print(spec["diagram_title"], spec["verdict"], len(spec["nodes"]), "nodes", len(spec["edges"]), "edges")
import { createHash } from "node:crypto";

// Same input + same attempt => same key. Reuse it when retrying.
const key = "flow-forge:" + createHash("sha256").update(payload.description).digest("hex").slice(0, 16) + ":a1";
const job = await api("POST", "/run", payload, { "Idempotency-Key": key });

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

const spec = JSON.parse(j.output.output);
console.log(spec.diagram_title, spec.verdict, spec.nodes.length, spec.edges.length);
import (
	"crypto/sha256"
	"encoding/hex"
	"time"
)

sum := sha256.Sum256([]byte(description))
key := "flow-forge:" + hex.EncodeToString(sum[:])[:16] + ":a1"

// call() with an extra header — add req.Header.Set("Idempotency-Key", key) there,
// or send "idempotency_key": key inside the body instead.
var job struct {
	JobID string `json:"job_id"`
}
if err := call("POST", "/run", payload, &job); err != nil {
	panic(err)
}

var j struct {
	Status string `json:"status"`
	Output struct {
		Output string `json:"output"`
	} `json:"output"`
}
for {
	if err := call("GET", "/jobs/"+job.JobID, nil, &j); err != nil {
		panic(err)
	}
	if j.Status == "succeeded" || j.Status == "failed" {
		break
	}
	time.Sleep(2 * time.Second)
}
fmt.Println(j.Output.Output) // the diagram spec, as a JSON string
// Add the header inside Api.call(), or build the request inline:
var runReq = HttpRequest.newBuilder(URI.create(Api.BASE + "/run"))
        .header("Authorization", "Bearer " + Api.TOKEN)
        .header("Content-Type", "application/json")
        .header("Idempotency-Key", "flow-forge:" + Integer.toHexString(description.hashCode()) + ":a1")
        .POST(HttpRequest.BodyPublishers.ofString(payload))
        .build();
var runRes = Api.HTTP.send(runReq, HttpResponse.BodyHandlers.ofString());
// read data.job_id, then poll GET /jobs/{job_id} until status is succeeded or failed.
// On a retry, send the very same Idempotency-Key value.
String job = Api.call("GET", "/jobs/" + jobId, null);
System.out.println(job);
require "digest"

key = "flow-forge:#{Digest::SHA256.hexdigest(payload["description"])[0, 16]}:a1"
job = api("POST", "/run", payload, { "Idempotency-Key" => key })   # reuse on retry

loop do
  @j = api("GET", "/jobs/#{job["job_id"]}")
  break if %w[succeeded failed].include?(@j["status"])
  sleep 2
end

spec = JSON.parse(@j["output"]["output"])
puts spec["diagram_title"], spec["verdict"], spec["nodes"].length
<?php
$key = "flow-forge:" . substr(hash("sha256", $payload["description"]), 0, 16) . ":a1";
$job = api("POST", "/run", $payload, ["Idempotency-Key" => $key]);   // reuse on retry

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

$spec = json_decode($j["output"]["output"], true);
echo $spec["diagram_title"], " ", $spec["verdict"], " ", count($spec["nodes"]), " nodes\n";
using System.Security.Cryptography;

var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(description)))[..16].ToLower();
var key = $"flow-forge:{hash}:a1";      // reuse this exact value on every retry
var job = await Api.Call(HttpMethod.Post, "/run", payload, ("Idempotency-Key", key));
var jobId = job.GetProperty("job_id").GetString();

JsonElement j;
do {
    await Task.Delay(2000);
    j = await Api.Call(HttpMethod.Get, $"/jobs/{jobId}");
} while (j.GetProperty("status").GetString() is not ("succeeded" or "failed"));

var spec = JsonDocument.Parse(j.GetProperty("output").GetProperty("output").GetString()!).RootElement;
Console.WriteLine(spec.GetProperty("verdict"));

Validate before you draw: at least two nodes, nodes[].id unique, and every edges[].source and edges[].target matching a node id. The app drops unmatched edges and tells the user how many it dropped rather than rendering a broken graph — do the same, because an mxGraph edge pointing at a missing cell is what makes draw.io refuse to open the file.

Step 5 — Stream instead (SSE)

POST /run-stream is the same call with Accept: text/event-stream. It emits an event: job frame, then a sequence of event: delta frames whose data.text carries fragments of the JSON in order, then event: done with the settled charge (or event: error). The frame name arrives on the SSE event: line, not as a type field inside the JSON payload. That is the single thing that breaks naive clients: they parse only the data: line, look for data.type, find nothing, and treat every frame alike. Track the current event name as you read, and reset it to the default on the blank line that ends each frame. Concatenate every delta's text and parse the whole thing once at the end — a half-arrived node list is not valid JSON.

curl -N -s -X POST "$API/run-stream" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -H "Accept: text/event-stream" -H "Idempotency-Key: $KEY" \
  -d @payload.json

# Standard SSE frames, separated by a blank line. The frame NAME is on the
# `event:` line; the payload on the `data:` line carries NO type field of its
# own, so you must track the current event name as you read:
#
#   event: job
#   data: {"job_id":"job_..."}
#
#   event: delta
#   data: {"text":"{\"diagram_title\":\"Order fulfilment\",\"nodes\":[..."}
#
#   event: done
#   data: {"status":"succeeded","charged_credits":640,"output":{"output":"..."}}
#
# Concatenate every delta's `text` in order and parse the result as one JSON
# object. An `event: error` frame carries a failure instead.
with requests.post(API + "/run-stream", json=payload, stream=True,
                   headers={"Authorization": f"Bearer {TOKEN}",
                            "Accept": "text/event-stream",
                            "Idempotency-Key": key}) as res:
    raw, event, done = "", "message", None
    for line in res.iter_lines(decode_unicode=True):
        if line is None:
            continue
        if line == "":                          # blank line ends a frame
            event = "message"
            continue
        if line.startswith("event:"):
            event = line[6:].strip()            # <- the frame name lives HERE
        elif line.startswith("data:"):
            data = json.loads(line[5:].strip()) # <- no "type" field in here
            if event == "delta":
                raw += data.get("text", "")
            elif event == "done":
                done = data
            elif event == "error":
                raise RuntimeError(data.get("message", "stream failed"))

spec = json.loads(raw[raw.index("{"): raw.rindex("}") + 1])
print(spec["diagram_title"], spec["verdict"], done["charged_credits"])
const res = await fetch(API + "/run-stream", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${TOKEN}`,
    "Content-Type": "application/json",
    Accept: "text/event-stream",
    "Idempotency-Key": key,
  },
  body: JSON.stringify(payload),
});

let raw = "", buf = "", done = null;
const reader = res.body.getReader();
const dec = new TextDecoder();
for (;;) {
  const chunk = await reader.read();
  if (chunk.done) break;
  buf += dec.decode(chunk.value, { stream: true });
  let idx;
  while ((idx = buf.indexOf("\n\n")) >= 0) {     // frames are blank-line separated
    const frame = buf.slice(0, idx);
    buf = buf.slice(idx + 2);
    let event = "message", dataStr = "";
    for (const line of frame.split("\n")) {
      if (line.startsWith("event:")) event = line.slice(6).trim();   // the frame name
      else if (line.startsWith("data:")) dataStr += line.slice(5).trim();
    }
    if (!dataStr) continue;
    const data = JSON.parse(dataStr);            // no data.type — use `event`
    if (event === "delta") raw += data.text ?? "";
    else if (event === "done") done = data;
    else if (event === "error") throw new Error(data.message ?? "stream failed");
  }
}
const spec = JSON.parse(raw.slice(raw.indexOf("{"), raw.lastIndexOf("}") + 1));
console.log(spec.diagram_title, spec.nodes.length, done?.charged_credits);
import (
	"bufio"
	"strings"
)

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("Accept", "text/event-stream")
req.Header.Set("Idempotency-Key", key)

stream, _ := http.DefaultClient.Do(req)
defer stream.Body.Close()

var raw bytes.Buffer
event := "message" // the frame name comes from the event: line, not the payload
sc := bufio.NewScanner(stream.Body)
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for sc.Scan() {
	line := sc.Text()
	switch {
	case line == "":
		event = "message" // blank line ends the frame
	case strings.HasPrefix(line, "event:"):
		event = strings.TrimSpace(line[6:])
	case strings.HasPrefix(line, "data:"):
		var d struct {
			Text string `json:"text"`
		}
		json.Unmarshal([]byte(line[5:]), &d)
		if event == "delta" {
			raw.WriteString(d.Text)
		}
	}
}
fmt.Println(raw.String()) // the diagram spec, as one JSON document
var streamReq = HttpRequest.newBuilder(URI.create(Api.BASE + "/run-stream"))
        .header("Authorization", "Bearer " + Api.TOKEN)
        .header("Content-Type", "application/json")
        .header("Accept", "text/event-stream")
        .header("Idempotency-Key", key)
        .POST(HttpRequest.BodyPublishers.ofString(payload))
        .build();

var raw = new StringBuilder();
var event = new String[]{"message"};   // the frame name, from the event: line
Api.HTTP.send(streamReq, HttpResponse.BodyHandlers.ofLines()).body().forEach(line -> {
    if (line.isEmpty()) {
        event[0] = "message";                 // blank line ends the frame
    } else if (line.startsWith("event:")) {
        event[0] = line.substring(6).trim();
    } else if (line.startsWith("data:") && event[0].equals("delta")) {
        // parse {"text":"..."} with your JSON library, then append the text
        raw.append(line.substring(5).trim());
    }
});
System.out.println(raw);
uri = URI(API + "/run-stream")
req = Net::HTTP::Post.new(uri)
req["Authorization"]   = "Bearer #{TOKEN}"
req["Content-Type"]    = "application/json"
req["Accept"]          = "text/event-stream"
req["Idempotency-Key"] = key
req.body = JSON.dump(payload)

raw   = +""
buf   = +""
event = "message"   # set from the event: line — the data: payload has no type
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
  http.request(req) do |res|
    res.read_body do |chunk|
      buf << chunk
      while (i = buf.index("\n\n"))
        frame = buf.slice!(0, i + 2)
        frame.each_line do |line|
          line = line.chomp
          if line.start_with?("event:")
            event = line[6..].strip
          elsif line.start_with?("data:") && event == "delta"
            raw << (JSON.parse(line[5..].strip)["text"] || "")
          end
        end
        event = "message"   # the frame ended
      end
    end
  end
end

spec = JSON.parse(raw[raw.index("{")..raw.rindex("}")])
puts spec["diagram_title"]
<?php
$raw   = "";
$buf   = "";
$event = "message";   // comes from the event: line, never from the data: payload

$ch = curl_init(API . "/run-stream");
curl_setopt_array($ch, [
    CURLOPT_POST       => true,
    CURLOPT_POSTFIELDS => json_encode($payload),
    CURLOPT_HTTPHEADER => [
        "Authorization: Bearer $TOKEN",
        "Content-Type: application/json",
        "Accept: text/event-stream",
        "Idempotency-Key: $key",
    ],
    CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$raw, &$buf, &$event) {
        $buf .= $chunk;
        while (($i = strpos($buf, "\n\n")) !== false) {
            $frame = substr($buf, 0, $i);
            $buf   = substr($buf, $i + 2);
            foreach (explode("\n", $frame) as $line) {
                if (str_starts_with($line, "event:")) {
                    $event = trim(substr($line, 6));
                } elseif (str_starts_with($line, "data:") && $event === "delta") {
                    $d = json_decode(substr($line, 5), true);
                    $raw .= $d["text"] ?? "";
                }
            }
            $event = "message";   // the frame ended
        }
        return strlen($chunk);
    },
]);
curl_exec($ch);
curl_close($ch);

$start = strpos($raw, "{");
$spec  = json_decode(substr($raw, $start, strrpos($raw, "}") - $start + 1), true);
echo $spec["diagram_title"], "\n";
var streamReq = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/run-stream");
streamReq.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
streamReq.Headers.Add("Accept", "text/event-stream");
streamReq.Headers.Add("Idempotency-Key", key);
streamReq.Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json");

var streamRes = await new HttpClient().SendAsync(streamReq, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await streamRes.Content.ReadAsStreamAsync());

var raw = new StringBuilder();
var evt = "message";   // the frame name: event: line only, no type in the payload
while (await reader.ReadLineAsync() is { } line) {
    if (line.Length == 0) { evt = "message"; continue; }   // blank line ends the frame
    if (line.StartsWith("event:")) { evt = line[6..].Trim(); continue; }
    if (!line.StartsWith("data:")) continue;
    var data = JsonDocument.Parse(line[5..]).RootElement;
    if (evt == "delta") raw.Append(data.GetProperty("text").GetString());
}
Console.WriteLine(raw.ToString());

If the stream dies mid-diagram you still hold every delta received so far. Slicing from the first { and closing the open brackets often recovers the node list — and a node list without its edges still draws something — so it is worth trying before you show an error.

The output contract

One JSON object, no prose and no code fences around it. These are the fields the app's own render path parses; anything missing makes the app fall back to showing the raw reply, so treat them as required.

FieldTypeMeaning
diagram_titlestringNames the diagram. Echoes your title when you sent one.
diagram_typestringThe resolved type: flowchart, architecture, sequence, er, uml-class or network. Anything else is rejected.
directionstringTB or LR — never auto. The layout reads this, not your request.
verdictstringdiagram-ready, assumptions-made or needs-detail. Anything else is rejected.
headlinestringOne line justifying the verdict.
summarystringWhat the diagram shows, in a few sentences.
nodesarrayAt least 2 entries of {id, label, kind, tier, group, note}. id must be unique across the array; kind must come from the request's node_kinds; tier is a 1-based layer hint the client layout uses when present (tier 1 nearest the start), and group/note may be empty strings.
edgesarray{source, target, label, kind, cardinality}. source and target must each match a nodes[].id — unmatched edges are dropped and the drop is reported to the user. kind comes from edge_kinds; cardinality is for er diagrams (e.g. 1..*) and empty elsewhere.
legendarray{kind, meaning} — one entry per node kind actually used, so the reader can decode the shapes.
entity_checkarray{id, included, node_id, note}, exactly one per id in prescan_entities. This is how you find the thing your description mentioned that the diagram quietly left out.
assumptions, open_questions, next_stepsarray<string>May be empty arrays, but never omitted. A needs-detail verdict should leave its reasons in open_questions.

Three cheap assertions make a spec safe to render: two or more nodes, unique ids, and no dangling edge endpoint. Add a fourth if you post-process — entity_check has one row per prescan_entities id — and you can fail a build when a diagram silently drops a service.

Step 6 — Writing the .drawio file yourself

The spec is a graph, so the export is a pure function of it — this is exactly what the browser does client-side after a run, and there is nothing browser-specific about it. A draw.io file is mxGraph XML: an mxfile wrapping a diagram, a mxGraphModel and a root whose first two cells are always id="0" and id="1" (the model root and the default layer); every other cell declares parent="1". Each node becomes a vertex="1" cell carrying an mxGeometry with x/y/width/height — there is no auto-layout on open, so a cell without geometry lands at the origin and the whole diagram stacks in one corner. Each edge becomes an edge="1" cell whose source and target are ids that exist in the file; a dangling endpoint is the usual reason draw.io refuses to open a generated file. Labels go in the value attribute and must be XML-escaped (&amp;, &lt;, &gt;, &quot;) — a service called Search & Rank corrupts the file otherwise.

Positioning is your choice; the app buckets nodes by tier (falling back to graph depth from the sources when tier is absent), spaces the tiers along direction and spreads each tier across the other axis. The skeleton:

<!-- The shape of the file you are producing. Cells 0 and 1 come first, always. -->
<mxfile host="flow-forge" modified="2026-08-06T09:00:00Z" agent="flow-forge">
  <diagram name="Order fulfilment" id="d1">
    <mxGraphModel dx="1100" dy="800" grid="1" gridSize="10" page="1"
                  pageWidth="1169" pageHeight="826">
      <root>
        <mxCell id="0"/>
        <mxCell id="1" parent="0"/>
        <mxCell id="n1" value="Shopper" style="ellipse;whiteSpace=wrap;html=1;"
                vertex="1" parent="1">
          <mxGeometry x="40" y="40" width="160" height="60" as="geometry"/>
        </mxCell>
        <mxCell id="n2" value="Checkout service" style="rounded=1;whiteSpace=wrap;html=1;"
                vertex="1" parent="1">
          <mxGeometry x="280" y="40" width="160" height="60" as="geometry"/>
        </mxCell>
        <mxCell id="e1" value="submits order" style="edgeStyle=orthogonalEdgeStyle;html=1;"
                edge="1" parent="1" source="n1" target="n2">
          <mxGeometry relative="1" as="geometry"/>
        </mxCell>
      </root>
    </mxGraphModel>
  </diagram>
</mxfile>

# Uncompressed mxGraph XML like this opens directly in draw.io / diagrams.net.
# Save it with a .drawio extension; no base64 or deflate wrapper is needed.
from xml.sax.saxutils import escape

SHAPE = {  # node kind -> mxGraph style
    "start": "ellipse;whiteSpace=wrap;html=1;", "end": "ellipse;whiteSpace=wrap;html=1;",
    "decision": "rhombus;whiteSpace=wrap;html=1;", "store": "shape=cylinder3;whiteSpace=wrap;html=1;",
    "actor": "shape=umlActor;html=1;", "note": "shape=note;whiteSpace=wrap;html=1;",
}
DEFAULT_SHAPE = "rounded=1;whiteSpace=wrap;html=1;"

def to_drawio(spec):
    ids = {n["id"] for n in spec["nodes"]}
    assert len(spec["nodes"]) >= 2 and len(ids) == len(spec["nodes"]), "bad node list"
    horizontal = spec.get("direction", "TB") == "LR"

    tiers = {}
    for n in spec["nodes"]:
        tiers.setdefault(int(n.get("tier") or 1), []).append(n)

    cells = ['<mxCell id="0"/>', '<mxCell id="1" parent="0"/>']
    for tier, group in sorted(tiers.items()):
        for i, n in enumerate(group):
            major, minor = (tier - 1) * 240 + 40, i * 110 + 40
            x, y = (major, minor) if horizontal else (minor, major)
            cells.append(
                f'<mxCell id="{escape(n["id"])}" value="{escape(n["label"])}" '
                f'style="{SHAPE.get(n.get("kind"), DEFAULT_SHAPE)}" vertex="1" parent="1">'
                f'<mxGeometry x="{x}" y="{y}" width="160" height="60" as="geometry"/></mxCell>')

    for i, e in enumerate(spec["edges"]):
        if e["source"] not in ids or e["target"] not in ids:
            continue                      # drop dangling edges; report the count
        label = e.get("label") or ""
        if e.get("cardinality"):
            label = f'{label} {e["cardinality"]}'.strip()
        cells.append(
            f'<mxCell id="e{i}" value="{escape(label)}" '
            f'style="edgeStyle=orthogonalEdgeStyle;html=1;" edge="1" parent="1" '
            f'source="{escape(e["source"])}" target="{escape(e["target"])}">'
            f'<mxGeometry relative="1" as="geometry"/></mxCell>')

    body = "".join(cells)
    return (f'<mxfile host="flow-forge"><diagram name="{escape(spec["diagram_title"])}" id="d1">'
            f'<mxGraphModel dx="1100" dy="800" grid="1" gridSize="10" page="1">'
            f'<root>{body}</root></mxGraphModel></diagram></mxfile>')

open("diagram.drawio", "w", encoding="utf-8").write(to_drawio(spec))
import { writeFileSync } from "node:fs";

const esc = (s) => String(s ?? "")
  .replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");

const SHAPE = {
  start: "ellipse;whiteSpace=wrap;html=1;", end: "ellipse;whiteSpace=wrap;html=1;",
  decision: "rhombus;whiteSpace=wrap;html=1;", store: "shape=cylinder3;whiteSpace=wrap;html=1;",
  actor: "shape=umlActor;html=1;", note: "shape=note;whiteSpace=wrap;html=1;",
};
const DEFAULT_SHAPE = "rounded=1;whiteSpace=wrap;html=1;";

function toDrawio(spec) {
  const ids = new Set(spec.nodes.map((n) => n.id));
  if (spec.nodes.length < 2 || ids.size !== spec.nodes.length) throw new Error("bad node list");
  const horizontal = spec.direction === "LR";

  const tiers = new Map();
  for (const n of spec.nodes) {
    const t = Number(n.tier) || 1;
    if (!tiers.has(t)) tiers.set(t, []);
    tiers.get(t).push(n);
  }

  // cells 0 and 1 must come first: the model root and the default layer
  const cells = ['<mxCell id="0"/>', '<mxCell id="1" parent="0"/>'];
  for (const t of [...tiers.keys()].sort((a, b) => a - b)) {
    tiers.get(t).forEach((n, i) => {
      const major = (t - 1) * 240 + 40, minor = i * 110 + 40;
      const [x, y] = horizontal ? [major, minor] : [minor, major];
      cells.push(
        `<mxCell id="${esc(n.id)}" value="${esc(n.label)}" ` +
        `style="${SHAPE[n.kind] ?? DEFAULT_SHAPE}" vertex="1" parent="1">` +
        `<mxGeometry x="${x}" y="${y}" width="160" height="60" as="geometry"/></mxCell>`);
    });
  }

  let dropped = 0;
  spec.edges.forEach((e, i) => {
    if (!ids.has(e.source) || !ids.has(e.target)) { dropped++; return; }
    const label = [e.label, e.cardinality].filter(Boolean).join(" ");
    cells.push(
      `<mxCell id="e${i}" value="${esc(label)}" style="edgeStyle=orthogonalEdgeStyle;html=1;" ` +
      `edge="1" parent="1" source="${esc(e.source)}" target="${esc(e.target)}">` +
      `<mxGeometry relative="1" as="geometry"/></mxCell>`);
  });
  if (dropped) console.warn(`${dropped} edge(s) referenced unknown nodes and were dropped`);

  return `<mxfile host="flow-forge"><diagram name="${esc(spec.diagram_title)}" id="d1">` +
         `<mxGraphModel dx="1100" dy="800" grid="1" gridSize="10" page="1">` +
         `<root>${cells.join("")}</root></mxGraphModel></diagram></mxfile>`;
}

writeFileSync("diagram.drawio", toDrawio(spec), "utf8");
import (
	"os"
	"sort"
	"strings"
)

type Node struct {
	ID, Label, Kind string
	Tier            int
}
type Edge struct{ Source, Target, Label string }

func esc(s string) string {
	r := strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;", `"`, "&quot;")
	return r.Replace(s)
}

func toDrawio(title, direction string, nodes []Node, edges []Edge) string {
	ids := map[string]bool{}
	for _, n := range nodes {
		ids[n.ID] = true
	}
	horizontal := direction == "LR"

	tiers := map[int][]Node{}
	for _, n := range nodes {
		t := n.Tier
		if t < 1 {
			t = 1
		}
		tiers[t] = append(tiers[t], n)
	}
	keys := make([]int, 0, len(tiers))
	for t := range tiers {
		keys = append(keys, t)
	}
	sort.Ints(keys)

	var b strings.Builder
	// cells 0 and 1 first: model root, then the default layer
	b.WriteString(`<mxCell id="0"/><mxCell id="1" parent="0"/>`)
	for _, t := range keys {
		for i, n := range tiers[t] {
			major, minor := (t-1)*240+40, i*110+40
			x, y := major, minor
			if !horizontal {
				x, y = minor, major
			}
			fmt.Fprintf(&b, `<mxCell id=%q value=%q style="rounded=1;whiteSpace=wrap;html=1;" vertex="1" parent="1">`+
				`<mxGeometry x="%d" y="%d" width="160" height="60" as="geometry"/></mxCell>`,
				esc(n.ID), esc(n.Label), x, y)
		}
	}
	for i, e := range edges {
		if !ids[e.Source] || !ids[e.Target] { // drop dangling edges
			continue
		}
		fmt.Fprintf(&b, `<mxCell id="e%d" value=%q style="edgeStyle=orthogonalEdgeStyle;html=1;" `+
			`edge="1" parent="1" source=%q target=%q><mxGeometry relative="1" as="geometry"/></mxCell>`,
			i, esc(e.Label), esc(e.Source), esc(e.Target))
	}

	return fmt.Sprintf(`<mxfile host="flow-forge"><diagram name=%q id="d1">`+
		`<mxGraphModel dx="1100" dy="800" grid="1" gridSize="10" page="1"><root>%s</root>`+
		`</mxGraphModel></diagram></mxfile>`, esc(title), b.String())
}

// os.WriteFile("diagram.drawio", []byte(toDrawio(title, dir, nodes, edges)), 0o644)
// Nodes and edges already read out of the spec with your JSON library.
import java.nio.file.Files;
import java.nio.file.Path;

static String esc(String s) {
    return s == null ? "" : s.replace("&", "&amp;").replace("<", "&lt;")
                             .replace(">", "&gt;").replace("\"", "&quot;");
}

static String toDrawio(String title, boolean horizontal,
                       List<Node> nodes, List<Edge> edges) {
    var ids = nodes.stream().map(n -> n.id).collect(java.util.stream.Collectors.toSet());
    if (nodes.size() < 2 || ids.size() != nodes.size()) throw new IllegalStateException("bad node list");

    var byTier = new java.util.TreeMap<Integer, List<Node>>();
    for (var n : nodes) byTier.computeIfAbsent(Math.max(1, n.tier), k -> new ArrayList<>()).add(n);

    var b = new StringBuilder();
    b.append("<mxCell id=\"0\"/><mxCell id=\"1\" parent=\"0\"/>");   // root, then layer
    for (var entry : byTier.entrySet()) {
        var group = entry.getValue();
        for (int i = 0; i < group.size(); i++) {
            int major = (entry.getKey() - 1) * 240 + 40, minor = i * 110 + 40;
            int x = horizontal ? major : minor, y = horizontal ? minor : major;
            b.append("<mxCell id=\"").append(esc(group.get(i).id))
             .append("\" value=\"").append(esc(group.get(i).label))
             .append("\" style=\"rounded=1;whiteSpace=wrap;html=1;\" vertex=\"1\" parent=\"1\">")
             .append("<mxGeometry x=\"").append(x).append("\" y=\"").append(y)
             .append("\" width=\"160\" height=\"60\" as=\"geometry\"/></mxCell>");
        }
    }
    for (int i = 0; i < edges.size(); i++) {
        var e = edges.get(i);
        if (!ids.contains(e.source) || !ids.contains(e.target)) continue;   // drop dangling
        b.append("<mxCell id=\"e").append(i).append("\" value=\"").append(esc(e.label))
         .append("\" style=\"edgeStyle=orthogonalEdgeStyle;html=1;\" edge=\"1\" parent=\"1\" source=\"")
         .append(esc(e.source)).append("\" target=\"").append(esc(e.target))
         .append("\"><mxGeometry relative=\"1\" as=\"geometry\"/></mxCell>");
    }
    return "<mxfile host=\"flow-forge\"><diagram name=\"" + esc(title) + "\" id=\"d1\">"
         + "<mxGraphModel dx=\"1100\" dy=\"800\" grid=\"1\" gridSize=\"10\" page=\"1\"><root>"
         + b + "</root></mxGraphModel></diagram></mxfile>";
}
// Files.writeString(Path.of("diagram.drawio"), toDrawio(title, true, nodes, edges));
require "cgi"

SHAPE = {
  "start" => "ellipse;whiteSpace=wrap;html=1;", "end" => "ellipse;whiteSpace=wrap;html=1;",
  "decision" => "rhombus;whiteSpace=wrap;html=1;", "store" => "shape=cylinder3;whiteSpace=wrap;html=1;",
  "actor" => "shape=umlActor;html=1;", "note" => "shape=note;whiteSpace=wrap;html=1;",
}
DEFAULT_SHAPE = "rounded=1;whiteSpace=wrap;html=1;"

def to_drawio(spec)
  ids = spec["nodes"].map { |n| n["id"] }
  raise "bad node list" if spec["nodes"].length < 2 || ids.uniq.length != ids.length
  horizontal = spec["direction"] == "LR"

  tiers = spec["nodes"].group_by { |n| [n["tier"].to_i, 1].max }
  cells = [%(<mxCell id="0"/>), %(<mxCell id="1" parent="0"/>)]   # root, then layer

  tiers.keys.sort.each do |t|
    tiers[t].each_with_index do |n, i|
      major = (t - 1) * 240 + 40
      minor = i * 110 + 40
      x, y = horizontal ? [major, minor] : [minor, major]
      cells << %(<mxCell id="#{CGI.escapeHTML(n["id"])}" value="#{CGI.escapeHTML(n["label"])}" ) +
                %(style="#{SHAPE.fetch(n["kind"], DEFAULT_SHAPE)}" vertex="1" parent="1">) +
                %(<mxGeometry x="#{x}" y="#{y}" width="160" height="60" as="geometry"/></mxCell>)
    end
  end

  spec["edges"].each_with_index do |e, i|
    next unless ids.include?(e["source"]) && ids.include?(e["target"])   # drop dangling
    label = [e["label"], e["cardinality"]].reject { |v| v.to_s.empty? }.join(" ")
    cells << %(<mxCell id="e#{i}" value="#{CGI.escapeHTML(label)}" ) +
              %(style="edgeStyle=orthogonalEdgeStyle;html=1;" edge="1" parent="1" ) +
              %(source="#{CGI.escapeHTML(e["source"])}" target="#{CGI.escapeHTML(e["target"])}">) +
              %(<mxGeometry relative="1" as="geometry"/></mxCell>)
  end

  %(<mxfile host="flow-forge"><diagram name="#{CGI.escapeHTML(spec["diagram_title"])}" id="d1">) +
    %(<mxGraphModel dx="1100" dy="800" grid="1" gridSize="10" page="1"><root>) +
    cells.join + %(</root></mxGraphModel></diagram></mxfile>)
end

File.write("diagram.drawio", to_drawio(spec))
<?php
const SHAPE = [
    "start" => "ellipse;whiteSpace=wrap;html=1;", "end" => "ellipse;whiteSpace=wrap;html=1;",
    "decision" => "rhombus;whiteSpace=wrap;html=1;", "store" => "shape=cylinder3;whiteSpace=wrap;html=1;",
    "actor" => "shape=umlActor;html=1;", "note" => "shape=note;whiteSpace=wrap;html=1;",
];
const DEFAULT_SHAPE = "rounded=1;whiteSpace=wrap;html=1;";

function e(?string $s): string { return htmlspecialchars((string) $s, ENT_QUOTES | ENT_XML1); }

function to_drawio(array $spec): string {
    $ids = array_column($spec["nodes"], "id");
    if (count($spec["nodes"]) < 2 || count(array_unique($ids)) !== count($ids)) {
        throw new RuntimeException("bad node list");
    }
    $horizontal = ($spec["direction"] ?? "TB") === "LR";

    $tiers = [];
    foreach ($spec["nodes"] as $n) { $tiers[max(1, (int) ($n["tier"] ?? 1))][] = $n; }
    ksort($tiers);

    $cells = ['<mxCell id="0"/>', '<mxCell id="1" parent="0"/>'];   // root, then layer
    foreach ($tiers as $t => $group) {
        foreach ($group as $i => $n) {
            $major = ($t - 1) * 240 + 40;
            $minor = $i * 110 + 40;
            [$x, $y] = $horizontal ? [$major, $minor] : [$minor, $major];
            $style = SHAPE[$n["kind"] ?? ""] ?? DEFAULT_SHAPE;
            $cells[] = '<mxCell id="' . e($n["id"]) . '" value="' . e($n["label"]) . '" style="' . $style
                     . '" vertex="1" parent="1"><mxGeometry x="' . $x . '" y="' . $y
                     . '" width="160" height="60" as="geometry"/></mxCell>';
        }
    }
    foreach ($spec["edges"] as $i => $edge) {
        if (!in_array($edge["source"], $ids, true) || !in_array($edge["target"], $ids, true)) {
            continue;   // drop dangling edges and tell the user how many
        }
        $label = trim(($edge["label"] ?? "") . " " . ($edge["cardinality"] ?? ""));
        $cells[] = '<mxCell id="e' . $i . '" value="' . e($label)
                 . '" style="edgeStyle=orthogonalEdgeStyle;html=1;" edge="1" parent="1" source="'
                 . e($edge["source"]) . '" target="' . e($edge["target"])
                 . '"><mxGeometry relative="1" as="geometry"/></mxCell>';
    }

    return '<mxfile host="flow-forge"><diagram name="' . e($spec["diagram_title"]) . '" id="d1">'
         . '<mxGraphModel dx="1100" dy="800" grid="1" gridSize="10" page="1"><root>'
         . implode("", $cells) . '</root></mxGraphModel></diagram></mxfile>';
}

file_put_contents("diagram.drawio", to_drawio($spec));
using System.Security;
using System.Text;

static string Esc(string? s) => SecurityElement.Escape(s ?? "");

static string ToDrawio(JsonElement spec) {
    var nodes = spec.GetProperty("nodes").EnumerateArray().ToList();
    var ids = nodes.Select(n => n.GetProperty("id").GetString()!).ToHashSet();
    if (nodes.Count < 2 || ids.Count != nodes.Count) throw new Exception("bad node list");
    var horizontal = spec.GetProperty("direction").GetString() == "LR";

    var tiers = nodes
        .GroupBy(n => n.TryGetProperty("tier", out var t) && t.TryGetInt32(out var v) ? Math.Max(1, v) : 1)
        .OrderBy(g => g.Key);

    var b = new StringBuilder();
    b.Append("<mxCell id=\"0\"/><mxCell id=\"1\" parent=\"0\"/>");   // root, then layer
    foreach (var tier in tiers) {
        var group = tier.ToList();
        for (var i = 0; i < group.Count; i++) {
            int major = (tier.Key - 1) * 240 + 40, minor = i * 110 + 40;
            int x = horizontal ? major : minor, y = horizontal ? minor : major;
            b.Append($"<mxCell id=\"{Esc(group[i].GetProperty("id").GetString())}\" " +
                     $"value=\"{Esc(group[i].GetProperty("label").GetString())}\" " +
                     "style=\"rounded=1;whiteSpace=wrap;html=1;\" vertex=\"1\" parent=\"1\">" +
                     $"<mxGeometry x=\"{x}\" y=\"{y}\" width=\"160\" height=\"60\" as=\"geometry\"/></mxCell>");
        }
    }

    var edges = spec.GetProperty("edges").EnumerateArray().ToList();
    for (var i = 0; i < edges.Count; i++) {
        var src = edges[i].GetProperty("source").GetString()!;
        var dst = edges[i].GetProperty("target").GetString()!;
        if (!ids.Contains(src) || !ids.Contains(dst)) continue;   // drop dangling
        var label = edges[i].TryGetProperty("label", out var l) ? l.GetString() : "";
        b.Append($"<mxCell id=\"e{i}\" value=\"{Esc(label)}\" " +
                 "style=\"edgeStyle=orthogonalEdgeStyle;html=1;\" edge=\"1\" parent=\"1\" " +
                 $"source=\"{Esc(src)}\" target=\"{Esc(dst)}\">" +
                 "<mxGeometry relative=\"1\" as=\"geometry\"/></mxCell>");
    }

    return $"<mxfile host=\"flow-forge\"><diagram name=\"{Esc(spec.GetProperty("diagram_title").GetString())}\" id=\"d1\">" +
           "<mxGraphModel dx=\"1100\" dy=\"800\" grid=\"1\" gridSize=\"10\" page=\"1\"><root>" +
           b + "</root></mxGraphModel></diagram></mxfile>";
}

File.WriteAllText("diagram.drawio", ToDrawio(spec));

The SVG export is the same walk with a different serialiser: one <rect> or <ellipse> plus a <text> per node at the coordinates you just computed, one <path> per edge, and a viewBox sized to the bounding box plus a margin. If you only need a picture, generate the SVG; if the diagram will be edited by a person afterwards, generate the .drawio — it round-trips through draw.io and keeps every label editable.