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.
| Status | Meaning |
|---|---|
400 | Malformed JSON body, or material missing entirely. |
401 | Missing or expired token — create a new session. |
402 | Not enough credits — top up at skillsafe.ai/account/credits. |
403 | The token isn't allowed to do this (e.g. a guest submitting a very large paste). |
404 | Unknown job id. |
409 | An Idempotency-Key you already used, replayed with a different body. Mint a new key for a genuinely new run. |
429 | Too many runs in flight — back off and retry. |
5xx | Transient 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
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
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
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 field | Meaning |
|---|---|
hold_credits | The worst-case reserve taken while the job runs. A reserve, not a quote. |
min_credits | The floor a run of this shape cannot come in under. |
model | The concrete model currently bound to this app. |
model_alias | The stable alias the app asks for; the concrete model behind it can change. |
markup_bps | The app's markup in basis points, already folded into the numbers above. |
The input fields
| Input field | Type | Notes |
|---|---|---|
material | string, required | The 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. |
context | string, optional | The 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. |
facts | string, optional | Plain 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_note | string, optional | Reserved — 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
/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
| Line | Value |
|---|---|
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
| Verdict | What it means |
|---|---|
Strong fit | Bounded 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. |
Workable | Achievable, 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 fit | The 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
| Element | Detail |
|---|---|
## 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 block | Each 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 set | The @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 rule | How the scaffold honours it |
|---|---|
| Availability | SystemLanguageModel.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. |
| Instructions | Sessions are created with instructions: that set the model's role and its output discipline. Instructions are never concatenated into the prompt. |
| Structured output | Results come back through @Generable types with @Guide descriptions on the fields — never string parsing of free text, never a hand-rolled JSON decode. |
| Snapshot streaming | Progressive UI uses streamResponse(to:generating:), with the type's PartiallyGenerated form driving @State so the view fills in as fields arrive. |
| One request per session | isResponding is respected: a second request is never issued into a session that is still answering. |
| Reading the result | Values are read from response.content — never from .output. |
| Token budget | The 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
| Rule | Detail |
|---|---|
## Integration notes | Always 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 risks | Always 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 bullet | Each 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 rule | FILES: 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 branch | 0 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
/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.
| Event | Payload | Meaning |
|---|---|---|
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.