← Brand Diagram / API
Tokens

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.

taskInput fieldsWhat comes back
skinmaterial (required), brand_notes, prefer_dark, prescanskin.light and skin.dark (ten roles each), skin.fonts (three families with a fidelity verdict), roles[], receipt
designbrief (required), material, type_hint, size, detail, audience, sketchy, skin_summary, prescandiagram (the spec), fidelity (the ledger of what was cut)
any laneretry_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
annotatediagram (required — the spec from a design run), focus, prescancallouts[] (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

codestatusWhat it means
UNAUTHORIZED401No token, a malformed token, or a token minted for another app. Check X-App-Slug as well as the bearer.
FORBIDDEN403A guest token on a metered lane where the publisher has not enabled sponsorship. Sign in for a personal token.
NOT_FOUND404Wrong path, or a job_id that belongs to another subject.
PAYMENT_REQUIRED402The balance is below min_credits. Never reachable if you compare /estimate against /me first.
VALIDATION_ERROR400The input object is malformed. error.details names the field.
RATE_LIMITED429Back off and retry. Do not tight-loop; the limit is shared across your whole account.
INTERNAL500Retry 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"

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

The dials on the design lane

FieldValuesEffect
type_hintauto or a type idauto lets the model choose, which is usually the right call. An explicit id is honoured.
sizedoc-inline (960x600, default), doc-wide, slide-16x9, slide-4x3, social-og, social-square, print-a4-landscape, print-letter-landscapeSets the viewBox and the type ramp. A slide gets 16px node names in 64px boxes, not shrunken body copy.
detailfaithful (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.
audienceengineer, mixed (default), executiveSets the wording, not the count: the same twelve nodes get named differently for a platform team than for a board.
sketchytrue / falseWobbles 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.

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}}

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

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}

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

Per-type requirements

typeUse it forWhat it needs
flowchartDecision logic, branching flows, triageShape carries type: oval terminus, rect step, diamond decision with at most three labelled exits, dot merge point.
architectureServices, stores and the calls between themAbove nine nodes every node needs a group, and there should be two to four groups.
swimlaneA process crossing teamsEvery node needs a group (its owner) and an order (its column). At least one edge should cross lanes.
sequenceAn ordered exchange between participantsParticipants are groups; messages are edges in order, every one labelled.
layersAbstraction layers, stacks, hierarchiesFour to six nodes, each with a distinct rank; rank 0 is the top.
pyramidRanks, priorities, conversion funnelsSet orientation to pyramid or funnel. Give every node a real value or none at all.
nestedContainment, scope, blast radiusThree to five nodes; rank 0 is the outermost ring.
treeReporting lines, taxonomiesEvery node except the root carries parent.
timelineReleases, milestones, incidentsEvery node carries at. Real dates are positioned by time, so unequal intervals stay unequal.
quadrantTwo-axis positioningaxes.x and axes.y each need label, low and high; every node needs x and y in 0..1.
vennTwo or three overlapping domainsgroups 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