OnDevice Forge — API

Post the feature, get the FoundationModels scaffold.

API tokens Open the app

Scaffold on-device AI features from your own tools

Send the AI feature you want in your iOS, iPadOS or macOS app — described the way you would describe it to a colleague, with your existing Swift types pasted underneath — and get back one plain-text document in a fixed shape: the feature named, an honest FIT: verdict, how many files follow, a confidence number, a two-to-four-sentence summary, then one ## File N section per Swift file, each holding one complete, compilable file inside a single ```swift fence, and finally ## Integration notes in wiring order and ## Limits and risks with real numbers. The types you paste are mirrored by name and field — never renamed, never re-shaped — and nothing is invented to fill a gap: a deployment floor the description does not state lowers the confidence instead of being guessed at, and a feature that needs what a small on-device model cannot give comes back as FIT: Poor fit with FILES: 0 and the honest alternative, not a scaffold that will disappoint in review. Everything this app does goes through the SkillSafe App API — plain JSON over HTTPS — so you can wire it into a build script, run it over a backlog of feature tickets, or drop the generated files straight into an Xcode target. Every code 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. There is no /apps/{slug}/ path segment — the app is bound to the token when you mint it, at POST /guest with {"slug":"ondevice-forge"}, so every later call is just /me, /estimate, /run or /run-stream. Every request sends Authorization: Bearer <token> and JSON bodies with Content-Type: application/json. Responses are wrapped in an envelope: {"ok":true,"data": …} on success, {"ok":false,"error":{"code","message"}} on failure — read .data, never the top level. The scaffold is written by the model this app is bound to — /estimate returns its current name in model. Estimates are free; runs are metered against your credit balance. There is a single run task — one feature description in, one scaffold out, no follow-up calls and no session state to carry.

POST /guest
GET /me
POST /estimate
POST /run
POST /run-stream
StatusMeaning
400Malformed JSON body, or material missing entirely.
401Missing or expired token — create a new session.
402Not enough credits — top up at skillsafe.ai/account/credits.
403The token isn't allowed to do this (e.g. a guest submitting a very large paste).
404Unknown job id.
409An Idempotency-Key you already used, replayed with a different body. Mint a new key for a genuinely new run.
429Too many runs in flight — back off and retry.
5xxTransient platform error — retry with backoff.

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 SKILLSAFE_TOKEN="YOUR_TOKEN"      # see step 1

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

API = "https://api.skillsafe.ai/v1/app-api"
TOKEN = os.environ.get("SKILLSAFE_TOKEN", "YOUR_TOKEN")  # see step 1

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 or payload.get("ok") is False:
        raise RuntimeError(payload.get("error", {}).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 - read it from your shell environment 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 || json.ok === false) throw new Error(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 {
		OK    bool            `json:"ok"`
		Data  json.RawMessage `json:"data"`
		Error *struct{ Message string `json:"message"` } `json:"error"`
	}
	json.NewDecoder(res.Body).Decode(&env)
	if res.StatusCode >= 400 || env.Error != nil {
		return fmt.Errorf("api %s %s: %s", method, path, 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: {"ok":true,"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)
  uri = URI(API + path)
  req = Net::HTTP.const_get(method.capitalize).new(uri)
  req["Authorization"] = "Bearer #{TOKEN}"
  req["Content-Type"] = "application/json"
  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)
  raise (payload.dig("error", "message") || res.message) unless res.is_a?(Net::HTTPSuccess)
  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): mixed {
    global $TOKEN;
    $ch = curl_init(API . $path);
    curl_setopt_array($ch, [
        CURLOPT_CUSTOMREQUEST  => $method,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER     => [
            "Authorization: Bearer $TOKEN",
            "Content-Type: application/json",
        ],
        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 || ($payload["ok"] ?? true) === false) {
        throw new Exception($payload["error"]["message"] ?? "HTTP $status");
    }
    return $payload["data"];
}
// .NET 8+
using System.Net.Http.Json;
using System.Text.Json;

static class SkillSafe
{
    const string Api = "https://api.skillsafe.ai/v1/app-api";
    static readonly HttpClient Http = new();

    static SkillSafe() =>
        Http.DefaultRequestHeaders.Authorization =
            new("Bearer", Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN")); // see step 1

    public static async Task<JsonElement> ApiAsync(HttpMethod method, string path, object? body = null)
    {
        var req = new HttpRequestMessage(method, Api + path);
        if (body != null) req.Content = JsonContent.Create(body);
        var res = await Http.SendAsync(req);
        var json = await res.Content.ReadFromJsonAsync<JsonElement>();
        if (!res.IsSuccessStatusCode)
            throw new Exception(json.GetProperty("error").GetProperty("message").GetString());
        return json.GetProperty("data");
    }
}

Step 1 — Get a token

POST /guest

A guest token lets you check balances and estimate costs for free. For metered scaffold 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, which every example below reads. 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 — and this is where the app slug is bound, which is why no later call needs it.

curl -s -X POST "$API/guest" \
  -H "Content-Type: application/json" \
  -d '{"slug":"ondevice-forge"}' | jq -r '.data.token'
token = api("POST", "/guest", {"slug": "ondevice-forge"})["token"]
const { token } = await api("POST", "/guest", { slug: "ondevice-forge" });
var guest struct{ Token string `json:"token"` }
err := call("POST", "/guest", map[string]string{"slug": "ondevice-forge"}, &guest)
String envelope = api("POST", "/guest", """
    {"slug":"ondevice-forge"}""");
// token is at data.token in the returned JSON
token = api("POST", "/guest", { slug: "ondevice-forge" })["token"]
$token = api("POST", "/guest", ["slug" => "ondevice-forge"])["token"];
var guest = await SkillSafe.ApiAsync(HttpMethod.Post, "/guest",
    new { slug = "ondevice-forge" });
var token = guest.GetProperty("token").GetString();

The app stores this browser's token under the localStorage key skillsafe_app_token:ondevice-forge, on the app's own origin. The token page reads and manages it 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 sending a whole feature specification with a folder of Swift types pasted underneath.

curl -s "$API/me" -H "Authorization: Bearer $SKILLSAFE_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"`
	Credits     int64  `json:"credits"`
}
err := call("GET", "/me", nil, &me)
String envelope = api("GET", "/me", null);
// data.subject_type, 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 SkillSafe.ApiAsync(HttpMethod.Get, "/me");
Console.WriteLine($"{me.GetProperty("subject_type")}: {me.GetProperty("credits")} credits");

Step 3 — Estimate the cost

POST /estimate

Send exactly the input you would send to /run; the response's hold_credits is the reserve placed on your balance, not the price — the settled cost comes back as charged_credits when the job finishes and is normally lower. Nothing is charged and no job is created, so estimating is free — useful when you are piping a long feature spec in and want a ceiling before spending credits. The input object is the request body itself — it is not wrapped in {"input": …}.

Estimate fieldMeaning
hold_creditsThe worst-case reserve taken while the job runs. A reserve, not a quote.
min_creditsThe floor a run of this shape cannot come in under.
modelThe concrete model currently bound to this app.
model_aliasThe stable alias the app asks for; the concrete model behind it can change.
markup_bpsThe app's markup in basis points, already folded into the numbers above.

The input fields

Input fieldTypeNotes
materialstring, requiredThe AI feature described the way you would describe it to a colleague: what it should do, where in the app it lives, what data it works on. Paste your existing Swift types below the prose — structs, enums, SwiftData models — and the @Generable mirrors will use exactly those names and fields, never a renamed or re-shaped version of them. Messy, partial and out of order is fine; a gap lowers CONFIDENCE rather than being filled in. This is the model's only evidence — no repository is read, no Xcode project is opened. If you clip a long paste, mark the cut in-band with [material truncated - N characters (~M lines) removed from the MIDDLE of the paste. The opening and the end are intact; the middle is missing, so scaffold only what is present, never guess at what the removed stretch contained, and say under Limits and risks that the middle was not read.] so the scaffold reports the gap instead of guessing at it. The web UI clips at 60,000 characters and inserts exactly that marker — and it cuts the middle, never the tail, because a paste carries its Swift type declarations, its platform floor and its late constraints at the end. A head-only slice(0, 60000) would throw away exactly the types the mirrors have to match.
contextstring, optionalThe app's name and domain, the deployment floor and platforms (iOS 26+, macOS 26+, iPad), and the conventions and constraints the code has to live inside — SwiftData models, no third-party packages, privacy is the selling point, an existing architecture the new files must slot into. It sharpens which app the scaffold is written for; it never licenses invention. An unknown deployment floor lowers CONFIDENCE rather than being assumed. The web UI caps it at 6,000 characters. Send "" when you have nothing to add.
factsstring, optionalPlain text, not an object — the summary of a mechanical browser-side prescan of material: the word count, the Swift type names detected, the framework-friendly verbs found (summarize, extract, classify, rewrite, suggest), any possible beyond-on-device needs flagged (live web knowledge, oversized inputs, image generation, server-side data, translation), the input sizes mentioned and the platforms named. Pure pattern-matching, offered as a hint to cross-check against, never a verdict: where the scan and the material disagree, the material wins — a flagged phrase in a sentence that rules the need out is not a Poor fit. Omit it, or send "", and nothing changes except that the model has one fewer cross-check. The exact wording the app sends is shown below.
retry_notestring, optionalReserved — reformat retry only. When a first reply does not match the output contract, the app sends the identical input once more with this field carrying a restatement of the required shape. It is not a place for instructions about the feature — nothing in it may appear in the scaffold as a requirement, a type or a limit. Leave it out of ordinary calls, and put anything you want the scaffold to reflect in context.

The facts block, in the exact shape the app's own scanner produces:

Mechanical scan of the feature description (pattern-matching, not judgement):
- 68 words. Swift types pasted: ShoppingItem.
- Framework-friendly verbs found: extract, classify.
- Input sizes mentioned: one dictated note.
- Platforms named: iOS 26+.

A run whose material trips a cloud-dependency phrase gets one more line, e.g. - Possible beyond-on-device needs: live web knowledge x2, oversized input x1. The examples below send material and context only, since facts is optional; add it as one more string field when you have a prescan of your own.

cat > material.txt <<'MATERIAL'
Tidy a dictated shopping note into a structured list. The user dictates one run-on
note in the Groceries tab; the feature splits it into items, each with a name, a
quantity and a category. Items should appear one by one as they are produced, not
all at once at the end. Runs on device, iOS 26+, no network call. The parsed items
are saved as ShoppingItem rows.

struct ShoppingItem {
    var name: String
    var quantity: Int
    var category: String
}
MATERIAL

# the input object IS the body - no {"input": ...} wrapper
jq -n --rawfile material material.txt \
  '{material: $material,
    context: "Pantry, a SwiftUI grocery app; iOS 26+ only, SwiftData models, no third-party packages, privacy is the selling point."}' > input.json

curl -s -X POST "$API/estimate" \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" -H "Content-Type: application/json" \
  -d @input.json | jq '.data | {hold_credits, min_credits, model, model_alias, markup_bps}'
MATERIAL = """Tidy a dictated shopping note into a structured list. The user dictates one run-on
note in the Groceries tab; the feature splits it into items, each with a name, a
quantity and a category. Items should appear one by one as they are produced, not
all at once at the end. Runs on device, iOS 26+, no network call. The parsed items
are saved as ShoppingItem rows.

struct ShoppingItem {
    var name: String
    var quantity: Int
    var category: String
}
"""

CONTEXT = ("Pantry, a SwiftUI grocery app; iOS 26+ only, SwiftData models, "
           "no third-party packages, privacy is the selling point.")

# the input object IS the body - no {"input": ...} wrapper
payload = {"material": MATERIAL, "context": CONTEXT}   # "facts" is optional

est = api("POST", "/estimate", payload)
print("reserve:", est["hold_credits"], "credits (floor", est["min_credits"], ")",
      "on", est.get("model"), "/", est.get("model_alias"))
const material = [
  "Tidy a dictated shopping note into a structured list. The user dictates one run-on",
  "note in the Groceries tab; the feature splits it into items, each with a name, a",
  "quantity and a category. Items should appear one by one as they are produced, not",
  "all at once at the end. Runs on device, iOS 26+, no network call. The parsed items",
  "are saved as ShoppingItem rows.",
  "",
  "struct ShoppingItem {",
  "    var name: String",
  "    var quantity: Int",
  "    var category: String",
  "}",
].join("\n");

const context =
  "Pantry, a SwiftUI grocery app; iOS 26+ only, SwiftData models, " +
  "no third-party packages, privacy is the selling point.";

// the input object IS the body - no {"input": ...} wrapper
const payload = { material, context };   // `facts` is optional

const est = await api("POST", "/estimate", payload);
console.log("reserve:", est.hold_credits, "credits, floor", est.min_credits, "on", est.model);
const material = "Tidy a dictated shopping note into a structured list. The user dictates one run-on\n" +
	"note in the Groceries tab; the feature splits it into items, each with a name, a\n" +
	"quantity and a category. Items should appear one by one as they are produced, not\n" +
	"all at once at the end. Runs on device, iOS 26+, no network call. The parsed items\n" +
	"are saved as ShoppingItem rows.\n" +
	"\n" +
	"struct ShoppingItem {\n" +
	"    var name: String\n" +
	"    var quantity: Int\n" +
	"    var category: String\n" +
	"}\n"

const featureContext = "Pantry, a SwiftUI grocery app; iOS 26+ only, SwiftData models, " +
	"no third-party packages, privacy is the selling point."

// the input object IS the body - no {"input": ...} wrapper
payload := map[string]any{
	"material": material,
	"context":  featureContext,
	// "facts" is optional - add it as one more string when you have a prescan
}

var est struct {
	HoldCredits int64  `json:"hold_credits"`
	MinCredits  int64  `json:"min_credits"`
	Model       string `json:"model"`
	ModelAlias  string `json:"model_alias"`
	MarkupBps   int64  `json:"markup_bps"`
}
err := call("POST", "/estimate", payload, &est)
String material = """
    Tidy a dictated shopping note into a structured list. The user dictates one run-on
    note in the Groceries tab; the feature splits it into items, each with a name, a
    quantity and a category. Items should appear one by one as they are produced, not
    all at once at the end. Runs on device, iOS 26+, no network call. The parsed items
    are saved as ShoppingItem rows.

    struct ShoppingItem {
        var name: String
        var quantity: Int
        var category: String
    }
    """;

String featureContext = """
    Pantry, a SwiftUI grocery app; iOS 26+ only, SwiftData models, \
    no third-party packages, privacy is the selling point.""";

// the input object IS the body - no {"input": ...} wrapper.
// toJsonString() is your JSON library's string escaper. "facts" is optional.
String jsonPayload = """
    {"material": %s,
     "context": %s}
    """.formatted(toJsonString(material), toJsonString(featureContext));

String envelope = api("POST", "/estimate", jsonPayload);
// the reserve is at data.hold_credits, the floor at data.min_credits
MATERIAL = <<~MATERIAL
  Tidy a dictated shopping note into a structured list. The user dictates one run-on
  note in the Groceries tab; the feature splits it into items, each with a name, a
  quantity and a category. Items should appear one by one as they are produced, not
  all at once at the end. Runs on device, iOS 26+, no network call. The parsed items
  are saved as ShoppingItem rows.

  struct ShoppingItem {
      var name: String
      var quantity: Int
      var category: String
  }
MATERIAL

FEATURE_CONTEXT = "Pantry, a SwiftUI grocery app; iOS 26+ only, SwiftData models, " \
                  "no third-party packages, privacy is the selling point."

# the input object IS the body - no {"input": ...} wrapper; :facts is optional
payload = { material: MATERIAL, context: FEATURE_CONTEXT }

est = api("POST", "/estimate", payload)
puts "reserve: #{est["hold_credits"]} credits (floor #{est["min_credits"]}) on #{est["model"]}"
$material = <<<'MATERIAL'
Tidy a dictated shopping note into a structured list. The user dictates one run-on
note in the Groceries tab; the feature splits it into items, each with a name, a
quantity and a category. Items should appear one by one as they are produced, not
all at once at the end. Runs on device, iOS 26+, no network call. The parsed items
are saved as ShoppingItem rows.

struct ShoppingItem {
    var name: String
    var quantity: Int
    var category: String
}
MATERIAL;

$featureContext = "Pantry, a SwiftUI grocery app; iOS 26+ only, SwiftData models, "
                . "no third-party packages, privacy is the selling point.";

// the input object IS the body - no {"input": ...} wrapper; "facts" is optional
$payload = [
    "material" => $material,
    "context"  => $featureContext,
];

$est = api("POST", "/estimate", $payload);
echo "reserve: {$est['hold_credits']} credits (floor {$est['min_credits']})\n";
var material = """
    Tidy a dictated shopping note into a structured list. The user dictates one run-on
    note in the Groceries tab; the feature splits it into items, each with a name, a
    quantity and a category. Items should appear one by one as they are produced, not
    all at once at the end. Runs on device, iOS 26+, no network call. The parsed items
    are saved as ShoppingItem rows.

    struct ShoppingItem {
        var name: String
        var quantity: Int
        var category: String
    }
    """;

var context = "Pantry, a SwiftUI grocery app; iOS 26+ only, SwiftData models, "
            + "no third-party packages, privacy is the selling point.";

// the input object IS the body - no {"input": ...} wrapper; "facts" is optional
var payload = new { material, context };

var est = await SkillSafe.ApiAsync(HttpMethod.Post, "/estimate", payload);
Console.WriteLine($"reserve: {est.GetProperty("hold_credits")} credits");

facts is a hint, not an instruction: if your prescan flags live web knowledge because the description contains the word "current", but the material makes clear the data is already in the app's database, the material wins and the fit is not downgraded. Its real value is the type list — a scaffold that introduces a model type which appears neither in that list nor in your paste is worth a second look before you wire anything in, because it usually means a shape was assumed rather than read.

Step 4 — Generate the scaffold and wait for the result

POST /run
GET /jobs/{job_id}

/run takes the same body as /estimate — the input object itself — 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 40–120 s for a normal feature, longer when four files are generated). Always send an Idempotency-Key header so a network retry can't start a second, double-charged run. The reply is in output — nested as data.output.output — and it is plain text, not JSON: write it straight to a .md file, or parse it with the snippet in the next section. The finished job also carries charged_credits, the settled price (which is normally below the hold_credits reserve), and a truncated flag that is true when the reply hit the output ceiling — a truncated scaffold usually means the last file's fence never closed, so treat it as a failed parse rather than a partial success.

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

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

# the scaffold is plain text - -r keeps it readable
echo "$JOB" | jq -r '.data.output.output' > scaffold.md
echo "$JOB" | jq -r '.data.charged_credits, .data.truncated'

head -5 scaffold.md                                   # the five header lines
grep '^## File ' scaffold.md                          # every generated file

# the feature is not what an on-device model does
grep -qx 'FIT: Poor fit' scaffold.md \
  && { echo "poor fit - read ## Integration notes for the alternative"; exit 1; }

# the count rule: FILES: must equal the number of ## File sections
DECLARED=$(grep -m1 '^FILES: ' scaffold.md | cut -d' ' -f2)
ACTUAL=$(grep -c '^## File ' scaffold.md)
[ "$DECLARED" = "$ACTUAL" ] || { echo "broken reply: FILES: $DECLARED, $ACTUAL sections"; exit 1; }
import time

job_id = api("POST", "/run", payload,
             **{"Idempotency-Key": "odf-001"})["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"))
if job.get("truncated"):
    raise RuntimeError("the reply hit the output ceiling - the last fence is unclosed")

raw = job["output"]
if isinstance(raw, dict) and "output" in raw:
    raw = raw["output"]
scaffold_text = raw if isinstance(raw, str) else json.dumps(raw)

with open("scaffold.md", "w", encoding="utf-8") as fh:
    fh.write(scaffold_text)

head, files, tail = parse_scaffold(scaffold_text)   # see the next section
print(head["feature"], "|", head["fit"], "|", head["confidence"])
for f in files:
    print(f'  File {f["n"]}: {f["name"]}  ({f["role"]}, {len(f["code"].splitlines())} lines)')
    with open(f["name"], "w", encoding="utf-8") as fh:
        fh.write(f["code"] + "\n")

print("charged:", job.get("charged_credits"))
if head["fit"] == "Poor fit":
    raise SystemExit("poor fit - read ## Integration notes for the alternative")
import { writeFileSync } from "node:fs";

const { job_id } = await api("POST", "/run", payload,
  { "Idempotency-Key": crypto.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");
if (job.truncated) throw new Error("the reply hit the output ceiling - the last fence is unclosed");

// plain text, not JSON
const scaffoldText = job.output?.output ?? job.output;
writeFileSync("scaffold.md", scaffoldText);

const scaffold = parseScaffold(scaffoldText);       // see the next section
console.log(`${scaffold.feature} | ${scaffold.fit} | ${scaffold.confidence}`);
for (const f of scaffold.files) {
  console.log(`  File ${f.n}: ${f.name}  (${f.role})`);
  writeFileSync(f.name, f.code + "\n");
}
console.log("charged:", job.charged_credits);
if (scaffold.fit === "Poor fit") {
  throw new Error("poor fit - read ## Integration notes for the alternative");
}
var started struct{ JobID string `json:"job_id"` }
if err := call("POST", "/run", payload, &started); err != nil {
	log.Fatal(err)
}

var job struct {
	Status         string          `json:"status"`
	Error          string          `json:"error"`
	ChargedCredits int64           `json:"charged_credits"`
	Truncated      bool            `json:"truncated"`
	Output         json.RawMessage `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)
}
if job.Truncated {
	log.Fatal("the reply hit the output ceiling - the last fence is unclosed")
}

// job.Output is {"output": "<the scaffold, as plain text>"} - one unwrap, no JSON parse
var wrapper struct{ Output string `json:"output"` }
json.Unmarshal(job.Output, &wrapper)
scaffoldText := wrapper.Output

os.WriteFile("scaffold.md", []byte(scaffoldText), 0o644)

s := parseScaffold(scaffoldText) // see the next section
fmt.Printf("%s | %s | %d\n", s.Feature, s.Fit, s.Confidence)
for _, f := range s.SwiftFiles {
	fmt.Printf("  File %d: %s  (%s)\n", f.N, f.Name, f.Role)
	os.WriteFile(f.Name, []byte(f.Code+"\n"), 0o644)
}
if s.Fit == "Poor fit" {
	log.Fatal("poor fit - read ## Integration notes for the alternative")
}
String envelope = api("POST", "/run", jsonPayload);
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);
}

// data.output.output is the scaffold as PLAIN TEXT - no second JSON parse.
// data.charged_credits is the settled price; data.truncated true means the
// reply hit the output ceiling and the last ```swift fence never closed.
String scaffoldText = /* data.output.output */;
Files.writeString(Path.of("scaffold.md"), scaffoldText);

// Header lines first (FEATURE:, FIT:, FILES:, CONFIDENCE:, SUMMARY:),
// then one "## File N: <Name.swift> - <role>" section per file, each holding
// exactly one ```swift fence with a complete Swift file, then
// "## Integration notes" and "## Limits and risks" in that order.
// See the parser in the next section.
started = api("POST", "/run", payload)

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"
raise "the reply hit the output ceiling" if job["truncated"]

# plain text, not JSON
raw = job["output"]
scaffold_text = raw.is_a?(Hash) ? raw.fetch("output", raw) : raw
File.write("scaffold.md", scaffold_text)

s = parse_scaffold(scaffold_text)  # see the next section
puts "#{s[:feature]} | #{s[:fit]} | #{s[:confidence]}"
s[:files].each do |f|
  puts "  File #{f[:n]}: #{f[:name]}  (#{f[:role]})"
  File.write(f[:name], f[:code] + "\n")
end
abort "poor fit - read ## Integration notes" if s[:fit] == "Poor fit"
$started = api("POST", "/run", $payload);

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");
}
if (!empty($job["truncated"])) {
    throw new Exception("the reply hit the output ceiling - the last fence is unclosed");
}

// plain text, not JSON
$raw = $job["output"];
$scaffoldText = is_array($raw) ? ($raw["output"] ?? "") : $raw;
file_put_contents("scaffold.md", $scaffoldText);

$s = parse_scaffold($scaffoldText);   // see the next section
echo "{$s['feature']} | {$s['fit']} | {$s['confidence']}\n";
foreach ($s["files"] as $f) {
    echo "  File {$f['n']}: {$f['name']}  ({$f['role']})\n";
    file_put_contents($f["name"], $f["code"] . "\n");
}
if ($s["fit"] === "Poor fit") {
    fwrite(STDERR, "poor fit - read ## Integration notes for the alternative\n");
    exit(1);
}
var started = await SkillSafe.ApiAsync(HttpMethod.Post, "/run", payload);
var jobId = started.GetProperty("job_id").GetString();

JsonElement job;
while (true)
{
    job = await SkillSafe.ApiAsync(HttpMethod.Get, $"/jobs/{jobId}");
    var status = job.GetProperty("status").GetString();
    if (status is "succeeded" or "failed") break;
    await Task.Delay(1500);
}

// plain text, not JSON
var scaffoldText = job.GetProperty("output").GetProperty("output").GetString()!;
await File.WriteAllTextAsync("scaffold.md", scaffoldText);

var s = ParseScaffold(scaffoldText);   // see the next section
Console.WriteLine($"{s.Feature} | {s.Fit} | {s.Confidence}");
foreach (var f in s.Files)
{
    Console.WriteLine($"  File {f.N}: {f.Name}  ({f.Role})");
    await File.WriteAllTextAsync(f.Name, f.Code + "\n");
}
if (s.Fit == "Poor fit")
    Console.Error.WriteLine("poor fit - read ## Integration notes for the alternative");

The model is asked for the bare document and nothing else, but a stray outer code fence is always possible — and here it is genuinely ambiguous, because the document contains ```swift fences of its own. Strip a leading ``` line only when the first line is not FEATURE:, and strip the matching trailing one, before parsing; that is what the app does before it falls back to a retry_note reformat run. If your parse fails, retry once with retry_note set to a restatement of the shape rather than re-prompting the feature description.

The scaffold — output contract

The reply is plain text, not JSON. It always has the same shape: five header lines, then one ## File N section per generated Swift file, then ## Integration notes and ## Limits and risks in that order — exactly two trailing sections, exactly that order. Every type, field and constraint in it comes from the material and context you sent: a Swift type you pasted is mirrored by name and by field, never renamed and never re-shaped; no product requirement is invented; a Poor fit is never softened into a Workable to have something to generate; and an unknown deployment floor lowers CONFIDENCE rather than being guessed at.

The five header lines

LineValue
FEATURE:First line. The on-device AI feature, named in one line, from the material. Never wraps.
FIT:Second line. Exactly one of Strong fit, Workable or Poor fit — no other wording, no hedging. This is the field to gate automation on. See the table below.
FILES:Third line. A bare integer equal to the number of ## File sections that follow — no words, no range. Normally 1 to 4; 0 only alongside FIT: Poor fit.
CONFIDENCE:Fourth line. A bare integer 0–100 — no percent sign, no range, no words. How confident the reply is that this scaffold matches the feature actually wanted: high when the description is specific and the Swift types are pasted, low when the deployment floor is unstated or the types had to be inferred from prose.
SUMMARY:Fifth line onwards. Two to four sentences: what the feature is, how the scaffold approaches it, and the one caveat that most matters. It may wrap over several lines and ends at the first blank line.

The three fit verdicts

VerdictWhat it means
Strong fitBounded text in, structured output out — the framework's home ground. Summarizing a note, extracting fields from a paragraph, classifying a short item, rewriting a sentence, suggesting from a small list. The scaffold is the whole answer.
WorkableAchievable, but with named engineering caveats — chunking around the 4,096-token window, tool calling to reach app data the model cannot see, a fallback path when the model is unavailable on the device. The caveats are named, with numbers, not waved at.
Poor fitThe feature needs what the framework cannot give: live web knowledge, contexts far beyond 4,096 tokens, image generation, cloud-scale reasoning. It comes with FILES: 0 and no ## File sections at all, and ## Integration notes carries the honest alternative instead of wiring steps — a cloud API lane, a simpler heuristic, or a narrower reframing of the feature that would fit on device. This is a valid reply, not a failure: no scaffold is generated to have something to show.

The file sections

ElementDetail
## File N: <FileName.swift> - <role>One heading per file, with N counting up from 1 with no gaps, a filename ending in .swift, then a space-hyphen-space and the file's role in one phrase. A normal feature yields 1 to 4 files.
Exactly one fenced blockEach section contains exactly one code fence, opened with ```swift and closed with a bare ```. Everything between them is one complete, compilable Swift file: imports included, no placeholder ellipses, no "rest of your code here" comment standing in for real code. A section with two fences, or with prose between the heading and the fence, is a broken reply.
The typical setThe @Generable models file mirroring your pasted types; a service or session file that owns the LanguageModelSession; a SwiftUI view file that gates on availability and renders the streamed snapshots; and, when the feature needs data the model cannot see, a Tool-calling file.

What every scaffold gets right by construction

Framework ruleHow the scaffold honours it
AvailabilitySystemLanguageModel.default.availability is checked before any session exists, and every unavailability case is handled — device not eligible, Apple Intelligence not enabled, model not ready — each with its own user-facing branch, never a single silent else.
InstructionsSessions are created with instructions: that set the model's role and its output discipline. Instructions are never concatenated into the prompt.
Structured outputResults come back through @Generable types with @Guide descriptions on the fields — never string parsing of free text, never a hand-rolled JSON decode.
Snapshot streamingProgressive UI uses streamResponse(to:generating:), with the type's PartiallyGenerated form driving @State so the view fills in as fields arrive.
One request per sessionisResponding is respected: a second request is never issued into a session that is still answering.
Reading the resultValues are read from response.content — never from .output.
Token budgetThe 4,096-token window shared by instructions, prompt and output is engineered around: the scaffold shows where to trim, chunk or cap, rather than assuming the input will fit.

The two trailing sections, and the count rule

RuleDetail
## Integration notesAlways present, immediately after the last file. Bullets, in wiring order: which file to add first, what to call from where, which of your existing types map to which generated ones, what to put in the app's entitlements or Info settings. For a Poor fit reply this section carries the honest alternative instead.
## Limits and risksAlways present, and always last. Bullets with real numbers, not vague cautions: the 4,096-token window shared by instructions, prompt and output; the iOS 26+ / macOS 26+ availability floor; device eligibility and Apple Intelligence being switched on; what happens when the input is longer than the window; and, when the paste was clipped, the fact that the middle was not read.
Every body line is a bulletEach line inside either trailing section starts with - . A long bullet may wrap onto indented continuation lines — fold those into the preceding bullet when parsing.
The count ruleFILES: must equal the number of ## File sections, and their numbers must run 1..N with no gaps. A mismatch is a broken reply — the app rejects it and retries once with retry_note, and your parser should refuse it too.
The FILES: 0 branch0 is legitimate only with FIT: Poor fit. A Strong fit or Workable reply with no files, or a Poor fit reply that still ships files, is a contract violation — refuse it rather than half-using it.

A reply for the dictated-shopping-note feature above, abbreviated to one file:

FEATURE: Turn a dictated run-on shopping note into a structured list of items with name, quantity and category
FIT: Strong fit
FILES: 3
CONFIDENCE: 84
SUMMARY: A short dictated note in, a bounded list of typed items out - this is exactly what a
small on-device model with structured output is for. The generated ShoppingItem mirrors your
pasted struct field for field, and snapshot streaming lets rows appear one at a time as the
model produces them. The only real constraint is note length against the 4,096-token window.

## File 1: ShoppingModels.swift - the @Generable types the model fills in
```swift
import FoundationModels

@Generable
struct ShoppingItem {
    @Guide(description: "The item as a shopper would write it on a list, singular")
    var name: String

    @Guide(description: "How many to buy; 1 when the note does not say")
    var quantity: Int

    @Guide(description: "Aisle-level category such as produce, dairy, pantry or household")
    var category: String
}

@Generable
struct ShoppingList {
    @Guide(description: "Every item mentioned in the note, in the order it was said")
    var items: [ShoppingItem]
}
```

## Integration notes
- Add ShoppingModels.swift first; the other two files refer to its types.
- ShoppingItem here mirrors your existing struct exactly - same name, same three fields, same
  types - so mapping into your SwiftData store is a field-by-field copy with no translation layer.
- Create the session once per screen and keep it in the view model; do not build one per tap.
- Bind the view to the PartiallyGenerated snapshot so rows appear as they arrive.

## Limits and risks
- The context window is 4,096 tokens shared by instructions, prompt and output. A dictated note
  over roughly 2,000 words will not leave room for the list; cap or chunk the note before sending.
- FoundationModels requires iOS 26 or later and an eligible device with Apple Intelligence
  switched on. Every other case must show the fallback branch, not an empty screen.
- Quantities dictated as words ("a couple") are interpreted by the model, not parsed; check the
  numbers before writing them into the store.

Parse the reply

A parser is about sixty lines: match the header lines, accumulate SUMMARY: until the first blank line, open a new file on ## File N: Name.swift - role, capture everything between that section's ```swift and its closing ``` as the file's code, switch to a trailing section on any other ## heading, and collect its - bullets. Then check the contract before you trust the result: FIT: is one of the three exact strings, FILES: equals the number of sections, the numbers run 1..N, every filename ends in .swift, every fence is closed, and FILES: 0 appears only with Poor fit.

# The document is already readable, so shell-side "parsing" is mostly slicing.
sed -n '1,5p' scaffold.md                                 # the header block

grep -m1 '^FIT: ' scaffold.md | cut -d' ' -f2-            # Strong fit | Workable | Poor fit
grep -m1 '^FILES: ' scaffold.md | cut -d' ' -f2           # bare integer, 0 only with Poor fit
grep -m1 '^CONFIDENCE: ' scaffold.md | cut -d' ' -f2      # bare integer 0-100
grep '^## File ' scaffold.md | sed 's/^## //'             # every file heading

# split the reply into real .swift files, one per section
awk '
  /^## File [0-9]+: / { name=$0
                        sub(/^## File [0-9]+: /, "", name)
                        sub(/ - .*$/, "", name)
                        next }
  /^```swift$/        { inblock=1; next }
  /^```$/             { inblock=0; next }
  inblock && name     { print > name }
' scaffold.md

# the two trailing sections
sed -n '/^## Integration notes$/,/^## /p' scaffold.md | sed '1d;$d' | sed '/^$/d'
sed -n '/^## Limits and risks$/,$p'       scaffold.md | sed '1d'   | sed '/^$/d'

# contract checks - refuse a reply that fails one of these
DECLARED=$(grep -m1 '^FILES: ' scaffold.md | cut -d' ' -f2)
ACTUAL=$(grep -c '^## File ' scaffold.md)
[ "$DECLARED" = "$ACTUAL" ] || echo "broken: FILES: $DECLARED but $ACTUAL sections"
[ "$(grep -c '^```swift$' scaffold.md)" = "$ACTUAL" ] || echo "broken: fence count"
grep -qx 'FIT: Poor fit' scaffold.md && [ "$DECLARED" != "0" ] \
  && echo "broken: Poor fit must ship FILES: 0"
grep -q '^## Integration notes$' scaffold.md || echo "broken: no Integration notes"
grep -q '^## Limits and risks$'  scaffold.md || echo "broken: no Limits and risks"
import re

FILE_RE = re.compile(r"^##\s+File\s+(\d+):\s*(\S+\.swift)\s*-\s*(.*)$", re.I)
TAIL = ("Integration notes", "Limits and risks")
FITS = ("Strong fit", "Workable", "Poor fit")


def _unwrap(text):
    """Drop a stray OUTER code fence, but never the inner ```swift fences."""
    t = text.strip()
    first, _, rest = t.partition("\n")
    if first.startswith("```") and "FEATURE:" not in first:
        t = rest.rstrip()
        if t.endswith("```"):
            t = t[:-3]
    return t.strip()


def parse_scaffold(text):
    head, summary, files, tail = {}, [], [], {}
    mode = None      # None | "summary" | "file" | a trailing-section name
    code = None      # line buffer while inside a ```swift fence

    for line in _unwrap(text).splitlines():
        if code is not None:
            if line.strip() == "```":
                files[-1]["code"] = "\n".join(code)
                code = None
            else:
                code.append(line)
            continue

        m = re.match(r"^(FEATURE|FIT|FILES|CONFIDENCE)\s*:\s*(.*)$", line)
        if m:
            head[m.group(1).lower()] = m.group(2).strip()
            mode = None
            continue
        m = re.match(r"^SUMMARY\s*:\s*(.*)$", line)
        if m:
            summary.append(m.group(1).strip())
            mode = "summary"
            continue
        m = FILE_RE.match(line)
        if m:
            files.append({"n": int(m.group(1)), "name": m.group(2).strip(),
                          "role": m.group(3).strip(), "code": ""})
            mode = "file"
            continue
        m = re.match(r"^##\s+(.*?)\s*$", line)
        if m:
            tail[m.group(1)] = []
            mode = m.group(1)
            continue

        if mode == "summary":
            if not line.strip():
                mode = None
            else:
                summary.append(line.strip())
            continue
        if mode == "file" and line.strip().startswith("```"):
            if line.strip().lower() != "```swift":
                raise ValueError("file %d opens a fence that is not swift" % files[-1]["n"])
            code = []
            continue
        if mode in tail and line.startswith("- "):
            tail[mode].append(line[2:].strip())

    head["confidence"] = int(head["confidence"])
    head["declared"] = int(head["files"])
    head["summary"] = " ".join(summary).strip()

    # contract checks - a reply that fails one of these is not usable
    if code is not None:
        raise ValueError("a swift fence was never closed - the reply is truncated")
    if head.get("fit") not in FITS:
        raise ValueError("FIT is %r, not one of %s" % (head.get("fit"), ", ".join(FITS)))
    if head["declared"] != len(files):
        raise ValueError("FILES says %d, %d sections follow" % (head["declared"], len(files)))
    if [f["n"] for f in files] != list(range(1, len(files) + 1)):
        raise ValueError("file numbers are not 1..N with no gaps")
    if (head["declared"] == 0) != (head["fit"] == "Poor fit"):
        raise ValueError("FILES: 0 is legitimate only with FIT: Poor fit")
    for f in files:
        if not f["name"].lower().endswith(".swift"):
            raise ValueError("file %d is not named *.swift" % f["n"])
        if not f["code"].strip():
            raise ValueError("file %d has an empty swift fence" % f["n"])
        if any(l.strip() in ("...", "// ...") for l in f["code"].splitlines()):
            raise ValueError("file %d contains a placeholder ellipsis" % f["n"])
    for name in TAIL:
        if name not in tail:
            raise ValueError("missing section: " + name)
        if not tail[name]:
            raise ValueError("section is empty: " + name)

    return head, files, tail
const FILE_RE = /^##\s+File\s+(\d+):\s*(\S+\.swift)\s*-\s*(.*)$/i;
const TAIL = ["Integration notes", "Limits and risks"];
const FITS = ["Strong fit", "Workable", "Poor fit"];

// Drop a stray OUTER code fence, but never the inner ```swift fences.
function unwrap(text) {
  let t = text.trim();
  const nl = t.indexOf("\n");
  const first = nl === -1 ? t : t.slice(0, nl);
  if (first.startsWith("```") && !first.includes("FEATURE:")) {
    t = t.slice(nl + 1).trimEnd();
    if (t.endsWith("```")) t = t.slice(0, -3);
  }
  return t.trim();
}

function parseScaffold(text) {
  const head = {}, files = [], tail = {}, summary = [];
  let mode = null;   // null | "summary" | "file" | a trailing-section name
  let code = null;   // line buffer while inside a ```swift fence

  for (const line of unwrap(text).split(/\r?\n/)) {
    if (code !== null) {
      if (line.trim() === "```") { files[files.length - 1].code = code.join("\n"); code = null; }
      else code.push(line);
      continue;
    }

    let m = /^(FEATURE|FIT|FILES|CONFIDENCE)\s*:\s*(.*)$/.exec(line);
    if (m) { head[m[1].toLowerCase()] = m[2].trim(); mode = null; continue; }
    m = /^SUMMARY\s*:\s*(.*)$/.exec(line);
    if (m) { summary.push(m[1].trim()); mode = "summary"; continue; }
    m = FILE_RE.exec(line);
    if (m) {
      files.push({ n: Number(m[1]), name: m[2].trim(), role: m[3].trim(), code: "" });
      mode = "file";
      continue;
    }
    m = /^##\s+(.*?)\s*$/.exec(line);
    if (m) { tail[m[1]] = []; mode = m[1]; continue; }

    if (mode === "summary") {
      if (!line.trim()) mode = null;
      else summary.push(line.trim());
      continue;
    }
    if (mode === "file" && line.trim().startsWith("```")) {
      if (line.trim().toLowerCase() !== "```swift") {
        throw new Error(`file ${files[files.length - 1].n} opens a non-swift fence`);
      }
      code = [];
      continue;
    }
    if (tail[mode] && line.startsWith("- ")) tail[mode].push(line.slice(2).trim());
  }

  const declared = Number(head.files);
  const fit = head.fit;

  // contract checks - a reply that fails one of these is not usable
  if (code !== null) throw new Error("a swift fence was never closed - the reply is truncated");
  if (!FITS.includes(fit)) throw new Error(`FIT is "${fit}", not one of ${FITS.join(", ")}`);
  if (declared !== files.length) {
    throw new Error(`FILES says ${declared}, ${files.length} sections follow`);
  }
  if ((declared === 0) !== (fit === "Poor fit")) {
    throw new Error("FILES: 0 is legitimate only with FIT: Poor fit");
  }
  files.forEach((f, i) => {
    if (f.n !== i + 1) throw new Error("file numbers are not 1..N with no gaps");
    if (!/\.swift$/i.test(f.name)) throw new Error(`file ${f.n} is not named *.swift`);
    if (!f.code.trim()) throw new Error(`file ${f.n} has an empty swift fence`);
    if (f.code.split("\n").some((l) => l.trim() === "..." || l.trim() === "// ...")) {
      throw new Error(`file ${f.n} contains a placeholder ellipsis`);
    }
  });
  for (const name of TAIL) {
    if (!tail[name]) throw new Error("missing section: " + name);
    if (!tail[name].length) throw new Error("section is empty: " + name);
  }

  return {
    feature: head.feature, fit, confidence: Number(head.confidence), declared,
    summary: summary.join(" ").trim(),
    files,
    notes: tail["Integration notes"],
    limits: tail["Limits and risks"],
  };
}
type SwiftFile struct {
	N          int
	Name, Role string
	Code       string
}

type Scaffold struct {
	Feature, Fit, Summary string
	Confidence, Declared  int
	SwiftFiles            []SwiftFile
	Notes, Limits         []string
}

var headRe = regexp.MustCompile(`^(FEATURE|FIT|FILES|CONFIDENCE|SUMMARY):\s*(.*)$`)
var fileRe = regexp.MustCompile(`^##\s+File\s+(\d+):\s*(\S+\.swift)\s*-\s*(.*)$`)

var fits = []string{"Strong fit", "Workable", "Poor fit"}

// unwrap drops a stray OUTER code fence, but never the inner ```swift fences.
func unwrap(text string) string {
	t := strings.TrimSpace(text)
	first := t
	if i := strings.Index(t, "\n"); i >= 0 {
		first = t[:i]
		if strings.HasPrefix(first, "```") && !strings.Contains(first, "FEATURE:") {
			t = strings.TrimRight(t[i+1:], "\n \t")
			t = strings.TrimSuffix(t, "```")
		}
	}
	return strings.TrimSpace(t)
}

func parseScaffold(text string) Scaffold {
	s := Scaffold{}
	var summary, code []string
	inFence := false
	mode := "" // "" | "summary" | "file" | "notes" | "limits"

	for _, line := range strings.Split(unwrap(text), "\n") {
		if inFence {
			if strings.TrimSpace(line) == "```" {
				s.SwiftFiles[len(s.SwiftFiles)-1].Code = strings.Join(code, "\n")
				code = nil
				inFence = false
			} else {
				code = append(code, line)
			}
			continue
		}

		if h := headRe.FindStringSubmatch(line); h != nil {
			v := strings.TrimSpace(h[2])
			mode = ""
			switch h[1] {
			case "FEATURE":
				s.Feature = v
			case "FIT":
				s.Fit = v
			case "FILES":
				s.Declared, _ = strconv.Atoi(v)
			case "CONFIDENCE":
				s.Confidence, _ = strconv.Atoi(v)
			case "SUMMARY":
				summary = append(summary, v)
				mode = "summary"
			}
			continue
		}
		if f := fileRe.FindStringSubmatch(line); f != nil {
			n, _ := strconv.Atoi(f[1])
			s.SwiftFiles = append(s.SwiftFiles, SwiftFile{
				N: n, Name: strings.TrimSpace(f[2]), Role: strings.TrimSpace(f[3])})
			mode = "file"
			continue
		}
		if line == "## Integration notes" {
			mode = "notes"
			continue
		}
		if line == "## Limits and risks" {
			mode = "limits"
			continue
		}
		if mode == "summary" {
			if strings.TrimSpace(line) == "" {
				mode = ""
			} else {
				summary = append(summary, strings.TrimSpace(line))
			}
			continue
		}
		if mode == "file" && strings.HasPrefix(strings.TrimSpace(line), "```") {
			if strings.TrimSpace(line) != "```swift" {
				log.Fatal("a file section opens a fence that is not swift")
			}
			inFence = true
			continue
		}
		if strings.HasPrefix(line, "- ") {
			item := strings.TrimSpace(line[2:])
			switch mode {
			case "notes":
				s.Notes = append(s.Notes, item)
			case "limits":
				s.Limits = append(s.Limits, item)
			}
		}
	}
	s.Summary = strings.Join(summary, " ")

	// contract checks - a reply that fails one of these is not usable
	if inFence {
		log.Fatal("a swift fence was never closed - the reply is truncated")
	}
	okFit := false
	for _, f := range fits {
		if s.Fit == f {
			okFit = true
		}
	}
	if !okFit {
		log.Fatalf("FIT is %q, not one of %s", s.Fit, strings.Join(fits, ", "))
	}
	if s.Declared != len(s.SwiftFiles) {
		log.Fatalf("FILES says %d, %d sections follow", s.Declared, len(s.SwiftFiles))
	}
	if (s.Declared == 0) != (s.Fit == "Poor fit") {
		log.Fatal("FILES: 0 is legitimate only with FIT: Poor fit")
	}
	for i, f := range s.SwiftFiles {
		if f.N != i+1 {
			log.Fatal("file numbers are not 1..N with no gaps")
		}
		if !strings.HasSuffix(strings.ToLower(f.Name), ".swift") {
			log.Fatalf("file %d is not named *.swift", f.N)
		}
		if strings.TrimSpace(f.Code) == "" {
			log.Fatalf("file %d has an empty swift fence", f.N)
		}
	}
	if len(s.Notes) == 0 {
		log.Fatal("missing or empty section: Integration notes")
	}
	if len(s.Limits) == 0 {
		log.Fatal("missing or empty section: Limits and risks")
	}
	return s
}
// Java 17+ - a compact regex pass rather than a line machine.
// record SwiftFile(int n, String name, String role, String code) {}
static final Pattern HEAD_RE =
    Pattern.compile("^(FEATURE|FIT|FILES|CONFIDENCE):\\s*(.*)$", Pattern.MULTILINE);
static final Pattern FILE_BLOCK = Pattern.compile(
    "^## File (\\d+): (\\S+\\.swift) - (.*)$\\R```swift\\R(.*?)\\R```\\s*$",
    Pattern.MULTILINE | Pattern.DOTALL);
static final Pattern SUMMARY_RE =
    Pattern.compile("^SUMMARY:\\s*(.*?)(?:\\R\\R|\\z)", Pattern.MULTILINE | Pattern.DOTALL);
static final List<String> FITS = List.of("Strong fit", "Workable", "Poor fit");

static Map<String, Object> parseScaffold(String text) {
    var head = new LinkedHashMap<String, Object>();
    var m = HEAD_RE.matcher(text);
    while (m.find()) head.put(m.group(1).toLowerCase(), m.group(2).strip());

    var sm = SUMMARY_RE.matcher(text);
    head.put("summary", sm.find() ? sm.group(1).replaceAll("\\R", " ").strip() : "");

    var files = new ArrayList<Map<String, Object>>();
    var fm = FILE_BLOCK.matcher(text);
    while (fm.find()) {
        files.add(Map.of("n", Integer.parseInt(fm.group(1)), "name", fm.group(2),
                         "role", fm.group(3).strip(), "code", fm.group(4)));
    }

    head.put("notes", bullets(text, "## Integration notes", "## Limits and risks"));
    head.put("limits", bullets(text, "## Limits and risks", null));

    // contract checks - a reply that fails one of these is not usable
    String fit = (String) head.get("fit");
    if (!FITS.contains(fit)) throw new IllegalStateException("FIT is not one of the three verdicts");
    int declared = Integer.parseInt((String) head.get("files"));
    if (declared != files.size())
        throw new IllegalStateException("FILES says " + declared + ", " + files.size() + " follow");
    if ((declared == 0) != fit.equals("Poor fit"))
        throw new IllegalStateException("FILES: 0 is legitimate only with FIT: Poor fit");
    for (int i = 0; i < files.size(); i++)
        if ((int) files.get(i).get("n") != i + 1)
            throw new IllegalStateException("file numbers are not 1..N with no gaps");
    if (((List<?>) head.get("notes")).isEmpty() || ((List<?>) head.get("limits")).isEmpty())
        throw new IllegalStateException("a trailing section is missing or empty");

    head.put("declared", declared);
    head.put("confidence", Integer.parseInt((String) head.get("confidence")));
    head.put("files", files);
    return head;
}

// Collect the "- " bullets between one heading and the next (or end of text).
static List<String> bullets(String text, String from, String until) {
    int a = text.indexOf(from);
    if (a < 0) return List.of();
    a += from.length();
    int b = until == null ? text.length() : text.indexOf(until, a);
    if (b < 0) b = text.length();
    var out = new ArrayList<String>();
    for (String line : text.substring(a, b).split("\\R"))
        if (line.startsWith("- ")) out.add(line.substring(2).strip());
    return out;
}
# A compact regex pass rather than a line machine.
FITS = ["Strong fit", "Workable", "Poor fit"].freeze
FILE_BLOCK = /^\#\# File (\d+): (\S+\.swift) - (.*)$\n```swift\n(.*?)\n```$/m.freeze

def parse_scaffold(text)
  s = {}
  %w[FEATURE FIT FILES CONFIDENCE].each do |key|
    s[key.downcase.to_sym] = text[/^#{key}:\s*(.*)$/, 1].to_s.strip
  end
  s[:confidence] = s[:confidence].to_i
  s[:declared] = s[:files].to_i
  s[:summary] = text[/^SUMMARY:\s*(.*?)(?:\n\n|\z)/m, 1].to_s.gsub(/\s+/, " ").strip

  s[:files] = text.scan(FILE_BLOCK).map do |n, name, role, code|
    { n: n.to_i, name: name, role: role.strip, code: code }
  end

  s[:notes]  = bullets(text, "## Integration notes", "## Limits and risks")
  s[:limits] = bullets(text, "## Limits and risks", nil)

  # contract checks - a reply that fails one of these is not usable
  raise "FIT is #{s[:fit].inspect}" unless FITS.include?(s[:fit])
  raise "FILES says #{s[:declared]}, #{s[:files].size} sections follow" if s[:declared] != s[:files].size
  raise "FILES: 0 is legitimate only with Poor fit" if (s[:declared] == 0) != (s[:fit] == "Poor fit")
  s[:files].each_with_index do |f, i|
    raise "file numbers are not 1..N with no gaps" if f[:n] != i + 1
    raise "file #{f[:n]} has an empty swift fence" if f[:code].strip.empty?
  end
  raise "missing or empty Integration notes" if s[:notes].empty?
  raise "missing or empty Limits and risks" if s[:limits].empty?
  s
end

def bullets(text, from, until_heading)
  a = text.index(from)
  return [] if a.nil?
  a += from.length
  b = until_heading ? (text.index(until_heading, a) || text.length) : text.length
  text[a...b].lines.map(&:chomp).select { |l| l.start_with?("- ") }.map { |l| l[2..].strip }
end
<?php
// A compact regex pass rather than a line machine.
const ODF_FITS = ["Strong fit", "Workable", "Poor fit"];

function parse_scaffold(string $text): array {
    $s = [];
    foreach (["feature", "fit", "files", "confidence"] as $key) {
        preg_match('/^' . strtoupper($key) . ':\s*(.*)$/m', $text, $m);
        $s[$key] = trim($m[1] ?? "");
    }
    $s["confidence"] = (int) $s["confidence"];
    $s["declared"] = (int) $s["files"];

    preg_match('/^SUMMARY:\s*(.*?)(?:\n\n|\z)/ms', $text, $m);
    $s["summary"] = trim(preg_replace('/\s+/', " ", $m[1] ?? ""));

    preg_match_all(
        '/^\#\# File (\d+): (\S+\.swift) - (.*)$\n```swift\n(.*?)\n```$/ms',
        $text, $blocks, PREG_SET_ORDER);
    $s["files"] = array_map(fn($b) => [
        "n" => (int) $b[1], "name" => $b[2], "role" => trim($b[3]), "code" => $b[4],
    ], $blocks);

    $s["notes"]  = odf_bullets($text, "## Integration notes", "## Limits and risks");
    $s["limits"] = odf_bullets($text, "## Limits and risks", null);

    // contract checks - a reply that fails one of these is not usable
    if (!in_array($s["fit"], ODF_FITS, true)) { throw new Exception("FIT is {$s['fit']}"); }
    if ($s["declared"] !== count($s["files"])) {
        throw new Exception("FILES says {$s['declared']}, " . count($s["files"]) . " sections follow");
    }
    if (($s["declared"] === 0) !== ($s["fit"] === "Poor fit")) {
        throw new Exception("FILES: 0 is legitimate only with FIT: Poor fit");
    }
    foreach ($s["files"] as $i => $f) {
        if ($f["n"] !== $i + 1) { throw new Exception("file numbers are not 1..N with no gaps"); }
        if (trim($f["code"]) === "") { throw new Exception("file {$f['n']} has an empty fence"); }
    }
    if (!$s["notes"] || !$s["limits"]) { throw new Exception("a trailing section is missing or empty"); }
    return $s;
}

function odf_bullets(string $text, string $from, ?string $until): array {
    $a = strpos($text, $from);
    if ($a === false) { return []; }
    $a += strlen($from);
    $b = $until === null ? strlen($text) : (strpos($text, $until, $a) ?: strlen($text));
    $out = [];
    foreach (preg_split('/\R/', substr($text, $a, $b - $a)) as $line) {
        if (str_starts_with($line, "- ")) { $out[] = trim(substr($line, 2)); }
    }
    return $out;
}
// .NET 8+ - a compact regex pass rather than a line machine.
record SwiftFile(int N, string Name, string Role, string Code);

record Scaffold(string Feature, string Fit, int Confidence, int Declared, string Summary,
                List<SwiftFile> Files, List<string> Notes, List<string> Limits);

static readonly string[] Fits = { "Strong fit", "Workable", "Poor fit" };

static Scaffold ParseScaffold(string text)
{
    string Head(string key) =>
        Regex.Match(text, $"^{key}:\\s*(.*)$", RegexOptions.Multiline).Groups[1].Value.Trim();

    var summary = Regex.Match(text, @"^SUMMARY:\s*(.*?)(?:\r?\n\r?\n|\z)",
        RegexOptions.Multiline | RegexOptions.Singleline).Groups[1].Value;
    summary = Regex.Replace(summary, @"\s+", " ").Trim();

    var files = Regex.Matches(text,
            @"^\#\# File (\d+): (\S+\.swift) - (.*)$\r?\n```swift\r?\n(.*?)\r?\n```$",
            RegexOptions.Multiline | RegexOptions.Singleline)
        .Select(m => new SwiftFile(int.Parse(m.Groups[1].Value), m.Groups[2].Value,
                                   m.Groups[3].Value.Trim(), m.Groups[4].Value))
        .ToList();

    var notes = Bullets(text, "## Integration notes", "## Limits and risks");
    var limits = Bullets(text, "## Limits and risks", null);

    var fit = Head("FIT");
    var declared = int.Parse(Head("FILES"));

    // contract checks - a reply that fails one of these is not usable
    if (!Fits.Contains(fit)) throw new Exception($"FIT is \"{fit}\", not one of the three verdicts");
    if (declared != files.Count) throw new Exception($"FILES says {declared}, {files.Count} follow");
    if ((declared == 0) != (fit == "Poor fit"))
        throw new Exception("FILES: 0 is legitimate only with FIT: Poor fit");
    for (var i = 0; i < files.Count; i++)
    {
        if (files[i].N != i + 1) throw new Exception("file numbers are not 1..N with no gaps");
        if (files[i].Code.Trim().Length == 0)
            throw new Exception($"file {files[i].N} has an empty swift fence");
    }
    if (notes.Count == 0 || limits.Count == 0)
        throw new Exception("a trailing section is missing or empty");

    return new Scaffold(Head("FEATURE"), fit, int.Parse(Head("CONFIDENCE")), declared,
                        summary, files, notes, limits);
}

// Collect the "- " bullets between one heading and the next (or end of text).
static List<string> Bullets(string text, string from, string? until)
{
    var a = text.IndexOf(from, StringComparison.Ordinal);
    if (a < 0) return new();
    a += from.Length;
    var b = until is null ? text.Length : text.IndexOf(until, a, StringComparison.Ordinal);
    if (b < 0) b = text.Length;
    return text[a..b].Split('\n')
        .Select(l => l.TrimEnd('\r'))
        .Where(l => l.StartsWith("- "))
        .Select(l => l[2..].Trim())
        .ToList();
}

This is AI-generated Swift built from text you supplied, not reviewed code and not a commitment. Read FIT: and CONFIDENCE: first, then ## Limits and risks — a low confidence usually means the deployment floor or the data shapes were never stated, and a Poor fit verdict is the most useful thing this app can tell you. Build the files in Xcode and test on a real, eligible device before anything ships: an on-device model's output is not deterministic, and the availability branches only prove themselves on hardware.

Step 5 — Stream the scaffold 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 four Swift files is a long document. 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. An Idempotency-Key header is supported here too, and recommended.

EventPayloadMeaning
job{job_id, status}Sent once, when the job is accepted — show "starting".
delta{text}A chunk of the document, in order. Append it; the accumulated length is your only progress signal (the total is not known in advance). Because the reply is plain text, the partial document is already readable — the FIT: and FILES: lines arrive in the first few deltas, so you can show the verdict immediately and then count ## File headings against that number as they land. That is exactly what the app's own stage list does.
done{job_id, status, charged_credits, truncated, output}The final, authoritative result — read the document from output.output rather than trusting concatenated deltas (the SSE tail can drop), the settled price from charged_credits, and check truncated before parsing.
error{code, message}Replaces done when the run fails.
# -N disables buffering so events print as they arrive
curl -N -s -X POST "$API/run-stream" \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: odf-$(date +%s)" \
  -d @input.json

# event: job
# data: {"job_id":"job_...","status":"running"}
#
# event: delta
# data: {"text":"FEATURE: Turn a dictated run-on shopping note"}
# ...
# event: done
# data: {"job_id":"job_...","status":"succeeded","charged_credits":312,"truncated":false,"output":{"output":"FEATURE: ..."}}
import json, requests

result = None
with requests.post(
    API + "/run-stream",
    headers={"Authorization": f"Bearer {TOKEN}",
             "Idempotency-Key": "odf-001"},
    json=payload,
    stream=True,
) as r:
    r.raise_for_status()
    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(data.get("message", "run failed"))

scaffold_text = result["output"]["output"]                # authoritative, plain text
print("\ncharged:", result["charged_credits"], "truncated:", result.get("truncated"))
head, files, tail = parse_scaffold(scaffold_text)
print(head["feature"], "-", head["fit"], head["confidence"])
for f in files:
    print(f'  File {f["n"]}: {f["name"]}  ({f["role"]})')
with open("scaffold.md", "w", encoding="utf-8") as fh:
    fh.write(scaffold_text)
const res = await fetch(API + "/run-stream", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${TOKEN}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify(payload),
});

const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "", done = null;

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.message ?? "run failed");
  }
}

const scaffoldText = done.output.output;               // authoritative, plain text
const scaffold = parseScaffold(scaffoldText);
console.log(`\n${done.charged_credits} credits - ${scaffold.fit} [${scaffold.declared} files]`);
for (const f of scaffold.files) {
  console.log(`  File ${f.n}: ${f.name}  (${f.role})`);
}
writeFileSync("scaffold.md", scaffoldText);
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", "odf-001")

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

var event string
var final map[string]any
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.Fatal(data["message"])
		}
	}
}

// The scaffold is plain text at final["output"]["output"] - no JSON parse.
scaffoldText := final["output"].(map[string]any)["output"].(string)
os.WriteFile("scaffold.md", []byte(scaffoldText), 0o644)
s := parseScaffold(scaffoldText)
fmt.Printf("\n%s [%s, %d files, confidence %d]\n", s.Feature, s.Fit, s.Declared, s.Confidence)
for _, f := range s.SwiftFiles {
	fmt.Printf("  File %d: %s  (%s)\n", f.N, f.Name, f.Role)
}
// 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", "odf-001")
    .POST(HttpRequest.BodyPublishers.ofString(jsonPayload))
    .build();

var res = HTTP.send(req, HttpResponse.BodyHandlers.ofLines());
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` as JSON; data.output.output is the scaffold as PLAIN TEXT.
// Feed it to parseScaffold() from the previous section, then:
//   Files.writeString(Path.of("scaffold.md"), scaffoldText);
//   for each file: Files.writeString(Path.of(file.name()), file.code());
require "net/http"
require "json"

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

event = nil
done = nil
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
  http.request(req) do |res|
    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["message"] || "run failed")
          end
        end
      end
    end
  end
end

scaffold_text = done["output"]["output"]       # authoritative, plain text
File.write("scaffold.md", scaffold_text)
s = parse_scaffold(scaffold_text)
puts "\n#{done["charged_credits"]} credits - #{s[:fit]} [#{s[:declared]} files]"
s[:files].each { |f| puts "  File #{f[:n]}: #{f[:name]}  (#{f[:role]})" }
$event = null;
$done  = null;

$ch = curl_init(API . "/run-stream");
curl_setopt_array($ch, [
    CURLOPT_POST       => true,
    CURLOPT_HTTPHEADER => [
        "Authorization: Bearer $TOKEN",
        "Content-Type: application/json",
        "Idempotency-Key: odf-001",
    ],
    CURLOPT_POSTFIELDS => json_encode($payload),
    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["message"] ?? "run failed"); }
            }
        }
        return strlen($chunk);
    },
]);
curl_exec($ch);
curl_close($ch);

$scaffoldText = $done["output"]["output"];     // authoritative, plain text
file_put_contents("scaffold.md", $scaffoldText);
$s = parse_scaffold($scaffoldText);
echo "\n{$done['charged_credits']} credits - {$s['fit']} [{$s['declared']} files]\n";
foreach ($s["files"] as $f) {
    echo "  File {$f['n']}: {$f['name']}  ({$f['role']})\n";
}
var req = new HttpRequestMessage(HttpMethod.Post, Api + "/run-stream") {
    Content = JsonContent.Create(payload),
};
req.Headers.Add("Idempotency-Key", "odf-001");

using var res = await Http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());

string? evt = null, done = null;
while (await reader.ReadLineAsync() is { } line)
{
    if (line.StartsWith("event:")) evt = line[6..].Trim();
    else if (line.StartsWith("data:"))
    {
        var data = line[5..].Trim();
        if (evt == "delta") Console.Write(".");            // live progress
        else if (evt == "done") done = data;
        else if (evt == "error") throw new Exception(data);
    }
}

using var final = JsonDocument.Parse(done!);
// plain text, not JSON
var scaffoldText = final.RootElement.GetProperty("output").GetProperty("output").GetString()!;
await File.WriteAllTextAsync("scaffold.md", scaffoldText);

var s = ParseScaffold(scaffoldText);
Console.WriteLine($"\n{s.Feature} [{s.Fit}, {s.Declared} files, confidence {s.Confidence}]");
foreach (var f in s.Files)
    Console.WriteLine($"  File {f.N}: {f.Name}  ({f.Role})");

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 above does. On an idempotent replay the server may answer with a plain JSON envelope instead of an event stream; check the Content-Type before you start parsing frames.