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.
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.
| Status | Meaning |
|---|---|
400 | Malformed body — a missing description, or the app slug sent as an X-App-Slug header instead of in the JSON body of POST /guest. |
401 | Missing or expired token — create a new session. |
402 | Not enough credits — top up at skillsafe.ai/account/billing. |
403 | The token isn't allowed to do this (e.g. a guest submitting a very large description). |
404 | Unknown path or unknown job id. On every endpoint but /jobs/{id} this almost always means an invented /apps/{slug}/ segment in the URL. |
429 | Rate limited — back off and retry. |
5xx | Transient 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
| Field | Type | Meaning |
|---|---|---|
description | string | Required. 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_type | string | One 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. |
style | string | One 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. |
notes | string | Optional. Extra instructions: the audience, elements that must appear, things to leave out, naming conventions to respect. |
$model | string | Optional. 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_note | string | Internal. 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.
| Field | Type | Meaning |
|---|---|---|
title | string | A short, specific name for this diagram. |
diagram_type | string | The 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. |
style | string | The style id actually used. Compare it with what you sent if you care about consistency across a batch. |
canvas | object | {"width": 1200, "height": 800} — matches the SVG root's viewBox, width and height. Use it to size a container before the markup arrives. |
svg | string | A 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_count | number | The 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. |
legend | array<string> | Every distinct label or caption appearing in the diagram. May be [], never omitted. |
notes | array<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.