← Class Upgrade Desk
API
Tokens

Run Class Upgrade Desk from your own code

Both metered lanes are available over HTTP and each returns one JSON object. The division of labour: the page masks the JSX, matches every class component, finds string refs, the unsafe lifecycles by case, legacy context and batching hazards, and makes the mechanical changes itself, then hands it all to the run as facts. The run writes the review or the rewrite; the page then holds it to its own facts and re-scans the upgraded file with the same scanner (upgradereply.js).

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":"class-upgrade-desk"}), so no slug header is needed afterwards. Send it as Authorization: Bearer YOUR_TOKEN. The run body is the input object, sent bare.

StatusCodeMeaning
400validation_errorThe body is not a JSON object, or a required key (task, unit, code, facts) is missing. The page always sends all five of task, unit, code, carry, facts; carry is an empty string for review.
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, chosen by task, in the order the work runs - inspect, then produce:

A missing or unknown task is answered as review. Both lanes send the same five fields. The facts are the page's scan, one finding per line with its id, and a line starting "must:" is a rule the reply has to hold. The page's handoff carries the review's decisions into carry for the rewrite lane. The review sends at most 24,000 characters of code (the middle of a longer file is cut on whole lines and marked; every finding is still in the facts); the rewrite lane takes at most 16,000.

The input

FieldRequiredWhat
taskyesreview or rewrite. If it is missing or neither, do review and set "task": "review".
unityesthe file's path and the target (React 18.3.1 or React 19).
codeyesthe component file as pasted (before the page's changes). A line starting "// [... N lines cut by the page" marks code the page scanned but did not send.
carrysend it (empty for review)rewrite task only: the review's decisions, one per line as id | confirmed or cleared or extra | where | fix or reason; may be empty.
factsyesthe page's scan: one finding per line as id | area | severity | line | where | evidence | source, every string ref and lifecycle with what the page did, and the legacy context providers and consumers. A line starting "must:" applies to your reply.
retry_notenooptional; if present, your previous reply was unusable - follow it.

The rules the model applies

From the source skills, @github/react18-string-refs, @github/react18-lifecycle-patterns, @github/react18-legacy-context, @github/react18-batching-patterns - the only standard the run applies:

House rules

The replies

review

Confirm or clear each of the page's findings, decide the cases the page left, add what a scan cannot see, and put the steps in order.

  1. summary - one sentence, at most 40 words: what breaks on React 19 (or warns on 18.3.1) today, what the page already changed, and the first step.
  2. findings - one object per page finding you confirm, in id order: id (the page's id), widget (the class and member, e.g. "SearchBox - componentWillReceiveProps"), issue (one sentence: what goes wrong on the upgrade), fix (one sentence: the decision, naming the case where one applies), code (the upgraded lines for that piece only, at most 6; "" when fix says it all). When two findings move code into the same method (componentDidUpdate is common), show that method once, merged, in the first of them and say so in the second.
  3. cleared - one object per page finding that is not a problem here, with id and why (one sentence tied to the code). Every page id appears exactly once, in findings or in cleared.
  4. extra - at most 5 issues the scan cannot see, each tied to a line of the code: id (X1, X2, ...), area (refs, lifecycle, context or batching), line (a number), widget, issue, fix. Examples: a subscription moved to componentDidMount with no cleanup in componentWillUnmount, a componentDidUpdate that compares nothing and loops, state built from this.state after an await instead of the updater form, a context key provided that no consumer reads, a ref passed to a child component. [] when there are none.
  5. plan - 2 to 10 short strings, the steps in order (what React 19 removes first, then the context migration with its other files, then batching, then the page's mechanical changes to accept), each naming the ids it covers (e.g. "Move componentWillReceiveProps to componentDidUpdate (U2)"). Every confirmed id appears in a step. [] only when nothing is confirmed.
  6. caveats - at most 4 short strings; [] when there is nothing to say.
{
  "task": "review",
  "summary": "one sentence",
  "findings": [ { "id": "U1", "widget": "SearchBox - componentWillReceiveProps", "issue": "one sentence", "fix": "one sentence", "code": "componentDidUpdate(prevProps) {\n  if (prevProps.query !== this.props.query) this.setState({ results: [], page: 0 });\n}" } ],
  "cleared": [ { "id": "U3", "why": "one sentence" } ],
  "extra": [ { "id": "X1", "area": "lifecycle", "line": 8, "widget": "SearchBox - componentDidMount", "issue": "one sentence", "fix": "one sentence" } ],
  "plan": [ "Move componentWillReceiveProps to componentDidUpdate (U2)" ],
  "caveats": [ ]
}

The page holds it to its own facts (check() in upgradereply.js; any warning makes the reply flagged): the reply answers the task it was sent; every page finding id appears exactly once across findings and cleared, and no id the page never found; a cleared id has a reason; a confirmed id has an issue and a fix; at most five extra issues, each on a line inside the paste; a fix order of 2 to 10 steps that names every confirmed id; a summary; no web link in the prose.

rewrite

Rewrite the whole component file, upgraded.

  1. summary - one sentence, at most 40 words: what the upgrade changed and what it left for the team.
  2. code - the complete upgraded file. Keep every import, class (by name), method that is not being replaced, prop and JSX element; change only what the findings name (and any carried extra), plus the imports a change needs. Never shorten the file with "..." or "unchanged" - write every line. If the code holds a line starting "// [... N lines cut by the page", keep that line exactly where it is. String refs - createRef fields read as .current (or a Map of refs for a list), exactly as the rules say. Each unsafe lifecycle - the replacement its case names, never an UNSAFE_ prefix. Legacy context in this file - the context module's import (name it after the provider, e.g. ThemeContext from "./contexts/ThemeContext"), the provider rendering Context.Provider, consumers using static contextType; when the context module itself was not pasted, write the import and say in a caveat that the team creates it (the page drafts it). Batching - no this.state read after an await (use the value just set or the updater form), merged setState calls, flushSync kept only where an intermediate render must show.
  3. changes - one object per page finding you acted on, in id order: id, change (one sentence: what changed, naming the class). Extra ids (X1, ...) from the carried review may appear too.
  4. kept - one object per page finding you did not change: id, why (one sentence - the review cleared it, or it needs a file the paste does not hold). Every page id appears exactly once, in changes or in kept; an id the carried review cleared belongs in kept.
  5. caveats - at most 4 short strings; [] when there is nothing to say.
{
  "task": "rewrite",
  "summary": "one sentence",
  "code": "the complete upgraded file",
  "changes": [ { "id": "U1", "change": "one sentence" } ],
  "kept": [ { "id": "U3", "why": "one sentence" } ],
  "caveats": [ ]
}

The page requires: the reply answers the task it was sent; every page finding id appears exactly once across changes and kept; every class component survives; the file is not shortened (at least 70% of its lines) and elides nothing; and the rewritten code is scanned again - a rule the changes claim fixed that still fires, or a rule the paste never had, is flagged. A class the rewrite drops or renames is flagged.

Worked examples

Three runs from the page's free samples (invented apps): the review of a search box that uses every deprecated API, the rewrite of the same file carrying that review, and the review of a theme provider and consumer on legacy context. Each input is exactly what the page sends; each reply is the saved reply the sample replays, shipped unedited - but the code, the facts and long strings are shortened in this display only, and every cut is listed under the example. The last line is the page's own check of the full reply.

The page read it as: Breaks in React 19 - 0 blocker, 4 major, 0 minor; findings U1 unsafe-lifecycle, U2 unsafe-lifecycle, U3 batching-state-read, U4 string-ref.

Input (facts is 1791 characters; the first 1397 are shown):

{
 "task": "review",
 "unit": "File: src/SearchBox.jsx\nTarget: React 19 (removed APIs)",
 "code": "import React from \"react\";\nimport { searchApi, store } from \"./services\";\n\nexport default class SearchBox extends React.Component {\n  state = { results: [], loading: false, page: 0 };\n\n  componentWillMount() {\n    this.unsubscribe = store.subscribe(() => this.forceUpdate());\n  }\n\n  componentWillReceiveProps(nextProps) {\n    if (nextProps.query !== this.props.query) {\n      this.setState({ results: [], page: 0 });\n    }\n  }\n\n  componentWillUnmount() {\n    this.unsubscribe();\n  }\n\n  async runSearch() {\n    this.setState({ loading: true });\n    const results = await searchApi(this.props.query, this.state.page);\n    if (this.state.loading) {\n      this.setState({ results, loading: false });\n    }\n  }\n\n  focus() {\n    this.refs.searchInput.focus();\n  }\n\n  render() {\n    return (\n      <form onSubmit={(e) => { e.preventDefault(); this.runSearch(); }}>\n        <input ref=\"searchInput\" defaultValue={this.props.query} />\n        <button type=\"submit\" disabled={this.state.loading}>Search</button>\n        <p>Results for what you typed - don't press twice</p>\n        <ul>{this.state.results.map((r) => <li key={r.id}>{r.title}</li>)}</ul>\n      </form>\n    );\n  }\n}\n",
 "carry": "",
 "facts": "The page's scan of 44 lines - 1 class component (SearchBox extends React.Component, line 4); 1 of 1 string ref and 1 of 2 unsafe lifecycles migrated by the page; verdict: Breaks in React 19.\nFindings (id | area | severity | line | where | evidence | source):\n- U1 | lifecycle | major | line 7 | SearchBox | componentWillMount runs side effects - case B; it warns in React 18.3.1 and is removed in React 19. | source: componentWillMount() {\n- U2 | lifecycle | major | line 11 | SearchBox | componentWillReceiveProps derives state from new props - case B; it warns in React 18.3.1 and is removed in React 19. | source: componentWillReceiveProps(nextProps) {\n- U3 | batching | major | line 21 | SearchBox.runSearch | runSearch sets state and then reads this.state after an await - in React 18 those setState calls are batched, so the read can see the old value (category A, a silent bug). | source: async runSearch() {\n- U4 | refs | major | line 36 | SearchBox | String ref \"searchInput\" (1 ref attribute, 1 this.refs.searchInput read) - deprecated since 16.3, warns in React 18.3.1 and removed in React 19. | source: <input ref=\"searchInput\" defaultValue={this.props.query} />\nString refs (class | ref | createRef field | ref attributes | this.refs reads | what the page did):\n- SearchBox | searchInput | searchInputRef | 1 | 1 | migrated\nUnsafe lifecycles (class | method | line | case | moves to |\n\n[... 394 more characters cut from this display ...]"
}

Reply:

{
 "task": "review",
 "summary": "SearchBox breaks on React 19 through componentWillReceiveProps and a string ref, and runSearch reads this.state after an await; the page already moved the subscription to componentDidMount and the ref to createRef; first replace componentWillReceiveProps.",
 "findings": [
  {
   "id": "U1",
   "widget": "SearchBox - componentWillMount",
   "issue": "componentWillMount subscribes to the store, a side effect that warns in React 18.3.1 and is gone in React 19.",
   "fix": "Accept the page's move to componentDidMount (side-effect case), which pairs with the existing unsubscribe in componentWillUnmount.",
   "code": "componentDidMount() {\n  this.unsubscribe = store.subscribe(() => this.forceUpdate());\n}"
  },
  {
   "id": "U2",
   "widget": "SearchBox - componentWillReceiveProps",
   "issue": "componentWillReceiveProps resets results and page when the query prop changes, and React 19 never calls it.",
   "fix": "Move the reset to componentDidUpdate(prevProps), comparing prevProps.query with this.props.query, which needs no extra state key for getDerivedStateFromProps.",
   "code": "componentDidUpdate(prevProps) {\n  if (prevProps.query !== this.props.query) {\n    this.setState({ results: [], page: 0 });\n  }\n}"
  },
  {
   "id": "U3",
   "widget": "SearchBox - runSearch",
   "issue": "After the await, runSearch decides on this.state.loading, a read that batching can leave stale (category A).",
   "fix": "Drop the this.state.loading read and set the results with loading false right after the await, since loading was just set to true.",
   "code": "async runSearch() {\n  this.setState({ loading: true });\n  const results = await searchApi(this.props.query, this.state.page);\n  this.setState({ results, loading: false });\n}"
  },
  {
   "id": "U4",
   "widget": "SearchBox - searchInput ref",
   "issue": "The string ref searchInput and this.refs.searchInput warn in React 18.3.1 and are removed in React 19.",
   "fix": "Accept the page's searchInputRef = React.createRef() field, with ref={this.searchInputRef} and this.searchInputRef.current.focus() in focus().",
   "code": "searchInputRef = React.createRef();\n\nfocus() {\n  this.searchInputRef.current.focus();\n}"
  }
 ],
 "cleared": [],
 "extra": [
  {
   "id": "X1",
   "area": "batching",
   "line": 23,
   "widget": "SearchBox - runSearch",
   "issue": "If the query prop changes while searchApi is pending, the old query's results land after componentDidUpdate has cleared the list.",
   "fix": "Keep the query in a local before the await and only set results when it still equals this.props.query; otherwise just set loading false."
  }
 ],
 "plan": [
  "Replace componentWillReceiveProps with componentDidUpdate(prevProps) comparing query (U2)",
  "Accept the page's move of the store subscription to componentDidMount (U1)",
  "Remove the this.state.loading read after the await in runSearch (U3)",
  "Ignore results that come back for an old query (X1)",
  "Accept the page's searchInputRef createRef field and .current read (U4)"
 ],
 "caveats": [
  "store and searchApi live in ./services, which the paste does not hold; the review assumes store.subscribe returns the unsubscribe function."
 ]
}

What step 5 prints for it: U1: Accept the page's move to componentDidMount (side-effect case), which pairs with the existing unsubscribe in componentWillUnmount. | U2: Move the reset to componentDidUpdate(prevProps), comparing prevProps.query with this.props.query, which needs no extra state key for getDerivedStateFromProps. | U3: Drop the this.state.loading read and set the results with loading false right after the await, since loading was just set to true. | U4: Accept the page's searchInputRef = React.createRef() field, with ref={this.searchInputRef} and this.searchInputRef.current.focus() in focus().

The page's check: kept - nothing to flag

The input carries the saved review (R.laneForm fills carry from the review reply), as the page's handoff does. The page re-scanned the rewritten code: 4 findings before, 0 after.

Input (facts is 1783 characters; the first 1397 are shown):

{
 "task": "rewrite",
 "unit": "File: src/SearchBox.jsx\nTarget: React 19 (removed APIs)",
 "code": "import React from \"react\";\nimport { searchApi, store } from \"./services\";\n\nexport default class SearchBox extends React.Component {\n  state = { results: [], loading: false, page: 0 };\n\n  componentWillMount() {\n    this.unsubscribe = store.subscribe(() => this.forceUpdate());\n  }\n\n  componentWillReceiveProps(nextProps) {\n    if (nextProps.query !== this.props.query) {\n      this.setState({ results: [], page: 0 });\n    }\n  }\n\n  componentWillUnmount() {\n    this.unsubscribe();\n  }\n\n  async runSearch() {\n    this.setState({ loading: true });\n    const results = await searchApi(this.props.query, this.state.page);\n    if (this.state.loading) {\n      this.setState({ results, loading: false });\n    }\n  }\n\n  focus() {\n    this.refs.searchInput.focus();\n  }\n\n  render() {\n    return (\n      <form onSubmit={(e) => { e.preventDefault(); this.runSearch(); }}>\n        <input ref=\"searchInput\" defaultValue={this.props.query} />\n        <button type=\"submit\" disabled={this.state.loading}>Search</button>\n        <p>Results for what you typed - don't press twice</p>\n        <ul>{this.state.results.map((r) => <li key={r.id}>{r.title}</li>)}</ul>\n      </form>\n    );\n  }\n}\n",
 "carry": "U1 | confirmed | SearchBox - componentWillMount | Accept the page's move to componentDidMount (side-effect case), which pairs with the existing unsubscribe in componentWillUnmount.\nU2 | confirmed | SearchBox - componentWillReceiveProps | Move the reset to componentDidUpdate(prevProps), comparing prevProps.query with this.props.query, which needs no extra state key for getDerivedStateFromProps.\nU3 | confirmed | SearchBox - runSearch | Drop the this.state.loading read and set the results with loading false right after the await, since loading was just set to true.\nU4 | confirmed | SearchBox - searchInput ref | Accept the page's searchInputRef = React.createRef() field, with ref={this.searchInputRef} and this.searchInputRef.current.focus() in focus().\nX1 | extra | SearchBox - runSearch (line 23) | Keep the query in a local before the await and only set results when it still equals this.props.query; otherwise just set loading false.",
 "facts": "The page's scan of 44 lines - 1 class component (SearchBox extends React.Component, line 4); 1 of 1 string ref and 1 of 2 unsafe lifecycles migrated by the page; verdict: Breaks in React 19.\nFindings (id | area | severity | line | where | evidence | source):\n- U1 | lifecycle | major | line 7 | SearchBox | componentWillMount runs side effects - case B; it warns in React 18.3.1 and is removed in React 19. | source: componentWillMount() {\n- U2 | lifecycle | major | line 11 | SearchBox | componentWillReceiveProps derives state from new props - case B; it warns in React 18.3.1 and is removed in React 19. | source: componentWillReceiveProps(nextProps) {\n- U3 | batching | major | line 21 | SearchBox.runSearch | runSearch sets state and then reads this.state after an await - in React 18 those setState calls are batched, so the read can see the old value (category A, a silent bug). | source: async runSearch() {\n- U4 | refs | major | line 36 | SearchBox | String ref \"searchInput\" (1 ref attribute, 1 this.refs.searchInput read) - deprecated since 16.3, warns in React 18.3.1 and removed in React 19. | source: <input ref=\"searchInput\" defaultValue={this.props.query} />\nString refs (class | ref | createRef field | ref attributes | this.refs reads | what the page did):\n- SearchBox | searchInput | searchInputRef | 1 | 1 | migrated\nUnsafe lifecycles (class | method | line | case | moves to |\n\n[... 386 more characters cut from this display ...]"
}

Reply (long strings shortened for display - see the cuts below):

{
 "task": "rewrite",
 "summary": "SearchBox now subscribes in componentDidMount, resets results in componentDidUpdate, reads its input through searchInputRef and drops the stale state read in runSearch; the team still checks the ./services store contract.",
 "code": "import React from \"react\";\nimport { searchApi, store } from \"./services\";\n\nexport default class SearchBox extends React.Component {\n  state = { results: [], loading: false, page: 0 };\n\n  searchInputRef = React.createRef();\n\n  componentDidMount() {\n    this.unsubscribe = store.subscribe(() => this.forceUpdate());\n  } [... 959 more characters cut from this display]",
 "changes": [
  {
   "id": "U1",
   "change": "SearchBox subscribes to the store in componentDidMount, paired with the existing unsubscribe in componentWillUnmount."
  },
  {
   "id": "U2",
   "change": "SearchBox resets results and page in componentDidUpdate(prevProps) when the query prop changes."
  },
  {
   "id": "U3",
   "change": "SearchBox.runSearch reads page into a local before the await, drops the this.state.loading check and sets results with loading false directly."
  },
  {
   "id": "U4",
   "change": "SearchBox uses a searchInputRef createRef field, with ref={this.searchInputRef} and this.searchInputRef.current.focus()."
  },
  {
   "id": "X1",
   "change": "SearchBox.runSearch keeps the query in a local and drops results that come back for an older query."
  }
 ],
 "kept": [],
 "caveats": [
  "store and searchApi come from ./services, which the paste does not hold; store.subscribe is assumed to return the unsubscribe function."
 ]
}

Cut from this display only (the saved reply and the check use the full text): code (1276 characters, 317 shown).

What step 5 prints for it: U1: SearchBox subscribes to the store in componentDidMount, paired with the existing unsubscribe in componentWillUnmount. | U2: SearchBox resets results and page in componentDidUpdate(prevProps) when the query prop changes. | U3: SearchBox.runSearch reads page into a local before the await, drops the this.state.loading check and sets results with loading false directly. | U4: SearchBox uses a searchInputRef createRef field, with ref={this.searchInputRef} and this.searchInputRef.current.focus(). | X1: SearchBox.runSearch keeps the query in a local and drops results that come back for an older query.

The page's check: kept - nothing to flag

review - A theme provider and consumer on legacy context

The page read it as: Breaks in React 19 - 0 blocker, 4 major, 0 minor; findings U1 legacy-provider, U2 legacy-consumer, U3 unsafe-lifecycle, U4 string-ref.

Input (facts is 1691 characters; the first 1399 are shown):

{
 "task": "review",
 "unit": "File: src/theme.jsx\nTarget: React 19 (removed APIs)",
 "code": "import React from \"react\";\nimport PropTypes from \"prop-types\";\n\nexport class ThemeProvider extends React.Component {\n  static childContextTypes = {\n    theme: PropTypes.object,\n    toggleTheme: PropTypes.func,\n  };\n\n  state = { theme: { fg: \"#111\", bg: \"#fff\" } };\n\n  getChildContext() {\n    return { theme: this.state.theme, toggleTheme: this.toggleTheme };\n  }\n\n  toggleTheme = () => {\n    this.setState(({ theme }) => ({ theme: { fg: theme.bg, bg: theme.fg } }));\n  };\n\n  render() {\n    return this.props.children;\n  }\n}\n\nexport class ThemedLog extends React.Component {\n  static contextTypes = {\n    theme: PropTypes.object,\n  };\n\n  componentWillUpdate() {\n    this.wasAtBottom = this.refs.log.scrollTop + this.refs.log.clientHeight >= this.refs.log.scrollHeight;\n  }\n\n  componentDidUpdate() {\n    if (this.wasAtBottom) this.refs.log.scrollTop = this.refs.log.scrollHeight;\n  }\n\n  render() {\n    const { fg, bg } = this.context.theme;\n    return <pre ref=\"log\" style={{ color: fg, background: bg }}>{this.props.lines.join(\"\\n\")}</pre>;\n  }\n}\n",
 "carry": "",
 "facts": "The page's scan of 43 lines - 2 class components (ThemeProvider extends React.Component, line 4; ThemedLog extends React.Component, line 25); 1 of 1 string ref and 0 of 1 unsafe lifecycle migrated by the page; verdict: Breaks in React 19.\nFindings (id | area | severity | line | where | evidence | source):\n- U1 | context | major | line 5 | ThemeProvider | ThemeProvider provides legacy context (theme, toggleTheme) - deprecated, warns in React 18.3.1 and removed in React 19. | source: static childContextTypes = {\n- U2 | context | major | line 26 | ThemedLog | ThemedLog reads legacy context (theme) through this.context - removed in React 19. | source: static contextTypes = {\n- U3 | lifecycle | major | line 30 | ThemedLog | componentWillUpdate reads the DOM before the update - case A; it warns in React 18.3.1 and is removed in React 19. | source: componentWillUpdate() {\n- U4 | refs | major | line 40 | ThemedLog | String ref \"log\" (1 ref attribute, 5 this.refs.log reads) - deprecated since 16.3, warns in React 18.3.1 and removed in React 19. | source: return <pre ref=\"log\" style={{ color: fg, background: bg }}>{this.props.lines.join(\"\\n\")}</pre>;\nString refs (class | ref | createRef field | ref attributes | this.refs reads | what the page did):\n- ThemedLog | log | logRef | 1 | 5 | migrated\nUnsafe lifecycles (class | method | line | case | moves to | what the page did):\n- ThemedLog |\n\n[... 292 more characters cut from this display ...]"
}

Reply:

{
 "task": "review",
 "summary": "ThemeProvider and ThemedLog use legacy context, componentWillUpdate and the string ref log, all removed in React 19; the page migrated the ref to logRef; first move componentWillUpdate to getSnapshotBeforeUpdate.",
 "findings": [
  {
   "id": "U1",
   "widget": "ThemeProvider - childContextTypes",
   "issue": "childContextTypes and getChildContext are legacy context, which warns in React 18.3.1 and is removed in React 19.",
   "fix": "Create a ThemeContext module with React.createContext() and render ThemeContext.Provider with value holding theme and toggleTheme, deleting childContextTypes and getChildContext.",
   "code": "render() {\n  const value = { theme: this.state.theme, toggleTheme: this.toggleTheme };\n  return <ThemeContext.Provider value={value}>{this.props.children}</ThemeContext.Provider>;\n}"
  },
  {
   "id": "U2",
   "widget": "ThemedLog - contextTypes",
   "issue": "ThemedLog reads theme through static contextTypes, which React 19 no longer fills in.",
   "fix": "Replace contextTypes with static contextType = ThemeContext, so this.context is the whole value and this.context.theme keeps working.",
   "code": "static contextType = ThemeContext;"
  },
  {
   "id": "U3",
   "widget": "ThemedLog - componentWillUpdate",
   "issue": "componentWillUpdate measures the scroll position before the update, and React 19 removes it.",
   "fix": "Move the measurement to getSnapshotBeforeUpdate (DOM-read case) and read it as snapshot in componentDidUpdate, checking snapshot !== null.",
   "code": "getSnapshotBeforeUpdate() {\n  const log = this.logRef.current;\n  return log.scrollTop + log.clientHeight >= log.scrollHeight ? true : null;\n}"
  },
  {
   "id": "U4",
   "widget": "ThemedLog - log ref",
   "issue": "The string ref log and its five this.refs.log reads warn in React 18.3.1 and are removed in React 19.",
   "fix": "Accept the page's logRef = React.createRef() field, with ref={this.logRef} and every this.refs.log read as this.logRef.current.",
   "code": "logRef = React.createRef();\n\ncomponentDidUpdate(prevProps, prevState, snapshot) {\n  if (snapshot !== null) this.logRef.current.scrollTop = this.logRef.current.scrollHeight;\n}"
  }
 ],
 "cleared": [],
 "extra": [
  {
   "id": "X1",
   "area": "context",
   "line": 13,
   "widget": "ThemeProvider - getChildContext",
   "issue": "toggleTheme is provided but no consumer in this paste reads it, so a consumer in another file may depend on it.",
   "fix": "Keep toggleTheme in the Provider value and migrate every other consumer of it in the same change."
  }
 ],
 "plan": [
  "Move componentWillUpdate to getSnapshotBeforeUpdate paired with componentDidUpdate (U3)",
  "Create the ThemeContext module and render ThemeContext.Provider in ThemeProvider (U1)",
  "Switch ThemedLog to static contextType = ThemeContext (U2)",
  "Find and migrate every other consumer of theme and toggleTheme in the same change (X1)",
  "Accept the page's logRef createRef field and .current reads (U4)"
 ],
 "caveats": [
  "The ThemeContext module is not in the paste; the team creates it (the page drafts it).",
  "Consumers of this legacy context in other files must move to ThemeContext in the same change."
 ]
}

What step 5 prints for it: U1: Create a ThemeContext module with React.createContext() and render ThemeContext.Provider with value holding theme and toggleTheme, deleting childContextTypes and getChildContext. | U2: Replace contextTypes with static contextType = ThemeContext, so this.context is the whole value and this.context.theme keeps working. | U3: Move the measurement to getSnapshotBeforeUpdate (DOM-read case) and read it as snapshot in componentDidUpdate, checking snapshot !== null. | U4: Accept the page's logRef = React.createRef() field, with ref={this.logRef} and every this.refs.log read as this.logRef.current.

The page's check: kept - nothing to flag

Build the input yourself

Load the engine in node (global.window = global; require("./upgradekit.js"); require("./upgradereply.js")), then:

const K = UpgradeKit, R = UpgradeReply;
const f = K.form({ name: "src/Box.jsx", targets: "react19", carry: "",
  code: "class Box extends React.Component {\n  componentWillMount() { store.subscribe(() => {}); }\n  f() { this.refs.a.focus(); }\n  render() { return <input ref=\"a\" />; }\n}" });
// f.findings, f.scan.refs / lifecycles / context / async, f.scan.migrated (the upgraded file), f.scan.template, f.checklist
// K.findingsCsv(f), K.itemsCsv(f), K.checklistMd(f) - the free exports
const b = K.buildInput(f, "review");                         // or "rewrite" (R.laneForm(form, "rewrite", review) fills carry)
const wire = K.toWire(b.input);                              // POST this to /estimate and /run
// then: const reply = R.normalize(R.parseReply(job.output.output), "review");
// R.check(reply, b)                                         // {status: kept or flags, notes}

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, 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://class-upgrade-desk.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":"class-upgrade-desk"}' | 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.

# input.json: the run input object - {"task": "review", "unit": "File: src/SearchBox.jsx\nTarget: React 19 (removed APIs)", "code": "import React from \"react\";\n...", "carry": "", "facts": "The page's scan of 47 lines - 1 class component ...\nFindings (id | area | severity | line | where | evidence | source):\n- U1 | lifecycle | major | line 7 | ..."}
# - 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 class-upgrade-desk:<task>:<inputHash>:a<attempt> - the lane is part of the key, retry_note is left out of the hash - and its one automatic reformat retry is a new attempt number, named in the page as one extra run. A review reply carries summary, findings, cleared, extra, plan and caveats; a rewrite reply carries summary, code, changes, kept and caveats. The code below prints the gist of either: each confirmed finding and its fix, or each change.

KEY="class-upgrade-desk:$(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 == "rewrite" then (.changes[] | "\(.id): \(.change)") 
       else (.findings[] | "\(.id): \(.fix)") end' reply.json

Step 6 — POST /run-stream

The same run as Server-Sent Events, with the same input.json. Browsers receive progress ticks rather than text deltas; the final done event carries the reply.

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