Run Base UI 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, finds every Radix import, part, asChild, data-state class and call-site prop, and makes the mechanical changes itself - the @base-ui/react imports, the part renames, Portal > Positioner > Popup, render, Part.Props, the class and variable rewrites, 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 (basereply.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":"base-ui-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, the fixes in the order to make them, each naming its ids, the behavior changes (flagged, never patched) and the hand checks - the page exports it as the skill's .migration/<component>.md report.rewrite- the whole file on Base UI, the <component>-base.tsx of a progressive migration - imports, parts, Positioner and Popup, render, the classes and props - with what changed and what was kept, every component name unchanged and the non-Radix imports untouched.
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, what it is (a shadcn ui wrapper, app code that uses the wrappers, or a hand-rolled Radix composition) and the component the report is named after. |
code | yes | the 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 | what the page did (migrated, left, or left alone). "migrated" means the page made that change in its own Base UI copy, which the person already has; the code field is still the original paste, so judge it as written. 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 skill, @shadcn/migrate-radix-to-base - the only standard the run applies:
- Imports: import { X as XPrimitive } from "radix-ui" and import * as XPrimitive from "@radix-ui/react-x" both become import { X as XPrimitive } from "@base-ui/react/x" (one subpath per component). DropdownMenu becomes Menu, HoverCard becomes PreviewCard; RadioGroup items move to Radio, ToggleGroup items to Toggle. Types: React.ComponentProps<typeof XPrimitive.Part> becomes XPrimitive.Part.Props.
- Parts: Overlay becomes Backdrop; Content becomes Popup (dialogs, without a Positioner) or Panel (accordion, collapsible, tabs); tabs Trigger becomes Tab; menu Label becomes GroupLabel, Sub and SubTrigger become SubmenuRoot and SubmenuTrigger, ItemIndicator becomes CheckboxItemIndicator or RadioItemIndicator; select Viewport becomes List and the scroll buttons ScrollUpArrow / ScrollDownArrow; slider Range becomes Indicator inside a new Control; single-part components (separator, toggle, toggle group, radio group) are callable with no .Root; alert-dialog Cancel becomes Close and Action has no part.
- Positioned popups (popover, tooltip, preview card, select, menus): Portal > Positioner > Popup; side, sideOffset, align and alignOffset move to the Positioner; Select's position becomes alignItemWithTrigger.
- asChild becomes render={<Child />}; a button part rendering a non-button adds nativeButton={false}; the Slot idiom becomes the Button primitive (button.tsx) or useRender with mergeProps.
- Classes: data-[state=open] becomes data-open (data-popup-open on a popup trigger, data-panel-open on an accordion or collapsible trigger), closed data-closed, checked data-checked, active data-active, on data-pressed; group-data and peer-data the same. animate-in / animate-out are restated as data-starting-style / data-ending-style transitions, never translated 1:1. --radix-*-content-transform-origin becomes --transform-origin, available-height --available-height, trigger-width --anchor-width, the accordion and collapsible heights --accordion-panel-height / --collapsible-panel-height.
- Hard rules: never touch cmdk, vaul, sonner, input-otp, react-day-picker or recharts. Label becomes a native label, AspectRatio a CSS aspect-ratio div, VisuallyHidden sr-only. Behavior deltas (tabs manual activation, menu items not closing on click, delays, accordion single mode always collapsible) are flagged, never silently patched. Never guess a mapping: what the tables do not cover is checked against node_modules/@base-ui/react type definitions and named as a gap. Honest reporting: what was skipped is listed as flagged, never as migrated.
House rules
- Use only the input. Never invent a prop, a part, a component, a module or a file the code does not hold; Base UI parts and props the rules above name (Positioner, Popup, render, nativeButton, initialFocus, closeOnClick ...) are fine. When a fix needs another file (a wrapper the consumer imports, the consumers of a wrapper, button.tsx), say so in a caveat.
- The page's ids and lines are measured facts: never renumber an id. A finding lists every line it covers ("2 places, lines 14, 39"). Each of the three "what the page did" values is handled the same way - confirm it or clear it with a reason - and differs only in the fix: "migrated" - the page made the change the finding's fix names (its Base UI copy is not in the input), so confirm with that change or clear it with the reason it is wrong for this code; "left" - a Radix gap the page flagged but did not change, so confirm it with the hand fix (for a behavior finding the fix is the flag itself: it is named in behavior, kept as Base UI does it in a rewrite, never patched); "left alone" - a non-Radix library, confirmed as left alone, never migrated.
- 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 parts and props plainly (DialogPrimitive.Popup, render={<Button />}). 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, add what a scan cannot see, put the steps in order, and write the two report sections the page cannot: the behavior changes and the hand checks. The page builds the .migration/<component>.md report from this reply.
summary- one sentence, at most 40 words: what breaks on Base UI today, what the page already moved, and the first step.findings- one object per page finding you confirm, in id order:id(the page's id),widget(where it sits, e.g. "DialogContent" or "the file"),issue(one sentence: what goes wrong on Base UI),fix(one sentence: the change),code(the Base UI lines for that piece only, at most 6; "" when fix says it all).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(imports, parts, positioning, render, types, styles, props or behavior),line(a number),widget,issue,fix. Examples: a handler that used the Radix event argument, a positioning prop spread through ...props that now lands on the Popup, a forceMount relied on for an exit animation, a class that styles a trigger through a cva outside the tag, a wrapper prop the consumers still pass.[]when there are none.plan- 2 to 10 short strings, the steps in order (imports and parts first, then positioning and render, then types, classes and call-site props, the behavior checks last), each naming the ids it covers (e.g. "Rename the parts and split Content into Positioner and Popup (B3, B5)"). Every confirmed id appears in a step.[]only when nothing is confirmed.behavior- one string per behavior change, flagged and never patched (differences that compile but act differently), each naming its ids; every confirmed behavior finding appears here.[]when there are none.verify- 2 to 8 hand checks for this component family, concrete and about a minute of clicking each (focus returns to the trigger when the dialog closes, arrow keys and typeahead in the menu, the tooltip delay feel, the slider commits on release).caveats- at most 4 short strings;[]when there is nothing to say.
{
"task": "review",
"summary": "one sentence",
"findings": [ { "id": "B1", "widget": "the file", "issue": "one sentence", "fix": "one sentence", "code": "import { Dialog as DialogPrimitive } from \"@base-ui/react/dialog\";" } ],
"cleared": [ { "id": "B4", "why": "one sentence" } ],
"extra": [ { "id": "X1", "area": "props", "line": 12, "widget": "DialogContent", "issue": "one sentence", "fix": "one sentence" } ],
"plan": [ "Rename the parts and split Content into Positioner and Popup (B3, B5)" ],
"behavior": [ "Tabs now activate on click or Enter, not on focus (B6)" ],
"verify": [ "Open the dialog with the keyboard, press Escape, and check focus returns to the trigger" ],
"caveats": [ ]
}
The page holds it to its own facts (check() in basereply.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; every confirmed behavior finding named in the behavior changes; 2 to 8 hand checks; a summary; no web link in the prose.
rewrite
Rewrite the whole file on Base UI - the <component>-base.tsx of the progressive migration.
summary- one sentence, at most 40 words: what the rewrite changed and what it left for the team.code- the complete Base UI file. Keep every component and export by name, every data-slot, every class the migration does not rename, and every import that is still used; change only what the findings name, plus every carried extra whose fix lives in this file (list it in changes; one that needs another file goes in a caveat). Leave the cmdk, vaul, sonner, input-otp, react-day-picker and recharts imports exactly as pasted. No import from radix-ui or @radix-ui may remain (except one the review kept with a reason). 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. Imports and types - the @base-ui/react subpath; Part.Props, and a positioned Popup's props as Popup.Props & Pick<Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset">. Parts and positioning - the renames above; Portal > Positioner > Popup with the side and align props on the Positioner. asChild - render={<Child />}, nativeButton={false} for a non-button element. Classes - the data attributes and variables above; the animate-in / animate-out classes restated as a transition with data-starting-style / data-ending-style. Call-site props - the Base UI names; a dismiss or focus callback restructured into onOpenChange with eventDetails, initialFocus or finalFocus, as the carry decided. Behavior deltas - kept as Base UI does them (the id goes in kept), named in a caveat, never patched back. Carried extras go in changes after the page's ids.changes- one object per page finding you acted on (including the ones the page already migrated, which the rewrite keeps), in id order:id,change(one sentence: what changed, naming where). 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, it is a non-Radix library left alone, a behavior delta flagged, 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 Base UI file",
"changes": [ { "id": "B1", "change": "one sentence" } ],
"kept": [ { "id": "B4", "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 component survives; the cmdk, vaul, sonner, input-otp, react-day-picker and recharts imports come back exactly as pasted; no Radix import is left; 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 component the rewrite drops or renames is flagged.
Worked examples
Three runs from the page's free samples (invented apps): the review of a hand-rolled delete confirmation, the rewrite of the same file carrying that review, and the review of a shadcn select wrapper. 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 hand-rolled delete confirmation
The page read it as: Needs hand work - 7 blocker, 6 major, 0 minor; findings B1 import-radix, B2 import-radix, B3 prop-rename, B4 as-child, B5 as-child, B6 positioner, B7 state-left, B8 animation, B9 part-rename, B10 part-rename, B11 prop-flag, B12 part-rename, B13 no-primitive.
Input (code is 1857 characters; the first 1366 are shown; facts is 3426 characters; the first 1396 are shown):
{
"task": "review",
"unit": "File: components/delete-project.tsx\nKind: a hand-rolled Radix composition\nComponent: delete-project",
"code": "import * as AlertDialog from \"@radix-ui/react-alert-dialog\";\nimport * as Tooltip from \"@radix-ui/react-tooltip\";\nimport { TrashIcon } from \"./icons\";\n\nexport function DeleteProject({ name, onDelete }: { name: string; onDelete: () => void }) {\n return (\n <Tooltip.Provider delayDuration={300}>\n <AlertDialog.Root>\n <Tooltip.Root>\n <Tooltip.Trigger asChild>\n <AlertDialog.Trigger asChild>\n <button className=\"icon-btn\" aria-label={\"Delete \" + name}>\n <TrashIcon />\n </button>\n </AlertDialog.Trigger>\n </Tooltip.Trigger>\n <Tooltip.Portal>\n <Tooltip.Content side=\"top\" sideOffset={6} className=\"tip data-[state=delayed-open]:opacity-100\">\n Delete project\n <Tooltip.Arrow className=\"fill-gray-900\" />\n </Tooltip.Content>\n </Tooltip.Portal>\n </Tooltip.Root>\n <AlertDialog.Portal>\n <AlertDialog.Overlay className=\"fixed inset-0 bg-black/40 data-[state=open]:animate-in data-[state=open]:fade-in-0\" />\n <AlertDialog.Content\n className=\"dialog fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2\"\n onEscapeKeyDown={(e) => e.preventDefault()}\n >\n <AlertDialog.Title className=\"text-lg font-semibold\">Delete {name}?</AlertDialog.Title>\n\n[... 491 more characters cut from this display ...]",
"carry": "",
"facts": "The page's scan of 42 lines - Radix imports: AlertDialog, Tooltip; 1 component (DeleteProject); 9 of 13 findings migrated by the page; 0 Radix import lines left in the page's copy; report: .migration/delete-project.md; verdict: Needs hand work.\nFindings (id | area | severity | line | where | evidence | source | what the page did):\n- B1 | imports | blocker | line 1 | the file | AlertDialog comes from @radix-ui/react-alert-dialog - Base UI ships it as AlertDialog from @base-ui/react/alert-dialog. | source: import * as AlertDialog from \"@radix-ui/react-alert-dialog\"; | migrated\n- B2 | imports | blocker | line 2 | the file | Tooltip comes from @radix-ui/react-tooltip - Base UI ships it as Tooltip from @base-ui/react/tooltip. | source: import * as Tooltip from \"@radix-ui/react-tooltip\"; | migrated\n- B3 | props | major | line 7 | DeleteProject | Tooltip.Provider delayDuration is delay in Base UI. | source: <Tooltip.Provider delayDuration={300}> | migrated\n- B4 | render | blocker | line 10 | DeleteProject | <Tooltip.Trigger asChild> wraps <AlertDialog.Trigger> - Base UI has no asChild. | source: <Tooltip.Trigger asChild> | migrated\n- B5 | render | blocker | line 11 | DeleteProject | <AlertDialog.Trigger asChild> wraps <button> - Base UI has no asChild. | source: <AlertDialog.Trigger asChild> | migrated\n- B6 | positioning | blocker | line 18 | DeleteProject | Tooltip.Content is one\n\n[... 2030 more characters cut from this display ...]"
}
Reply:
{
"task": "review",
"summary": "Both Radix imports, the nested asChild triggers, the tooltip placement, Overlay, Content and Cancel break on Base UI; the page moved nine findings, and the first step is the two @base-ui/react imports.",
"findings": [
{
"id": "B1",
"widget": "the file",
"issue": "The @radix-ui/react-alert-dialog namespace is gone after the switch, so every AlertDialog part fails to resolve.",
"fix": "Import AlertDialog from the @base-ui/react/alert-dialog subpath.",
"code": "import { AlertDialog } from \"@base-ui/react/alert-dialog\";"
},
{
"id": "B2",
"widget": "the file",
"issue": "The @radix-ui/react-tooltip namespace is gone after the switch, so every Tooltip part fails to resolve.",
"fix": "Import Tooltip from the @base-ui/react/tooltip subpath.",
"code": "import { Tooltip } from \"@base-ui/react/tooltip\";"
},
{
"id": "B3",
"widget": "DeleteProject",
"issue": "Tooltip.Provider ignores delayDuration on Base UI, so the 300 ms delay is lost.",
"fix": "Rename the prop to delay.",
"code": "<Tooltip.Provider delay={300}>"
},
{
"id": "B4",
"widget": "DeleteProject",
"issue": "Tooltip.Trigger has no asChild on Base UI, so the alert-dialog trigger would be nested inside a second button.",
"fix": "Render the alert-dialog trigger through the tooltip trigger's render prop so one button carries both behaviors.",
"code": "<Tooltip.Trigger\n render={<AlertDialog.Trigger className=\"icon-btn\" aria-label={\"Delete \" + name} />}\n>\n <TrashIcon />\n</Tooltip.Trigger>"
},
{
"id": "B5",
"widget": "DeleteProject",
"issue": "AlertDialog.Trigger has no asChild on Base UI and already renders a button, so wrapping the plain button nests one button in another.",
"fix": "Drop the inner button and move its className and aria-label onto AlertDialog.Trigger, as in the B4 code.",
"code": ""
},
{
"id": "B6",
"widget": "DeleteProject",
"issue": "Tooltip.Content is not a Base UI part and side and sideOffset do nothing without a Positioner.",
"fix": "Split it into Portal > Positioner > Popup with side and sideOffset on the Positioner and the Arrow inside the Popup.",
"code": "<Tooltip.Portal>\n <Tooltip.Positioner side=\"top\" sideOffset={6}>\n <Tooltip.Popup className=\"tip data-open:opacity-100\">\n Delete project\n <Tooltip.Arrow className=\"fill-gray-900\" />\n </Tooltip.Popup>"
},
{
"id": "B7",
"widget": "DeleteProject",
"issue": "The Base UI popup never carries data-state=delayed-open, so the tip stays at its base opacity.",
"fix": "Key the class on data-open, which Base UI sets for delayed and instant opens alike.",
"code": "className=\"tip data-open:opacity-100\""
},
{
"id": "B8",
"widget": "DeleteProject",
"issue": "animate-in and fade-in-0 hang off data-state=open, which the Base UI backdrop never sets, so the fade is lost.",
"fix": "Restate the fade as an opacity transition on data-starting-style and data-ending-style.",
"code": "className=\"fixed inset-0 bg-black/40 transition-opacity data-starting-style:opacity-0 data-ending-style:opacity-0\""
},
{
"id": "B9",
"widget": "DeleteProject",
"issue": "AlertDialog.Overlay does not exist on Base UI.",
"fix": "Rename it to AlertDialog.Backdrop.",
"code": "<AlertDialog.Backdrop className=\"fixed inset-0 bg-black/40 ...\" />"
},
{
"id": "B10",
"widget": "DeleteProject",
"issue": "AlertDialog.Content does not exist on Base UI, in either the opening or the closing tag.",
"fix": "Rename both tags to AlertDialog.Popup.",
"code": "<AlertDialog.Popup className=\"dialog fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2\">"
},
{
"id": "B11",
"widget": "DeleteProject",
"issue": "Base UI has no onEscapeKeyDown on the Popup, so Escape would close the dialog the file meant to keep open.",
"fix": "Move the block to the Root's onOpenChange and cancel the change when the reason is escape-key.",
"code": "<AlertDialog.Root\n onOpenChange={(open, eventDetails) => {\n if (eventDetails.reason === \"escape-key\") eventDetails.cancel()\n }}\n>"
},
{
"id": "B12",
"widget": "DeleteProject",
"issue": "AlertDialog.Cancel does not exist on Base UI.",
"fix": "Rename both tags to AlertDialog.Close.",
"code": "<AlertDialog.Close className=\"btn\">Cancel</AlertDialog.Close>"
},
{
"id": "B13",
"widget": "DeleteProject",
"issue": "Base UI has no Action part, so the Delete button renders nothing.",
"fix": "Use a second AlertDialog.Close and run onDelete in its onClick so the dialog still closes after the action.",
"code": "<AlertDialog.Close className=\"btn danger\" onClick={onDelete}>Delete</AlertDialog.Close>"
}
],
"cleared": [],
"extra": [
{
"id": "X1",
"area": "styles",
"line": 20,
"widget": "DeleteProject",
"issue": "Radix drew the arrow as an svg so fill-gray-900 colored it, but the Base UI Arrow renders an empty div that fill does not paint.",
"fix": "Give the Arrow its own svg child carrying fill-gray-900, or style the div itself with a background and a rotation."
}
],
"plan": [
"Swap both imports to the @base-ui/react subpaths (B1, B2)",
"Rename Overlay, Content and Cancel, and replace Action with a Close that runs onDelete (B9, B10, B12, B13)",
"Split the tooltip into Portal > Positioner > Popup with side and sideOffset on the Positioner (B6)",
"Fold the two asChild triggers into one render chain on the trash button (B4, B5)",
"Rename the provider delay and move the Escape block into onOpenChange (B3, B11)",
"Restate the state classes and the backdrop fade, and redraw the arrow (B7, B8, X1)"
],
"behavior": [
"The tip now reaches full opacity on instant opens too, which the delayed-open class used to skip (B7)"
],
"verify": [
"Hover the trash button, wait about 300 ms and check the tooltip shows above it with its arrow visible",
"Tab to the trash button and check the tooltip appears and the button announces Delete plus the project name",
"Click the trash button and check the alert dialog opens with the backdrop fading in and focus inside the dialog",
"Press Escape with the dialog open and check it stays open",
"Click Cancel and check the dialog closes, nothing is deleted and focus returns to the trash button",
"Click Delete and check onDelete runs once and the dialog closes"
],
"caveats": [
"Click outside the dialog stays inert as before, since the Base UI alert dialog never closes on an outside press."
]
}
What step 5 prints for it: B1: Import AlertDialog from the @base-ui/react/alert-dialog subpath. | B2: Import Tooltip from the @base-ui/react/tooltip subpath. | B3: Rename the prop to delay. | B4: Render the alert-dialog trigger through the tooltip trigger's render prop so one button carries both behaviors. | B5: Drop the inner button and move its className and aria-label onto AlertDialog.Trigger, as in the B4 code. | B6: Split it into Portal > Positioner > Popup with side and sideOffset on the Positioner and the Arrow inside the Popup. | B7: Key the class on data-open, which Base UI sets for delayed and instant opens alike. | B8: Restate the fade as an opacity transition on data-starting-style and data-ending-style. | B9: Rename it to AlertDialog.Backdrop. | B10: Rename both tags to AlertDialog.Popup. | B11: Move the block to the Root's onOpenChange and cancel the change when the reason is escape-key. | B12: Rename both tags to AlertDialog.Close. | B13: Use a second AlertDialog.Close and run onDelete in its onClick so the dialog still closes after the action.
The page's check: kept - nothing to flag
rewrite - A hand-rolled delete confirmation
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: 13 findings before, 0 after.
Input (code is 1857 characters; the first 1366 are shown; facts is 3452 characters; the first 1396 are shown; carry is 1545 characters; the first 1396 are shown):
{
"task": "rewrite",
"unit": "File: components/delete-project.tsx\nKind: a hand-rolled Radix composition\nComponent: delete-project",
"code": "import * as AlertDialog from \"@radix-ui/react-alert-dialog\";\nimport * as Tooltip from \"@radix-ui/react-tooltip\";\nimport { TrashIcon } from \"./icons\";\n\nexport function DeleteProject({ name, onDelete }: { name: string; onDelete: () => void }) {\n return (\n <Tooltip.Provider delayDuration={300}>\n <AlertDialog.Root>\n <Tooltip.Root>\n <Tooltip.Trigger asChild>\n <AlertDialog.Trigger asChild>\n <button className=\"icon-btn\" aria-label={\"Delete \" + name}>\n <TrashIcon />\n </button>\n </AlertDialog.Trigger>\n </Tooltip.Trigger>\n <Tooltip.Portal>\n <Tooltip.Content side=\"top\" sideOffset={6} className=\"tip data-[state=delayed-open]:opacity-100\">\n Delete project\n <Tooltip.Arrow className=\"fill-gray-900\" />\n </Tooltip.Content>\n </Tooltip.Portal>\n </Tooltip.Root>\n <AlertDialog.Portal>\n <AlertDialog.Overlay className=\"fixed inset-0 bg-black/40 data-[state=open]:animate-in data-[state=open]:fade-in-0\" />\n <AlertDialog.Content\n className=\"dialog fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2\"\n onEscapeKeyDown={(e) => e.preventDefault()}\n >\n <AlertDialog.Title className=\"text-lg font-semibold\">Delete {name}?</AlertDialog.Title>\n\n[... 491 more characters cut from this display ...]",
"carry": "B1 | confirmed | the file | Import AlertDialog from the @base-ui/react/alert-dialog subpath.\nB2 | confirmed | the file | Import Tooltip from the @base-ui/react/tooltip subpath.\nB3 | confirmed | DeleteProject | Rename the prop to delay.\nB4 | confirmed | DeleteProject | Render the alert-dialog trigger through the tooltip trigger's render prop so one button carries both behaviors.\nB5 | confirmed | DeleteProject | Drop the inner button and move its className and aria-label onto AlertDialog.Trigger, as in the B4 code.\nB6 | confirmed | DeleteProject | Split it into Portal > Positioner > Popup with side and sideOffset on the Positioner and the Arrow inside the Popup.\nB7 | confirmed | DeleteProject | Key the class on data-open, which Base UI sets for delayed and instant opens alike.\nB8 | confirmed | DeleteProject | Restate the fade as an opacity transition on data-starting-style and data-ending-style.\nB9 | confirmed | DeleteProject | Rename it to AlertDialog.Backdrop.\nB10 | confirmed | DeleteProject | Rename both tags to AlertDialog.Popup.\nB11 | confirmed | DeleteProject | Move the block to the Root's onOpenChange and cancel the change when the reason is escape-key.\nB12 | confirmed | DeleteProject | Rename both tags to AlertDialog.Close.\nB13 | confirmed | DeleteProject | Use a second AlertDialog.Close and run onDelete in its onClick so the dialog still closes after the action.\nX1 |\n\n[... 149 more characters cut from this display ...]",
"facts": "The page's scan of 42 lines - Radix imports: AlertDialog, Tooltip; 1 component (DeleteProject); 9 of 13 findings migrated by the page; 0 Radix import lines left in the page's copy; report: .migration/delete-project.md; verdict: Needs hand work.\nFindings (id | area | severity | line | where | evidence | source | what the page did):\n- B1 | imports | blocker | line 1 | the file | AlertDialog comes from @radix-ui/react-alert-dialog - Base UI ships it as AlertDialog from @base-ui/react/alert-dialog. | source: import * as AlertDialog from \"@radix-ui/react-alert-dialog\"; | migrated\n- B2 | imports | blocker | line 2 | the file | Tooltip comes from @radix-ui/react-tooltip - Base UI ships it as Tooltip from @base-ui/react/tooltip. | source: import * as Tooltip from \"@radix-ui/react-tooltip\"; | migrated\n- B3 | props | major | line 7 | DeleteProject | Tooltip.Provider delayDuration is delay in Base UI. | source: <Tooltip.Provider delayDuration={300}> | migrated\n- B4 | render | blocker | line 10 | DeleteProject | <Tooltip.Trigger asChild> wraps <AlertDialog.Trigger> - Base UI has no asChild. | source: <Tooltip.Trigger asChild> | migrated\n- B5 | render | blocker | line 11 | DeleteProject | <AlertDialog.Trigger asChild> wraps <button> - Base UI has no asChild. | source: <AlertDialog.Trigger asChild> | migrated\n- B6 | positioning | blocker | line 18 | DeleteProject | Tooltip.Content is one\n\n[... 2056 more characters cut from this display ...]"
}
Reply (long strings shortened for display - see the cuts below):
{
"task": "rewrite",
"summary": "Moved the delete dialog and its tooltip to Base UI: subpath imports, one trigger button through render, Positioner and Popup, Backdrop and Close parts, an escape guard in onOpenChange and fade transitions; the focus and delay feel are left for the team.",
"code": "import { AlertDialog } from \"@base-ui/react/alert-dialog\";\nimport { Tooltip } from \"@base-ui/react/tooltip\";\nimport { TrashIcon } from \"./icons\";\n\nexport function DeleteProject({ name, onDelete }: { name: string; onDelete: () => void }) {\n return (\n <Tooltip.Provider delay={300}>\n <AlertDialog.Root [... 1792 more characters cut from this display]",
"changes": [
{
"id": "B1",
"change": "The alert-dialog namespace import now reads import { AlertDialog } from @base-ui/react/alert-dialog."
},
{
"id": "B2",
"change": "The tooltip namespace import now reads import { Tooltip } from @base-ui/react/tooltip."
},
{
"id": "B3",
"change": "Tooltip.Provider now takes delay={300} instead of delayDuration={300}."
},
{
"id": "B4",
"change": "Tooltip.Trigger renders AlertDialog.Trigger through its render prop, so a single button opens the dialog and shows the tooltip."
},
{
"id": "B5",
"change": "The inner button is gone; icon-btn and the Delete aria-label now sit on the AlertDialog.Trigger inside render, with TrashIcon as the trigger's child."
},
{
"id": "B6",
"change": "Tooltip.Content became Tooltip.Positioner with side=\"top\" and sideOffset={6} around a Tooltip.Popup that holds the text and the Arrow."
},
{
"id": "B7",
"change": "The tooltip Popup class now uses data-open:opacity-100 in place of the delayed-open data-state variant."
},
{
"id": "B8",
"change": "The Backdrop fade became transition-opacity with opacity-0 on data-starting-style and data-ending-style."
},
{
"id": "B9",
"change": "AlertDialog.Overlay became AlertDialog.Backdrop with its classes kept."
},
{
"id": "B10",
"change": "Both AlertDialog.Content tags became AlertDialog.Popup."
},
{
"id": "B11",
"change": "The onEscapeKeyDown handler left the Popup, and AlertDialog.Root now cancels the change in onOpenChange when eventDetails.reason is escape-key."
},
{
"id": "B12",
"change": "Both AlertDialog.Cancel tags became AlertDialog.Close."
},
{
"id": "B13",
"change": "The Delete button is a second AlertDialog.Close that runs onDelete in its onClick, so the dialog still closes after the action."
},
{
"id": "X1",
"change": "Tooltip.Arrow now holds an svg triangle that carries fill-gray-900, since the Base UI arrow renders a plain div."
}
],
"kept": [],
"caveats": [
"Base UI focuses the first tabbable element when the dialog opens, which may be Cancel or Delete depending on order; Radix always focused Cancel, so check it before shipping",
"The tooltip delay is now set on the Provider with Base UI timing; hover the trash button to confirm the 300 ms feel",
"The svg arrow is sized 10 by 5 to match the Radix default; adjust it if the tip class expects a different size"
]
}
Cut from this display only (the saved reply and the check use the full text): code (2100 characters, 308 shown).
What step 5 prints for it: B1: The alert-dialog namespace import now reads import { AlertDialog } from @base-ui/react/alert-dialog. | B2: The tooltip namespace import now reads import { Tooltip } from @base-ui/react/tooltip. | B3: Tooltip.Provider now takes delay={300} instead of delayDuration={300}. | B4: Tooltip.Trigger renders AlertDialog.Trigger through its render prop, so a single button opens the dialog and shows the tooltip. | B5: The inner button is gone; icon-btn and the Delete aria-label now sit on the AlertDialog.Trigger inside render, with TrashIcon as the trigger's child. | B6: Tooltip.Content became Tooltip.Positioner with side="top" and sideOffset={6} around a Tooltip.Popup that holds the text and the Arrow. | B7: The tooltip Popup class now uses data-open:opacity-100 in place of the delayed-open data-state variant. | B8: The Backdrop fade became transition-opacity with opacity-0 on data-starting-style and data-ending-style. | B9: AlertDialog.Overlay became AlertDialog.Backdrop with its classes kept. | B10: Both AlertDialog.Content tags became AlertDialog.Popup. | B11: The onEscapeKeyDown handler left the Popup, and AlertDialog.Root now cancels the change in onOpenChange when eventDetails.reason is escape-key. | B12: Both AlertDialog.Cancel tags became AlertDialog.Close. | B13: The Delete button is a second AlertDialog.Close that runs onDelete in its onClick, so the dialog still closes after the action. | X1: Tooltip.Arrow now holds an svg triangle that carries fill-gray-900, since the Base UI arrow renders a plain div.
The page's check: kept - nothing to flag
review - A shadcn select wrapper
The page read it as: Nearly on Base UI - 3 blocker, 9 major, 0 minor; findings B1 import-radix, B2 props-type, B3 state-class, B4 as-child, B5 part-rename, B6 positioner, B7 animation, B8 css-var, B9 part-rename, B10 part-rename, B11 css-var, B12 css-var.
Input (code is 2262 characters; the first 1398 are shown; facts is 3397 characters; the first 1398 are shown):
{
"task": "review",
"unit": "File: components/ui/select.tsx\nKind: a shadcn ui wrapper (components/ui)\nComponent: select",
"code": "import * as React from \"react\"\nimport * as SelectPrimitive from \"@radix-ui/react-select\"\nimport { CheckIcon, ChevronDownIcon } from \"lucide-react\"\n\nfunction SelectTrigger({ className, children, ...props }: React.ComponentProps<typeof SelectPrimitive.Trigger>) {\n return (\n <SelectPrimitive.Trigger data-slot=\"select-trigger\" className={cn(\"flex h-9 items-center data-[placeholder]:text-muted-foreground [&[data-state=open]>svg]:rotate-180\", className)} {...props}>\n {children}\n <SelectPrimitive.Icon asChild>\n <ChevronDownIcon className=\"size-4 opacity-50\" />\n </SelectPrimitive.Icon>\n </SelectPrimitive.Trigger>\n )\n}\n\nfunction SelectContent({\n className,\n children,\n position = \"popper\",\n ...props\n}: React.ComponentProps<typeof SelectPrimitive.Content>) {\n return (\n <SelectPrimitive.Portal>\n <SelectPrimitive.Content\n data-slot=\"select-content\"\n className={cn(\n \"relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] rounded-md border data-[state=open]:animate-in\",\n position === \"popper\" && \"data-[side=bottom]:translate-y-1\",\n className\n )}\n position={position}\n {...props}\n >\n <SelectPrimitive.ScrollUpButton className=\"flex cursor-default items-center justify-center py-1\" />\n <SelectPrimitive.Viewport\n className={cn(\"p-1\", position ===\n\n[... 864 more characters cut from this display ...]",
"carry": "",
"facts": "The page's scan of 59 lines - Radix imports: Select (SelectPrimitive); 3 components (SelectTrigger, SelectContent, SelectItem); 11 of 12 findings migrated by the page; 0 Radix import lines left in the page's copy; report: .migration/select.md; verdict: Nearly on Base UI.\nFindings (id | area | severity | line | where | evidence | source | what the page did):\n- B1 | imports | blocker | line 2 | the file | SelectPrimitive comes from @radix-ui/react-select - Base UI ships it as Select from @base-ui/react/select. | source: import * as SelectPrimitive from \"@radix-ui/react-select\" | migrated\n- B2 | types | major | line 5 | SelectTrigger | React.ComponentProps<typeof SelectPrimitive.Part> - Base UI parts carry their own Props type. (3 places, lines 5, 21, 45) | source: function SelectTrigger({ className, children, ...props }: React.ComponentProps<typeof SelectPrimitive.Trigger>) { | migrated\n- B3 | styles | major | line 7 | SelectTrigger | [&[data-state=open]>svg]: selects Radix's data-state, which Base UI does not set. | source: <SelectPrimitive.Trigger data-slot=\"select-trigger\" className={cn(\"flex h-9 items-center... | migrated\n- B4 | render | blocker | line 9 | SelectTrigger | <SelectPrimitive.Icon asChild> wraps <ChevronDownIcon> - Base UI has no asChild. | source: <SelectPrimitive.Icon asChild> | migrated\n- B5 | parts | major | line 21 | SelectContent | SelectPrimitive.Content\n\n[... 1999 more characters cut from this display ...]"
}
Reply:
{
"task": "review",
"summary": "On Base UI the Radix import, the asChild icon, the single Content part and the Radix variables all break; the page moved eleven of twelve, leaving the open animation, and the first step is the @base-ui/react/select import.",
"findings": [
{
"id": "B1",
"widget": "the file",
"issue": "The @radix-ui/react-select namespace import does not exist in a Base UI project, so every SelectPrimitive part is undefined.",
"fix": "Import Select as SelectPrimitive from the @base-ui/react/select subpath.",
"code": "import { Select as SelectPrimitive } from \"@base-ui/react/select\""
},
{
"id": "B2",
"widget": "SelectTrigger",
"issue": "React.ComponentProps over the Radix parts types the three wrappers against parts that are gone, so the props no longer type-check.",
"fix": "Type each wrapper with the part's own Props type: SelectPrimitive.Trigger.Props, SelectPrimitive.Popup.Props and SelectPrimitive.Item.Props.",
"code": "function SelectTrigger({ className, children, ...props }: SelectPrimitive.Trigger.Props) {"
},
{
"id": "B3",
"widget": "SelectTrigger",
"issue": "The chevron never rotates, because the Base UI trigger marks the open state with data-popup-open rather than data-state=open.",
"fix": "Select on data-popup-open in the trigger's arbitrary variant.",
"code": "\"flex h-9 items-center data-[placeholder]:text-muted-foreground [&[data-popup-open]>svg]:rotate-180\""
},
{
"id": "B4",
"widget": "SelectTrigger",
"issue": "SelectPrimitive.Icon ignores asChild, so the chevron ends up nested inside an extra span instead of replacing it.",
"fix": "Pass the chevron through render and drop the children.",
"code": "<SelectPrimitive.Icon render={<ChevronDownIcon className=\"size-4 opacity-50\" />} />"
},
{
"id": "B5",
"widget": "SelectContent",
"issue": "Base UI has no Select Content part; the styled list box is SelectPrimitive.Popup.",
"fix": "Rename Content to Popup for the element that carries data-slot select-content and the classes.",
"code": "<SelectPrimitive.Popup data-slot=\"select-content\" className={cn(\"relative z-50 min-w-[8rem] rounded-md border\", className)}>"
},
{
"id": "B6",
"widget": "SelectContent",
"issue": "Positioning lived on the single Radix Content; on Base UI the popup is not placed at all unless a Positioner sits between Portal and Popup.",
"fix": "Nest Portal > Positioner > Popup and put side, sideOffset, align, alignOffset and alignItemWithTrigger on the Positioner.",
"code": "<SelectPrimitive.Portal>\n <SelectPrimitive.Positioner side={side} sideOffset={sideOffset} align={align} alignOffset={alignOffset} alignItemWithTrigger={alignItemWithTrigger}>\n <SelectPrimitive.Popup data-slot=\"select-content\" {...props}>"
},
{
"id": "B7",
"widget": "SelectContent",
"issue": "data-[state=open]:animate-in keys a keyframe on a data-state value the Base UI popup never sets, so the enter animation is dead and no exit is defined.",
"fix": "Restate it as an opacity and scale transition driven by data-starting-style and data-ending-style on the Popup.",
"code": "\"transition-[opacity,scale] duration-150 data-starting-style:scale-95 data-starting-style:opacity-0 data-ending-style:scale-95 data-ending-style:opacity-0\""
},
{
"id": "B8",
"widget": "SelectContent",
"issue": "--radix-select-content-available-height is never set, so the max height falls away and a long list can run off screen.",
"fix": "Use the Positioner's --available-height variable.",
"code": "max-h-(--available-height)"
},
{
"id": "B9",
"widget": "SelectContent",
"issue": "SelectPrimitive.ScrollUpButton is not a Base UI part, so the element renders nothing.",
"fix": "Rename it to SelectPrimitive.ScrollUpArrow and keep its classes.",
"code": "<SelectPrimitive.ScrollUpArrow className=\"flex cursor-default items-center justify-center py-1\" />"
},
{
"id": "B10",
"widget": "SelectContent",
"issue": "SelectPrimitive.Viewport has no Base UI counterpart by that name, so the item container is missing at both the opening and the closing tag.",
"fix": "Rename Viewport to List in both places.",
"code": "<SelectPrimitive.List className={cn(\"p-1\", !alignItemWithTrigger && \"h-[var(--anchor-height)] w-full min-w-[var(--anchor-width)] scroll-my-1\")}>"
},
{
"id": "B11",
"widget": "SelectContent",
"issue": "--radix-select-trigger-height is undefined on Base UI, so the list height class resolves to nothing.",
"fix": "Use --anchor-height, which the Positioner sets.",
"code": "h-[var(--anchor-height)]"
},
{
"id": "B12",
"widget": "SelectContent",
"issue": "--radix-select-trigger-width is undefined on Base UI, so the list no longer matches the trigger width.",
"fix": "Use --anchor-width, which the Positioner sets.",
"code": "min-w-[var(--anchor-width)]"
}
],
"cleared": [],
"extra": [
{
"id": "X1",
"area": "props",
"line": 19,
"widget": "SelectContent",
"issue": "The wrapper's position prop, defaulted to popper and tested on lines 28 and 36, is not a Base UI prop and would be spread as an unknown attribute.",
"fix": "Replace it with alignItemWithTrigger defaulted to false on the Positioner, and test !alignItemWithTrigger where the classes tested position === \"popper\"."
},
{
"id": "X2",
"area": "positioning",
"line": 32,
"widget": "SelectContent",
"issue": "Any side, sideOffset, align or alignOffset a consumer passes travels through ...props and now lands on the Popup, which ignores it.",
"fix": "Destructure those props, pass them to the Positioner, and type the wrapper as SelectPrimitive.Popup.Props & Pick<SelectPrimitive.Positioner.Props, \"align\" | \"alignOffset\" | \"side\" | \"sideOffset\" | \"alignItemWithTrigger\">."
},
{
"id": "X3",
"area": "behavior",
"line": 24,
"widget": "SelectContent",
"issue": "In popper placement Radix aligned the list to the start of the trigger, while the Base UI Positioner centers it unless align is given.",
"fix": "Flag the centered placement for the team; passing align=\"start\" at the call site restores the old edge."
}
],
"plan": [
"Switch the import to @base-ui/react/select (B1)",
"Rename Content to Popup, ScrollUpButton to ScrollUpArrow and Viewport to List (B5, B9, B10)",
"Wrap the Popup in Portal > Positioner and move the placement props onto the Positioner (B6, X2)",
"Replace position with alignItemWithTrigger and update the two class tests (X1)",
"Pass the chevron to SelectPrimitive.Icon through render (B4)",
"Retype the three wrappers with the parts' Props types (B2)",
"Swap data-state and the Radix variables for data-popup-open, --available-height, --anchor-height and --anchor-width (B3, B8, B11, B12)",
"Restate the open animation as a starting-style and ending-style transition (B7)",
"Check the centered popper placement with the team (X3)"
],
"behavior": [
"The popper list now centers under the trigger instead of lining up with its start edge, since the Base UI Positioner defaults align to center (X3)"
],
"verify": [
"Focus the trigger, press Space or Enter, move with the arrow keys, pick an item with Enter, and check focus returns to the trigger",
"With the list open, type the first letters of an item and check typeahead highlights it",
"Open the select and check the chevron rotates and turns back when the list closes",
"Check the open list is at least as wide as the trigger and sits just below it",
"Fill the list with more items than fit the viewport and check the scroll up arrow appears and the list stays on screen",
"Choose an item, reopen the list, and check the check icon shows beside the chosen item only",
"Open and close the list a few times and check it fades in and out with no flash"
],
"caveats": [
"cn is called but never imported in the paste; keep whatever import the real file has",
"Consumers that pass position=\"item-aligned\" or position=\"popper\" to SelectContent need to switch to alignItemWithTrigger in their own files",
"The paste holds no SelectValue; on Base UI it shows the raw value unless Root gets items, so check that wrapper in its own file"
]
}
What step 5 prints for it: B1: Import Select as SelectPrimitive from the @base-ui/react/select subpath. | B2: Type each wrapper with the part's own Props type: SelectPrimitive.Trigger.Props, SelectPrimitive.Popup.Props and SelectPrimitive.Item.Props. | B3: Select on data-popup-open in the trigger's arbitrary variant. | B4: Pass the chevron through render and drop the children. | B5: Rename Content to Popup for the element that carries data-slot select-content and the classes. | B6: Nest Portal > Positioner > Popup and put side, sideOffset, align, alignOffset and alignItemWithTrigger on the Positioner. | B7: Restate it as an opacity and scale transition driven by data-starting-style and data-ending-style on the Popup. | B8: Use the Positioner's --available-height variable. | B9: Rename it to SelectPrimitive.ScrollUpArrow and keep its classes. | B10: Rename Viewport to List in both places. | B11: Use --anchor-height, which the Positioner sets. | B12: Use --anchor-width, which the Positioner sets.
The page's check: kept - nothing to flag
Build the input yourself
Load the engine in node (global.window = global; require("./basekit.js"); require("./basereply.js")), then:
const K = BaseKit, R = BaseReply;
const f = K.form({ name: "components/ui/dialog.tsx", targets: "wrapper", carry: "",
code: "import { Dialog as DialogPrimitive } from \"radix-ui\"\nexport function DialogOverlay(props) { return <DialogPrimitive.Overlay className=\"data-[state=open]:bg-black/50\" {...props} />; }" });
// f.findings (each with .auto when the page migrated it), f.scan.migrated (the dialog-base.tsx copy), 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}; R.report(b, reply) is the .migration file
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://base-ui-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://base-ui-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"]
// base-ui-desk.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "YOUR_TOKEN"; // from https://base-ui-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://base-ui-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
}
// BaseUiDesk.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 BaseUiDesk {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "YOUR_TOKEN"; // from https://base-ui-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://base-ui-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://base-ui-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://base-ui-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":"base-ui-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": "base-ui-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: "base-ui-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": "base-ui-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", "base-ui-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: "base-ui-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" => "base-ui-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 = "base-ui-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: components/delete-project.tsx\nKind: a hand-rolled Radix composition\nComponent: delete-project", "code": "import * as AlertDialog from \"@radix-ui/react-alert-dialog\";\n...", "carry": "", "facts": "The page's scan of 42 lines - Radix imports: AlertDialog, Tooltip ...\nFindings (id | area | severity | line | where | evidence | source | what the page did):\n- B1 | imports | blocker | line 1 | ..."}
# - 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: components/delete-project.tsx\nKind: a hand-rolled Radix composition\nComponent: delete-project", "code": "import * as AlertDialog from \"@radix-ui/react-alert-dialog\";\n...", "carry": "", "facts": "The page's scan of 42 lines - Radix imports: AlertDialog, Tooltip ...\nFindings (id | area | severity | line | where | evidence | source | what the page did):\n- B1 | imports | blocker | line 1 | ..."}
# - 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 base-ui-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, behavior, verify 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="base-ui-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 = "base-ui-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 = `base-ui-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 := "base-ui-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 = "base-ui-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 = "base-ui-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 = "base-ui-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 = $"base-ui-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: base-ui-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: components/delete-project.tsx\nKind: a hand-rolled Radix composition\nComponent: delete-project", "code": "import * as AlertDialog from \"@radix-ui/react-alert-dialog\";\n...", "carry": "", "facts": "The page's scan of 42 lines - Radix imports: AlertDialog, Tooltip ...\nFindings (id | area | severity | line | where | evidence | source | what the page did):\n- B1 | imports | blocker | line 1 | ..."}
# - 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": "base-ui-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": `base-ui-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", "base-ui-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", "base-ui-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" => "base-ui-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: base-ui-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", $"base-ui-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);