WinUI Migrator — API

UWP code in, WinUI 3 code out — from your own build.

API tokens Open the app

Migrate UWP source from your own tools

Send a UWP file — a page code-behind, a view model, a XAML page, a .csproj, or a fragment of any of them — and get back the same code rewritten for WinUI 3 / Windows App SDK, with every legacy API replaced by its modern equivalent, plus a findings list that marks each of the nine known migration traps MUST-FIX, CHECK or CLEAN with the evidence from your code, the manual steps the rewrite cannot perform, the behaviour differences that survive a correct rewrite, and a verdict with a confidence score. Everything this app does goes through the SkillSafe App API — JSON in over HTTPS, plain text out — so you can run it over a directory of legacy files, wire it into a migration branch's CI, or fail a build when a file still comes back Needs redesign. Every step below is shown in cURL, Python, JavaScript, Go, Java, Ruby, PHP and C#; pick a language once and the whole page follows.

Basics

Base URL: https://api.skillsafe.ai/v1/app-api. The routes are /guest, /me, /estimate, /run and /run-stream — there is no /apps/{slug}/ path segment anywhere. The app slug winui-migrator appears exactly once, in the body of POST /guest, and from then on it is bound to the token you were issued.

Every request sends Authorization: Bearer <token> and JSON bodies with Content-Type: application/json. Responses are wrapped in an envelope: {"data": …} on success, {"error": {"code", "message"}} on failure. Estimates are free; runs are metered against your credit balance. There is a single run task — one paste of code in, one migration report out, no follow-up calls and no session state to carry.

The request body is the input object itself. Both /estimate and /run take {"code": …, "notes": …, "legacyscan": …} at the top level. It is not wrapped in {"input": …}. A wrapped body is worse than an error: the wrapper is simply not a field this app reads, so code arrives empty and you get a priced estimate — or a billed run — against no code at all, with no complaint from the API.

StatusError codeMeaning
400validation_errorThe body failed validation — most often a non-string field or malformed JSON. Note that wrapping the input in {"input": …} does not raise this: it is accepted and read as an empty input, so check your body shape rather than waiting for an error.
401unauthorizedMissing, malformed or expired token — mint a new one with POST /guest or sign in again.
402payment_requiredNot enough credits to place the hold — top up at skillsafe.ai/account/credits, or check /estimate first.
404not_foundUnknown job id, or a route that does not exist (check you did not add an /apps/… segment).
429rate_limitedToo many requests — back off and retry. Reuse the same Idempotency-Key on the retry so the run cannot be billed twice.
5xxinternal_errorTransient platform error — retry with backoff and the same idempotency key.

Browsers enforce CORS for this API, so run these examples from a server, script or terminal — not from another website's frontend.

Step 0 — A tiny client

Every task below is a single HTTP call, so start with a short helper that adds the auth header, sends JSON and unwraps the data envelope. The later steps reuse it.

export API="https://api.skillsafe.ai/v1/app-api"
export TOKEN="YOUR_TOKEN"      # see step 1

# every call looks like:
#   curl -s "$API/..." -H "Authorization: Bearer $TOKEN" [-d '{json}']
# jq is used below to pull fields out of the {"data": ...} envelope
import json, requests

API = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN"  # see step 1 — load it from your secret store in real code

def api(method, path, body=None, **headers):
    res = requests.request(method, API + path, json=body,
                           headers={"Authorization": f"Bearer {TOKEN}", **headers})
    payload = res.json()
    if not res.ok:
        err = payload.get("error", {})
        raise RuntimeError(f'{err.get("code", res.status_code)}: {err.get("message", res.reason)}')
    return payload["data"]
// Node 18+ (built-in fetch)
const API = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN"; // see step 1 — load it from your secret store in real code

async function api(method, path, body, extraHeaders = {}) {
  const res = await fetch(API + path, {
    method,
    headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json", ...extraHeaders },
    body: body === undefined ? undefined : JSON.stringify(body),
  });
  const json = await res.json();
  if (!res.ok) throw new Error(`${json.error?.code ?? res.status}: ${json.error?.message ?? res.statusText}`);
  return json.data;
}
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"net/http"
	"os"
)

const API = "https://api.skillsafe.ai/v1/app-api"

var token = os.Getenv("SKILLSAFE_TOKEN") // see step 1

func call(method, path string, body, out any) error {
	var buf bytes.Buffer
	if body != nil {
		json.NewEncoder(&buf).Encode(body)
	}
	req, _ := http.NewRequest(method, API+path, &buf)
	req.Header.Set("Authorization", "Bearer "+token)
	req.Header.Set("Content-Type", "application/json")
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		return err
	}
	defer res.Body.Close()
	var env struct {
		Data  json.RawMessage `json:"data"`
		Error *struct {
			Code    string `json:"code"`
			Message string `json:"message"`
		} `json:"error"`
	}
	json.NewDecoder(res.Body).Decode(&env)
	if res.StatusCode >= 400 {
		return fmt.Errorf("api %s %s: %s: %s", method, path, env.Error.Code, env.Error.Message)
	}
	if out == nil {
		return nil
	}
	return json.Unmarshal(env.Data, out)
}
// Java 17+, no dependencies. Pair with your JSON library (Jackson, Gson…)
// to read fields out of the returned envelope.
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

public class SkillSafe {
    static final String API = "https://api.skillsafe.ai/v1/app-api";
    static final String TOKEN = System.getenv("SKILLSAFE_TOKEN"); // see step 1
    static final HttpClient HTTP = HttpClient.newHttpClient();

    static String api(String method, String path, String jsonBody) throws Exception {
        var req = HttpRequest.newBuilder(URI.create(API + path))
            .header("Authorization", "Bearer " + TOKEN)
            .header("Content-Type", "application/json")
            .method(method, jsonBody == null
                ? HttpRequest.BodyPublishers.noBody()
                : HttpRequest.BodyPublishers.ofString(jsonBody))
            .build();
        var res = HTTP.send(req, HttpResponse.BodyHandlers.ofString());
        if (res.statusCode() >= 400) throw new RuntimeException(res.body());
        return res.body(); // envelope: {"data": …}
    }
}
require "net/http"
require "json"

API = "https://api.skillsafe.ai/v1/app-api"
TOKEN = ENV.fetch("SKILLSAFE_TOKEN") # see step 1

def api(method, path, body = nil, extra = {})
  uri = URI(API + path)
  req = Net::HTTP.const_get(method.capitalize).new(uri)
  req["Authorization"] = "Bearer #{TOKEN}"
  req["Content-Type"] = "application/json"
  extra.each { |k, v| req[k] = v }
  req.body = body.to_json if body
  res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
  payload = JSON.parse(res.body)
  unless res.is_a?(Net::HTTPSuccess)
    raise "#{payload.dig("error", "code")}: #{payload.dig("error", "message") || res.message}"
  end
  payload["data"]
end
<?php
const API = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = getenv("SKILLSAFE_TOKEN"); // see step 1

function api(string $method, string $path, ?array $body = null, array $extra = []): mixed {
    global $TOKEN;
    $ch = curl_init(API . $path);
    curl_setopt_array($ch, [
        CURLOPT_CUSTOMREQUEST  => $method,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER     => array_merge([
            "Authorization: Bearer $TOKEN",
            "Content-Type: application/json",
        ], $extra),
        CURLOPT_POSTFIELDS     => $body === null ? null : json_encode($body),
    ]);
    $payload = json_decode(curl_exec($ch), true);
    $status  = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    curl_close($ch);
    if ($status >= 400) {
        throw new Exception(($payload["error"]["code"] ?? "http_$status") . ": "
            . ($payload["error"]["message"] ?? "request failed"));
    }
    return $payload["data"];
}
// .NET 8+ — the migration client you would actually keep in the repo.
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;

namespace WinUiMigrator;

public sealed class SkillSafeException(string code, string message)
    : Exception($"{code}: {message}")
{
    public string Code { get; } = code;
}

public sealed class SkillSafeClient : IDisposable
{
    private const string BaseUrl = "https://api.skillsafe.ai/v1/app-api";
    private readonly HttpClient _http = new() { Timeout = TimeSpan.FromMinutes(5) };

    /// <summary>Exposed so the streaming helper in step 5 can share the connection.</summary>
    internal HttpClient Http => _http;

    public SkillSafeClient(string token) =>
        _http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);

    public async Task<JsonElement> SendAsync(
        HttpMethod method, string path, object? body = null,
        string? idempotencyKey = null, CancellationToken ct = default)
    {
        using var req = new HttpRequestMessage(method, BaseUrl + path);
        if (body is not null) req.Content = JsonContent.Create(body);
        if (idempotencyKey is not null) req.Headers.Add("Idempotency-Key", idempotencyKey);

        using var res = await _http.SendAsync(req, ct);
        var env = await res.Content.ReadFromJsonAsync<JsonElement>(ct);

        if (!res.IsSuccessStatusCode)
        {
            var err = env.TryGetProperty("error", out var e) ? e : default;
            throw new SkillSafeException(
                err.ValueKind is JsonValueKind.Object ? err.GetProperty("code").GetString()! : $"http_{(int)res.StatusCode}",
                err.ValueKind is JsonValueKind.Object ? err.GetProperty("message").GetString()! : res.ReasonPhrase ?? "request failed");
        }
        return env.GetProperty("data");
    }

    public void Dispose() => _http.Dispose();
}

Step 1 — Get a token

POST /guest

A guest token lets you check balances and estimate costs for free. For metered migration runs billed to your own account, use your personal token: open the token page, sign in with SkillSafe, and press Copy shell export — it puts export SKILLSAFE_TOKEN="…" on your clipboard, ready for the examples below. Treat the token like a password: it can spend your credits. For fully headless scripts, POST /guest mints a guest token with no browser involved — this is the one and only place the slug is sent.

curl -s -X POST "$API/guest" \
  -H "Content-Type: application/json" \
  -d '{"slug":"winui-migrator"}' | jq -r '.data.token'
token = api("POST", "/guest", {"slug": "winui-migrator"})["token"]
const { token } = await api("POST", "/guest", { slug: "winui-migrator" });
var guest struct {
	Token   string `json:"token"`
	GuestID string `json:"guest_id"`
}
err := call("POST", "/guest", map[string]string{"slug": "winui-migrator"}, &guest)
String envelope = api("POST", "/guest", """
    {"slug":"winui-migrator"}""");
// token is at data.token in the returned JSON
token = api("POST", "/guest", { slug: "winui-migrator" })["token"]
$token = api("POST", "/guest", ["slug" => "winui-migrator"])["token"];
// A guest token needs no Authorization header, so mint it with a bare client.
using var bootstrap = new HttpClient();
var res = await bootstrap.PostAsJsonAsync(
    "https://api.skillsafe.ai/v1/app-api/guest",
    new { slug = "winui-migrator" });
res.EnsureSuccessStatusCode();

var envelope = await res.Content.ReadFromJsonAsync<JsonElement>();
var token = envelope.GetProperty("data").GetProperty("token").GetString()!;

using var client = new SkillSafeClient(token);

The app stores this browser's token under the localStorage key skillsafe_app_token:winui-migrator, on the app's own origin, and remembers the guest id under skillsafe_guest:winui-migrator so a later sign-in can carry the guest wallet over. The token page reads and manages both for you — you never need to open developer tools.

Step 2 — Check who you are and your balance

GET /me

Returns subject_type ("user" or "guest"), subject_id and your credits balance. Check this before pushing a large file through — a whole page code-behind plus its XAML comes back as a full rewrite, which is a long reply.

curl -s "$API/me" -H "Authorization: Bearer $TOKEN" | jq '.data'
me = api("GET", "/me")
print(me["subject_type"], me["credits"])
const me = await api("GET", "/me");
console.log(me.subject_type, me.credits);
var me struct {
	SubjectType string `json:"subject_type"`
	SubjectID   string `json:"subject_id"`
	Credits     int64  `json:"credits"`
}
err := call("GET", "/me", nil, &me)
fmt.Printf("%s %s: %d credits\n", me.SubjectType, me.SubjectID, me.Credits)
String envelope = api("GET", "/me", null);
// data.subject_type, data.subject_id, data.credits
me = api("GET", "/me")
puts "#{me["subject_type"]}: #{me["credits"]} credits"
$me = api("GET", "/me");
echo "{$me['subject_type']}: {$me['credits']} credits\n";
var me = await client.SendAsync(HttpMethod.Get, "/me");

var subjectType = me.GetProperty("subject_type").GetString();
var credits = me.GetProperty("credits").GetInt64();
Console.WriteLine($"{subjectType} {me.GetProperty("subject_id").GetString()}: {credits:N0} credits");

Step 3 — Estimate the cost

POST /estimate

Send exactly the input you would send to /run — the input object itself, at the top level of the body. The response's hold_credits is the worst-case cost and model names the model that will do the work. Nothing is charged and no job is created, so estimating is free — which is what makes it safe to price a whole directory of legacy files before spending anything.

Input fieldTypeNotes
codestring, requiredThe UWP source to migrate: C#, XAML, or a mix (a page's XAML followed by its code-behind), possibly a .csproj. A whole file works best; a fragment migrates too. The web UI clips this at 40,000 characters by removing whole lines from the middle of the file and leaving a bracketed marker in their place that says how many lines went — a source file carries its meaning at both ends, so a head-only cut discards the very methods being migrated. Clip the same way when you cut a file yourself: leave a bracketed marker where the removed lines were, and the reply will say in its summary that the input was cut instead of pretending the rewrite is complete. An empty code is a 400.
notesstring, optionalWhat you know about the app that the code does not show: packaged or unpackaged, single-window or multi-window, which window owns this page, the target framework. This genuinely changes the answer — pickers, dialogs and settings storage all migrate differently depending on it. Anything you leave out comes back in ## Open questions rather than as a silent assumption. The web UI clips it at 4,000 characters.
legacyscanstring, optionalThe browser-side scan summary, as a plain-text digest — a scanned N lines, M matches across K patterns header followed by one - [SEVERITY rule-id] where: title -> fix line per match. It is regular-expression matching with no understanding of the code, so it is treated as a hint to verify, never as fact: matches inside comments and string literals are reported too, and an unconfirmable item is never repeated back. Omit it, or send the empty string, and the migration is unaffected.
retry_notestring, optionalOnly set by the app's automatic reformat retry, when a first reply failed to parse. It re-states the required reply shape and must never carry information about the code — it changes the layout of the reply, never the findings, the verdict or the confidence. Leave it out.

The body is the object above, sent directly. Do not wrap it: with {"input": {"code": …}} the API sees no code field at all and answers about an empty input instead of rejecting the call.

cat > EditorPage.cs.txt <<'CODE'
using Windows.UI.Xaml.Controls;
using Windows.UI.Popups;
using Windows.Storage.Pickers;

public sealed partial class EditorPage : Page
{
    private async void Save_Click(object sender, RoutedEventArgs e)
    {
        var picker = new FileSavePicker();
        var file = await picker.PickSaveFileAsync();
        if (file == null) return;

        await Dispatcher.RunAsync(CoreDispatcherPriority.Normal,
            () => Status.Text = "Saved " + file.Name);
        await new MessageDialog("Saved.", "Editor").ShowAsync();
    }
}
CODE

cat > scan.txt <<'SCAN'
scanned 17 lines, 4 matches across 4 patterns
- [BAD xaml-namespace] line 1: Windows.UI.Xaml namespace -> Microsoft.UI.Xaml
- [BAD core-dispatcher] line 13: Dispatcher.RunAsync -> DispatcherQueue.TryEnqueue
- [BAD message-dialog] line 15: MessageDialog -> ContentDialog with XamlRoot
- [WARN picker-interop] line 9: FileSavePicker -> needs InitializeWithWindow
SCAN

# the input object IS the body — no {"input": ...} wrapper
jq -n --rawfile code EditorPage.cs.txt --rawfile scan scan.txt \
  '{code: $code,
    notes: "Unpackaged WinUI 3 app, single window held in App.MainWindow.",
    legacyscan: $scan}' > input.json

curl -s -X POST "$API/estimate" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d @input.json | jq '.data | {hold_credits, model}'
CODE = """using Windows.UI.Xaml.Controls;
using Windows.UI.Popups;
using Windows.Storage.Pickers;

public sealed partial class EditorPage : Page
{
    private async void Save_Click(object sender, RoutedEventArgs e)
    {
        var picker = new FileSavePicker();
        var file = await picker.PickSaveFileAsync();
        if (file == null) return;

        await Dispatcher.RunAsync(CoreDispatcherPriority.Normal,
            () => Status.Text = "Saved " + file.Name);
        await new MessageDialog("Saved.", "Editor").ShowAsync();
    }
}
"""

SCAN = """scanned 17 lines, 4 matches across 4 patterns
- [BAD xaml-namespace] line 1: Windows.UI.Xaml namespace -> Microsoft.UI.Xaml
- [BAD core-dispatcher] line 13: Dispatcher.RunAsync -> DispatcherQueue.TryEnqueue
- [BAD message-dialog] line 15: MessageDialog -> ContentDialog with XamlRoot
- [WARN picker-interop] line 9: FileSavePicker -> needs InitializeWithWindow"""

# the input object IS the body — no {"input": ...} wrapper
payload = {
    "code": CODE,
    "notes": "Unpackaged WinUI 3 app, single window held in App.MainWindow.",
    "legacyscan": SCAN,
}

est = api("POST", "/estimate", payload)
print("worst case:", est["hold_credits"], "credits on", est["model"])
import { readFileSync } from "node:fs";

const code = readFileSync("EditorPage.xaml.cs", "utf8");

const scan = [
  "scanned 17 lines, 4 matches across 4 patterns",
  "- [BAD xaml-namespace] line 1: Windows.UI.Xaml namespace -> Microsoft.UI.Xaml",
  "- [BAD core-dispatcher] line 13: Dispatcher.RunAsync -> DispatcherQueue.TryEnqueue",
  "- [BAD message-dialog] line 15: MessageDialog -> ContentDialog with XamlRoot",
  "- [WARN picker-interop] line 9: FileSavePicker -> needs InitializeWithWindow",
].join("\n");

// clip long files the way the web UI does: cut the MIDDLE, keep both ends.
// A source file carries its meaning at the top (usings, class declaration) AND
// at the bottom (the methods that use the legacy APIs), so a head-only slice
// throws away the code you are migrating.
const CODE_LIMIT = 40000;
function clip(src) {
  if (src.length <= CODE_LIMIT) return src;
  const lines = src.split("\n");
  const head = [], tail = [];
  let used = 0, i = 0, j = lines.length - 1;
  for (; i < lines.length && used + lines[i].length + 1 <= CODE_LIMIT * 0.6; i++) {
    head.push(lines[i]); used += lines[i].length + 1;
  }
  for (used = 0; j >= i && used + lines[j].length + 1 <= CODE_LIMIT * 0.4; j--) {
    tail.unshift(lines[j]); used += lines[j].length + 1;
  }
  const gone = lines.length - head.length - tail.length;
  return head.join("\n") +
    "\n// [" + gone + " lines were removed from the MIDDLE of this file to fit the " +
    "input limit. The lines above and below are verbatim and complete.]\n" +
    tail.join("\n");
}
const clipped = clip(code);

// the input object IS the body — no { input: ... } wrapper
const payload = {
  code: clipped,
  notes: "Unpackaged WinUI 3 app, single window held in App.MainWindow.",
  legacyscan: scan,
};

const est = await api("POST", "/estimate", payload);
console.log("worst case:", est.hold_credits, "credits on", est.model);
codeBytes, err := os.ReadFile("EditorPage.xaml.cs")
if err != nil {
	log.Fatal(err)
}

// Cut the MIDDLE, not the tail: a head-only slice keeps the usings and
// discards the methods that actually use the legacy APIs.
const codeLimit = 40000
code := string(codeBytes)
if len(code) > codeLimit {
	head := codeLimit * 6 / 10
	tail := codeLimit - head
	code = code[:head] +
		"\n// [the MIDDLE of this file was removed to fit the input limit; " +
		"the lines above and below are verbatim and complete]\n" +
		code[len(code)-tail:]
}

scan := "scanned 17 lines, 4 matches across 4 patterns\n" +
	"- [BAD xaml-namespace] line 1: Windows.UI.Xaml namespace -> Microsoft.UI.Xaml\n" +
	"- [BAD core-dispatcher] line 13: Dispatcher.RunAsync -> DispatcherQueue.TryEnqueue\n" +
	"- [BAD message-dialog] line 15: MessageDialog -> ContentDialog with XamlRoot\n" +
	"- [WARN picker-interop] line 9: FileSavePicker -> needs InitializeWithWindow"

// the input object IS the body — no {"input": ...} wrapper
payload := map[string]any{
	"code":       code,
	"notes":      "Unpackaged WinUI 3 app, single window held in App.MainWindow.",
	"legacyscan": scan,
}

var est struct {
	HoldCredits int64  `json:"hold_credits"`
	Model       string `json:"model"`
}
if err := call("POST", "/estimate", payload, &est); err != nil {
	log.Fatal(err)
}
fmt.Printf("worst case: %d credits on %s\n", est.HoldCredits, est.Model)
String code = Files.readString(Path.of("EditorPage.xaml.cs"));
// Cut the MIDDLE, not the tail — keep the top and the bottom of the file.
final int CODE_LIMIT = 40_000;
if (code.length() > CODE_LIMIT) {
    int head = CODE_LIMIT * 6 / 10, tail = CODE_LIMIT - head;
    code = code.substring(0, head)
        + "\n// [the MIDDLE of this file was removed to fit the input limit; "
        + "the lines above and below are verbatim and complete]\n"
        + code.substring(code.length() - tail);
}

String scan = """
    scanned 17 lines, 4 matches across 4 patterns
    - [BAD xaml-namespace] line 1: Windows.UI.Xaml namespace -> Microsoft.UI.Xaml
    - [BAD core-dispatcher] line 13: Dispatcher.RunAsync -> DispatcherQueue.TryEnqueue
    - [BAD message-dialog] line 15: MessageDialog -> ContentDialog with XamlRoot
    - [WARN picker-interop] line 9: FileSavePicker -> needs InitializeWithWindow""";

// the input object IS the body — no {"input": ...} wrapper.
// toJsonString() is your JSON library's string escaper.
String jsonPayload = """
    {"code": %s,
     "notes": "Unpackaged WinUI 3 app, single window held in App.MainWindow.",
     "legacyscan": %s}
    """.formatted(toJsonString(code), toJsonString(scan));

String envelope = api("POST", "/estimate", jsonPayload);
// worst-case cost is at data.hold_credits, the model at data.model
CODE_LIMIT = 40_000
code = File.read("EditorPage.xaml.cs")
# Cut the MIDDLE, not the tail — keep the top and the bottom of the file.
if code.length > CODE_LIMIT
  head = CODE_LIMIT * 6 / 10
  tail = CODE_LIMIT - head
  code = code[0, head] +
    "\n# [the MIDDLE of this file was removed to fit the input limit; " \
    "the lines above and below are verbatim and complete]\n" +
    code[-tail..]
end

scan = <<~SCAN.chomp
  scanned 17 lines, 4 matches across 4 patterns
  - [BAD xaml-namespace] line 1: Windows.UI.Xaml namespace -> Microsoft.UI.Xaml
  - [BAD core-dispatcher] line 13: Dispatcher.RunAsync -> DispatcherQueue.TryEnqueue
  - [BAD message-dialog] line 15: MessageDialog -> ContentDialog with XamlRoot
  - [WARN picker-interop] line 9: FileSavePicker -> needs InitializeWithWindow
SCAN

# the input object IS the body — no { input: ... } wrapper
payload = { code: code,
            notes: "Unpackaged WinUI 3 app, single window held in App.MainWindow.",
            legacyscan: scan }

est = api("POST", "/estimate", payload)
puts "worst case: #{est["hold_credits"]} credits on #{est["model"]}"
const CODE_LIMIT = 40000;
$code = file_get_contents("EditorPage.xaml.cs");
// Cut the MIDDLE, not the tail — keep the top and the bottom of the file.
if (strlen($code) > CODE_LIMIT) {
    $head = intdiv(CODE_LIMIT * 6, 10);
    $tail = CODE_LIMIT - $head;
    $code = substr($code, 0, $head)
        . "\n// [the MIDDLE of this file was removed to fit the input limit; "
        . "the lines above and below are verbatim and complete]\n"
        . substr($code, -$tail);
}

$scan = <<<'SCAN'
scanned 17 lines, 4 matches across 4 patterns
- [BAD xaml-namespace] line 1: Windows.UI.Xaml namespace -> Microsoft.UI.Xaml
- [BAD core-dispatcher] line 13: Dispatcher.RunAsync -> DispatcherQueue.TryEnqueue
- [BAD message-dialog] line 15: MessageDialog -> ContentDialog with XamlRoot
- [WARN picker-interop] line 9: FileSavePicker -> needs InitializeWithWindow
SCAN;

// the input object IS the body — no ["input" => ...] wrapper
$payload = [
    "code"       => $code,
    "notes"      => "Unpackaged WinUI 3 app, single window held in App.MainWindow.",
    "legacyscan" => $scan,
];

$est = api("POST", "/estimate", $payload);
echo "worst case: {$est['hold_credits']} credits on {$est['model']}\n";
// The request body, as a record — the input object IS the body.
// Omitted members are simply left out of the JSON; RetryNote is set only by
// the app's own reformat retry, so it stays null here.
public sealed record MigrationInput(
    [property: JsonPropertyName("code")] string Code,
    [property: JsonPropertyName("notes")] string Notes = "",
    [property: JsonPropertyName("legacyscan")] string LegacyScan = "",
    [property: JsonPropertyName("retry_note")]
    [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
    string? RetryNote = null)
{
    public const int CodeLimit = 40_000;

    /// <summary>
    /// Clips like the web UI does: the cut goes through the MIDDLE of the file.
    /// A head-only slice keeps the usings and discards the methods that use the
    /// legacy APIs, which is precisely the code being migrated.
    /// </summary>
    public static MigrationInput FromFile(string path, string notes = "", string scan = "")
    {
        var code = File.ReadAllText(path);
        if (code.Length > CodeLimit)
        {
            var head = CodeLimit * 6 / 10;
            var tail = CodeLimit - head;
            code = string.Concat(
                code.AsSpan(0, head),
                "\n// [the MIDDLE of this file was removed to fit the input limit; " +
                "the lines above and below are verbatim and complete]\n",
                code.AsSpan(code.Length - tail));
        }
        return new MigrationInput(code, notes, scan);
    }
}

var input = MigrationInput.FromFile(
    "EditorPage.xaml.cs",
    notes: "Unpackaged WinUI 3 app, single window held in App.MainWindow.",
    scan: """
        scanned 17 lines, 4 matches across 4 patterns
        - [BAD xaml-namespace] line 1: Windows.UI.Xaml namespace -> Microsoft.UI.Xaml
        - [BAD core-dispatcher] line 13: Dispatcher.RunAsync -> DispatcherQueue.TryEnqueue
        - [BAD message-dialog] line 15: MessageDialog -> ContentDialog with XamlRoot
        - [WARN picker-interop] line 9: FileSavePicker -> needs InitializeWithWindow
        """);

var est = await client.SendAsync(HttpMethod.Post, "/estimate", input);
Console.WriteLine($"worst case: {est.GetProperty("hold_credits").GetInt64():N0} credits " +
                  $"on {est.GetProperty("model").GetString()}");

legacyscan is a hint, not an instruction. Every item is verified against the code before it is acted on, a match inside a comment or a string literal is discarded, and an item that cannot be confirmed is never echoed back as a finding. Equally, a clean scan is never treated as proof the code is clean — all nine findings are assessed on every run whatever you send here.

Step 4 — Run the migration and wait for the result

POST /run
GET /jobs/{job_id}

/run takes the same input as /estimate, places a credit hold and returns a job_id. Poll /jobs/{job_id} every 1–2 seconds until status is succeeded or failed. A run typically takes 30–120 s, because the reply carries your entire file rewritten, not a diff. Always send an Idempotency-Key header so a network retry, a 429 back-off or a crashed worker cannot start a second, double-charged run: replaying the same key returns the original job instead of billing again. The reply is in output — usually nested as output.output — and it is plain text, not JSON. Its exact grammar is the next section.

IDEM="wm-$(date +%s)-$RANDOM"

JOB_ID=$(curl -s -X POST "$API/run" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: $IDEM" \
  -d @input.json | jq -r '.data.job_id')

while :; do
  JOB=$(curl -s "$API/jobs/$JOB_ID" -H "Authorization: Bearer $TOKEN")
  STATUS=$(echo "$JOB" | jq -r '.data.status')
  [ "$STATUS" = "succeeded" ] || [ "$STATUS" = "failed" ] && break
  sleep 2
done

[ "$STATUS" = "succeeded" ] || { echo "$JOB" | jq -r '.data.error'; exit 1; }

# plain text, not JSON — unwrap once and keep the whole report
echo "$JOB" | jq -r '.data.output.output' > report.md

# the three header lines
sed -n '1,3p' report.md

# the migrated code: everything inside the fences under "## Migrated code"
awk '/^## Migrated code$/{s=1;next} /^## /{s=0} s' report.md \
  | awk '/^```/{f=!f;next} f' > EditorPage.migrated.cs

# a non-empty Manual steps section forbids a Drop-in rewrite verdict
awk '/^## Manual steps$/{s=1;next} /^## /{s=0} s && /^- /' report.md

grep -q '^VERDICT: Needs redesign$' report.md && { echo "redesign required"; exit 1; }
exit 0
import re, time, uuid

job_id = api("POST", "/run", payload,
             **{"Idempotency-Key": str(uuid.uuid4())})["job_id"]

while True:
    job = api("GET", f"/jobs/{job_id}")
    if job["status"] in ("succeeded", "failed"):
        break
    time.sleep(1.5)

if job["status"] == "failed":
    raise RuntimeError(job.get("error", "run failed"))

raw = job["output"]
if isinstance(raw, dict) and "output" in raw:
    raw = raw["output"]          # plain text, NOT json.loads()

SECTIONS = ["Findings", "Migrated code", "Manual steps",
            "Compatibility notes", "Open questions"]

def parse(report):
    verdict = re.search(r"^VERDICT:\s*(.+)$", report, re.M).group(1).strip()
    confidence = int(re.search(r"^CONFIDENCE:\s*(\d{1,3})\s*$", report, re.M).group(1))
    summary = re.search(r"^SUMMARY:\s*(.*?)(?:\n\s*\n|\n## )", report, re.M | re.S)
    body = {}
    for name in SECTIONS:
        m = re.search(rf"^## {re.escape(name)}\s*$(.*?)(?=^## |\Z)", report, re.M | re.S)
        body[name] = m.group(1).strip() if m else ""
    return {
        "verdict": verdict,
        "confidence": confidence,
        "summary": " ".join(summary.group(1).split()),
        "sections": body,
        "blocks": re.findall(r"```([A-Za-z0-9+#-]*)\n(.*?)```", body["Migrated code"], re.S),
    }

def bullets(text):
    items = [ln[2:].strip() for ln in text.splitlines() if ln.startswith("- ")]
    return [] if items == ["None."] else items

report = parse(raw)
print(f'{report["verdict"]} ({report["confidence"]}%) — {report["summary"]}')

for line in bullets(report["sections"]["Findings"]):
    print("  finding:", line)      # "<Name> - MUST-FIX|CHECK|CLEAN: <evidence>"
for step in bullets(report["sections"]["Manual steps"]):
    print("  manual:", step)
for note in bullets(report["sections"]["Compatibility notes"]):
    print("  note:", note)
for q in bullets(report["sections"]["Open questions"]):
    print("  open:", q)

with open("report.md", "w", encoding="utf-8") as fh:
    fh.write(raw)
for i, (lang, code) in enumerate(report["blocks"]):
    ext = {"csharp": "cs", "xaml": "xaml", "xml": "xml"}.get(lang, "txt")
    with open(f"migrated_{i}.{ext}", "w", encoding="utf-8") as fh:
        fh.write(code)

# the contract's own consistency rule, worth asserting on
assert not (report["verdict"] == "Drop-in rewrite"
            and bullets(report["sections"]["Manual steps"])), "inconsistent verdict"
import { writeFileSync } from "node:fs";
import { randomUUID } from "node:crypto";

const { job_id } = await api("POST", "/run", payload,
  { "Idempotency-Key": randomUUID() });

let job;
do {
  await new Promise((r) => setTimeout(r, 1500));
  job = await api("GET", `/jobs/${job_id}`);
} while (job.status !== "succeeded" && job.status !== "failed");

if (job.status === "failed") throw new Error(job.error ?? "run failed");

// plain text, NOT JSON.parse()
const raw = typeof job.output === "string" ? job.output : job.output.output;

const SECTIONS = ["Findings", "Migrated code", "Manual steps",
                  "Compatibility notes", "Open questions"];

function parse(report) {
  const sections = {};
  for (const name of SECTIONS) {
    const re = new RegExp(`^## ${name}\\s*$([\\s\\S]*?)(?=^## |$(?![\\s\\S]))`, "m");
    sections[name] = (re.exec(report)?.[1] ?? "").trim();
  }
  const blocks = [...sections["Migrated code"]
    .matchAll(/```([A-Za-z0-9+#-]*)\n([\s\S]*?)```/g)]
    .map(([, lang, code]) => ({ lang: lang || "code", code }));

  return {
    verdict: /^VERDICT:\s*(.+)$/m.exec(report)[1].trim(),
    confidence: Number(/^CONFIDENCE:\s*(\d{1,3})\s*$/m.exec(report)[1]),
    summary: /^SUMMARY:\s*([\s\S]*?)(?:\n\s*\n|\n## )/m.exec(report)[1]
      .split(/\s+/).join(" ").trim(),
    sections,
    blocks,
  };
}

const bullets = (text) => {
  const items = text.split("\n").filter((l) => l.startsWith("- ")).map((l) => l.slice(2).trim());
  return items.length === 1 && items[0] === "None." ? [] : items;
};

const report = parse(raw);
console.log(`${report.verdict} (${report.confidence}%) — ${report.summary}`);

for (const f of bullets(report.sections["Findings"])) console.log("  finding:", f);
for (const s of bullets(report.sections["Manual steps"])) console.log("  manual:", s);
for (const n of bullets(report.sections["Compatibility notes"])) console.log("  note:", n);
for (const q of bullets(report.sections["Open questions"])) console.log("  open:", q);

writeFileSync("report.md", raw);
report.blocks.forEach((b, i) => {
  const ext = { csharp: "cs", xaml: "xaml", xml: "xml" }[b.lang] ?? "txt";
  writeFileSync(`migrated_${i}.${ext}`, b.code);
});

if (report.verdict === "Needs redesign") process.exitCode = 1;
idem := uuid.NewString() // any unique, stable-per-attempt string

var started struct {
	JobID string `json:"job_id"`
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", API+"/run", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", idem)
// ...send it and decode data.job_id into started (same envelope as call()).

var job struct {
	Status string `json:"status"`
	Error  string `json:"error"`
	Output struct {
		Output string `json:"output"`
	} `json:"output"`
}
for {
	if err := call("GET", "/jobs/"+started.JobID, nil, &job); err != nil {
		log.Fatal(err)
	}
	if job.Status == "succeeded" || job.Status == "failed" {
		break
	}
	time.Sleep(1500 * time.Millisecond)
}
if job.Status == "failed" {
	log.Fatal(job.Error)
}

report := job.Output.Output // plain text, not JSON

var (
	verdictRe = regexp.MustCompile(`(?m)^VERDICT:\s*(.+)$`)
	confRe    = regexp.MustCompile(`(?m)^CONFIDENCE:\s*(\d{1,3})\s*$`)
	fenceRe   = regexp.MustCompile("(?s)```([A-Za-z0-9+#-]*)\n(.*?)```")
)

// section returns the body under a "## Name" heading.
func section(report, name string) string {
	re := regexp.MustCompile(`(?ms)^## ` + regexp.QuoteMeta(name) + `\s*$(.*?)(?:^## |\z)`)
	m := re.FindStringSubmatch(report)
	if m == nil {
		return ""
	}
	return strings.TrimSpace(m[1])
}

func bullets(body string) []string {
	var out []string
	for _, ln := range strings.Split(body, "\n") {
		if strings.HasPrefix(ln, "- ") {
			out = append(out, strings.TrimSpace(ln[2:]))
		}
	}
	if len(out) == 1 && out[0] == "None." {
		return nil
	}
	return out
}

verdict := strings.TrimSpace(verdictRe.FindStringSubmatch(report)[1])
confidence, _ := strconv.Atoi(confRe.FindStringSubmatch(report)[1])
fmt.Printf("%s (%d%%)\n", verdict, confidence)

for _, f := range bullets(section(report, "Findings")) {
	fmt.Println("  finding:", f) // "<Name> - MUST-FIX|CHECK|CLEAN: <evidence>"
}
for _, s := range bullets(section(report, "Manual steps")) {
	fmt.Println("  manual:", s)
}

os.WriteFile("report.md", []byte(report), 0o644)
for i, m := range fenceRe.FindAllStringSubmatch(section(report, "Migrated code"), -1) {
	ext := map[string]string{"csharp": "cs", "xaml": "xaml", "xml": "xml"}[m[1]]
	if ext == "" {
		ext = "txt"
	}
	os.WriteFile(fmt.Sprintf("migrated_%d.%s", i, ext), []byte(m[2]), 0o644)
}

if verdict == "Needs redesign" {
	os.Exit(1)
}
import java.util.regex.*;

String startEnvelope = api("POST", "/run", jsonPayload);   // add the header below
// Send POST /run with an "Idempotency-Key" header — UUID.randomUUID().toString()
// is fine — so a retry cannot start a second, double-charged run.
String jobId = /* data.job_id via your JSON library */;

String job;
String status;
while (true) {
    job = api("GET", "/jobs/" + jobId, null);
    status = /* data.status */;
    if (status.equals("succeeded") || status.equals("failed")) break;
    Thread.sleep(1500);
}
if (status.equals("failed")) throw new RuntimeException(/* data.error */);

// data.output.output is PLAIN TEXT — do not parse it as JSON.
String report = /* data.output.output */;

Matcher mv = Pattern.compile("^VERDICT:\\s*(.+)$", Pattern.MULTILINE).matcher(report);
Matcher mc = Pattern.compile("^CONFIDENCE:\\s*(\\d{1,3})\\s*$", Pattern.MULTILINE).matcher(report);
mv.find();
mc.find();
String verdict = mv.group(1).trim();
int confidence = Integer.parseInt(mc.group(1));

// Body under a "## Name" heading.
java.util.function.BiFunction<String, String, String> section = (rep, name) -> {
    Matcher m = Pattern.compile("^## " + Pattern.quote(name) + "\\s*$(.*?)(?=^## |\\z)",
        Pattern.MULTILINE | Pattern.DOTALL).matcher(rep);
    return m.find() ? m.group(1).strip() : "";
};

List<String> manual = section.apply(report, "Manual steps").lines()
    .filter(l -> l.startsWith("- ")).map(l -> l.substring(2).strip()).toList();
if (manual.equals(List.of("None."))) manual = List.of();

System.out.printf("%s (%d%%)%n", verdict, confidence);
manual.forEach(s -> System.out.println("  manual: " + s));

// The fenced blocks under "## Migrated code" are the rewritten files, in input order.
Matcher fence = Pattern.compile("```([A-Za-z0-9+#-]*)\\n(.*?)```", Pattern.DOTALL)
    .matcher(section.apply(report, "Migrated code"));
int i = 0;
Files.writeString(Path.of("report.md"), report);
while (fence.find()) {
    String ext = switch (fence.group(1)) {
        case "csharp" -> "cs";
        case "xaml" -> "xaml";
        case "xml" -> "xml";
        default -> "txt";
    };
    Files.writeString(Path.of("migrated_" + (i++) + "." + ext), fence.group(2));
}
require "securerandom"

started = api("POST", "/run", payload,
              { "Idempotency-Key" => SecureRandom.uuid })

job = nil
loop do
  job = api("GET", "/jobs/#{started["job_id"]}")
  break if %w[succeeded failed].include?(job["status"])
  sleep 1.5
end
raise (job["error"] || "run failed") if job["status"] == "failed"

# plain text, not JSON
report = job["output"].is_a?(Hash) ? job["output"]["output"] : job["output"]

def section(report, name)
  m = report.match(/^## #{Regexp.escape(name)}\s*$(.*?)(?=^## |\z)/m)
  m ? m[1].strip : ""
end

def bullets(body)
  items = body.lines.select { |l| l.start_with?("- ") }.map { |l| l[2..].strip }
  items == ["None."] ? [] : items
end

verdict    = report[/^VERDICT:\s*(.+)$/, 1].strip
confidence = report[/^CONFIDENCE:\s*(\d{1,3})\s*$/, 1].to_i
summary    = report[/^SUMMARY:\s*(.*?)(?:\n\s*\n|\n## )/m, 1].split.join(" ")

puts "#{verdict} (#{confidence}%) — #{summary}"
bullets(section(report, "Findings")).each { |f| puts "  finding: #{f}" }
bullets(section(report, "Manual steps")).each { |s| puts "  manual: #{s}" }
bullets(section(report, "Compatibility notes")).each { |n| puts "  note: #{n}" }
bullets(section(report, "Open questions")).each { |q| puts "  open: #{q}" }

File.write("report.md", report)
exts = { "csharp" => "cs", "xaml" => "xaml", "xml" => "xml" }
section(report, "Migrated code").scan(/```([A-Za-z0-9+#-]*)\n(.*?)```/m).each_with_index do |(lang, code), i|
  File.write("migrated_#{i}.#{exts.fetch(lang, "txt")}", code)
end

exit 1 if verdict == "Needs redesign"
$started = api("POST", "/run", $payload,
    ["Idempotency-Key: " . bin2hex(random_bytes(16))]);

do {
    sleep(2);
    $job = api("GET", "/jobs/" . $started["job_id"]);
} while (!in_array($job["status"], ["succeeded", "failed"]));

if ($job["status"] === "failed") {
    throw new Exception($job["error"] ?? "run failed");
}

// plain text, not JSON
$report = is_array($job["output"]) ? $job["output"]["output"] : $job["output"];

function section(string $report, string $name): string {
    $re = "/^## " . preg_quote($name, "/") . '\s*$(.*?)(?=^## |\z)/ms';
    return preg_match($re, $report, $m) ? trim($m[1]) : "";
}

function bullets(string $body): array {
    $items = [];
    foreach (explode("\n", $body) as $line) {
        if (str_starts_with($line, "- ")) { $items[] = trim(substr($line, 2)); }
    }
    return $items === ["None."] ? [] : $items;
}

preg_match('/^VERDICT:\s*(.+)$/m', $report, $mv);
preg_match('/^CONFIDENCE:\s*(\d{1,3})\s*$/m', $report, $mc);
preg_match('/^SUMMARY:\s*(.*?)(?:\n\s*\n|\n## )/ms', $report, $msum);

$verdict = trim($mv[1]);
echo "{$verdict} ({$mc[1]}%) — " . preg_replace('/\s+/', " ", trim($msum[1])) . "\n";

foreach (bullets(section($report, "Findings")) as $f)      { echo "  finding: $f\n"; }
foreach (bullets(section($report, "Manual steps")) as $s)  { echo "  manual: $s\n"; }
foreach (bullets(section($report, "Compatibility notes")) as $n) { echo "  note: $n\n"; }
foreach (bullets(section($report, "Open questions")) as $q) { echo "  open: $q\n"; }

file_put_contents("report.md", $report);
preg_match_all('/```([A-Za-z0-9+#-]*)\n(.*?)```/s', section($report, "Migrated code"), $blocks, PREG_SET_ORDER);
$exts = ["csharp" => "cs", "xaml" => "xaml", "xml" => "xml"];
foreach ($blocks as $i => $b) {
    file_put_contents("migrated_$i." . ($exts[$b[1]] ?? "txt"), $b[2]);
}

exit($verdict === "Needs redesign" ? 1 : 0);
using System.Text.RegularExpressions;

// The reply contract, decoded once, in one place — mirror of the app's own parser.
public enum Verdict { DropInRewrite, RewriteWithManualSteps, NeedsRedesign }

public sealed record CodeBlock(string Language, string Code)
{
    public string Extension => Language switch
    {
        "csharp" or "cs" => "cs",
        "xaml" => "xaml",
        "xml" => "xml",
        _ => "txt",
    };
}

public sealed record Finding(string Name, string Status, string Evidence);

public sealed partial class MigrationReport
{
    private static readonly string[] SectionNames =
        ["Findings", "Migrated code", "Manual steps", "Compatibility notes", "Open questions"];

    [GeneratedRegex(@"^VERDICT:\s*(.+)$", RegexOptions.Multiline)]
    private static partial Regex VerdictRegex();

    [GeneratedRegex(@"^CONFIDENCE:\s*(\d{1,3})\s*$", RegexOptions.Multiline)]
    private static partial Regex ConfidenceRegex();

    [GeneratedRegex(@"^SUMMARY:\s*([\s\S]*?)(?:\n\s*\n|\n## )", RegexOptions.Multiline)]
    private static partial Regex SummaryRegex();

    [GeneratedRegex(@"```([A-Za-z0-9+#-]*)\n([\s\S]*?)```")]
    private static partial Regex FenceRegex();

    [GeneratedRegex(@"^(.*?)\s+-\s+(MUST-FIX|CHECK|CLEAN):\s*(.*)$")]
    private static partial Regex FindingRegex();

    public required Verdict Verdict { get; init; }
    public required int Confidence { get; init; }
    public required string Summary { get; init; }
    public required IReadOnlyList<Finding> Findings { get; init; }
    public required IReadOnlyList<CodeBlock> Blocks { get; init; }
    public required IReadOnlyList<string> ManualSteps { get; init; }
    public required IReadOnlyList<string> CompatibilityNotes { get; init; }
    public required IReadOnlyList<string> OpenQuestions { get; init; }
    public required string Raw { get; init; }

    /// <summary>A non-empty Manual steps section forbids "Drop-in rewrite".</summary>
    public bool IsConsistent =>
        Verdict is not Verdict.DropInRewrite || ManualSteps.Count == 0;

    public static MigrationReport Parse(string report)
    {
        var sections = SectionNames.ToDictionary(
            name => name,
            name => Regex.Match(report,
                $@"^## {Regex.Escape(name)}\s*$([\s\S]*?)(?=^## |\z)",
                RegexOptions.Multiline) is { Success: true } m ? m.Groups[1].Value.Trim() : "");

        static IReadOnlyList<string> Bullets(string body)
        {
            var items = body.Split('\n')
                .Where(l => l.StartsWith("- ", StringComparison.Ordinal))
                .Select(l => l[2..].Trim())
                .ToArray();
            return items is ["None."] ? [] : items;
        }

        var findings = Bullets(sections["Findings"]).Select(line =>
        {
            var m = FindingRegex().Match(line);
            return m.Success
                ? new Finding(m.Groups[1].Value.Trim(), m.Groups[2].Value, m.Groups[3].Value.Trim())
                : new Finding(line, "NOTED", "");
        }).ToArray();

        return new MigrationReport
        {
            Verdict = VerdictRegex().Match(report).Groups[1].Value.Trim() switch
            {
                "Drop-in rewrite" => Verdict.DropInRewrite,
                "Rewrite with manual steps" => Verdict.RewriteWithManualSteps,
                "Needs redesign" => Verdict.NeedsRedesign,
                var other => throw new FormatException($"unknown verdict: {other}"),
            },
            Confidence = int.Parse(ConfidenceRegex().Match(report).Groups[1].ValueSpan),
            Summary = string.Join(' ', SummaryRegex().Match(report).Groups[1].Value.Split(
                (char[]?)null, StringSplitOptions.RemoveEmptyEntries)),
            Findings = findings,
            Blocks = FenceRegex().Matches(sections["Migrated code"])
                .Select(m => new CodeBlock(
                    m.Groups[1].Value is { Length: > 0 } lang ? lang.ToLowerInvariant() : "code",
                    m.Groups[2].Value))
                .ToArray(),
            ManualSteps = Bullets(sections["Manual steps"]),
            CompatibilityNotes = Bullets(sections["Compatibility notes"]),
            OpenQuestions = Bullets(sections["Open questions"]),
            Raw = report,
        };
    }
}

// --- run it -----------------------------------------------------------------

var started = await client.SendAsync(HttpMethod.Post, "/run", input,
    idempotencyKey: Guid.NewGuid().ToString());   // a retry must never double-bill
var jobId = started.GetProperty("job_id").GetString();

JsonElement job;
while (true)
{
    job = await client.SendAsync(HttpMethod.Get, $"/jobs/{jobId}");
    if (job.GetProperty("status").GetString() is "succeeded" or "failed") break;
    await Task.Delay(TimeSpan.FromSeconds(1.5));
}
if (job.GetProperty("status").GetString() == "failed")
    throw new InvalidOperationException(job.GetProperty("error").GetString());

// output.output is PLAIN TEXT — never JsonDocument.Parse it.
var raw = job.GetProperty("output").GetProperty("output").GetString()!;
var report = MigrationReport.Parse(raw);

Console.WriteLine($"{report.Verdict} ({report.Confidence}%) — {report.Summary}");
foreach (var f in report.Findings)
    Console.WriteLine($"  [{f.Status,8}] {f.Name}: {f.Evidence}");
foreach (var step in report.ManualSteps) Console.WriteLine($"  manual: {step}");
foreach (var note in report.CompatibilityNotes) Console.WriteLine($"  note: {note}");
foreach (var q in report.OpenQuestions) Console.WriteLine($"  open: {q}");

await File.WriteAllTextAsync("report.md", report.Raw);
foreach (var (block, i) in report.Blocks.Select((b, i) => (b, i)))
    await File.WriteAllTextAsync($"migrated_{i}.{block.Extension}", block.Code);

if (!report.IsConsistent)
    Console.Error.WriteLine("warning: Drop-in rewrite with manual steps — re-run.");

return report.Verdict is Verdict.NeedsRedesign ? 1 : 0;

The reply is asked for as bare text with nothing before VERDICT: and no fence around the whole response, but a stray wrapper is always possible. Strip a leading ``` fence line and its trailing partner before parsing — that is what the app does before it falls back to a retry_note reformat run.

The reply — output contract

The output is plain text, not JSON. It is three tagged header lines followed by five ## sections, always all five and always in this order. Anything that breaks the grammar below is a failed parse, and the app retries once with the shape spelled out in retry_note.

Line / sectionGrammar
VERDICT: The first line. Its value is exactly one of Drop-in rewrite, Rewrite with manual steps or Needs redesign, spelled and capitalized that way. There is no fourth verdict.
CONFIDENCE: A bare integer from 0 to 100. No percent sign, no range, no word. It is confidence in the migration, not in the input: lower for a fragment whose surrounding types are not visible, for truncated input, or when packaged-versus-unpackaged is unstated and the answer depends on it.
SUMMARY: Two to four sentences. It may wrap over several lines and ends at the first blank line — join the lines with a space when you read it.
## FindingsOne - bullet per finding, in the shape - <Name> - MUST-FIX|CHECK|CLEAN: <evidence>. Always has content — never - None. See the table below.
## Migrated codeOne or more fenced blocks tagged csharp, xml or xaml, one per file or fragment in the input, in input order, each carrying the whole rewritten piece rather than a diff or an elision. When the input contained nothing to migrate, the section is the single bullet - None. instead. A ## line is never nested inside a fence.
## Manual steps- bullets, or the single bullet - None. The work outside the code block: retarget the project file, create the App.MainWindow static, move tests to a WinUI 3 test project, decide packaging.
## Compatibility notes- bullets, or - None. Behaviour that differs even after a correct rewrite — for example the ASTA reentrancy protection WinUI 3 does not have.
## Open questions- bullets, or - None. What was not stated and would change the migration — typically the things left out of notes.

The consistency rule worth asserting on: a non-empty ## Manual steps section forbids the verdict Drop-in rewrite. There is no "trivial step" exception. A reply that carries both is self-inconsistent — the app flags it, and so should your pipeline.

Findings bullets

Nine named findings are assessed on every run, one bullet each, in this order, whatever the legacyscan said: XAML namespaces, Colors and text, Dispatcher, Window.Current, MessageDialog, ContentDialog XamlRoot, pickers and hwnd interop, GetForCurrentView APIs, and background tasks and lifecycle. Extra bullets may follow the nine when the code contains another pattern that affects the migration.

StatusMeaning
MUST-FIXThe legacy pattern is present in the code and the rewrite changes it.
CHECKThe pattern is present but cannot be fully resolved from the pasted code alone — the fix depends on the app's window, project shape or packaging — or the API migrates but behaves differently.
CLEANThe pattern does not occur, or is already in its WinUI 3 form.

Every bullet carries its evidence after the colon: the identifier, member or line involved. A permissive parser should split on the last - before the status word, since a finding name may itself contain a hyphen.

A small, realistic reply for the snippet above:

VERDICT: Rewrite with manual steps
CONFIDENCE: 82
SUMMARY: Every legacy API in this handler has a direct Windows App SDK equivalent, so the
code migrates in full: the namespaces move to Microsoft.UI.Xaml, Dispatcher.RunAsync becomes
DispatcherQueue.TryEnqueue, and the MessageDialog becomes a ContentDialog with an explicit
XamlRoot. Two things remain outside the file: the FileSavePicker needs the real window handle
wired in, and the project must be retargeted. The notes say the window lives in App.MainWindow,
so that is what the rewrite calls.

## Findings
- XAML namespaces - MUST-FIX: `using Windows.UI.Xaml.Controls;` at line 1, rewritten to
  `Microsoft.UI.Xaml.Controls`.
- Colors and text - CLEAN: no `Windows.UI.Colors` or `Windows.UI.Text` reference in the input.
- Dispatcher - MUST-FIX: `Dispatcher.RunAsync(CoreDispatcherPriority.Normal, ...)` at line 13
  becomes `DispatcherQueue.TryEnqueue(DispatcherQueuePriority.Normal, ...)`.
- Window.Current - CLEAN: not referenced; the window is reached through `App.MainWindow`.
- MessageDialog - MUST-FIX: `new MessageDialog("Saved.", "Editor")` at line 15 becomes a
  `ContentDialog` with Title, Content and a CloseButtonText.
- ContentDialog XamlRoot - MUST-FIX: the new dialog sets `XamlRoot = this.XamlRoot` before
  `ShowAsync`, which UWP did not require.
- Pickers and hwnd interop - CHECK: `new FileSavePicker()` at line 9 is initialized with
  `App.MainWindow`'s handle; confirm that static exists.
- GetForCurrentView APIs - CLEAN: no `GetForCurrentView()` call in the input.
- Background tasks and lifecycle - CLEAN: no `IBackgroundTask` or `BackgroundTaskBuilder`.

## Migrated code
```csharp
using Microsoft.UI.Dispatching;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Windows.Storage.Pickers;

public sealed partial class EditorPage : Page
{
    private async void Save_Click(object sender, RoutedEventArgs e)
    {
        var picker = new FileSavePicker();
        WinRT.Interop.InitializeWithWindow.Initialize(
            picker, WinRT.Interop.WindowNative.GetWindowHandle(App.MainWindow));

        var file = await picker.PickSaveFileAsync();
        if (file == null) return;

        DispatcherQueue.TryEnqueue(DispatcherQueuePriority.Normal,
            () => Status.Text = "Saved " + file.Name);

        var dialog = new ContentDialog
        {
            Title = "Editor",
            Content = "Saved.",
            CloseButtonText = "OK",
            XamlRoot = this.XamlRoot,
        };
        await dialog.ShowAsync();
    }
}
```

## Manual steps
- Retarget the project to `net10.0-windows10.0.22621.0` with `<UseWinUI>true</UseWinUI>` and a
  Windows App SDK package reference.
- Create or confirm the `App.MainWindow` static that the picker interop and the dialog rely on.

## Compatibility notes
- `DispatcherQueue.TryEnqueue` returns a bool and does not await the queued work, unlike
  `Dispatcher.RunAsync` which returned an awaitable operation; sequencing that depended on the
  await must be restructured.
- WinUI 3 has no ASTA reentrancy protection, so a modal dialog no longer blocks input to the
  rest of the window in the way UWP guaranteed.

## Open questions
- Is this page ever hosted in a second window? `this.XamlRoot` is correct per-window, but the
  picker is wired to `App.MainWindow` specifically.

This is an AI-generated migration, not a compiled build: the rewrite is only as good as the code you pasted, and it cannot see the types, resources and project settings that are not in the input. Read ## Open questions and every CHECK finding before you commit, and build the result.

Step 5 — Stream the migration as it is written

POST /run-stream

/run-stream takes exactly the same body as /run but answers with server-sent events, so you can show progress instead of a spinner — useful here because the reply carries the whole file rewritten and can run to thousands of characters. This app's own progress panel is this endpoint. Events are separated by a blank line; each has an event: line and a data: line carrying JSON.

Send an Idempotency-Key header. The bundled SDK's runStream(input, opts) sets it from opts.idempotencyKey, and every sample below sets it by hand. A stream can drop mid-reply for reasons that have nothing to do with the run; without the key the natural retry starts a second run and bills you twice, and with it the replay returns the original result. On such a replay the server may answer with a plain JSON envelope rather than an event stream, so check the Content-Type before you start parsing frames — the SDK does exactly that.

EventPayloadMeaning
job{job_id, status}Sent once, when the job is accepted — show "starting".
delta{text}A chunk of the plain-text reply, in order. Append it; the accumulated length is your only progress signal, since the total is not known in advance. Watching for the ## headings as they arrive gives you a step list for free.
done{job_id, status, charged_credits, output}The final, authoritative result — read the report from output.output rather than trusting concatenated deltas, and the settled price from charged_credits.
error{code, message}Replaces done when the run fails.
# -N disables buffering so events print as they arrive.
# Reuse the same key on a retry and the run cannot be billed twice.
IDEM="wm-$(date +%s)-$RANDOM"

curl -N -s -X POST "$API/run-stream" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: $IDEM" \
  -d @input.json

# event: job
# data: {"job_id":"job_...","status":"running"}
#
# event: delta
# data: {"text":"VERDICT: Rewrite with manual steps\nCONFIDENCE: 82\n"}
# ...
# event: done
# data: {"job_id":"job_...","status":"succeeded","charged_credits":512,"output":{"output":"VERDICT: ..."}}
import json, uuid, requests

idem = str(uuid.uuid4())   # reuse this exact value on any retry
result = None

with requests.post(
    API + "/run-stream",
    headers={"Authorization": f"Bearer {TOKEN}",
             "Idempotency-Key": idem},
    json=payload,          # the input object itself
    stream=True,
) as r:
    r.raise_for_status()
    if "text/event-stream" not in r.headers.get("content-type", ""):
        result = r.json()["data"]                 # idempotent replay
    else:
        event = None
        for line in r.iter_lines(decode_unicode=True):
            if not line:
                continue
            if line.startswith("event:"):
                event = line[len("event:"):].strip()
            elif line.startswith("data:"):
                data = json.loads(line[len("data:"):].strip())
                if event == "delta":
                    print(".", end="", flush=True)   # live progress
                elif event == "done":
                    result = data
                elif event == "error":
                    raise RuntimeError(f'{data.get("code")}: {data.get("message")}')

raw = result["output"]["output"]                  # authoritative plain text
report = parse(raw)                               # the parser from step 4
print(f'\ncharged {result["charged_credits"]} — {report["verdict"]} ({report["confidence"]}%)')
with open("report.md", "w", encoding="utf-8") as fh:
    fh.write(raw)
const res = await fetch(API + "/run-stream", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${TOKEN}`,
    "Content-Type": "application/json",
    "Idempotency-Key": randomUUID(),   // reuse the same value on a retry
  },
  body: JSON.stringify(payload),       // the input object itself
});

let done;
if (!(res.headers.get("content-type") ?? "").includes("text/event-stream")) {
  done = (await res.json()).data;      // idempotent replay
} else {
  const reader = res.body.getReader();
  const decoder = new TextDecoder();
  let buf = "";
  for (;;) {
    const chunk = await reader.read();
    if (chunk.done) break;
    buf += decoder.decode(chunk.value, { stream: true });
    const frames = buf.split("\n\n");
    buf = frames.pop();
    for (const frame of frames) {
      const name = /^event:\s*(.+)$/m.exec(frame)?.[1];
      const body = /^data:\s*(.+)$/m.exec(frame)?.[1];
      if (!name || !body) continue;
      const data = JSON.parse(body);
      if (name === "delta") process.stdout.write(".");   // live progress
      if (name === "done") done = data;
      if (name === "error") throw new Error(`${data.code}: ${data.message}`);
    }
  }
}

const raw = done.output.output;        // authoritative plain text
const report = parse(raw);             // the parser from step 4
console.log(`\n${done.charged_credits} credits — ${report.verdict} (${report.confidence}%)`);
writeFileSync("report.md", raw);
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", API+"/run-stream", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", idem) // reuse the same value on a retry

res, err := http.DefaultClient.Do(req)
if err != nil {
	log.Fatal(err)
}
defer res.Body.Close()

var final map[string]any
if !strings.Contains(res.Header.Get("Content-Type"), "text/event-stream") {
	var env struct{ Data map[string]any `json:"data"` }
	json.NewDecoder(res.Body).Decode(&env) // idempotent replay
	final = env.Data
} else {
	var event string
	sc := bufio.NewScanner(res.Body)
	sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
	for sc.Scan() {
		line := sc.Text()
		switch {
		case strings.HasPrefix(line, "event:"):
			event = strings.TrimSpace(strings.TrimPrefix(line, "event:"))
		case strings.HasPrefix(line, "data:"):
			var data map[string]any
			json.Unmarshal([]byte(strings.TrimPrefix(line, "data:")), &data)
			switch event {
			case "delta":
				fmt.Print(".") // live progress
			case "done":
				final = data
			case "error":
				log.Fatalf("%v: %v", data["code"], data["message"])
			}
		}
	}
}

report := final["output"].(map[string]any)["output"].(string) // plain text
os.WriteFile("report.md", []byte(report), 0o644)
fmt.Println("\n" + verdictRe.FindStringSubmatch(report)[1]) // parser from step 4
// Java 17+ — read the stream line by line instead of buffering the body.
var req = HttpRequest.newBuilder(URI.create(API + "/run-stream"))
    .header("Authorization", "Bearer " + TOKEN)
    .header("Content-Type", "application/json")
    .header("Idempotency-Key", idem)   // reuse the same value on a retry
    .POST(HttpRequest.BodyPublishers.ofString(jsonPayload))
    .build();

var res = HTTP.send(req, HttpResponse.BodyHandlers.ofLines());
// If the Content-Type is not text/event-stream this was an idempotent replay:
// the body is a plain {"data": ...} envelope, so read data.output.output directly.

String event = null, done = null;
for (String line : (Iterable<String>) res.body()::iterator) {
    if (line.startsWith("event:")) {
        event = line.substring(6).trim();
    } else if (line.startsWith("data:")) {
        String data = line.substring(5).trim();
        if ("delta".equals(event)) System.out.print(".");   // live progress
        else if ("done".equals(event)) done = data;
        else if ("error".equals(event)) throw new RuntimeException(data);
    }
}
// Parse `done`, take data.output.output — it is PLAIN TEXT, not JSON — then run
// the step-4 parser over it: VERDICT, CONFIDENCE, SUMMARY, then the five
// sections. data.charged_credits is the settled price.
// Files.writeString(Path.of("report.md"), report);
require "net/http"
require "json"
require "securerandom"

idem = SecureRandom.uuid   # reuse this exact value on any retry

uri = URI(API + "/run-stream")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = idem
req.body = payload.to_json   # the input object itself

event = nil
done = nil
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
  http.request(req) do |res|
    if !res["content-type"].to_s.include?("text/event-stream")
      done = JSON.parse(res.body)["data"]   # idempotent replay
      next
    end
    res.read_body do |chunk|
      chunk.each_line do |line|
        line = line.strip
        if line.start_with?("event:")
          event = line.delete_prefix("event:").strip
        elsif line.start_with?("data:")
          data = JSON.parse(line.delete_prefix("data:").strip)
          case event
          when "delta" then print "."           # live progress
          when "done"  then done = data
          when "error" then raise "#{data["code"]}: #{data["message"]}"
          end
        end
      end
    end
  end
end

report = done["output"]["output"]   # authoritative plain text
puts "\n#{done["charged_credits"]} credits - #{report[/^VERDICT:\s*(.+)$/, 1]}"
File.write("report.md", report)
$event = null;
$done  = null;
$idem  = bin2hex(random_bytes(16));   // reuse the same value on a retry

$ch = curl_init(API . "/run-stream");
curl_setopt_array($ch, [
    CURLOPT_POST       => true,
    CURLOPT_HTTPHEADER => [
        "Authorization: Bearer $TOKEN",
        "Content-Type: application/json",
        "Idempotency-Key: $idem",
    ],
    CURLOPT_POSTFIELDS => json_encode($payload),   // the input object itself
    CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$event, &$done) {
        foreach (explode("\n", $chunk) as $line) {
            $line = trim($line);
            if (str_starts_with($line, "event:")) {
                $event = trim(substr($line, 6));
            } elseif (str_starts_with($line, "data:")) {
                $data = json_decode(trim(substr($line, 5)), true);
                if ($event === "delta") { echo "."; }        // live progress
                elseif ($event === "done") { $done = $data; }
                elseif ($event === "error") {
                    throw new Exception("{$data['code']}: {$data['message']}");
                }
            }
        }
        return strlen($chunk);
    },
]);
curl_exec($ch);
curl_close($ch);

$report = $done["output"]["output"];   // authoritative plain text
preg_match('/^VERDICT:\s*(.+)$/m', $report, $mv);
echo "\n{$done['charged_credits']} credits - " . trim($mv[1]) . "\n";
file_put_contents("report.md", $report);
// Streaming run, as an extension on the step-0 client (put it in a static
// class of your own). The idempotency key is what makes a dropped stream safe
// to retry — reuse the same value for the same attempt.
public static async Task<MigrationReport> RunStreamAsync(
    this SkillSafeClient client, MigrationInput input, string idempotencyKey,
    IProgress<string>? onDelta = null, CancellationToken ct = default)
{
    using var req = new HttpRequestMessage(
        HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/run-stream")
    {
        Content = JsonContent.Create(input),   // the input object itself
    };
    req.Headers.Add("Idempotency-Key", idempotencyKey);

    using var res = await client.Http.SendAsync(
        req, HttpCompletionOption.ResponseHeadersRead, ct);
    res.EnsureSuccessStatusCode();

    // An idempotent replay answers with a plain envelope, not an event stream.
    if (res.Content.Headers.ContentType?.MediaType != "text/event-stream")
    {
        var replay = await res.Content.ReadFromJsonAsync<JsonElement>(ct);
        return MigrationReport.Parse(
            replay.GetProperty("data").GetProperty("output").GetProperty("output").GetString()!);
    }

    using var reader = new StreamReader(await res.Content.ReadAsStreamAsync(ct));
    string? evt = null;
    JsonElement done = default;

    while (await reader.ReadLineAsync(ct) is { } line)
    {
        if (line.StartsWith("event:", StringComparison.Ordinal))
        {
            evt = line[6..].Trim();
        }
        else if (line.StartsWith("data:", StringComparison.Ordinal))
        {
            using var frame = JsonDocument.Parse(line[5..].Trim());
            switch (evt)
            {
                case "delta":
                    onDelta?.Report(frame.RootElement.GetProperty("text").GetString() ?? "");
                    break;
                case "done":
                    done = frame.RootElement.Clone();
                    break;
                case "error":
                    throw new SkillSafeException(
                        frame.RootElement.GetProperty("code").GetString()!,
                        frame.RootElement.GetProperty("message").GetString()!);
            }
        }
    }

    Console.WriteLine($"charged {done.GetProperty("charged_credits").GetInt64():N0} credits");

    // Trust the final payload, not the concatenated deltas.
    return MigrationReport.Parse(
        done.GetProperty("output").GetProperty("output").GetString()!);
}

// --- use it -----------------------------------------------------------------

var streamed = await client.RunStreamAsync(
    input,
    idempotencyKey: Guid.NewGuid().ToString(),
    onDelta: new Progress<string>(_ => Console.Write(".")));

Console.WriteLine();
Console.WriteLine($"{streamed.Verdict} ({streamed.Confidence}%)");
await File.WriteAllTextAsync("report.md", streamed.Raw);

In a browser, the native EventSource only speaks GET and this endpoint is a POST — read the fetch response body incrementally, as the JavaScript sample does. Deltas are for progress only: they can be cut short if a run runs out of credits mid-reply, so the report you keep is always the one in the done event.