Diagram Forge — API tutorial

Describe the system, get one standalone SVG.

API tokens Open the app

Draw the diagram from your own tools

Send a plain-language description of a system, a process, a deployment, an event pipeline, a data model or a concept — plus the diagram type and the visual style you want — and get back one JSON object whose svg field is a complete, standalone SVG document: every shape, arrowhead, gradient and label inlined, no external fonts, no external images, no <script>, no <foreignObject>. That string drops straight into a README, a docs build, a static site or a slide, and it stays editable text rather than a screenshot. Alongside it come the resolved diagram_type, the style that was used, the canvas size, an elements_count you can reconcile against what you count in the markup yourself, a legend, and the notes the model wrote about what it had to assume. Everything the app does goes through the SkillSafe App API — plain JSON over HTTPS — so you can regenerate an architecture diagram from a README on every commit, or batch-render a folder of design docs. Every 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 diagram-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. Estimates are free; runs are metered against your credit balance. There is a single run task — one description in, one SVG diagram out.

POST /guest GET /me POST /estimate POST /run POST /run-stream GET /jobs/{id}

The paths above are complete. There is no /apps/{slug}/ segment anywhere in this API — it is POST /v1/app-api/run, never POST /v1/app-api/apps/diagram-forge/run. The slug is bound to the token once, at POST /guest; after that the token is the app binding and the URL carries no app name at all. Guessing a slug segment returns a flat 404 with nothing useful in the body, which is exactly how a sibling app's author burned a release before spotting it. If you are getting 404s on every call, delete the app name from your URL before you debug anything else.

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 path or unknown job id. On every endpoint but /jobs/{id} this almost always means an invented /apps/{slug}/ segment in the URL.
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.
# Note the paths: no /apps/diagram-forge/ segment, ever.
import json, requests

API = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN"  # see step 1 — keep the real value out of source control

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 — keep the real value out of source control

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

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

var token = "YOUR_TOKEN" // see step 1 — inject it at runtime, don't commit it

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 = "YOUR_TOKEN";   // see step 1 — inject it at runtime
    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 = "YOUR_TOKEN"   # see step 1 — inject it at runtime, don't commit it

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 = "YOUR_TOKEN";   // see step 1 — inject it at runtime, don't commit it

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";
    const string Token = "YOUR_TOKEN";   // see step 1 — inject it at runtime
    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":"diagram-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. This is the only place the slug is ever mentioned; from here on the token carries it.

# 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":"diagram-forge"}' | jq -r .data.token

# For a personal token (metered runs bill your account), open
#   https://diagram-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": "diagram-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://diagram-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: "diagram-forge" }),
})).json();
const token = guest.data.token;

// For a personal token (metered runs bill your account), open
//   https://diagram-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": "diagram-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://diagram-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\":\"diagram-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://diagram-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" => "diagram-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://diagram-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" => "diagram-forge"]),
]);
$guest  = json_decode(curl_exec($ch), true);
curl_close($ch);
$TOKEN = $guest["data"]["token"];

// For a personal token, open https://diagram-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\":\"diagram-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://diagram-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 or a secret manager rather than in source control — the "YOUR_TOKEN" placeholders above are there so nothing on this page can be copy-pasted into a repository by accident.

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. The body is the input object itself — there is no {"input": …} wrapper on this API, and wrapping it produces an estimate for an empty description. hold_credits is a worst-case reservation, priced against the full output cap; the settled charged_credits is usually 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 the SVG string is cut off mid-element, so check that the markup ends in </svg> before you trust it.

Input fields

FieldTypeMeaning
descriptionstringRequired. 1–12000 characters of plain language: what to draw, in your own words. The app clips anything longer client-side before it ever reaches this endpoint and appends a note saying what was cut, so a direct caller should do the same rather than sending an oversized body.
diagram_typestringOne of auto, architecture, dataflow, flowchart, sequence, uml, er, cloud, event, reliability, agent, network, timeline, concept. Default auto, which lets the model infer the best fit from the description; a specific value is honoured even when another type would fit marginally better.
stylestringOne of flat-icon (default), terminal-dark, blueprint, minimal-clean, glassmorphism, warm-interface, clean-sdk, dark-luxury, c4-review, cloud-fabric, event-transit, ops-pulse. Each is a complete visual system — palette, line weight, corner radius, typography — not a background swap.
notesstringOptional. Extra instructions: the audience, elements that must appear, things to leave out, naming conventions to respect.
$modelstringOptional. Overrides the model alias the app is bound to — e.g. "$model": "gpt-sol" for the most capable tier. Changes the price, so re-run /estimate with the same override before you rely on the number.
retry_notestringInternal. The app's own reformat-retry lane sets this when a first reply failed to parse, so the model knows exactly what to fix. A direct API caller never needs to send it.
# The body IS the input object — no {"input": ...} wrapper on this API.
cat > payload.json <<'JSON'
{
  "description": "A checkout service architecture: a web frontend calls a Checkout API. The Checkout API writes orders to a Postgres database, publishes an OrderPlaced event to a Kafka topic, and calls a third-party Payments API over HTTPS. A worker service consumes the OrderPlaced topic and sends a confirmation email via an Email API.",
  "diagram_type": "architecture",
  "style": "flat-icon",
  "notes": "Audience is a backend engineer joining next week. Leave out monitoring."
}
JSON

curl -s -X POST "$API/estimate" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d @payload.json | jq .data

# { "model": "...", "model_alias": "...", "markup_bps": 1000,
#   "hold_credits": 3120, "min_credits": 480, "sponsor_enabled": false }
payload = {
    "description": (
        "A checkout service architecture: a web frontend calls a Checkout API. "
        "The Checkout API writes orders to a Postgres database, publishes an "
        "OrderPlaced event to a Kafka topic, and calls a third-party Payments API "
        "over HTTPS. A worker service consumes the OrderPlaced topic and sends a "
        "confirmation email via an Email API."
    ),
    "diagram_type": "architecture",   # or auto | dataflow | flowchart | sequence | uml | er |
                                      # cloud | event | reliability | agent | network |
                                      # timeline | concept
    "style": "flat-icon",             # one of the twelve style ids
    "notes": "Audience is a backend engineer joining next week. Leave out monitoring.",
    # "$model": "gpt-sol",            # optional: override the model alias
}
est = api("POST", "/estimate", payload)   # the body is the input itself, unwrapped
print(est["model"], est["model_alias"], est["hold_credits"], est["min_credits"])
const payload = {
  description:
    "A checkout service architecture: a web frontend calls a Checkout API. " +
    "The Checkout API writes orders to a Postgres database, publishes an OrderPlaced " +
    "event to a Kafka topic, and calls a third-party Payments API over HTTPS. " +
    "A worker service consumes the OrderPlaced topic and sends a confirmation email " +
    "via an Email API.",
  diagram_type: "architecture", // auto | architecture | dataflow | flowchart | sequence | uml |
                                // er | cloud | event | reliability | agent | network |
                                // timeline | concept
  style: "flat-icon",           // one of the twelve style ids
  notes: "Audience is a backend engineer joining next week. Leave out monitoring.",
  // "$model": "gpt-sol",       // optional: override the model alias
};
const est = await api("POST", "/estimate", payload);  // no {input:...} wrapper
console.log(est.model, est.hold_credits, est.min_credits);
description := "A checkout service architecture: a web frontend calls a Checkout API. " +
	"The Checkout API writes orders to a Postgres database, publishes an OrderPlaced " +
	"event to a Kafka topic, and calls a third-party Payments API over HTTPS."

// The body is the input object itself — there is no {"input": ...} wrapper.
payload := map[string]any{
	"description":  description,
	"diagram_type": "architecture",
	"style":        "flat-icon",
	"notes":        "Audience is a backend engineer joining next week.",
	// "$model":    "gpt-sol",
}

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)
// The body is the input object itself — there is no {"input": ...} wrapper.
String payload = """
    {"description":"A checkout service architecture: a web frontend calls a Checkout API. The Checkout API writes orders to a Postgres database, publishes an OrderPlaced event to a Kafka topic, and calls a third-party Payments API over HTTPS.",
     "diagram_type":"architecture",
     "style":"flat-icon",
     "notes":"Audience is a backend engineer joining next week. Leave out monitoring."}
    """;
String est = Api.call("POST", "/estimate", payload);
System.out.println(est);   // model, model_alias, markup_bps, hold_credits, min_credits
# The body is the input object itself — there is no {"input" => ...} wrapper.
payload = {
  "description"  => "A checkout service architecture: a web frontend calls a Checkout API. " \
                    "The Checkout API writes orders to a Postgres database, publishes an " \
                    "OrderPlaced event to a Kafka topic, and calls a third-party Payments API.",
  "diagram_type" => "architecture",
  "style"        => "flat-icon",
  "notes"        => "Audience is a backend engineer joining next week.",
  # "$model"     => "gpt-sol",
}
est = api("POST", "/estimate", payload)
puts est["model"], est["model_alias"], est["hold_credits"], est["min_credits"]
<?php
// The body is the input object itself — there is no {"input" => ...} wrapper.
$payload = [
    "description"  => "A checkout service architecture: a web frontend calls a Checkout API. "
                    . "The Checkout API writes orders to a Postgres database, publishes an "
                    . "OrderPlaced event to a Kafka topic, and calls a third-party Payments API.",
    "diagram_type" => "architecture",
    "style"        => "flat-icon",
    "notes"        => "Audience is a backend engineer joining next week.",
    // '$model'    => "gpt-sol",
];
$est = api("POST", "/estimate", $payload);
echo $est["model"], " ", $est["hold_credits"], " ", $est["min_credits"], "\n";
var description = "A checkout service architecture: a web frontend calls a Checkout API. " +
    "The Checkout API writes orders to a Postgres database, publishes an OrderPlaced event " +
    "to a Kafka topic, and calls a third-party Payments API over HTTPS.";

// The body is the input object itself — there is no { input = ... } wrapper.
var payload = new {
    description,
    diagram_type = "architecture",
    style = "flat-icon",
    notes = "Audience is a backend engineer joining next week. Leave out monitoring.",
};
var est = await Api.Call(HttpMethod.Post, "/estimate", payload);
Console.WriteLine($"{est.GetProperty("model")} {est.GetProperty("hold_credits")}");

Two cheap assertions are worth making in a test: model_alias is the alias you expect (the app's default, or whatever you passed as $model), and markup_bps is the publisher markup you agreed to. Together they prove the call is bound to the right model at the right price, and they cost nothing to check. sponsor_enabled tells you whether a sponsor is currently covering runs for this app.

Step 4 — Run

POST /run is synchronous: same body as /estimate, and the response carries the finished reply in data.output.output along with status, job_id and the settled charged_credits. Send an Idempotency-Key header derived from the input content plus an attempt counter — a retried POST that reuses the same key returns the original result instead of billing a second one, so a network blip cannot double-charge a diagram; a retry that invents a fresh key will. A diagram takes a while to draw, so allow a generous client timeout; if a response ever comes back with a non-terminal status, poll GET /jobs/{job_id} until it reads succeeded or failed, or use the streaming call in step 5 instead.

# The Idempotency-Key makes a retried POST return the original result 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="diagram-forge:$(shasum -a 256 payload.json | cut -c1-16):a1"

curl -s -X POST "$API/run" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  --max-time 600 \
  -d @payload.json > run.json

jq -r .data.output.output run.json \
  | jq '{title, diagram_type, style, elements_count, svg_bytes: (.svg | length)}'
import hashlib

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

raw = run["output"]["output"]          # the model's reply, as one JSON string
print(run["status"], run.get("charged_credits"))
# parsing `raw` into the diagram object is step 6
import { createHash } from "node:crypto";

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

const raw = run.output.output;          // the model's reply, as one JSON string
console.log(run.status, run.charged_credits);
// parsing `raw` into the diagram object is step 6
import (
	"crypto/sha256"
	"encoding/hex"
)

sum := sha256.Sum256([]byte(description))
key := "diagram-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 run struct {
	JobID          string `json:"job_id"`
	Status         string `json:"status"`
	ChargedCredits int64  `json:"charged_credits"`
	Output         struct {
		Output string `json:"output"`
	} `json:"output"`
}
if err := call("POST", "/run", payload, &run); err != nil {
	panic(err)
}
fmt.Println(run.Status, run.ChargedCredits)
raw := run.Output.Output // the model's reply, as one JSON string — parsed in step 6
// 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", "diagram-forge:" + Integer.toHexString(payload.hashCode()) + ":a1")
        .POST(HttpRequest.BodyPublishers.ofString(payload))
        .build();
var runRes = Api.HTTP.send(runReq, HttpResponse.BodyHandlers.ofString());
// runRes.body() is {"data":{"job_id":"...","status":"succeeded",
//                           "charged_credits":2140,"output":{"output":"{...}"}}}
// Read data.output.output — that string is the diagram object, parsed in step 6.
// On a retry, send the very same Idempotency-Key value.
System.out.println(runRes.body());
require "digest"

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

raw = run["output"]["output"]   # the model's reply, as one JSON string
puts run["status"], run["charged_credits"]
# parsing `raw` into the diagram object is step 6
<?php
$key = "diagram-forge:" . substr(hash("sha256", $payload["description"]), 0, 16) . ":a1";
$run = api("POST", "/run", $payload, ["Idempotency-Key" => $key]);   // reuse on retry

$raw = $run["output"]["output"];   // the model's reply, as one JSON string
echo $run["status"], " ", $run["charged_credits"], "\n";
// parsing $raw into the diagram object is step 6
using System.Security.Cryptography;

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

var raw = run.GetProperty("output").GetProperty("output").GetString()!;
Console.WriteLine(run.GetProperty("status"));
// parsing `raw` into the diagram object is step 6

output.output is text, not an object. Even when the model obeys the contract perfectly you still have a JSON document sitting inside a JSON string, so you parse twice: once for the envelope, once for the reply. Step 6 shows the tolerant parse the app itself uses.

Step 5 — Stream instead (SSE)

POST /run-stream is the same call with the same body and Accept: text/event-stream, and it accepts the same Idempotency-Key header. It emits an event: job frame, then a sequence of event: delta frames whose data.text carries fragments of the reply 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 on the blank line that ends each frame. Concatenate every delta's text and parse once at the end — half an SVG string is not valid JSON. The done frame's output.output is authoritative: if it is present, prefer it over your own concatenation.

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":"{\"title\":\"Checkout service architecture\",\"svg\":\"<svg ..."}
#
#   event: done
#   data: {"status":"succeeded","charged_credits":2140,"output":{"output":"..."}}
#
# Concatenate every delta's `text` in order and parse the result as one JSON
# object — or just read output.output out of the done frame, which is
# authoritative. 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"))

# the done frame wins when it carries the full reply
raw = (done or {}).get("output", {}).get("output") or raw
print(len(raw), "chars", (done or {}).get("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");
  }
}
raw = done?.output?.output ?? raw;               // the done frame is authoritative
console.log(raw.length, "chars", 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), 4*1024*1024) // SVG deltas get long
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.Len(), "chars") // one JSON document — parsed in step 6
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.length());
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

puts raw.length   # one JSON document — parsed in step 6
<?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);

echo strlen($raw), " chars\n";   // one JSON document — parsed in step 6
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.Length);   // one JSON document — parsed in step 6

If the stream dies mid-diagram you still hold every delta received so far. The svg field is emitted as one long string, so a truncated reply usually means an unterminated string rather than a missing field — check whether the markup you have ends in </svg>, and show the partial diagram rather than nothing if it does.

The output contract

output.output is a single JSON object rendered as text — no prose around it and, in the normal case, no code fence either. These are the fields the app's own render path reads; anything missing makes it fall back to showing the raw reply, so treat them as required.

FieldTypeMeaning
titlestringA short, specific name for this diagram.
diagram_typestringThe resolved type — one of the fourteen ids, and never auto, even when auto was requested. This is how you learn what the model decided it was drawing.
stylestringThe style id actually used. Compare it with what you sent if you care about consistency across a batch.
canvasobject{"width": 1200, "height": 800} — matches the SVG root's viewBox, width and height. Use it to size a container before the markup arrives.
svgstringA complete, standalone <svg>…</svg> document as one string: background, shapes, markers, gradients and text all inline. No external references of any kind, no <script>, no on*= attributes, no <foreignObject>, no external fonts or images. Sanitize it anyway before you inject it into a page — the app does.
elements_countnumberThe model's own count of distinct component shapes. The app counts the shapes in the markup independently and reports a mismatch; do the same if you are generating diagrams unattended, because a large disagreement is the cheapest signal that the drawing lost part of the description.
legendarray<string>Every distinct label or caption appearing in the diagram. May be [], never omitted.
notesarray<string>Real caveats: what did not fit, which relationships were inferred rather than stated, where two readings were plausible. May be [], never omitted.

Four cheap assertions make a reply safe to publish: svg starts with <svg and ends with </svg>; it contains no <script, no on-prefixed attribute and no http reference; diagram_type is not auto; and elements_count is within a shape or two of what you count yourself.

Step 6 — Parse the reply the way the app does

Be tolerant in exactly one direction: strip a leading and trailing markdown code fence if one is present, then take the substring from the first { to the last } and parse that. Do not try to repair the JSON beyond this — the app's own retry lane re-asks the model instead, sending retry_note so it knows what to fix. Once parsed, the svg string is ready to write to a .svg file, embed in a page, or hand to a rasterizer.

# jq's fromjson does the second parse. sed strips a fence if the model added one.
jq -r .data.output.output run.json \
  | sed '1{/^```/d;}; ${/^```$/d;}' \
  | jq -r '.svg' > diagram.svg

# Sanity checks before you publish it anywhere:
head -c 4 diagram.svg              # expect: <svg
tail -c 7 diagram.svg              # expect: </svg>
grep -c "<script" diagram.svg      # expect: 0
import re

def parse_reply(text):
    """The tolerant parse the app itself uses: strip a fence, then first { .. last }."""
    t = (text or "").strip()
    t = re.sub(r"^```[a-zA-Z]*\s*", "", t)
    t = re.sub(r"```\s*$", "", t)
    i, j = t.find("{"), t.rfind("}")
    if i < 0 or j <= i:
        raise ValueError("no JSON object found")
    return json.loads(t[i:j + 1])

diagram = parse_reply(raw)
svg = diagram["svg"]
assert svg.lstrip().startswith("<svg") and svg.rstrip().endswith("</svg>"), "truncated svg"
assert "<script" not in svg.lower(), "unexpected script in svg"
print(diagram["title"], diagram["diagram_type"], diagram["style"], diagram["elements_count"])
for n in diagram["notes"]:
    print(" note:", n)
// The tolerant parse the app itself uses: strip a fence, then first { .. last }.
function parseReply(text) {
  let t = String(text || "").trim();
  t = t.replace(/^```[a-z]*\s*/i, "").replace(/```\s*$/, "");
  const i = t.indexOf("{"), j = t.lastIndexOf("}");
  if (i < 0 || j <= i) throw new Error("no JSON object found");
  return JSON.parse(t.slice(i, j + 1));
}

const diagram = parseReply(raw);
const svg = diagram.svg;
if (!svg.trimStart().startsWith("<svg") || !svg.trimEnd().endsWith("</svg>")) {
  throw new Error("truncated svg");
}
if (/<script|\son[a-z]+\s*=/i.test(svg)) throw new Error("unexpected active content in svg");
console.log(diagram.title, diagram.diagram_type, diagram.style, diagram.elements_count);
diagram.notes.forEach((n) => console.log(" note:", n));
import (
	"errors"
	"regexp"
	"strings"
)

var fenceHead = regexp.MustCompile("^```[a-zA-Z]*\\s*")
var fenceTail = regexp.MustCompile("```\\s*$")

type Diagram struct {
	Title         string   `json:"title"`
	DiagramType   string   `json:"diagram_type"`
	Style         string   `json:"style"`
	Canvas        struct{ Width, Height int } `json:"canvas"`
	SVG           string   `json:"svg"`
	ElementsCount int      `json:"elements_count"`
	Legend        []string `json:"legend"`
	Notes         []string `json:"notes"`
}

// Strip a fence, then take first "{" .. last "}".
func parseReply(text string) (*Diagram, error) {
	t := strings.TrimSpace(text)
	t = fenceTail.ReplaceAllString(fenceHead.ReplaceAllString(t, ""), "")
	i, j := strings.Index(t, "{"), strings.LastIndex(t, "}")
	if i < 0 || j <= i {
		return nil, errors.New("no JSON object found")
	}
	var d Diagram
	if err := json.Unmarshal([]byte(t[i:j+1]), &d); err != nil {
		return nil, err
	}
	if !strings.HasSuffix(strings.TrimSpace(d.SVG), "</svg>") {
		return nil, errors.New("truncated svg")
	}
	return &d, nil
}
// Strip a fence, then take first "{" .. last "}" — then hand that to your
// JSON library (Jackson, Gson…) and read the fields off the result.
static String extractJson(String text) {
    String t = text == null ? "" : text.strip();
    t = t.replaceFirst("^```[a-zA-Z]*\\s*", "").replaceFirst("```\\s*$", "");
    int i = t.indexOf('{'), j = t.lastIndexOf('}');
    if (i < 0 || j <= i) throw new IllegalStateException("no JSON object found");
    return t.substring(i, j + 1);
}

String json = extractJson(raw);
// var diagram = new ObjectMapper().readTree(json);
// String svg = diagram.get("svg").asText();
// if (!svg.strip().endsWith("</svg>")) throw new IllegalStateException("truncated svg");
System.out.println(json.length());
# The tolerant parse the app itself uses: strip a fence, then first { .. last }.
def parse_reply(text)
  t = text.to_s.strip
  t = t.sub(/\A```[a-zA-Z]*\s*/, "").sub(/```\s*\z/, "")
  i = t.index("{")
  j = t.rindex("}")
  raise "no JSON object found" if i.nil? || j.nil? || j <= i
  JSON.parse(t[i..j])
end

diagram = parse_reply(raw)
svg = diagram["svg"]
raise "truncated svg" unless svg.strip.start_with?("<svg") && svg.strip.end_with?("</svg>")
puts diagram["title"], diagram["diagram_type"], diagram["style"], diagram["elements_count"]
diagram["notes"].each { |n| puts " note: #{n}" }
<?php
// The tolerant parse the app itself uses: strip a fence, then first { .. last }.
function parse_reply(?string $text): array {
    $t = trim((string) $text);
    $t = preg_replace('/^```[a-zA-Z]*\s*/', "", $t);
    $t = preg_replace('/```\s*$/', "", $t);
    $i = strpos($t, "{");
    $j = strrpos($t, "}");
    if ($i === false || $j === false || $j <= $i) {
        throw new RuntimeException("no JSON object found");
    }
    return json_decode(substr($t, $i, $j - $i + 1), true, 512, JSON_THROW_ON_ERROR);
}

$diagram = parse_reply($raw);
$svg = $diagram["svg"];
if (!str_ends_with(trim($svg), "</svg>")) { throw new RuntimeException("truncated svg"); }
echo $diagram["title"], " ", $diagram["diagram_type"], " ", $diagram["elements_count"], "\n";
foreach ($diagram["notes"] as $n) { echo " note: $n\n"; }
using System.Text.RegularExpressions;

// Strip a fence, then take first "{" .. last "}".
static JsonElement ParseReply(string? text) {
    var t = (text ?? "").Trim();
    t = Regex.Replace(t, @"^```[a-zA-Z]*\s*", "");
    t = Regex.Replace(t, @"```\s*$", "");
    int i = t.IndexOf('{'), j = t.LastIndexOf('}');
    if (i < 0 || j <= i) throw new Exception("no JSON object found");
    return JsonDocument.Parse(t[i..(j + 1)]).RootElement;
}

var diagram = ParseReply(raw);
var svg = diagram.GetProperty("svg").GetString()!;
if (!svg.TrimEnd().EndsWith("</svg>")) throw new Exception("truncated svg");
Console.WriteLine($"{diagram.GetProperty("title")} {diagram.GetProperty("diagram_type")} " +
                  $"{diagram.GetProperty("elements_count")}");

The SVG is deliberately self-contained, which makes the rest easy: it renders in any browser without a network fetch, converts to PNG or PDF with any standard rasterizer, and can be inlined in Markdown or HTML as-is. Because it is text, it also diffs — regenerate a diagram on every commit and the review shows which boxes and arrows changed, not just that an image did.