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.
| Status | Code | Meaning |
|---|---|---|
| 400 | validation_error | The 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. |
| 401 | unauthorized | No token, or a stale one. Mint a guest token or sign in again. |
| 402 | insufficient_credits | The balance is under min_credits. Price with /estimate first. |
| 403 | forbidden | A guest token tried to run: both lanes are metered and need a personal sign-in. |
| 404 | not_found | Unknown job id. |
| 429 | rate_limited | Back off and retry. |
| 5xx | server_error | Transient. 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:
review- each of the page's findings confirmed (with the issue, the fix and a short code fix) or cleared (with why), up to five issues a scan cannot see on real lines, and the fixes in the order to make them, each naming its ids.rewrite- the whole component upgraded - createRef fields, the safe lifecycles by case, createContext with static contextType, batching-safe state - with what changed and what was kept, every class name unchanged.
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
| Field | Required | What |
|---|---|---|
task | yes | review or rewrite. If it is missing or neither, do review and set "task": "review". |
unit | yes | the file's path and the target (React 18.3.1 or React 19). |
code | yes | the 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. |
carry | send 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. |
facts | yes | the 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_note | no | optional; 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:
- String refs: every ref="name" becomes a class field nameRef = React.createRef(); the JSX writes ref={this.nameRef}, and every this.refs.name becomes this.nameRef.current. Refs in a list need one ref per item - a Map of createRef keyed by the item's id (getOrCreateRef(id)), or a callback ref into an object.
- componentWillMount: case A, setting initial state, moves to the constructor (merged into an existing this.state); case C, deriving initial state from props, moves to the constructor with props; case B, a side effect (fetch, subscription, DOM), moves to componentDidMount, paired with cleanup.
- componentWillReceiveProps: case A, an async side effect on a prop change, moves to componentDidUpdate, comparing prevProps.x with this.props.x (with cancellation for async work); case B, pure state derivation, moves to getDerivedStateFromProps. When in doubt, componentDidUpdate is always safe.
- getDerivedStateFromProps traps: it runs before every render, not only on prop changes, so it compares against the previous prop stored in state and returns null when nothing changed; it is static and cannot use this; it never runs side effects.
- componentWillUpdate: case A, reading the DOM before the update (scroll, size, position), moves to getSnapshotBeforeUpdate, whose return value arrives as snapshot in componentDidUpdate (check snapshot !== null; it must be paired with componentDidUpdate); case B, side effects before the update, move to componentDidUpdate with a prev comparison.
- UNSAFE_ prefixes are never a permanent fix - they silence the warning but React 19 removes the methods anyway. As a temporary hold only, mark them with: // TODO: React 19 will remove this. Migrate before React 19 upgrade.
- Legacy context is always a cross-file migration: create the context module, update the provider (Context.Provider value={...}, no childContextTypes or getChildContext), and update every consumer in the same change - a class consumer uses static contextType (singular, one per class; Context.Consumer for more), where this.context is the whole value; a function consumer uses useContext.
- Batching: in React 18, setState in setTimeout, promise callbacks, async/await and native event listeners is batched. Category A: code that reads this.state after an await to make a decision - remove the read and use the value just set. Category B: several setState calls in an async context - merge them. Category C: an intermediate render the user must see (a spinner before a fetch) - flushSync, used sparingly.
House rules
- Use only the input. Never invent a prop, a state key, a context key, a helper or a file the code does not hold; React and react-dom APIs a fix needs (createRef, createContext, flushSync, getSnapshotBeforeUpdate) are fine. When a fix depends on another file (a context consumer elsewhere, the provider of a context this file reads), say so in a caveat instead of guessing its contents.
- The page's ids and lines are measured facts: never renumber an id.
- No web links (http or https) in prose.
- Write in English. Keep the JSON keys and the ids exactly as given.
- Never use backticks. Name calls plainly (React.createRef(), componentDidUpdate(prevProps)). Words from the rules above are free to use; what you never do is paste a whole sentence from this prompt.
- Never write a gendered pronoun for a user; write they, the user or the person.
- Reply with ONLY one JSON object - no prose before or after, no code fences.
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.
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.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.cleared- one object per page finding that is not a problem here, withidandwhy(one sentence tied to the code). Every page id appears exactly once, in findings or in cleared.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.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.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.
summary- one sentence, at most 40 words: what the upgrade changed and what it left for the team.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.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.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.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.
review - A search box that uses every deprecated API
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
rewrite - A search box that uses every deprecated API
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"}
}
import json, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN" # from https://class-upgrade-desk.skillsafe.ai/tokens.html
def api(method, path, body=None, headers=None):
req = urllib.request.Request(BASE + path, method=method,
data=None if body is None else json.dumps(body).encode(),
headers={"Content-Type": "application/json", **({"Authorization": "Bearer " + TOKEN} if TOKEN else {}), **(headers or {})})
with urllib.request.urlopen(req) as r:
env = json.load(r)
if env.get("error"):
raise RuntimeError(env["error"])
return env["data"]
// class-upgrade-desk.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "YOUR_TOKEN"; // from https://class-upgrade-desk.skillsafe.ai/tokens.html
async function api(method, path, body, headers = {}) {
const r = await fetch(BASE + path, { method, headers: { "Content-Type": "application/json",
...(TOKEN ? { Authorization: "Bearer " + TOKEN } : {}), ...headers },
body: body === undefined ? undefined : JSON.stringify(body) });
const env = await r.json();
if (env.error) throw new Error(env.error.code + ": " + env.error.message);
return env.data;
}
package main
import ("bytes"; "encoding/json"; "fmt"; "net/http")
const base = "https://api.skillsafe.ai/v1/app-api"
var token = "YOUR_TOKEN" // from https://class-upgrade-desk.skillsafe.ai/tokens.html
func api(method, path string, body any, headers map[string]string) (map[string]any, error) {
var buf bytes.Buffer
if body != nil { json.NewEncoder(&buf).Encode(body) }
req, _ := http.NewRequest(method, base+path, &buf)
req.Header.Set("Content-Type", "application/json")
if token != "" { req.Header.Set("Authorization", "Bearer "+token) }
for k, v := range headers { req.Header.Set(k, v) }
res, err := http.DefaultClient.Do(req)
if err != nil { return nil, err }
defer res.Body.Close()
var env struct { Data map[string]any; Error map[string]any }
json.NewDecoder(res.Body).Decode(&env)
if env.Error != nil { return nil, fmt.Errorf("%v", env.Error) }
return env.Data, nil
}
// ClassUpgradeDesk.java -- Java 17+, with com.fasterxml.jackson.core:jackson-databind
import com.fasterxml.jackson.databind.*;
import java.net.URI; import java.net.http.*; import java.util.*;
public class ClassUpgradeDesk {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "YOUR_TOKEN"; // from https://class-upgrade-desk.skillsafe.ai/tokens.html
static final ObjectMapper M = new ObjectMapper();
static final HttpClient C = HttpClient.newHttpClient();
static JsonNode api(String method, String path, Object body, Map<String, String> headers) throws Exception {
var b = HttpRequest.newBuilder(URI.create(BASE + path)).header("Content-Type", "application/json");
if (!token.isEmpty()) b.header("Authorization", "Bearer " + token);
if (headers != null) headers.forEach(b::header);
b.method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(M.writeValueAsString(body)));
JsonNode env = M.readTree(C.send(b.build(), HttpResponse.BodyHandlers.ofString()).body());
if (env.hasNonNull("error")) throw new RuntimeException(env.get("error").toString());
return env.get("data");
}
}
require "json"
require "net/http"
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN" # from https://class-upgrade-desk.skillsafe.ai/tokens.html
def api(method, path, body = nil, headers = {})
uri = URI(BASE + path)
req = Net::HTTPGenericRequest.new(method, !body.nil?, true, uri.request_uri,
{ "Content-Type" => "application/json" }.merge(TOKEN.empty? ? {} : { "Authorization" => "Bearer #{TOKEN}" }).merge(headers))
req.body = JSON.generate(body) unless body.nil?
env = JSON.parse(Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }.body)
raise env["error"].to_s if env["error"]
env["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = "YOUR_TOKEN"; // from https://class-upgrade-desk.skillsafe.ai/tokens.html
function api(string $method, string $path, $body = null, array $headers = []) {
global $TOKEN;
$h = ["Content-Type: application/json"];
if ($TOKEN) $h[] = "Authorization: Bearer $TOKEN";
foreach ($headers as $k => $v) $h[] = "$k: $v";
$ch = curl_init(BASE . $path);
curl_setopt_array($ch, [CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $h, CURLOPT_RETURNTRANSFER => true]);
if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
$env = json_decode(curl_exec($ch), true);
if (!empty($env["error"])) throw new Exception(json_encode($env["error"]));
return $env["data"];
}
// Program.cs -- .NET 6+ console app
using System.Net.Http.Json; using System.Text.Json; using System.Text.Json.Nodes;
static class Api {
public const string Base = "https://api.skillsafe.ai/v1/app-api";
public static string Token = "YOUR_TOKEN"; // from https://class-upgrade-desk.skillsafe.ai/tokens.html
static readonly HttpClient C = new();
public static async Task<JsonNode?> Call(string method, string path, object? body = null, Dictionary<string, string>? headers = null) {
var req = new HttpRequestMessage(new HttpMethod(method), Base + path);
if (Token != "") req.Headers.Add("Authorization", "Bearer " + Token);
if (headers != null) foreach (var h in headers) req.Headers.Add(h.Key, h.Value);
if (body != null) req.Content = JsonContent.Create(body);
var env = JsonNode.Parse(await (await C.SendAsync(req)).Content.ReadAsStringAsync())!;
if (env["error"] is JsonNode e && e.GetValueKind() != JsonValueKind.Null) throw new Exception(e.ToJsonString());
return env["data"];
}
}
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')
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN = ""
TOKEN = api("POST", "/guest", {"slug": "class-upgrade-desk"})["token"]
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN = "";
TOKEN = (await api("POST", "/guest", { slug: "class-upgrade-desk" })).token;
// inside main: a guest token (enough for /me and /estimate); a run needs your personal token
token = ""
g, err := api("POST", "/guest", map[string]string{"slug": "class-upgrade-desk"}, nil)
if err != nil { panic(err) }
token = g["token"].(string)
// inside main: a guest token (enough for /me and /estimate); a run needs your personal token
token = "";
token = api("POST", "/guest", Map.of("slug", "class-upgrade-desk"), null).get("token").asText();
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN.replace("")
TOKEN.replace(api("POST", "/guest", { slug: "class-upgrade-desk" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "class-upgrade-desk"])["token"];
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
Api.Token = "";
Api.Token = (await Api.Call("POST", "/guest", new { slug = "class-upgrade-desk" }))!["token"]!.GetValue<string>();
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}'
me = api("GET", "/me")
print(me["subject_type"], me.get("credits"))
const me = await api("GET", "/me");
console.log(me.subject_type, me.credits);
me, _ := api("GET", "/me", nil, nil)
fmt.Println(me["subject_type"], me["credits"])
JsonNode me = api("GET", "/me", null, null);
System.out.println(me.get("subject_type") + " " + me.get("credits"));
me = api("GET", "/me")
puts [me["subject_type"], me["credits"]].inspect
$me = api("GET", "/me");
echo $me["subject_type"], " ", $me["credits"] ?? "", "\n";
var me = await Api.Call("GET", "/me");
Console.WriteLine($"{me!["subject_type"]} {me["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}'
inp = json.load(open("input.json")) # {"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
est = api("POST", "/estimate", inp)
print(est["hold_credits"], est["min_credits"], est["model"], est.get("warnings"))
import { readFileSync } from "node:fs";
const input = JSON.parse(readFileSync("input.json", "utf8")); // {task: "review" or "rewrite", unit, code, carry, facts} (see the input table)
const est = await api("POST", "/estimate", input);
console.log(est.hold_credits, est.min_credits, est.model, est.warnings);
raw, _ := os.ReadFile("input.json") // add "os" to the imports
var input map[string]any
json.Unmarshal(raw, &input)
est, _ := api("POST", "/estimate", input, nil)
fmt.Println(est["hold_credits"], est["min_credits"], est["model"])
Map<String, Object> input = M.readValue(new java.io.File("input.json"), Map.class);
JsonNode est = api("POST", "/estimate", input, null);
System.out.println(est.get("hold_credits") + " " + est.get("min_credits") + " " + est.get("model"));
input = JSON.parse(File.read("input.json"))
est = api("POST", "/estimate", input)
puts [est["hold_credits"], est["min_credits"], est["model"]].inspect
$input = json_decode(file_get_contents("input.json"), true);
$est = api("POST", "/estimate", $input);
echo $est["hold_credits"], " ", $est["min_credits"], " ", $est["model"], "\n";
var input = JsonNode.Parse(File.ReadAllText("input.json"))!;
var est = await Api.Call("POST", "/estimate", input);
Console.WriteLine($"{est!["hold_credits"]} {est["min_credits"]} {est["model"]}");
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
import hashlib, time
key = "class-upgrade-desk:" + inp["task"] + ":" + hashlib.sha256(json.dumps(inp).encode()).hexdigest()[:16] + ":a1"
job = api("POST", "/run", inp, {"Idempotency-Key": key})
while True:
j = api("GET", "/jobs/" + job["job_id"])
if j["status"] in ("succeeded", "failed", "cancelled"):
break
time.sleep(2)
reply = json.loads(j["output"]["output"]) # the reply as one JSON object
print(j["charged_credits"], j.get("truncated"), reply["task"])
if reply["task"] == "rewrite":
for x in reply["changes"]:
print(x["id"], x["change"])
else:
for x in reply["findings"]:
print(x["id"], x["fix"])
import { createHash } from "node:crypto";
const key = `class-upgrade-desk:${input.task}:${createHash("sha256").update(JSON.stringify(input)).digest("hex").slice(0, 16)}:a1`;
const job = await api("POST", "/run", input, { "Idempotency-Key": key });
let j;
do { await new Promise((r) => setTimeout(r, 2000)); j = await api("GET", "/jobs/" + job.job_id); }
while (!["succeeded", "failed", "cancelled"].includes(j.status));
const reply = JSON.parse(j.output.output);
console.log(j.charged_credits, j.truncated, reply.task);
if (reply.task === "rewrite") { for (const x of reply.changes) console.log(x.id, x.change); }
else { for (const x of reply.findings) console.log(x.id, x.fix); }
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "class-upgrade-desk:" + input["task"].(string) + ":" + hex.EncodeToString(sum[:8]) + ":a1"
job, _ := api("POST", "/run", input, map[string]string{"Idempotency-Key": key})
var j map[string]any
for {
time.Sleep(2 * time.Second)
j, _ = api("GET", "/jobs/"+job["job_id"].(string), nil, nil)
if s := j["status"]; s == "succeeded" || s == "failed" || s == "cancelled" { break }
}
var reply map[string]any
json.Unmarshal([]byte(j["output"].(map[string]any)["output"].(string)), &reply)
fmt.Println(j["charged_credits"], j["truncated"], reply["task"])
key, b := "findings", "fix"
if reply["task"] == "rewrite" {
key, b = "changes", "change"
}
xs, _ := reply[key].([]any)
for _, x := range xs {
xx := x.(map[string]any)
fmt.Println(xx["id"], xx[b])
}
var digest = java.security.MessageDigest.getInstance("SHA-256").digest(M.writeValueAsBytes(input));
String key = "class-upgrade-desk:" + input.get("task") + ":" + HexFormat.of().formatHex(digest, 0, 8) + ":a1";
JsonNode job = api("POST", "/run", input, Map.of("Idempotency-Key", key)), j;
do { Thread.sleep(2000); j = api("GET", "/jobs/" + job.get("job_id").asText(), null, null); }
while (!Set.of("succeeded", "failed", "cancelled").contains(j.get("status").asText()));
JsonNode reply = M.readTree(j.get("output").get("output").asText());
System.out.println(j.get("charged_credits") + " " + reply.path("task").asText());
if (reply.path("task").asText().equals("rewrite")) {
for (JsonNode x : reply.path("changes")) System.out.println(x.path("id").asText() + " " + x.path("change").asText());
} else {
for (JsonNode x : reply.path("findings")) System.out.println(x.path("id").asText() + " " + x.path("fix").asText());
}
require "digest"
key = "class-upgrade-desk:#{input["task"]}:#{Digest::SHA256.hexdigest(JSON.generate(input))[0, 16]}:a1"
job = api("POST", "/run", input, { "Idempotency-Key" => key })
j = nil
loop do
sleep 2
j = api("GET", "/jobs/#{job["job_id"]}")
break if %w[succeeded failed cancelled].include?(j["status"])
end
reply = JSON.parse(j["output"]["output"])
puts [j["charged_credits"], j["truncated"], reply["task"]].inspect
if reply["task"] == "rewrite"
reply["changes"].each { |x| puts "#{x["id"]} #{x["change"]}" }
else
reply["findings"].each { |x| puts "#{x["id"]} #{x["fix"]}" }
end
$key = "class-upgrade-desk:" . $input["task"] . ":" . substr(hash("sha256", json_encode($input)), 0, 16) . ":a1";
$job = api("POST", "/run", $input, ["Idempotency-Key" => $key]);
do { sleep(2); $j = api("GET", "/jobs/" . $job["job_id"]); }
while (!in_array($j["status"], ["succeeded", "failed", "cancelled"]));
$reply = json_decode($j["output"]["output"], true);
echo $j["charged_credits"], " ", $reply["task"], "\n";
if ($reply["task"] === "rewrite") { foreach ($reply["changes"] as $x) echo $x["id"], " ", $x["change"], "\n"; }
else { foreach ($reply["findings"] as $x) echo $x["id"], " ", $x["fix"], "\n"; }
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"class-upgrade-desk:{input["task"]}:{Convert.ToHexString(digest, 0, 8).ToLowerInvariant()}:a1";
var job = await Api.Call("POST", "/run", input, new() { ["Idempotency-Key"] = key });
JsonNode j;
do { await Task.Delay(2000); j = (await Api.Call("GET", "/jobs/" + job!["job_id"]))!; }
while (!new[] { "succeeded", "failed", "cancelled" }.Contains(j["status"]!.GetValue<string>()));
var reply = JsonNode.Parse(j["output"]!["output"]!.GetValue<string>())!;
var task = (string?)reply["task"];
Console.WriteLine($"{j["charged_credits"]} {task}");
if (task == "rewrite") { foreach (var x in reply["changes"]!.AsArray()) Console.WriteLine($"{x!["id"]} {x["change"]}"); }
else { foreach (var x in reply["findings"]!.AsArray()) Console.WriteLine($"{x!["id"]} {x["fix"]}"); }
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
rev = json.load(open("input.json")) # the same run input as input.json: {"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
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "class-upgrade-desk:" + rev["task"] + ":" + hashlib.sha256(json.dumps(rev).encode()).hexdigest()[:16] + ":a1"})
with urllib.request.urlopen(req) as r:
for line in r:
line = line.decode().strip()
if line.startswith("data:"):
print(line[5:].strip()[:200])
const rev = JSON.parse(readFileSync("input.json", "utf8"));
const r = await fetch(BASE + "/run-stream", { method: "POST", body: JSON.stringify(rev), headers: {
"Content-Type": "application/json", Authorization: "Bearer " + TOKEN,
"Idempotency-Key": `class-upgrade-desk:${rev.task}:${createHash("sha256").update(JSON.stringify(rev)).digest("hex").slice(0, 16)}:a1` } });
const dec = new TextDecoder();
for await (const chunk of r.body) for (const line of dec.decode(chunk).split("\n"))
if (line.startsWith("data:")) console.log(line.slice(5).trim().slice(0, 200));
rraw, _ := os.ReadFile("input.json") // add "bufio", "strings" to the imports
rs := sha256.Sum256(rraw)
req, _ := http.NewRequest("POST", base+"/run-stream", bytes.NewReader(rraw))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Idempotency-Key", "class-upgrade-desk:"+input["task"].(string)+":"+hex.EncodeToString(rs[:8])+":a1")
res, _ := http.DefaultClient.Do(req)
sc := bufio.NewScanner(res.Body)
for sc.Scan() { if l := sc.Text(); strings.HasPrefix(l, "data:") { fmt.Println(l[5:]) } }
String rev = java.nio.file.Files.readString(java.nio.file.Path.of("input.json"));
var rd = java.security.MessageDigest.getInstance("SHA-256").digest(rev.getBytes());
var sreq = HttpRequest.newBuilder(URI.create(BASE + "/run-stream")).header("Content-Type", "application/json")
.header("Authorization", "Bearer " + token).header("Idempotency-Key", "class-upgrade-desk:" + input.get("task") + ":" + HexFormat.of().formatHex(rd, 0, 8) + ":a1")
.POST(HttpRequest.BodyPublishers.ofString(rev)).build();
C.send(sreq, HttpResponse.BodyHandlers.ofLines()).body().filter(l -> l.startsWith("data:")).forEach(System.out::println);
rev = File.read("input.json")
uri = URI(BASE + "/run-stream")
req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json", "Authorization" => "Bearer #{TOKEN}",
"Idempotency-Key" => "class-upgrade-desk:#{JSON.parse(rev)["task"]}:#{Digest::SHA256.hexdigest(rev)[0, 16]}:a1")
req.body = rev
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |h|
h.request(req) { |res| res.read_body { |chunk| chunk.each_line { |l| puts l if l.start_with?("data:") } } }
end
$rev = file_get_contents("input.json");
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => $rev, CURLOPT_HTTPHEADER => [
"Content-Type: application/json", "Authorization: Bearer $TOKEN",
"Idempotency-Key: class-upgrade-desk:" . json_decode($rev, true)["task"] . ":" . substr(hash("sha256", $rev), 0, 16) . ":a1"],
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) { foreach (explode("\n", $chunk) as $l) if (str_starts_with($l, "data:")) echo $l, "\n"; return strlen($chunk); }]);
curl_exec($ch);
var rev = File.ReadAllText("input.json");
var rd = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(rev));
var sreq = new HttpRequestMessage(HttpMethod.Post, Api.Base + "/run-stream") { Content = new StringContent(rev, System.Text.Encoding.UTF8, "application/json") };
sreq.Headers.Add("Authorization", "Bearer " + Api.Token);
sreq.Headers.Add("Idempotency-Key", $"class-upgrade-desk:{input["task"]}:{Convert.ToHexString(rd, 0, 8).ToLowerInvariant()}:a1");
using var http = new HttpClient();
using var res = await http.SendAsync(sreq, HttpCompletionOption.ResponseHeadersRead);
using var rdr = new StreamReader(await res.Content.ReadAsStreamAsync());
for (string? l; (l = await rdr.ReadLineAsync()) != null;) if (l.StartsWith("data:")) Console.WriteLine(l);