← GenUI Clinic / API
Tokens

Drive GenUI Clinic from your own code

Everything the web page does is available over HTTP: post the catalog that constrains your model and a spec it generated against that catalog, get the same structured reliability review back. The natural use is a CI job that re-reviews the catalog whenever it changes, or an eval harness that runs every spec your model produced overnight through the same review and fails the build when one comes back unsafe-to-render.

Base URL and the envelope

Every endpoint lives under https://api.skillsafe.ai/v1/app-api and every response uses the same envelope, so one helper covers the whole API:

{ "ok": true,  "data":  { ... } }
{ "ok": false, "error": { "code": "...", "message": "...", "status": 402, "details": { ... } } }

Send your app slug as X-App-Slug: genui-clinic and your token as Authorization: Bearer … on every call.

Error codes

codestatuswhat to do
unauthorized401The token is missing, malformed or expired. Get a new one from the token page.
forbidden403The token is valid but not for this app. Check the X-App-Slug header.
payment_required402The balance is below min_credits. Call /estimate first and top up.
validation_error400The input object is missing a required field - `catalog` and `spec` are the usual ones.
rate_limited429Too many requests. Back off and retry; do not tight-loop. /similar is capped at 30 per minute per IP.
not_found404Unknown job id, an undeclared collection, or the app slug does not exist.
internal500A server-side failure. Retry with the SAME Idempotency-Key so you are not billed twice.

1. Get a token

The easiest route is the token page: it shows the token this browser already holds, with Copy token and Copy shell export buttons, and a sign-in button for a personal token. You never need to open the developer console.

A guest token can call /me and /estimate. Running a review is metered, so it needs a personal token from signing in — and the reviews collection is scoped to the calling subject, so only a personal token sees a history worth querying.

2. A tiny client

One helper that adds the headers, unwraps data and raises on error.

3. Check the session and the balance

/me tells you whether the token is a guest or a person, and what the balance is. Compare it against min_credits from the next step before you run, so a shortfall surfaces as your own clear message rather than a 402.

4. Price the run — free

The input object is exactly what the app's own form submits:

fieldtypemeaning
catalogstring, requiredThe defineCatalog(...) source, or a JSON catalog of the form {"components": {...}, "actions": {...}}. This is the only evidence for what is allowed, so a review of a spec without its catalog is a review of nothing.
specstring, requiredThe generated JSON spec under review — the flat {root, elements} document, a Remotion timeline or a Next.js app spec. Either string may be clipped in the middle with a /* ... clipped ... */ marker.
rendererstringreact, shadcn, react-native, vue, svelte, solid, next, remotion, react-pdf, react-email, ink, three or unknown.
concernstringgeneral, reliability, state-and-actions, catalog-design, streaming or accessibility. Emphasis, not exclusivity: a high-severity finding from another category is never suppressed.
contextstring, optionalFree-form notes: what the UI is for, who uses it, which model generates the specs, what has already gone wrong in production.
prescan_factsobject{resources: [{id,label}], flags: [{id,label}]} — see below.
retry_notestring, optionalOnly used by the reformat-retry lane: if a previous reply failed to parse, the app re-sends the same input with a note telling the model exactly what the reply must look like. You do not normally send it.

prescan_facts is not a schema the platform validates — it is whatever a deterministic walker established before the run. In the browser that walker is the app's own specscan.js: it resolves the element graph and reports resources (the components and actions the catalog declares, element counts, state paths, computed functions) and flags (checks that fired: child-dangling:trend-1, prop-unconstrained:Metric.format, state-read-never-written:/user/name and so on). The model is required to reconcile every flags id exactly once in coverage_check, which is what holds it to facts you established rather than to its own impressions.

If you have not run a walker of your own, send {"resources": [], "flags": []} and the review still works — it is simply an unassisted read of the catalog and the spec, with an empty coverage_check. Sending real flags is what makes the review reproducible.

/estimate creates no job and charges nothing. It returns the model binding and the reservation: model, model_alias, markup_bps, hold_credits, min_credits and sponsor_enabled. hold_credits is what gets held, and the actual charge is normally far lower because the hold prices the full output cap.

5. Run it, then poll

/run returns a job_id; poll jobs/{job_id} until status is succeeded or failed. The review JSON is the string at data.output.output.

Always send an Idempotency-Key. Derive it from the input, as the web app does (genui-clinic:<hash>:a<attempt>). A retried request carrying the same key returns the same job instead of billing a second run — which is what makes a CI retry safe. The a<attempt> suffix is how the app's own reformat retry stays distinguishable from a deliberate re-run.

6. Or stream it

/run-stream is the same call over server-sent events, and it is what the web app actually uses. Section headings arrive in order, so a UI can advance a staged progress display, and whatever parsed survives if the stream dies mid-flight. The final done event carries charged_credits and a truncated flag — truncated: true means the balance cut the reply short, not that the model finished.

The output contract

data.output.output is a JSON string holding one object. The app strips a leading and trailing code fence, then takes everything from the first { to the last } before parsing — do the same and a chatty model costs you nothing. This is exactly what the web app parses, so anything that renders here will render there:

{
  "review_name":   "Revenue dashboard catalog - 3 blockers before it can stream",
  "verdict_level": "ship-it | tighten-before-ship | unsafe-to-render",
  "verdict":       "one sentence naming the single thing that decides the level",
  "renderer":      "react",
  "spec_shape":    "flat | timeline | next-app | unknown",
  "exec_summary":  "2-3 paragraphs separated by blank lines",
  "assumptions":    ["explicit assumption that fills a gap in the paste"],
  "open_questions": ["question whose answer would change the review"],
  "inventory": [
    { "kind": "Component", "name": "Metric", "detail": "label, value, format", "role": "..." }
  ],
  "findings": [
    {
      "id": "GU-001",
      "category":   "spec-validity | catalog-design | state | actions | streaming | accessibility | prompt | hygiene",
      "severity":   "low | medium | high",
      "likelihood": "low | medium | high",
      "priority":   "critical | high | medium | low",
      "resource": "Component/Metric, Element/m-1, Prop/Metric.format, Action/refresh_data, StatePath//user/name",
      "problem":  "what is wrong, in this catalog or this spec",
      "impact":   "what it costs at render time, in practice",
      "fix":      "the concrete change to make",
      "snippet":  "corrected JSON or TypeScript fragment, or \"\""
    }
  ],
  "coverage_check": [
    { "id": "child-dangling:trend-1", "addressed": true, "note": "GU-001." }
  ],
  "corrected_spec":   "the whole corrected spec as a JSON string, or \"\" if the spec did not parse",
  "catalog_upgrades": [
    { "component": "Metric", "change": "Close the format prop.", "snippet": "props: z.object({ ... })" }
  ],
  "prompt_guidance": ["a line to add to a component description so the model stops producing this"],
  "quick_wins":      ["one-line change worth doing immediately"],
  "focus_areas":     [{ "area": "...", "why": "...", "finding_ids": ["GU-001"] }],
  "summary": "closing paragraph"
}

Field by field, and what the app's own normalize() does with each one:

fieldtypenotes
review_namestringShort title naming the app and the verdict. Empty falls back to "Untitled generative-UI review".
verdict_levelenumship-it, tighten-before-ship, unsafe-to-render. Anything else is coerced to tighten-before-ship.
verdictstringOne sentence justifying the level and naming the thing that decides it.
rendererstringThe renderer echoed back. Empty becomes unknown.
spec_shapeenumflat, timeline, next-app, unknown — coerced to unknown.
exec_summarystringTwo or three paragraphs separated by blank lines.
assumptionsstring[]Blank entries are dropped.
open_questionsstring[]Blank entries are dropped.
inventory{kind,name,detail,role}[]Rows with neither a kind nor a name are dropped. kind is one of Component, Action, Element, Prop, StatePath, Expression, Route, Layout, Track, Clip.
findingsobject[]Ids are sequential GU-001, GU-002, …; a missing id is filled in by position. category, severity, likelihood and priority are coerced to the enums above (hygiene, medium, medium, medium). A row with neither a problem nor a fix is dropped.
coverage_check{id,addressed,note}[]One row per prescan_facts.flags id, exactly once. addressed: false with a reason in note is a legitimate answer for a flag that is not a real problem here.
corrected_specstringThe whole corrected spec as a JSON string, in the same shape as the input. "" only when the spec did not parse at all.
catalog_upgrades{component,change,snippet}[]The cause, not the symptom: the schema change that makes a class of bad output impossible. Rows with neither a component nor a change are dropped.
prompt_guidancestring[]Lines to add to the catalog prompt or a component description.
quick_winsstring[]One-line changes worth doing immediately.
focus_areas{area,why,finding_ids}[]Every finding_ids entry that does not name a real finding id is silently dropped; a row with no area is dropped whole.
summarystringClosing paragraph: what to do first and what remains.

Two rules worth enforcing on your side, because the app enforces them too. findings must be non-empty — a reply whose findings all get dropped is rejected outright and the app re-runs once with a retry_note rather than showing an empty review. And every prescan_facts.flags id must appear exactly once in coverage_check: if a flag is missing from the reconciliation, the model quietly skipped a fact you established — treat that as a failed run, not a passing one.

7. Your saved reviews

Every run the app completes is written to the reviews collection, so a review follows the user across devices. It is declared acl_read: owner and acl_write: user: rows are scoped to the calling subject, which means a script must reuse one token across the run and the query or it will see an empty collection. Each POST /guest mints a new guest subject, so guest tokens are not a way to share history.

fieldtypemeaning
titlestringreview_name from the reply.
verdict_levelstringship-it, tighten-before-ship or unsafe-to-render.
verdictstringThe one-sentence verdict.
rendererstringThe renderer the review targeted.
spec_shapestringflat, timeline, next-app or unknown.
input_hashstringHash of the submitted catalog and spec — the cheap way to tell whether anything actually changed between runs.
findings_countnumberfindings.length.
high_countnumberHow many findings came back priority: "critical" or "high".
ran_attimestampWhen the run completed. The natural sort key.

Those nine fields are declared, and therefore filterable and sortable. The rest of the document — the whole review, its meta and the original input — round-trips intact but is not indexed. embed is ["title", "verdict", "renderer"], so POST /collections/reviews/similar finds past reviews that read like this one: useful for "have we seen this failure shape before?" across a fleet of catalogs. /similar is rate-limited to 30 requests per minute per IP, the query text is capped at 2000 characters, and limit maxes out at 20.

Two shapes to get right. Every where entry must be an operator object — eq, ne, lt, lte, gt, gte, in (up to 20 values) or contains; the bare-value shorthand {"verdict_level": "unsafe-to-render"} is rejected. And the sort key is sort, an object: {"field": "ran_at", "dir": "desc"}. An order_by key is silently ignored, which looks exactly like a collection that is not sorting. Records come back wrapped: data.records[].doc holds the fields, alongside a record_id — never read the fields flat off the record.

A CI gate

The verdict level is the natural exit code. Fail the job when a generated spec drifts into unsafe-to-render, warn on tighten-before-ship, and pass on ship-it — with the Idempotency-Key derived from the input so a re-run of the same commit replays instead of re-billing. Comparing input_hash against the newest row in the reviews collection tells you whether the catalog changed at all, and therefore whether it is worth spending the credits.

LEVEL=$(printf '%s' "$REVIEW" | python3 -c 'import sys,json;print(json.load(sys.stdin)["verdict_level"])')
HIGH=$(printf '%s' "$REVIEW" | python3 -c 'import sys,json;print(sum(1 for f in json.load(sys.stdin)["findings"] if f["priority"] in ("critical","high")))')

case "$LEVEL" in
  unsafe-to-render)     echo "::error::GenUI review: unsafe to render ($HIGH high)"; exit 1 ;;
  tighten-before-ship)  echo "::warning::GenUI review: tighten before ship"; exit 0 ;;
  ship-it)              echo "GenUI review: ship it"; exit 0 ;;
esac