← Base UI Desk
API
Tokens

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.

StatusCodeMeaning
400validation_errorThe body is not a JSON object, or a required key (task, unit, code, facts) is missing. The page always sends all five of task, unit, code, carry, facts; carry is an empty string for review.
401unauthorizedNo token, or a stale one. Mint a guest token or sign in again.
402insufficient_creditsThe balance is under min_credits. Price with /estimate first.
403forbiddenA guest token tried to run: both lanes are metered and need a personal sign-in.
404not_foundUnknown job id.
429rate_limitedBack off and retry.
5xxserver_errorTransient. Retry with the same Idempotency-Key so a retry never double-bills.

The task field comes first

Two lanes, chosen by task, in the order the work runs - inspect, then produce:

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

The input

FieldRequiredWhat
taskyesreview or rewrite. If it is missing or neither, do review and set "task": "review".
unityesthe file's path, 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.
codeyesthe file as pasted (before the page's changes). A line starting "// [... N lines cut by the page" marks code the page scanned but did not send.
carrysend it (empty for review)rewrite task only: the review's decisions, one per line as id | confirmed or cleared or extra | where | fix or reason; may be empty.
factsyesthe page's scan: one finding per line as id | area | severity | line | where | evidence | source | 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_notenooptional; 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:

House rules

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.

  1. summary - one sentence, at most 40 words: what breaks on Base UI today, what the page already moved, and the first step.
  2. 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).
  3. cleared - one object per page finding that is not a problem here, with id and why (one sentence tied to the code). Every page id appears exactly once, in findings or in cleared.
  4. extra - at most 5 issues the scan cannot see, each tied to a line of the code: id (X1, X2, ...), area (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.
  5. 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.
  6. 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.
  7. 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).
  8. 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.

  1. summary - one sentence, at most 40 words: what the rewrite changed and what it left for the team.
  2. 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.
  3. 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.
  4. 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.
  5. 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"}
}

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')

Step 3 — GET /me

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

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

Step 4 — POST /estimate

Free. The body is the run input itself, the bare object (never wrapped in {"input": ...}). hold_credits is a reservation, not the price; min_credits is what the balance must cover; model_alias reads gpt-terra.

# input.json: the run input object - {"task": "review", "unit": "File: 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}' 

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

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