Drive Brand Diagram from your own code
Everything the page does over the network is available over HTTP. The natural uses are a docs build that regenerates its architecture figures from a checked-in brief whenever the brief changes, a design-system pipeline that re-derives the diagram skin whenever the brand tokens move, and a script that redraws a whole directory of diagrams after a rebrand so a hundred figures change colour in one pass.
One thing to know before you start: the API returns a diagram spec, not an image. Drawing happens in the browser, which is why editing and re-rendering are free on the page. If you want pixels from a script, render the spec yourself — the shape is documented in full below and the page's own renderer is a plain, dependency-free module you can read at /render.js.
The task field comes first
This app has one endpoint and three lanes. Every run input carries a task field,
and it decides which contract you get back. Send it explicitly — if it is missing the model
picks the closest lane and names its choice in notes, which is a fallback, not a
feature.
task | Input fields | What comes back |
|---|---|---|
skin | material (required), brand_notes, prefer_dark, prescan | skin.light and skin.dark (ten roles each), skin.fonts (three families with a fidelity verdict), roles[], receipt |
design | brief (required), material, type_hint, size, detail, audience, sketchy, skin_summary, prescan | diagram (the spec), fidelity (the ledger of what was cut) |
| any lane | retry_note — send only when re-asking after a reply that failed to parse. Quote the parse error and restate the contract; the model redoes the same task on the same input and returns only the JSON object. Reuse an Idempotency-Key derived from the same input with an attempt counter appended, so the retry cannot double-bill. | unchanged |
annotate | diagram (required — the spec from a design run), focus, prescan | callouts[] (at most two), focal, pattern, alt_title, alt_desc, caption, demote[] |
Every lane returns the same outer envelope — lane, title,
headline, coverage, notes, warnings —
with its own body merged in. One parser covers all three.
Base URL and the response envelope
Every endpoint lives under https://api.skillsafe.ai/v1/app-api and every response uses the same envelope:
{ "ok": true, "data": { ... } }
{ "ok": false, "error": { "code": "...", "message": "...", "status": 402, "details": { ... } } }
Send your app slug as X-App-Slug: brand-diagram and your token as
Authorization: Bearer … on every call.
Error codes
| code | status | What it means |
|---|---|---|
UNAUTHORIZED | 401 | No token, a malformed token, or a token minted for another app. Check X-App-Slug as well as the bearer. |
FORBIDDEN | 403 | A guest token on a metered lane where the publisher has not enabled sponsorship. Sign in for a personal token. |
NOT_FOUND | 404 | Wrong path, or a job_id that belongs to another subject. |
PAYMENT_REQUIRED | 402 | The balance is below min_credits. Never reachable if you compare /estimate against /me first. |
VALIDATION_ERROR | 400 | The input object is malformed. error.details names the field. |
RATE_LIMITED | 429 | Back off and retry. Do not tight-loop; the limit is shared across your whole account. |
INTERNAL | 500 | Retry once with the same Idempotency-Key. A retry under the same key cannot double-bill. |
1. Get a token
A guest token is one POST away and is enough for /me and /estimate,
and for everything the page does in your browser. Note that /guest wants the slug
in the request body as well as in the X-App-Slug header - a bare
{} comes back as a 400 saying slug is required.
both free. Running a lane spends credits, so it needs a personal token: sign in on the
tokens page and copy it from there. Treat it like a password —
it can spend your balance.
# A guest token. No account, no card - and it can run the metered lanes only if
# the publisher has sponsorship on, which this app does not. Use it to explore
# /me and /estimate, both of which are free.
curl -s -X POST https://api.skillsafe.ai/v1/app-api/guest \
-H 'X-App-Slug: brand-diagram' \
-H 'Content-Type: application/json' \
-d '{"slug":"brand-diagram"}' # the slug goes in the BODY here, not only the header
# -> {"ok":true,"data":{"token":"aut_...","guest_id":"gst_...","expires_at":"..."}}
# Note what is NOT in there: no subject_type and no balance. Call /me for those,
# or assume guest-with-no-balance, which is what a token you just minted is.
# For a token that can actually spend your credits, sign in at
# https://brand-diagram.skillsafe.ai/tokens.html and copy it from there.
export SKILLSAFE_TOKEN="aut_your_token_here"
import json, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
SLUG = "brand-diagram"
def post(path, body=None, token=None):
data = json.dumps(body or {}).encode()
req = urllib.request.Request(BASE + path, data=data, method="POST")
req.add_header("Content-Type", "application/json")
req.add_header("X-App-Slug", SLUG)
if token:
req.add_header("Authorization", "Bearer " + token)
with urllib.request.urlopen(req) as r:
return json.load(r)
# /guest wants the slug in the body as well as the header.
guest = post("/guest", {"slug": SLUG})["data"]
print(guest["token"], guest["guest_id"]) # no subject_type here - see /me below
# A personal token comes from the tokens page, not from code:
TOKEN = "YOUR_TOKEN"
const BASE = "https://api.skillsafe.ai/v1/app-api";
const SLUG = "brand-diagram";
const guest = await fetch(`${BASE}/guest`, {
method: "POST",
headers: { "Content-Type": "application/json", "X-App-Slug": SLUG },
// /guest wants the slug in the body as well as the header
body: JSON.stringify({ slug: SLUG })
}).then(r => r.json());
console.log(guest.data.token, guest.data.guest_id); // no subject_type here
// A personal token comes from the tokens page, not from code:
const TOKEN = "YOUR_TOKEN";
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
)
const base = "https://api.skillsafe.ai/v1/app-api"
const slug = "brand-diagram"
type envelope struct {
OK bool `json:"ok"`
Data json.RawMessage `json:"data"`
Error *struct {
Code string `json:"code"`
Message string `json:"message"`
Status int `json:"status"`
} `json:"error"`
}
func call(method, path, token string, body any) (envelope, error) {
var buf bytes.Buffer
if body != nil {
json.NewEncoder(&buf).Encode(body)
}
req, _ := http.NewRequest(method, base+path, &buf)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-App-Slug", slug)
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return envelope{}, err
}
defer res.Body.Close()
var env envelope
err = json.NewDecoder(res.Body).Decode(&env)
return env, err
}
func main() {
// /guest wants the slug in the body as well as the header
env, err := call("POST", "/guest", "", map[string]any{"slug": slug})
if err != nil {
panic(err)
}
fmt.Println(string(env.Data))
// A personal token comes from the tokens page, not from code:
token := "YOUR_TOKEN"
_ = token
}
import java.net.URI;
import java.net.http.*;
public class BrandDiagram {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final String SLUG = "brand-diagram";
static final HttpClient HTTP = HttpClient.newHttpClient();
static String call(String method, String path, String token, String body) throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(BASE + path))
.header("Content-Type", "application/json")
.header("X-App-Slug", SLUG);
if (token != null) b.header("Authorization", "Bearer " + token);
b.method(method, body == null
? HttpRequest.BodyPublishers.noBody()
: HttpRequest.BodyPublishers.ofString(body));
return HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString()).body();
}
public static void main(String[] args) throws Exception {
// /guest wants the slug in the body as well as the header
System.out.println(call("POST", "/guest", null, "{"slug":"" + SLUG + ""}"));
// A personal token comes from the tokens page, not from code:
String token = "YOUR_TOKEN";
}
}
require "json"
require "net/http"
BASE = URI("https://api.skillsafe.ai/v1/app-api")
SLUG = "brand-diagram"
def call(method, path, token: nil, body: nil)
uri = URI(BASE.to_s + path)
klass = method == "GET" ? Net::HTTP::Get : Net::HTTP::Post
req = klass.new(uri)
req["Content-Type"] = "application/json"
req["X-App-Slug"] = SLUG
req["Authorization"] = "Bearer #{token}" if token
req.body = JSON.generate(body || {}) unless method == "GET"
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
JSON.parse(res.body)
end
# /guest wants the slug in the body as well as the header.
guest = call("POST", "/guest", body: {"slug" => SLUG})["data"]
puts guest["token"]
# A personal token comes from the tokens page, not from code:
TOKEN = "YOUR_TOKEN"
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
const SLUG = "brand-diagram";
function call(string $method, string $path, ?string $token = null, ?array $body = null): array {
$headers = ["Content-Type: application/json", "X-App-Slug: " . SLUG];
if ($token) $headers[] = "Authorization: Bearer " . $token;
$ch = curl_init(BASE . $path);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => $headers,
]);
if ($method !== "GET") {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body ?? new stdClass()));
}
$out = curl_exec($ch);
curl_close($ch);
return json_decode($out, true);
}
// /guest wants the slug in the body as well as the header.
$guest = call("POST", "/guest", null, ["slug" => SLUG])["data"];
echo $guest["token"], PHP_EOL;
// A personal token comes from the tokens page, not from code:
$token = "YOUR_TOKEN";
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
const string Base = "https://api.skillsafe.ai/v1/app-api";
const string Slug = "brand-diagram";
var http = new HttpClient();
async Task<JsonDocument> Call(HttpMethod method, string path, string? token, object? body)
{
var req = new HttpRequestMessage(method, Base + path);
req.Headers.Add("X-App-Slug", Slug);
if (token is not null)
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
if (method != HttpMethod.Get)
req.Content = new StringContent(JsonSerializer.Serialize(body ?? new {}),
Encoding.UTF8, "application/json");
var res = await http.SendAsync(req);
return JsonDocument.Parse(await res.Content.ReadAsStringAsync());
}
// /guest wants the slug in the body as well as the header
var guest = await Call(HttpMethod.Post, "/guest", null, new { slug = Slug });
Console.WriteLine(guest.RootElement.GetProperty("data").GetProperty("token").GetString());
// A personal token comes from the tokens page, not from code:
var token = "YOUR_TOKEN";
2. Build the run input
One object per run. The three shapes below are the whole surface; anything else you send is
ignored rather than rejected, and anything required that is missing comes back as a
VALIDATION_ERROR naming the field.
# Every lane sends ONE object with a "task" field. This is the design lane.
cat > input.json <<'JSON'
{
"task": "design",
"brief": "Client -> CDN -> API Gateway -> Orders Service -> Postgres\nOrders Service -> Payments (Stripe)\nThe gateway is the only synchronous hop.",
"material": "",
"type_hint": "auto",
"size": "doc-inline",
"detail": "balanced",
"audience": "engineer",
"sketchy": false,
"skin_summary": { "accent": "#c73a2b", "paper": "#f8f6f0", "ink": "#14120f" },
"prescan": { "findings": [] }
}
JSON
DESIGN_INPUT = {
"task": "design",
"brief": (
"Client -> CDN -> API Gateway -> Orders Service -> Postgres\n"
"Orders Service -> Payments (Stripe)\n"
"The gateway is the only synchronous hop."
),
"material": "",
"type_hint": "auto", # or any of the eleven type ids
"size": "doc-inline",
"detail": "balanced",
"audience": "engineer",
"sketchy": False,
"skin_summary": {"accent": "#c73a2b", "paper": "#f8f6f0", "ink": "#14120f"},
"prescan": {"findings": []}, # send your own findings and they must come back reconciled
}
SKIN_INPUT = {
"task": "skin",
"material": ":root { --color-page: #f8f6f0; --color-cta: #c73a2b; }",
"brand_notes": "The gold is a badge colour, not the accent.",
"prefer_dark": False,
"prescan": {"findings": []},
}
ANNOTATE_INPUT = {
"task": "annotate",
"diagram": {"type": "architecture", "nodes": [], "edges": []}, # the spec from a design run
"focus": "The gateway is the bottleneck.",
"prescan": {"findings": []},
}
const designInput = {
task: "design",
brief: [
"Client -> CDN -> API Gateway -> Orders Service -> Postgres",
"Orders Service -> Payments (Stripe)",
"The gateway is the only synchronous hop."
].join("\n"),
material: "",
type_hint: "auto",
size: "doc-inline",
detail: "balanced",
audience: "engineer",
sketchy: false,
skin_summary: { accent: "#c73a2b", paper: "#f8f6f0", ink: "#14120f" },
prescan: { findings: [] }
};
const skinInput = {
task: "skin",
material: ":root { --color-page: #f8f6f0; --color-cta: #c73a2b; }",
brand_notes: "The gold is a badge colour, not the accent.",
prefer_dark: false,
prescan: { findings: [] }
};
const annotateInput = {
task: "annotate",
diagram: designResultSpec, // whatever the design lane returned
focus: "The gateway is the bottleneck.",
prescan: { findings: [] }
};
type DesignInput struct {
Task string `json:"task"`
Brief string `json:"brief"`
Material string `json:"material"`
TypeHint string `json:"type_hint"`
Size string `json:"size"`
Detail string `json:"detail"`
Audience string `json:"audience"`
Sketchy bool `json:"sketchy"`
SkinSummary map[string]string `json:"skin_summary,omitempty"`
Prescan map[string]any `json:"prescan,omitempty"`
}
input := DesignInput{
Task: "design",
Brief: "Client -> CDN -> API Gateway -> Orders Service -> Postgres\n" +
"Orders Service -> Payments (Stripe)\n" +
"The gateway is the only synchronous hop.",
TypeHint: "auto",
Size: "doc-inline",
Detail: "balanced",
Audience: "engineer",
SkinSummary: map[string]string{
"accent": "#c73a2b", "paper": "#f8f6f0", "ink": "#14120f",
},
Prescan: map[string]any{"findings": []any{}},
}
String brief = String.join("\n",
"Client -> CDN -> API Gateway -> Orders Service -> Postgres",
"Orders Service -> Payments (Stripe)",
"The gateway is the only synchronous hop.");
String designInput = """
{
"task": "design",
"brief": %s,
"material": "",
"type_hint": "auto",
"size": "doc-inline",
"detail": "balanced",
"audience": "engineer",
"sketchy": false,
"skin_summary": {"accent": "#c73a2b", "paper": "#f8f6f0", "ink": "#14120f"},
"prescan": {"findings": []}
}
""".formatted(quote(brief));
// quote() is any JSON string escaper - Jackson's writeValueAsString does it.
DESIGN_INPUT = {
"task" => "design",
"brief" => [
"Client -> CDN -> API Gateway -> Orders Service -> Postgres",
"Orders Service -> Payments (Stripe)",
"The gateway is the only synchronous hop."
].join("\n"),
"material" => "",
"type_hint" => "auto",
"size" => "doc-inline",
"detail" => "balanced",
"audience" => "engineer",
"sketchy" => false,
"skin_summary" => {"accent" => "#c73a2b", "paper" => "#f8f6f0", "ink" => "#14120f"},
"prescan" => {"findings" => []}
}
<?php
$designInput = [
"task" => "design",
"brief" => implode("\n", [
"Client -> CDN -> API Gateway -> Orders Service -> Postgres",
"Orders Service -> Payments (Stripe)",
"The gateway is the only synchronous hop.",
]),
"material" => "",
"type_hint" => "auto",
"size" => "doc-inline",
"detail" => "balanced",
"audience" => "engineer",
"sketchy" => false,
"skin_summary" => ["accent" => "#c73a2b", "paper" => "#f8f6f0", "ink" => "#14120f"],
"prescan" => ["findings" => []],
];
var designInput = new
{
task = "design",
brief = string.Join("\n",
"Client -> CDN -> API Gateway -> Orders Service -> Postgres",
"Orders Service -> Payments (Stripe)",
"The gateway is the only synchronous hop."),
material = "",
type_hint = "auto",
size = "doc-inline",
detail = "balanced",
audience = "engineer",
sketchy = false,
skin_summary = new { accent = "#c73a2b", paper = "#f8f6f0", ink = "#14120f" },
prescan = new { findings = Array.Empty<object>() }
};
The dials on the design lane
| Field | Values | Effect |
|---|---|---|
type_hint | auto or a type id | auto lets the model choose, which is usually the right call. An explicit id is honoured. |
size | doc-inline (960x600, default), doc-wide, slide-16x9, slide-4x3, social-og, social-square, print-a4-landscape, print-letter-landscape | Sets the viewBox and the type ramp. A slide gets 16px node names in 64px boxes, not shrunken body copy. |
detail | faithful (24 nodes, 32 edges), balanced (12 and 16, default), simplified (7 and 9, no sublabels) | A hard budget. Over it, the degrade ladder runs in a fixed order and every cut appears in fidelity. |
audience | engineer, mixed (default), executive | Sets the wording, not the count: the same twelve nodes get named differently for a platform team than for a board. |
sketchy | true / false | Wobbles the strokes with an SVG displacement filter. For an essay, not for documentation. |
Sending your own prescan
prescan.findings is a list of { id, severity, message, fix } objects.
Whatever you put there, the model must answer for: every id comes back in
coverage marked applied or set-aside with a reason. This
is how the page holds the model to what its own free reader already established, and it works
just as well from a script — put your own lint output in and you get a reconciliation back.
Send {"findings": []} if you have nothing.
3. Check the session and the balance
curl -s https://api.skillsafe.ai/v1/app-api/me \
-H 'X-App-Slug: brand-diagram' \
-H "Authorization: Bearer $SKILLSAFE_TOKEN"
# -> {"ok":true,"data":{"subject_type":"user","credits":184320,"app":{"slug":"brand-diagram"}}}
# subject_type "guest" means the token cannot spend; "user" means it can.
def get(path, token):
req = urllib.request.Request(BASE + path)
req.add_header("X-App-Slug", SLUG)
req.add_header("Authorization", "Bearer " + token)
with urllib.request.urlopen(req) as r:
return json.load(r)
me = get("/me", TOKEN)["data"]
print(me["subject_type"], me["credits"])
if me["subject_type"] != "user":
raise SystemExit("this token cannot spend credits - sign in on the tokens page")
const me = await fetch(`${BASE}/me`, {
headers: { "X-App-Slug": SLUG, Authorization: `Bearer ${TOKEN}` }
}).then(r => r.json());
console.log(me.data.subject_type, me.data.credits);
if (me.data.subject_type !== "user") {
throw new Error("this token cannot spend credits");
}
env, err := call("GET", "/me", token, nil)
if err != nil || !env.OK {
panic("could not read the session")
}
var me struct {
SubjectType string `json:"subject_type"`
Credits int `json:"credits"`
}
json.Unmarshal(env.Data, &me)
fmt.Println(me.SubjectType, me.Credits)
String me = call("GET", "/me", token, null);
System.out.println(me);
// Parse with your JSON library and check data.subject_type == "user"
// before assuming the token can spend credits.
me = call("GET", "/me", token: TOKEN)["data"]
puts "#{me['subject_type']} #{me['credits']}"
abort "this token cannot spend credits" unless me["subject_type"] == "user"
<?php
$me = call("GET", "/me", $token)["data"];
echo $me["subject_type"], " ", $me["credits"], PHP_EOL;
if ($me["subject_type"] !== "user") {
exit("this token cannot spend credits\n");
}
var me = await Call(HttpMethod.Get, "/me", token, null);
var data = me.RootElement.GetProperty("data");
Console.WriteLine($"{data.GetProperty("subject_type").GetString()} " +
$"{data.GetProperty("credits").GetInt32()}");
4. Price the run — free
/estimate creates no job and charges nothing. It is also the authoritative check
that your input shape is valid and that the app is bound to the model you expect:
model reads gpt-5.6-terra, model_alias reads
gpt-terra, and markup_bps is 1000.
Estimate the lane you are about to run. The hold differs per lane, so a hold
from the design lane is not the price of an annotate run.
# Free. No job is created and nothing is charged. Estimate the LANE you are
# about to run - the hold differs per lane because the prompts and the output
# caps differ.
curl -s -X POST https://api.skillsafe.ai/v1/app-api/estimate \
-H 'X-App-Slug: brand-diagram' \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" \
-H 'Content-Type: application/json' \
--data-binary @input.json
# -> {"ok":true,"data":{
# "model":"gpt-5.6-terra","model_alias":"gpt-terra","markup_bps":1000,
# "hold_credits":4180,"min_credits":220,"sponsor_enabled":false}}
est = post("/estimate", DESIGN_INPUT, TOKEN)["data"]
print(est["model"], est["model_alias"], est["hold_credits"], est["min_credits"])
# The hold prices the full output cap; you are charged for what the run uses,
# which is usually far less. Compare it to your balance BEFORE you run.
if me["credits"] < est["min_credits"]:
raise SystemExit(f"short by {est['min_credits'] - me['credits']} credits")
if me["credits"] < est["hold_credits"]:
print("warning: the reply may be truncated at this balance")
const est = await fetch(`${BASE}/estimate`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-App-Slug": SLUG,
Authorization: `Bearer ${TOKEN}`
},
body: JSON.stringify(designInput)
}).then(r => r.json()).then(r => r.data);
console.log(est.model, est.hold_credits, est.min_credits);
if (me.data.credits < est.min_credits) {
throw new Error(`short by ${est.min_credits - me.data.credits} credits`);
}
env, _ = call("POST", "/estimate", token, input)
var est struct {
Model string `json:"model"`
ModelAlias string `json:"model_alias"`
MarkupBps int `json:"markup_bps"`
Hold int `json:"hold_credits"`
Min int `json:"min_credits"`
}
json.Unmarshal(env.Data, &est)
fmt.Printf("%s hold=%d min=%d\n", est.Model, est.Hold, est.Min)
if me.Credits < est.Min {
panic(fmt.Sprintf("short by %d credits", est.Min-me.Credits))
}
String est = call("POST", "/estimate", token, designInput);
System.out.println(est);
// data.hold_credits is a reservation, not a price. data.model reports
// "gpt-5.6-terra" and data.model_alias reports "gpt-terra".
est = call("POST", "/estimate", token: TOKEN, body: DESIGN_INPUT)["data"]
puts "#{est['model']} hold=#{est['hold_credits']} min=#{est['min_credits']}"
abort "short by #{est['min_credits'] - me['credits']}" if me["credits"] < est["min_credits"]
<?php
$est = call("POST", "/estimate", $token, $designInput)["data"];
printf("%s hold=%d min=%d\n", $est["model"], $est["hold_credits"], $est["min_credits"]);
if ($me["credits"] < $est["min_credits"]) {
exit("short by " . ($est["min_credits"] - $me["credits"]) . " credits\n");
}
var estDoc = await Call(HttpMethod.Post, "/estimate", token, designInput);
var est = estDoc.RootElement.GetProperty("data");
Console.WriteLine($"{est.GetProperty("model").GetString()} " +
$"hold={est.GetProperty("hold_credits").GetInt32()} " +
$"min={est.GetProperty("min_credits").GetInt32()}");
5. Run it, then poll
POST /run returns a job_id; poll GET /jobs/{job_id} until
status is succeeded, failed or cancelled. The
reply text is at data.output.output.
Always send an Idempotency-Key, and put the lane in it. Two lanes
over the same material are two distinct runs and must not collide on one key. Reusing a key
after a network failure returns the original job instead of starting a second billed one.
# The Idempotency-Key MUST include the lane: two lanes over the same material
# are two distinct runs and must not collide on one key. Reuse the same key on a
# retry after a network failure and you will never be billed twice.
KEY="brand-diagram:design:$(shasum -a 256 input.json | cut -c1-16):a1"
JOB=$(curl -s -X POST https://api.skillsafe.ai/v1/app-api/run \
-H 'X-App-Slug: brand-diagram' \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" \
-H "Idempotency-Key: $KEY" \
-H 'Content-Type: application/json' \
--data-binary @input.json | python3 -c 'import json,sys;print(json.load(sys.stdin)["data"]["job_id"])')
# Poll until terminal.
until curl -s "https://api.skillsafe.ai/v1/app-api/jobs/$JOB" \
-H 'X-App-Slug: brand-diagram' -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
| tee job.json | grep -q '"status":"\(succeeded\|failed\|cancelled\)"'; do sleep 2; done
python3 -c 'import json;print(json.load(open("job.json"))["data"]["output"]["output"])' > reply.json
import hashlib, time
def idempotency_key(lane, payload, attempt=1):
body = json.dumps(payload, sort_keys=True).encode()
digest = hashlib.sha256(body).hexdigest()[:16]
# The lane is part of the key on purpose.
return f"{SLUG}:{lane}:{digest}:a{attempt}"
def run(payload, token, attempt=1):
data = json.dumps(payload).encode()
req = urllib.request.Request(BASE + "/run", data=data, method="POST")
req.add_header("Content-Type", "application/json")
req.add_header("X-App-Slug", SLUG)
req.add_header("Authorization", "Bearer " + token)
req.add_header("Idempotency-Key", idempotency_key(payload["task"], payload, attempt))
with urllib.request.urlopen(req) as r:
return json.load(r)["data"]["job_id"]
job_id = run(DESIGN_INPUT, TOKEN)
while True:
job = get(f"/jobs/{job_id}", TOKEN)["data"]
if job["status"] in ("succeeded", "failed", "cancelled"):
break
time.sleep(2)
if job["status"] != "succeeded":
raise SystemExit(job.get("error") or job["status"])
reply = json.loads(job["output"]["output"])
print(reply["lane"], reply["title"])
print("charged", job.get("charged_credits"), "truncated", job.get("truncated"))
import { createHash } from "node:crypto";
function idempotencyKey(lane, payload, attempt = 1) {
const digest = createHash("sha256")
.update(JSON.stringify(payload))
.digest("hex")
.slice(0, 16);
return `${SLUG}:${lane}:${digest}:a${attempt}`; // the lane belongs in the key
}
const started = await fetch(`${BASE}/run`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-App-Slug": SLUG,
Authorization: `Bearer ${TOKEN}`,
"Idempotency-Key": idempotencyKey("design", designInput)
},
body: JSON.stringify(designInput)
}).then(r => r.json());
let job;
do {
await new Promise(r => setTimeout(r, 2000));
job = await fetch(`${BASE}/jobs/${started.data.job_id}`, {
headers: { "X-App-Slug": SLUG, Authorization: `Bearer ${TOKEN}` }
}).then(r => r.json()).then(r => r.data);
} while (!["succeeded", "failed", "cancelled"].includes(job.status));
const reply = JSON.parse(job.output.output);
console.log(reply.lane, reply.title, job.charged_credits);
import (
"crypto/sha256"
"encoding/hex"
"time"
)
func idempotencyKey(lane string, payload any, attempt int) string {
b, _ := json.Marshal(payload)
sum := sha256.Sum256(b)
return fmt.Sprintf("%s:%s:%s:a%d", slug, lane, hex.EncodeToString(sum[:])[:16], attempt)
}
func runJob(token string, payload any, lane string) (string, error) {
var buf bytes.Buffer
json.NewEncoder(&buf).Encode(payload)
req, _ := http.NewRequest("POST", base+"/run", &buf)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-App-Slug", slug)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Idempotency-Key", idempotencyKey(lane, payload, 1))
res, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer res.Body.Close()
var env envelope
json.NewDecoder(res.Body).Decode(&env)
var started struct{ JobID string `json:"job_id"` }
json.Unmarshal(env.Data, &started)
return started.JobID, nil
}
jobID, _ := runJob(token, input, "design")
for {
env, _ := call("GET", "/jobs/"+jobID, token, nil)
var job struct {
Status string `json:"status"`
Charged int `json:"charged_credits"`
Output struct{ Output string `json:"output"` } `json:"output"`
}
json.Unmarshal(env.Data, &job)
if job.Status == "succeeded" {
fmt.Println(job.Output.Output, job.Charged)
break
}
if job.Status == "failed" || job.Status == "cancelled" {
panic(job.Status)
}
time.Sleep(2 * time.Second)
}
import java.security.MessageDigest;
static String idempotencyKey(String lane, String body, int attempt) throws Exception {
byte[] d = MessageDigest.getInstance("SHA-256").digest(body.getBytes("UTF-8"));
StringBuilder hex = new StringBuilder();
for (int i = 0; i < 8; i++) hex.append(String.format("%02x", d[i]));
return SLUG + ":" + lane + ":" + hex + ":a" + attempt; // the lane belongs in the key
}
HttpRequest req = HttpRequest.newBuilder(URI.create(BASE + "/run"))
.header("Content-Type", "application/json")
.header("X-App-Slug", SLUG)
.header("Authorization", "Bearer " + token)
.header("Idempotency-Key", idempotencyKey("design", designInput, 1))
.POST(HttpRequest.BodyPublishers.ofString(designInput))
.build();
String started = HTTP.send(req, HttpResponse.BodyHandlers.ofString()).body();
// Then GET /jobs/{job_id} every two seconds until status is
// succeeded, failed or cancelled, and read data.output.output.
require "digest"
def idempotency_key(lane, payload, attempt = 1)
digest = Digest::SHA256.hexdigest(JSON.generate(payload))[0, 16]
"#{SLUG}:#{lane}:#{digest}:a#{attempt}" # the lane belongs in the key
end
uri = URI(BASE.to_s + "/run")
req = Net::HTTP::Post.new(uri)
req["Content-Type"] = "application/json"
req["X-App-Slug"] = SLUG
req["Authorization"] = "Bearer #{TOKEN}"
req["Idempotency-Key"] = idempotency_key("design", DESIGN_INPUT)
req.body = JSON.generate(DESIGN_INPUT)
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
job_id = JSON.parse(res.body)["data"]["job_id"]
loop do
job = call("GET", "/jobs/#{job_id}", token: TOKEN)["data"]
if %w[succeeded failed cancelled].include?(job["status"])
abort job["status"] unless job["status"] == "succeeded"
reply = JSON.parse(job["output"]["output"])
puts "#{reply['lane']} #{reply['title']} charged=#{job['charged_credits']}"
break
end
sleep 2
end
<?php
function idempotency_key(string $lane, array $payload, int $attempt = 1): string {
$digest = substr(hash("sha256", json_encode($payload)), 0, 16);
return SLUG . ":$lane:$digest:a$attempt"; // the lane belongs in the key
}
$ch = curl_init(BASE . "/run");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
"Content-Type: application/json",
"X-App-Slug: " . SLUG,
"Authorization: Bearer $token",
"Idempotency-Key: " . idempotency_key("design", $designInput),
],
CURLOPT_POSTFIELDS => json_encode($designInput),
]);
$jobId = json_decode(curl_exec($ch), true)["data"]["job_id"];
curl_close($ch);
do {
sleep(2);
$job = call("GET", "/jobs/$jobId", $token)["data"];
} while (!in_array($job["status"], ["succeeded", "failed", "cancelled"], true));
$reply = json_decode($job["output"]["output"], true);
echo $reply["lane"], " ", $reply["title"], PHP_EOL;
using System.Security.Cryptography;
string IdempotencyKey(string lane, object payload, int attempt = 1)
{
var body = JsonSerializer.Serialize(payload);
var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(body)))[..16].ToLower();
return $"{Slug}:{lane}:{hash}:a{attempt}"; // the lane belongs in the key
}
var runReq = new HttpRequestMessage(HttpMethod.Post, Base + "/run");
runReq.Headers.Add("X-App-Slug", Slug);
runReq.Headers.Add("Idempotency-Key", IdempotencyKey("design", designInput));
runReq.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
runReq.Content = new StringContent(JsonSerializer.Serialize(designInput),
Encoding.UTF8, "application/json");
var startedRes = await http.SendAsync(runReq);
var jobId = JsonDocument.Parse(await startedRes.Content.ReadAsStringAsync())
.RootElement.GetProperty("data").GetProperty("job_id").GetString();
JsonElement job;
do
{
await Task.Delay(2000);
job = (await Call(HttpMethod.Get, $"/jobs/{jobId}", token, null))
.RootElement.GetProperty("data");
} while (job.GetProperty("status").GetString() is not ("succeeded" or "failed" or "cancelled"));
6. Or stream it
POST /run-stream takes the same body and the same headers and emits server-sent
events: a job event with the id, a run of delta events each carrying a
text fragment, and a final done event with status,
charged_credits and truncated. Concatenate the deltas to get the same
string /run would have handed you.
# Server-sent events. Same body, same Idempotency-Key rules.
curl -N -s -X POST https://api.skillsafe.ai/v1/app-api/run-stream \
-H 'X-App-Slug: brand-diagram' \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" \
-H "Idempotency-Key: $KEY" \
-H 'Content-Type: application/json' \
-H 'Accept: text/event-stream' \
--data-binary @input.json
# event: job data: {"job_id":"job_..."}
# event: delta data: {"text":"{\"lane\":\"design\""}
# event: delta data: {"text":",\"title\":\"Checkout"}
# event: done data: {"status":"succeeded","charged_credits":3120,"truncated":false}
import urllib.request, json
def run_stream(payload, token, on_delta):
data = json.dumps(payload).encode()
req = urllib.request.Request(BASE + "/run-stream", data=data, method="POST")
req.add_header("Content-Type", "application/json")
req.add_header("Accept", "text/event-stream")
req.add_header("X-App-Slug", SLUG)
req.add_header("Authorization", "Bearer " + token)
req.add_header("Idempotency-Key", idempotency_key(payload["task"], payload))
raw, done = "", None
with urllib.request.urlopen(req) as r:
event = None
for line in r:
line = line.decode("utf-8").rstrip("\n")
if line.startswith("event:"):
event = line[6:].strip()
elif line.startswith("data:"):
body = json.loads(line[5:].strip() or "{}")
if event == "delta":
raw += body.get("text", "")
on_delta(body.get("text", ""))
elif event == "done":
done = body
return raw, done
raw, done = run_stream(DESIGN_INPUT, TOKEN, lambda t: print(t, end="", flush=True))
reply = json.loads(raw)
if done and done.get("truncated"):
print("\nthe reply was cut short by the available balance")
const res = await fetch(`${BASE}/run-stream`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "text/event-stream",
"X-App-Slug": SLUG,
Authorization: `Bearer ${TOKEN}`,
"Idempotency-Key": idempotencyKey("design", designInput)
},
body: JSON.stringify(designInput)
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "", raw = "", event = null, done = null;
while (true) {
const { value, done: finished } = await reader.read();
if (finished) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop();
for (const line of lines) {
if (line.startsWith("event:")) event = line.slice(6).trim();
else if (line.startsWith("data:")) {
const body = JSON.parse(line.slice(5).trim() || "{}");
if (event === "delta") raw += body.text ?? "";
if (event === "done") done = body;
}
}
}
const reply = JSON.parse(raw);
console.log(reply.diagram.type, done?.charged_credits, done?.truncated);
import "bufio"
req, _ := http.NewRequest("POST", base+"/run-stream", &buf)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "text/event-stream")
req.Header.Set("X-App-Slug", slug)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Idempotency-Key", idempotencyKey("design", input, 1))
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
var raw bytes.Buffer
event := ""
scanner := bufio.NewScanner(res.Body)
scanner.Buffer(make([]byte, 1024*1024), 1024*1024)
for scanner.Scan() {
line := scanner.Text()
switch {
case strings.HasPrefix(line, "event:"):
event = strings.TrimSpace(line[6:])
case strings.HasPrefix(line, "data:"):
var body struct {
Text string `json:"text"`
Status string `json:"status"`
Truncated bool `json:"truncated"`
}
json.Unmarshal([]byte(strings.TrimSpace(line[5:])), &body)
if event == "delta" {
raw.WriteString(body.Text)
}
if event == "done" && body.Truncated {
fmt.Println("the reply was cut short by the available balance")
}
}
}
HttpRequest stream = HttpRequest.newBuilder(URI.create(BASE + "/run-stream"))
.header("Content-Type", "application/json")
.header("Accept", "text/event-stream")
.header("X-App-Slug", SLUG)
.header("Authorization", "Bearer " + token)
.header("Idempotency-Key", idempotencyKey("design", designInput, 1))
.POST(HttpRequest.BodyPublishers.ofString(designInput))
.build();
StringBuilder raw = new StringBuilder();
String[] event = { "" };
HTTP.send(stream, HttpResponse.BodyHandlers.ofLines()).body().forEach(line -> {
if (line.startsWith("event:")) {
event[0] = line.substring(6).trim();
} else if (line.startsWith("data:") && event[0].equals("delta")) {
// data is {"text":"..."} - append the decoded text field
raw.append(extractText(line.substring(5).trim()));
}
});
uri = URI(BASE.to_s + "/run-stream")
req = Net::HTTP::Post.new(uri)
req["Content-Type"] = "application/json"
req["Accept"] = "text/event-stream"
req["X-App-Slug"] = SLUG
req["Authorization"] = "Bearer #{TOKEN}"
req["Idempotency-Key"] = idempotency_key("design", DESIGN_INPUT)
req.body = JSON.generate(DESIGN_INPUT)
raw = ""
event = nil
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
http.request(req) do |res|
res.read_body do |chunk|
chunk.each_line do |line|
line = line.chomp
if line.start_with?("event:")
event = line[6..].strip
elsif line.start_with?("data:")
body = JSON.parse(line[5..].strip.empty? ? "{}" : line[5..].strip)
raw << body.fetch("text", "") if event == "delta"
warn "cut short" if event == "done" && body["truncated"]
end
end
end
end
end
reply = JSON.parse(raw)
<?php
$raw = "";
$event = null;
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
"Content-Type: application/json",
"Accept: text/event-stream",
"X-App-Slug: " . SLUG,
"Authorization: Bearer $token",
"Idempotency-Key: " . idempotency_key("design", $designInput),
],
CURLOPT_POSTFIELDS => json_encode($designInput),
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$raw, &$event) {
foreach (explode("\n", $chunk) as $line) {
$line = rtrim($line);
if (str_starts_with($line, "event:")) {
$event = trim(substr($line, 6));
} elseif (str_starts_with($line, "data:")) {
$body = json_decode(trim(substr($line, 5)) ?: "{}", true);
if ($event === "delta") $raw .= $body["text"] ?? "";
}
}
return strlen($chunk);
},
]);
curl_exec($ch);
curl_close($ch);
$reply = json_decode($raw, true);
var streamReq = new HttpRequestMessage(HttpMethod.Post, Base + "/run-stream");
streamReq.Headers.Add("X-App-Slug", Slug);
streamReq.Headers.Add("Accept", "text/event-stream");
streamReq.Headers.Add("Idempotency-Key", IdempotencyKey("design", designInput));
streamReq.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
streamReq.Content = new StringContent(JsonSerializer.Serialize(designInput),
Encoding.UTF8, "application/json");
using var streamRes = await http.SendAsync(streamReq,
HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await streamRes.Content.ReadAsStreamAsync());
var raw = new StringBuilder();
string? evt = null;
while (await reader.ReadLineAsync() is { } line)
{
if (line.StartsWith("event:")) evt = line[6..].Trim();
else if (line.StartsWith("data:") && evt == "delta")
{
var body = JsonDocument.Parse(line[5..].Trim()).RootElement;
if (body.TryGetProperty("text", out var t)) raw.Append(t.GetString());
}
}
7. Read the reply
The checks worth writing are the ones the page itself makes: that the lane is the one you asked for, that every edge endpoint exists, that at most two elements are focal, and that every finding you sent came back reconciled.
# The reply is the envelope with the lane's body merged in. Pull out what you
# need with any JSON tool.
python3 - reply.json <<'PY'
import json, sys
r = json.load(open(sys.argv[1]))
d = r["diagram"]
print(r["lane"], "-", r["title"])
print("type:", d["type"], "-", d.get("type_reason", ""))
print("nodes:", len(d["nodes"]), "edges:", len(d.get("edges", [])))
focal = [n["label"] for n in d["nodes"] if n.get("focal")]
assert len(focal) <= 2, "at most two focal elements"
print("accent on:", ", ".join(focal))
for c in r["coverage"]:
print(" ", c["status"], c["id"], "-", c["note"])
PY
# The envelope is the same for every lane; only the body differs.
assert reply["lane"] == "design"
assert isinstance(reply["coverage"], list)
d = reply["diagram"]
print(d["type"], d["type_reason"])
print(d["size"], d["detail"], d["audience"])
by_id = {n["id"]: n for n in d["nodes"]}
for e in d.get("edges", []):
assert e["from"] in by_id, f"edge from an unknown node: {e['from']}"
assert e["to"] in by_id, f"edge to an unknown node: {e['to']}"
focal = [n for n in d["nodes"] if n.get("focal")]
assert len(focal) <= 2, "the grammar allows at most two focal elements"
# Every finding you sent in must come back reconciled.
sent = {f["id"] for f in DESIGN_INPUT["prescan"]["findings"]}
answered = {c["id"] for c in reply["coverage"]}
missing = sent - answered
if missing:
print("not reconciled:", ", ".join(sorted(missing)))
led = reply["fidelity"]
print(f"{led['source_elements']} elements in, {led['drawn']} drawn")
for line in led["dropped"]:
print("dropped:", line)
// The envelope is the same for every lane; only the body differs.
if (reply.lane !== "design") throw new Error(`wrong lane: ${reply.lane}`);
const d = reply.diagram;
const byId = new Map(d.nodes.map(n => [n.id, n]));
for (const e of d.edges ?? []) {
if (!byId.has(e.from) || !byId.has(e.to)) {
throw new Error(`edge ${e.from} -> ${e.to} references a node that does not exist`);
}
}
const focal = d.nodes.filter(n => n.focal);
if (focal.length > 2) throw new Error("at most two focal elements");
const sent = new Set((designInput.prescan.findings ?? []).map(f => f.id));
const answered = new Set(reply.coverage.map(c => c.id));
const missing = [...sent].filter(id => !answered.has(id));
if (missing.length) console.warn("not reconciled:", missing.join(", "));
console.log(`${reply.fidelity.source_elements} in, ${reply.fidelity.drawn} drawn`);
type Node struct {
ID string `json:"id"`
Label string `json:"label"`
Sublabel string `json:"sublabel"`
Kind string `json:"kind"`
Focal bool `json:"focal"`
Shape string `json:"shape"`
Group string `json:"group"`
Rank int `json:"rank"`
Order int `json:"order"`
Value float64 `json:"value"`
At string `json:"at"`
}
type Reply struct {
Lane string `json:"lane"`
Title string `json:"title"`
Headline string `json:"headline"`
Diagram struct {
Type string `json:"type"`
TypeReason string `json:"type_reason"`
Nodes []Node `json:"nodes"`
Edges []struct {
From, To, Label, Style, Kind string
} `json:"edges"`
} `json:"diagram"`
Coverage []struct {
ID, Status, Note string
} `json:"coverage"`
}
var reply Reply
if err := json.Unmarshal(raw.Bytes(), &reply); err != nil {
panic(err)
}
focal := 0
for _, n := range reply.Diagram.Nodes {
if n.Focal {
focal++
}
}
if focal > 2 {
panic("at most two focal elements")
}
// With Jackson: map the envelope once and reuse it for all three lanes.
record Node(String id, String label, String sublabel, String kind, boolean focal,
String shape, String group, Integer rank, Integer order,
Double value, String at) {}
record Edge(String from, String to, String label, String style, String kind) {}
record Diagram(String type, String type_reason, String size, String detail,
String audience, String title, String alt_desc,
List<Node> nodes, List<Edge> edges) {}
record Coverage(String id, String status, String note) {}
record Reply(String lane, String title, String headline, Diagram diagram,
List<Coverage> coverage, List<String> notes) {}
Reply reply = new ObjectMapper().readValue(raw.toString(), Reply.class);
long focal = reply.diagram().nodes().stream().filter(Node::focal).count();
if (focal > 2) throw new IllegalStateException("at most two focal elements");
raise "wrong lane: #{reply['lane']}" unless reply["lane"] == "design"
d = reply["diagram"]
by_id = d["nodes"].to_h { |n| [n["id"], n] }
(d["edges"] || []).each do |e|
raise "edge references a missing node" unless by_id[e["from"]] && by_id[e["to"]]
end
focal = d["nodes"].select { |n| n["focal"] }
raise "at most two focal elements" if focal.size > 2
sent = (DESIGN_INPUT["prescan"]["findings"] || []).map { |f| f["id"] }.to_set
answered = reply["coverage"].map { |c| c["id"] }.to_set
missing = sent - answered
warn "not reconciled: #{missing.to_a.join(', ')}" unless missing.empty?
<?php
if ($reply["lane"] !== "design") {
exit("wrong lane: {$reply['lane']}\n");
}
$d = $reply["diagram"];
$byId = array_column($d["nodes"], null, "id");
foreach ($d["edges"] ?? [] as $e) {
if (!isset($byId[$e["from"]], $byId[$e["to"]])) {
exit("edge {$e['from']} -> {$e['to']} references a missing node\n");
}
}
$focal = array_filter($d["nodes"], fn($n) => $n["focal"] ?? false);
if (count($focal) > 2) exit("at most two focal elements\n");
$sent = array_column($designInput["prescan"]["findings"] ?? [], "id");
$answered = array_column($reply["coverage"], "id");
$missing = array_diff($sent, $answered);
if ($missing) echo "not reconciled: ", implode(", ", $missing), PHP_EOL;
record Node(string id, string label, string sublabel, string kind, bool focal,
string shape, string group, int rank, int order, double? value, string at);
record Edge(string from, string to, string label, string style, string kind);
record Diagram(string type, string type_reason, string size, string detail,
string audience, string title, string alt_desc,
List<Node> nodes, List<Edge> edges);
record Coverage(string id, string status, string note);
record Reply(string lane, string title, string headline, Diagram diagram,
List<Coverage> coverage, List<string> notes);
var reply = JsonSerializer.Deserialize<Reply>(raw.ToString(),
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
if (reply.diagram.nodes.Count(n => n.focal) > 2)
throw new InvalidOperationException("at most two focal elements");
var byId = reply.diagram.nodes.ToDictionary(n => n.id);
foreach (var e in reply.diagram.edges ?? new())
if (!byId.ContainsKey(e.from) || !byId.ContainsKey(e.to))
throw new InvalidOperationException($"edge {e.from} -> {e.to} is dangling");
The output contract
The envelope, on every lane
{
"lane": "design",
"title": "Checkout, as it runs today",
"headline": "one sentence a person could read instead of the whole output",
"coverage": [{ "id": "no-focal", "status": "applied", "note": "what was done about it" }],
"notes": ["assumptions and choices"],
"warnings": [{ "id": "slug", "severity": "medium", "message": "...", "fix": "..." }]
}
The diagram spec
One shape covers all eleven types. Nodes, edges and groups do most of the work: a swimlane's
lanes and a Venn's sets are both groups, a layer stack's bands and a
pyramid's tiers are both nodes with a rank, and a sequence diagram's
messages are ordered edges. Send only the arrays your type needs.
{
"type": "architecture",
"type_reason": "why this type and not the next-closest",
"size": "doc-inline", "detail": "balanced", "audience": "engineer",
"orientation": "pyramid", // pyramid lane only
"sketchy": false,
"title": "...", "subtitle": "...",
"alt_title": "...", "alt_desc": "...", "caption": "...",
"groups": [{ "id": "edge", "label": "Edge", "sublabel": "", "kind": "zone", "order": 0 }],
"nodes": [{ "id": "gateway", "label": "API Gateway", "sublabel": "TLS ends - :443",
"kind": "focal", "focal": true, "shape": "rect", "group": "edge",
"parent": "", "rank": 2, "order": 2, "value": null, "at": "",
"x": null, "y": null, "milestone": false }],
"edges": [{ "from": "cdn", "to": "gateway", "label": "origin",
"style": "solid", "kind": "primary", "order": 1 }],
"regions": [{ "of": ["a", "b"], "label": "The overlap", "focal": true }],
"axes": { "x": { "label": "Effort", "low": "low", "high": "high" },
"y": { "label": "Impact", "low": "low", "high": "high" } },
"callouts":[{ "text": "everything waits behind this", "target": "gateway",
"placement": "top-right", "intent": "focal" }],
"legend": [{ "label": "dashed", "meaning": "asynchronous" }],
"pattern": { "name": "request path", "why": "one sentence" }
}
Field vocabularies
node.kind—focal,backend,store,external,input,optional,security. Decides fill and stroke from the treatment table.node.shape—rect,oval,diamond,dot. Meaningful onflowchartonly; leave itrectelsewhere.edge.style—solidordashed.edge.kind—primary,link(leaves your boundary, drawn in the link role),handoff(drawn in the accent),return.callout.placement—top-right,top-left,bottom-right,bottom-left. Margins only.callout.intent—neutral,focal,muted.rankis the stacking or flow depth (0 is the top, the apex, or the outermost ring);orderbreaks ties left to right, and is the column in a swimlane.
Per-type requirements
| type | Use it for | What it needs |
|---|---|---|
flowchart | Decision logic, branching flows, triage | Shape carries type: oval terminus, rect step, diamond decision with at most three labelled exits, dot merge point. |
architecture | Services, stores and the calls between them | Above nine nodes every node needs a group, and there should be two to four groups. |
swimlane | A process crossing teams | Every node needs a group (its owner) and an order (its column). At least one edge should cross lanes. |
sequence | An ordered exchange between participants | Participants are groups; messages are edges in order, every one labelled. |
layers | Abstraction layers, stacks, hierarchies | Four to six nodes, each with a distinct rank; rank 0 is the top. |
pyramid | Ranks, priorities, conversion funnels | Set orientation to pyramid or funnel. Give every node a real value or none at all. |
nested | Containment, scope, blast radius | Three to five nodes; rank 0 is the outermost ring. |
tree | Reporting lines, taxonomies | Every node except the root carries parent. |
timeline | Releases, milestones, incidents | Every node carries at. Real dates are positioned by time, so unequal intervals stay unequal. |
quadrant | Two-axis positioning | axes.x and axes.y each need label, low and high; every node needs x and y in 0..1. |
venn | Two or three overlapping domains | groups are the sets (two or three, never four); regions are the overlaps, with exactly one focal. |
The skin object
Ten roles, in light and dark: paper,
paper_2, ink, muted, soft,
rule, rule_solid, accent, accent_tint,
link. Values are hex, or rgba() where the role is a translucent
hairline or tint. Three fonts — title, body, mono
— each with a family, a generic fallback, and a
fidelity of exact, fallback or default.
fallback means the family was detected but cannot be embedded in an export, so a
reader without it installed sees a substitute.
Truncation and partial replies
If your balance sits between min_credits and hold_credits the run
still executes with a reduced output cap and the terminal payload carries
"truncated": true. The reply will be valid JSON up to the point it stopped and
invalid after it. Handle it the way the page does: try to close the object at the shapes a
reply can die inside, render what parsed, and say how much arrived rather than presenting a
clipped answer as complete.
A malformed reply that is not truncated is worth exactly one retry, with a note telling the model what was wrong — and that retry must reuse a key derived from the same input so a formatting failure cannot bill you twice.
Rendering the spec yourself
/render.js is a plain module with no dependencies and no framework. It
takes a normalized spec and a skin and returns a nested
{ tag, attrs, text, kids } tree, plus a toString() that serializes it
with real escaping. It runs unchanged under Node, which is how the page's own test harness
asserts on box positions and arrow endpoints without a browser. Normalize the model's
diagram object through /spec.js first —
DiagramSpec.normalize() resolves edges written by label, assigns ranks and fills
defaults, and DiagramSpec.lint() gives you the same grammar findings the page
shows. /export.js turns the same spec into the standalone HTML file,
the SVG, the Mermaid source, the CSVs and the style-guide.md.
All three are MIT-spirited derived work over @cathrynlavery/diagram-design. Read them, copy them, vendor them.
Rate limits and etiquette
- Limits are shared across your whole account. On a
429, back off with jitter; never tight-loop. /estimateis free but not unmetered attention — debounce it rather than calling it per keystroke.- Poll
/jobs/{id}every two seconds or slower. A design run is usually under twenty. - Cache by input hash. A brief that has not changed does not need a second run, and the page keys its history the same way.