← Angular Clinic
API
Tokens

Run Angular Clinic from your own code

Both metered lanes are available over HTTP and each returns one JSON object. The division of labour: the page scans the component for free - every legacy API and real defect against Angular's own guidance, each with its rule id, line, fix and official schematic, gated by the project's Angular version - in ngscan.js. The run writes the review or rewrites the component; the page then holds the reply to its own scan and re-scans any rewrite (ngreply.js). Not affiliated with Google or the Angular team.

Base URL and the response shape

Every endpoint lives under https://api.skillsafe.ai/v1/app-api, and every response has one shape:

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

The token is minted for this app (the guest call takes {"slug":"angular-clinic"}), so no slug header is needed afterwards. Send it as Authorization: Bearer …. The run body is the input object, sent bare.

StatusCodeMeaning
400validation_errorThe body is not a JSON object, or a field the run needs (task, code, version, facts) is missing or not a string. Send every field as a string, version included ("20", not 20).
401unauthorizedNo token, or a stale one. Mint a guest token or sign in again.
402insufficient_creditsThe balance is under min_credits. Price with /estimate first.
403forbiddenA guest token tried to run: both lanes are metered and need a personal sign-in.
404not_foundUnknown job id.
429rate_limitedBack off and retry.
5xxserver_errorTransient. Retry with the same Idempotency-Key so a retry never double-bills.

The task field comes first

Two lanes - review (judge the component: a verdict, findings with fixes, a call on every scan finding and a migration order) and modernize (rewrite it to the standard for its Angular version, keeping its public API). A missing or unknown task is answered as review, with a caveat saying so. Both lanes send the same fields.

The input

Every field is a string.

FieldRequiredWhat
taskyesreview or modernize. The model answers that task and only that task.
codeyesthe component's TypeScript. The page clips a long file (over 24,000 characters) by keeping the head and the tail and putting a /* ... N lines (a-b) omitted by Angular Clinic ... */ marker in the middle; the model will not guess what was left out.
templateyes (may be "")the external template file when the component uses templateUrl; empty when the template is inline in code.
versionyesthe Angular major the project is on, for example "22", "19", "16". No API the version lacks is ever recommended - below it, the answer is "upgrade first".
factsyesthe page's own scan of the same code: the version, a prescan verdict, the state of each API area, the public API to keep, and every finding as rule | severity | where | line | text. See the facts format.
focusnowhat the developer wants weighed, for example "we render this on the server". May be empty; when given, the summary says how it changed the answer.
retry_notenopresent only on a reformat retry: the previous reply could not be read, and this says how to answer again. Leave it out otherwise.

The facts format

facts is the page's scan, written as plain text. An API caller may send their own short summary of the code instead, but the scan's format gets the most out of a run: the review answers every error and warn rule id it finds there (one prescan entry per id), and the rewrite keeps every name listed under "Public API to keep". The format, line by line:

Angular version: <major>.
Prescan verdict: <ship|refactor|rework> (<n> errors, <n> warnings, <n> notes).
API areas: Control flow <state>; Inputs <state>; Outputs <state>; Injection <state>; Queries <state>; Host bindings <state>; Standalone <state>; Change detection <state>.
Public API to keep: selector <selector>; class <ClassName>; inputs <a, b>; outputs <c>.
Clipped: <only when code or template was clipped>
Findings (rule | severity | where | line | text):
- <rule id> | <error|warn|info> | <ts|template> | line <n> | <title> | <the source line>
Schematics that apply: <command>; <command>.

An area's state is modern, legacy, mixed or none. A real example - the facts the page sends for the sample used in the worked examples below:

Angular version: 20.
Prescan verdict: refactor (1 error, 4 warnings, 2 notes).
API areas: Control flow mixed; Inputs modern; Outputs none; Injection legacy; Queries none; Host bindings legacy; Standalone modern; Change detection modern.
Public API to keep: selector app-cart-summary; class CartSummary; inputs currency, discount; outputs (none).
Findings (rule | severity | where | line | text):
- AC-EFFSET | error | ts | line 29 | effect() writes a signal | effect(() => {
- AC-CTORDI | warn | ts | line 27 | Constructor-parameter injection | constructor(private store: CartStore) {
- AC-DOMGLOBAL | warn | ts | line 28 | window / document / localStorage | this.lines.set(JSON.parse(localStorage.getItem('cart') ?? '[]'));
- AC-HOSTDEC | warn | ts | line 35 | @HostBinding / @HostListener | @HostListener('window:storage')
- AC-NGIF | warn | template | line 16 | *ngIf instead of @if | <p *ngIf="discount() > 0">Discount: {{ discount() }}%</p>
- AC-TRACKIDX | info | template | line 12 | @for tracks $index | @for (line of lines(); track $index) {
- AC-NGCLASS | info | template | line 13 | ngClass / ngStyle | <li [ngClass]="{ low: line.stock < 3 }">{{ line.name }} x {{ line.qty }}</li>
Schematics that apply: ng generate @angular/core:control-flow; ng generate @angular/core:inject.

Rule ids the scan uses: AC-NGIF, AC-NGFOR, AC-NGSWITCH (legacy structural directives), AC-TRACK (a @for with no track, an error), AC-TRACKIDX, AC-SELFCLOSE, AC-ARROWEVT (a binding to an arrow function, an error), AC-TPLCALL, AC-NGCLASS, AC-ASYNCPIPE, AC-INPUT, AC-OUTPUT, AC-CTORDI, AC-QUERY, AC-HOSTDEC, AC-NOTSA, AC-SATRUE, AC-CDDEFAULT, AC-CDMISSING, AC-MANUALCD, AC-EFFSET (an effect() that writes a signal, an error), AC-ONCHANGES, AC-SUBLEAK, AC-TOPROMISE, AC-HTTPMODULE, AC-UNTYPEDFORM, AC-REACTIVEFORM, AC-SFNULL, AC-BYPASS, AC-NATIVEHTML, AC-DOMGLOBAL, AC-ANY. A finding the scan cannot see comes back with the rule NEW.

The standard the model applies

The source skill, @angular/angular-developer, is the only standard:

House rules

The replies

review

verdict is ship (modern and correct for its version; at most minor notes), refactor (works, but has legacy APIs or one real defect worth fixing now) or rework (several legacy areas at once, or defects that make it wrong or unsafe). A scan error the model agrees with rules out ship.

  1. summary - two or three sentences: what the component does, the verdict and the one thing to fix first.
  2. findings - most severe first: rule (the facts' rule id, or NEW), severity (high, medium or low), title, where (a line reference such as line 33 or template line 4), why (one or two sentences specific to this code), fix (the corrected code for that spot).
  3. prescan - one entry per error and warn rule id in the facts: rule, call (agree or disagree), note (why, one sentence).
  4. strengths - what the component already does right (may be empty).
  5. order - the migration plan as ordered steps: step, schematic (one of the seven commands, or "" for a hand edit).
  6. caveats - what could not be seen or verified.
{
  "task": "review",
  "verdict": "refactor",
  "summary": "",
  "findings": [ { "rule": "AC-NGFOR", "severity": "medium", "title": "", "where": "template line 4", "why": "", "fix": "" } ],
  "prescan": [ { "rule": "AC-NGFOR", "call": "agree", "note": "" } ],
  "strengths": [ "" ],
  "order": [ { "step": "", "schematic": "ng generate @angular/core:control-flow" } ],
  "caveats": [ "" ]
}

The page holds it to its own scan: every scan error and warning answered by rule id, no ship over an undisputed error, no schematic that does not exist or needs a newer version, and no claim that the code was built or tested.

modernize

A behaviour-preserving rewrite to the standard for its version. The selector, the class name and every input and output listed under "Public API to keep" survive (an input that becomes model() keeps its name), as does the project's file naming.

  1. summary - two sentences.
  2. files - the rewritten files, each complete: name (such as user-list.component.ts), lang (ts, or html for an external template returned as its own file), code. An inline template stays inline; a clipped input keeps its omitted-lines marker.
  3. changes - one per change: rule (the rule id or NEW), what.
  4. kept - anything deliberately left: rule, what, why.
  5. schematics - the official commands that could be run instead of or before applying this by hand, in order.
  6. tests - what to update in specs and in parent templates.
  7. caveats - what could not be seen.
{
  "task": "modernize",
  "summary": "",
  "files": [ { "name": "user-list.component.ts", "lang": "ts", "code": "" } ],
  "changes": [ { "rule": "AC-INPUT", "what": "" } ],
  "kept": [ { "rule": "AC-REACTIVEFORM", "what": "", "why": "" } ],
  "schematics": [ "ng generate @angular/core:control-flow" ],
  "tests": [ "" ],
  "caveats": [ "" ]
}

The page re-scans the rewritten files: a legacy pattern still present and not listed in kept, a new problem introduced, or a selector, class, input or output that disappeared is named. It shows the diff and offers a .patch.

Worked examples - both lanes, one component

One half-modernized component (a v20 cart summary with a scan error, four warnings and two notes, and a focus on server rendering), sent once to each lane. Each input is exactly what the page sends; each reply is a real reply, abbreviated for reading: long strings end in ... and the rewritten file shows only its first lines. The real replies are complete JSON with every field in full.

CartSummary (Angular 20) - review

Input:

{
 "task": "review",
 "code": "import { Component, ChangeDetectionStrategy, HostListener, computed, effect, input, signal } from '@angular/core';\nimport { NgClass } from '@angular/common';\nimport { CartStore, CartLine } from './cart-store';\n\n@Component({\n  selector: 'app-cart-summary',\n  imports: [NgClass],\n  changeDetection: ChangeDetectionStrategy.OnPush,\n  template: `\n    <h2>Your cart ({{ count() }})</h2>\n    <ul>\n      @for (line of lines(); track $index) {\n        <li [ngClass]=\"{ low: line.stock < 3 }\">{{ line.name }} x {{ line.qty }}</li>\n      }\n    </ul>\n    <p *ngIf=\"discount() > 0\">Discount: {{ discount() }}%</p>\n    <strong>Total: {{ total() | currency: currency() }}</strong>\n  `,\n})\nexport class CartSummary {\n  readonly currency = input('USD');\n  readonly discount = input(0);\n  protected readonly lines = signal<CartLine[]>([]);\n  protected readonly count = computed(() => this.lines().length);\n  protected readonly total = signal(0);\n\n  constructor(private store: CartStore) {\n    this.lines.set(JSON.parse(localStorage.getItem('cart') ?? '[]'));\n    effect(() => {\n      const sum = this.lines().reduce((s, l) => s + l.price * l.qty, 0);\n      this.total.set(sum * (1 - this.discount() / 100));\n    });\n  }\n\n  @HostListener('window:storage')\n  reload() {\n    this.lines.set(this.store.read());\n  }\n}",
 "template": "",
 "version": "20",
 "facts": "Angular version: 20.\nPrescan verdict: refactor (1 error, 4 warnings, 2 notes).\nAPI areas: Control flow mixed; Inputs modern; Outputs none; Injection legacy; Queries none; Host bindings legacy; Standalone modern; Change detection modern.\nPublic API to keep: selector app-cart-summary; class CartSummary; inputs currency, discount; outputs (none).\nFindings (rule | severity | where | line | text):\n- AC-EFFSET | error | ts | line 29 | effect() writes a signal | effect(() => {\n- AC-CTORDI | warn | ts | line 27 | Constructor-parameter injection | constructor(private store: CartStore) {\n- AC-DOMGLOBAL | warn | ts | line 28 | window / document / localStorage | this.lines.set(JSON.parse(localStorage.getItem('cart') ?? '[]'));\n- AC-HOSTDEC | warn | ts | line 35 | @HostBinding / @HostListener | @HostListener('window:storage')\n- AC-NGIF | warn | template | line 16 | *ngIf instead of @if | <p *ngIf=\"discount() > 0\">Discount: {{ discount() }}%</p>\n- AC-TRACKIDX | info | template | line 12 | @for tracks $index | @for (line of lines(); track $index) {\n- AC-NGCLASS | info | template | line 13 | ngClass / ngStyle | <li [ngClass]=\"{ low: line.stock < 3 }\">{{ line.name }} x {{ line.qty }}</li>\nSchematics that apply: ng generate @angular/core:control-flow; ng generate @angular/core:inject.",
 "focus": "We render this on the server too."
}

Reply (abbreviated). The prescan said refactor; the review agreed with all five scan errors and warnings, added two NEW findings (a currency pipe that is not imported, two load paths that can drift) and, weighing the focus on server rendering, moved the verdict to rework:

{
 "task": "review",
 "verdict": "rework",
 "summary": "CartSummary lists the cart lines from a signal, shows an optional percentage discount and a currency-formatted total .... Because you render on the server, the bare localStorage read in the constructor (line 28) is a crash on every server render, and together with the missing CurrencyPipe/NgIf imports (lines 7, 16-17) and the effect() that writes total (line 29) that moves the verdict from the prescan's refactor to rework. Fix first: move the localStorage read into afterNextRender so SSR stops throwing, then make total a computed.",
 "findings": [
  {
   "rule": "AC-DOMGLOBAL",
   "severity": "high",
   "title": "localStorage read in the constructor crashes server rendering",
   "where": "line 28",
   "why": "localStorage does not exist on the server, so constructing CartSummary during SSR throws ...",
   "fix": "constructor() {\n  afterNextRender(() => {\n    this.lines.set(JSON.parse(localStorage.getItem('cart') ?? '[]'));\n  });\n}"
  },
  {
   "rule": "NEW",
   "severity": "high",
   "title": "currency pipe is used but CurrencyPipe is not imported",
   "where": "line 7 / template line 17",
   "why": "A standalone component only sees the pipes in its imports; imports lists only NgClass, so ...",
   "fix": "import { CurrencyPipe } from '@angular/common';"
  },
  {
   "rule": "AC-EFFSET",
   "severity": "high",
   "title": "effect() copies derived state into the total signal",
   "where": "line 29",
   "why": "total is purely derived from lines() and discount(), so the effect on lines 29-32 is the ...",
   "fix": "protected readonly total = computed(() => {"
  },
  {
   "rule": "AC-NGIF",
   "severity": "medium",
   "title": "*ngIf without NgIf in imports - the discount line is broken",
   "where": "template line 16",
   "why": "Besides being the legacy syntax, NgIf is not in imports (only NgClass is), so the ...",
   "fix": "@if (discount() > 0) {"
  },
  {
   "rule": "AC-CTORDI",
   "severity": "medium",
   "title": "CartStore injected through a constructor parameter",
   "where": "line 27",
   "why": "On v20 dependencies come from inject() in a field initialiser; once the localStorage read ...",
   "fix": "private readonly store = inject(CartStore);"
  },
  {
   "rule": "AC-HOSTDEC",
   "severity": "medium",
   "title": "@HostListener for the window ...",
   "where": "line 35",
   "why": "Host listeners belong in the component's host property; the global window: target works ...",
   "fix": "host: { '(window:storage)': 'reload()' },"
  },
  {
   "rule": "NEW",
   "severity": "low",
   "title": "Initial load and reload read the cart from two different sources",
   "where": "line 28 / line 37",
   "why": "The first load parses localStorage 'cart' directly while reload() calls store.read(), and ...",
   "fix": "afterNextRender(() => this.lines.set(this.store.read()));"
  },
  {
   "rule": "AC-TRACKIDX",
   "severity": "low",
   "title": "@for tracks $index on a list that changes",
   "where": "template line 12",
   "why": "lines() is replaced wholesale on every ...",
   "fix": "@for (line of lines(); track line.id) {"
  },
  {
   "rule": "AC-NGCLASS",
   "severity": "low",
   "title": "ngClass for a single class toggle",
   "where": "template line 13",
   "why": "One conditional class is a plain class binding, which lets you drop NgClass from imports.",
   "fix": "<li [class.low]=\"line.stock < 3\">{{ line.name }} x {{ ..."
  }
 ],
 "prescan": [
  {
   "rule": "AC-EFFSET",
   "call": "agree",
   "note": "The effect on lines 29-32 only derives total from lines() and ..."
  },
  {
   "rule": "AC-CTORDI",
   "call": "agree",
   "note": "CartStore arrives as a constructor parameter property on line 27 ..."
  },
  {
   "rule": "AC-DOMGLOBAL",
   "call": "agree",
   "note": "Line 28 touches localStorage at construction time, which throws ..."
  },
  {
   "rule": "AC-HOSTDEC",
   "call": "agree",
   "note": "The window:storage listener on line 35 moves unchanged into host: { ..."
  },
  {
   "rule": "AC-NGIF",
   "call": "agree",
   "note": "Line 16 is the only legacy control flow left and NgIf is not even ..."
  }
 ],
 "strengths": [
  "Inputs already use input() with defaults (lines 21-22) and the template reads ...",
  "OnPush change detection and signal state (lines 23-24) with count as a computed.",
  "The list already uses built-in @for (line 12), and the component is standalone ..."
 ],
 "order": [
  {
   "step": "Convert *ngIf on template line 16 to @if.",
   "schematic": "ng generate @angular/core:control-flow"
  },
  {
   "step": "Move CartStore from the constructor parameter to inject().",
   "schematic": "ng generate @angular/core:inject"
  },
  {
   "step": "Wrap the localStorage read from line 28 in afterNextRender so server renders stop throwing.",
   "schematic": ""
  },
  {
   "step": "Replace the total signal and its effect (lines 25, 29-32) with a computed.",
   "schematic": ""
  },
  {
   "step": "Add CurrencyPipe to imports; swap ngClass for [class.low] and drop NgClass.",
   "schematic": ""
  },
  {
   "step": "Move @HostListener('window:storage') into host: {}, then run ng build and the SSR build.",
   "schematic": ""
  }
 ],
 "caveats": [
  "CartStore and CartLine (./cart-store) are not shown: I could not check what store.read() returns, whether it ...",
  "The parent and the server/hydration setup are not shown; with the read in afterNextRender the server and ...",
  "No spec was provided; add a test that discount() > 0 shows the discount line and that total reflects the ..."
 ]
}

CartSummary (Angular 20) - modernize

Input - the same fields, with task set to modernize:

{
 "task": "modernize",
 "code": "import { Component, ChangeDetectionStrategy, HostListener, computed, effect, input, signal } from '@angular/core';\nimport { NgClass } from '@angular/common';\nimport { CartStore, CartLine } from './cart-store';\n\n@Component({\n  selector: 'app-cart-summary',\n  imports: [NgClass],\n  changeDetection: ChangeDetectionStrategy.OnPush,\n  template: `\n    <h2>Your cart ({{ count() }})</h2>\n    <ul>\n      @for (line of lines(); track $index) {\n        <li [ngClass]=\"{ low: line.stock < 3 }\">{{ line.name }} x {{ line.qty }}</li>\n      }\n    </ul>\n    <p *ngIf=\"discount() > 0\">Discount: {{ discount() }}%</p>\n    <strong>Total: {{ total() | currency: currency() }}</strong>\n  `,\n})\nexport class CartSummary {\n  readonly currency = input('USD');\n  readonly discount = input(0);\n  protected readonly lines = signal<CartLine[]>([]);\n  protected readonly count = computed(() => this.lines().length);\n  protected readonly total = signal(0);\n\n  constructor(private store: CartStore) {\n    this.lines.set(JSON.parse(localStorage.getItem('cart') ?? '[]'));\n    effect(() => {\n      const sum = this.lines().reduce((s, l) => s + l.price * l.qty, 0);\n      this.total.set(sum * (1 - this.discount() / 100));\n    });\n  }\n\n  @HostListener('window:storage')\n  reload() {\n    this.lines.set(this.store.read());\n  }\n}",
 "template": "",
 "version": "20",
 "facts": "Angular version: 20.\nPrescan verdict: refactor (1 error, 4 warnings, 2 notes).\nAPI areas: Control flow mixed; Inputs modern; Outputs none; Injection legacy; Queries none; Host bindings legacy; Standalone modern; Change detection modern.\nPublic API to keep: selector app-cart-summary; class CartSummary; inputs currency, discount; outputs (none).\nFindings (rule | severity | where | line | text):\n- AC-EFFSET | error | ts | line 29 | effect() writes a signal | effect(() => {\n- AC-CTORDI | warn | ts | line 27 | Constructor-parameter injection | constructor(private store: CartStore) {\n- AC-DOMGLOBAL | warn | ts | line 28 | window / document / localStorage | this.lines.set(JSON.parse(localStorage.getItem('cart') ?? '[]'));\n- AC-HOSTDEC | warn | ts | line 35 | @HostBinding / @HostListener | @HostListener('window:storage')\n- AC-NGIF | warn | template | line 16 | *ngIf instead of @if | <p *ngIf=\"discount() > 0\">Discount: {{ discount() }}%</p>\n- AC-TRACKIDX | info | template | line 12 | @for tracks $index | @for (line of lines(); track $index) {\n- AC-NGCLASS | info | template | line 13 | ngClass / ngStyle | <li [ngClass]=\"{ low: line.stock < 3 }\">{{ line.name }} x {{ line.qty }}</li>\nSchematics that apply: ng generate @angular/core:control-flow; ng generate @angular/core:inject.",
 "focus": "We render this on the server too."
}

Reply (abbreviated). total became a computed, CartStore moved to inject(), the localStorage read moved into afterNextRender, the listener moved to host, and track $index was kept with a reason, because the line type was not shown:

{
 "task": "modernize",
 "summary": "CartSummary now derives total with computed, injects CartStore with inject(), reads localStorage only in afterNextRender so server renders no longer throw, declares the window storage listener in host, and uses @if plus a class binding with CurrencyPipe imported. Because you render on the server, the browser-only read was the deciding change: the server and first client render show an empty cart, then the browser fills it from localStorage as before.",
 "files": [
  {
   "name": "cart-summary.ts",
   "lang": "ts",
   "code": "import { Component, ChangeDetectionStrategy, afterNextRender, computed, inject, input, signal } from '@angular/core';\nimport { CurrencyPipe } from '@angular/common';\nimport { CartStore, CartLine } from './cart-store';\n\n@Component({\n  selector: 'app-cart-summary',\n  imports: [CurrencyPipe],\n  changeDetection: ChangeDetectionStrategy.OnPush,\n  host: {\n    '(window:storage)': 'reload()',\n  },\n..."
  }
 ],
 "changes": [
  {
   "rule": "AC-EFFSET",
   "what": "Removed the effect() that wrote total; total is now a computed over lines() and ..."
  },
  {
   "rule": "AC-CTORDI",
   "what": "constructor(private store: CartStore) became private readonly store = inject(CartStore)."
  },
  {
   "rule": "AC-DOMGLOBAL",
   "what": "The localStorage 'cart' read moved from the constructor body into afterNextRender, so it ..."
  },
  {
   "rule": "AC-HOSTDEC",
   "what": "@HostListener('window:storage') on reload() replaced by host: { '(window:storage)': ..."
  },
  {
   "rule": "AC-NGIF",
   "what": "<p *ngIf=\"discount() > 0\"> became an @if block."
  },
  {
   "rule": "NEW",
   "what": "Added CurrencyPipe to imports: the template used | currency without it, and NgIf was ..."
  },
  {
   "rule": "AC-NGCLASS",
   "what": "[ngClass]=\"{ low: line.stock < 3 }\" became [class.low]=\"line.stock < 3\" and NgClass was ..."
  }
 ],
 "kept": [
  {
   "rule": "AC-TRACKIDX",
   "what": "@for still tracks $index.",
   "why": "CartLine is defined in ./cart-store, which was not shown, so there is ..."
  },
  {
   "rule": "NEW",
   "what": "Initial load still parses localStorage 'cart' directly while reload() uses store.read().",
   "why": "Behaviour-preserving: I cannot see whether CartStore.read() reads the ..."
  }
 ],
 "schematics": [
  "ng generate @angular/core:control-flow",
  "ng generate @angular/core:inject"
 ],
 "tests": [
  "Parents are unchanged: selector app-cart-summary, class CartSummary and the currency/discount inputs keep ...",
  "Specs that seed localStorage 'cart' must now let the first render complete (fixture.detectChanges() then ...",
  "Specs that set total directly must stop: total is a read-only computed; set lines or the discount input ...",
  "..."
 ],
 "caveats": [
  "CartStore and CartLine (./cart-store) were not shown, so store.read() and the CartLine fields used in the ...",
  "The file name cart-summary.ts is assumed from the suffix-less class CartSummary; keep your existing file name.",
  "JSON.parse still throws on a malformed 'cart' value in the browser, as before; wrap it in try/catch if that ...",
  "Run ng build and your SSR build after applying; nothing here was compiled or tested."
 ]
}

Build the input yourself

The scanner and the reply checker are plain scripts that run under node (global.window = global; require("./ngscan.js"); require("./ngreply.js")), then:

const S = NgScan, R = NgReply;
const st = { code: fs.readFileSync("cart-summary.ts", "utf8"), template: "", version: "20", focus: "We render this on the server too." };
const scan = S.scan(st);                        // version, verdict, axes, api, findings, schematics
const b = S.buildInput(st, "review");           // or "modernize"; clips long files and writes the facts
const wire = S.toWire(b.input);                 // every field a string - POST this to /estimate and /run
// then: const reply = R.normalize(R.parseReply(job.output.output), "review");
// R.check(reply, b)                            // {notes, warn}: anything the reply missed or got wrong

Get a token for the steps below from /tokens.html.

Step 1 — a tiny client helper

One function that sends JSON and unwraps the response. Paste a personal token from /tokens.html in place of YOUR_TOKEN, or leave it empty and mint a guest token in step 2.

BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="YOUR_TOKEN"   # from https://angular-clinic.skillsafe.ai/tokens.html
api() { # api METHOD PATH [JSON-BODY]
  curl -s -X "$1" "$BASE$2" -H "Content-Type: application/json" \
    ${TOKEN:+-H "Authorization: Bearer $TOKEN"} ${3:+--data "$3"}
}

Step 2 — a token

A guest token is enough for /me and /estimate. A run is metered, so it needs a personal token: sign in on /tokens.html and copy it from there.

# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN=$(curl -s -X POST "$BASE/guest" -H "Content-Type: application/json" -d '{"slug":"angular-clinic"}' | jq -r '.data.token')

Step 3 — GET /me

Who the token is (subject_type: user or guest) and the credit balance.

api GET /me | jq '.data | {subject_type, credits}'

Step 4 — POST /estimate

Free. The body is the run input itself, the bare object (never wrapped in {"input": ...}). hold_credits is a reservation, not the price; min_credits is what the balance must cover; model_alias reads gpt-terra. Save one of the worked-example inputs above as input.json to follow along.

# input.json: the run input object - {"task": "review", "code": "import { Component, ... } ...", "template": "", "version": "20", "facts": "Angular version: 20. ...", "focus": "..."}
# - see the input table
api POST /estimate "$(cat input.json)" | jq '.data | {hold_credits, min_credits, model, model_alias, markup_bps, warnings}' 

Step 5 — POST /run, then poll the job

Send an Idempotency-Key built from the lane and the input: a retry with the same key never bills twice. The page uses angular-clinic:<task>:<inputHash>:a<attempt> - the lane is part of the key, so a review and a rewrite of the same component are two runs - and its one automatic reformat retry (sent with a retry_note) is a new attempt number, a2, named in the page as one extra run. Any stable hash of the input works for your own key. A review carries verdict, summary, findings, prescan, strengths, order and caveats; a rewrite carries summary, files, changes, kept, schematics, tests and caveats. The code below prints the headline parts of either: for a review the verdict, the summary and each finding's severity and title; for a rewrite the summary and each file name.

KEY="angular-clinic:$(jq -r .task input.json):$(shasum -a 256 input.json | cut -c1-16):a1"
JOB=$(curl -s -X POST "$BASE/run" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: $KEY" --data @input.json | jq -r '.data.job_id')
while :; do
  J=$(api GET "/jobs/$JOB"); S=$(echo "$J" | jq -r '.data.status')
  case "$S" in succeeded|failed|cancelled) break;; esac; sleep 2
done
echo "$J" | jq -r '.data.output.output' > reply.json   # one JSON object, the reply contract
echo "$J" | jq '.data | {charged_credits, truncated}'
jq -r 'if .task == "modernize"
  then .summary, (.files[] | "file: \(.name)")
  else "verdict: \(.verdict)", .summary, (.findings[] | "[\(.severity)] \(.title)") end' reply.json

Step 6 — POST /run-stream

The same run as an SSE stream, with the same input.json and the same kind of key. Browsers receive progress ticks rather than text deltas; the final done message carries the job, whose output.output is the reply.

# SSE: tick/delta messages while the job runs, then a final message with the job
curl -N -s -X POST "$BASE/run-stream" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: angular-clinic:$(jq -r .task input.json):$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json