Story Forge — API

Post the feature brief, get the stories.

API tokens Open the app

Write user stories from your own tools

Send whatever the discussion has produced — a feature description, a PRD fragment, design notes, the thread where the feature was argued out, a list of requirements — and get back one plain-text document in a fixed shape: the feature named, the product named, how many stories follow, a confidence number, a two-to-four-sentence summary, then one ## Story N section per slice of user value, each carrying an As a …, I want …, so that … description, a design reference and three to six acceptance criteria a tester can check without asking what the author meant, and finally the open questions the brief leaves unanswered and the sequencing notes for the sprint. Nothing is invented to fill a gap: a limit the brief does not state is not written, a design link is never fabricated, a persona the brief never names is never introduced, and a paste with no feature in it comes back as STORIES: 0 with the questions a usable brief would have to answer. Everything this app does goes through the SkillSafe App API — plain JSON over HTTPS — so you can wire it to the planning doc, run it over a folder of briefs, or push the stories straight onto the board. 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":"story-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: {"data": …} on success, {"error": {"code", "message"}} on failure. The stories are 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 paste of a brief in, one backlog 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 brief).
404Unknown job id.
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 {"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:
        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) 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 {
		Data  json.RawMessage `json:"data"`
		Error *struct{ Message string `json:"message"` } `json:"error"`
	}
	json.NewDecoder(res.Body).Decode(&env)
	if res.StatusCode >= 400 {
		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: {"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) {
        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 story 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":"story-forge"}' | jq -r '.data.token'
token = api("POST", "/guest", {"slug": "story-forge"})["token"]
const { token } = await api("POST", "/guest", { slug: "story-forge" });
var guest struct{ Token string `json:"token"` }
err := call("POST", "/guest", map[string]string{"slug": "story-forge"}, &guest)
String envelope = api("POST", "/guest", """
    {"slug":"story-forge"}""");
// token is at data.token in the returned JSON
token = api("POST", "/guest", { slug: "story-forge" })["token"]
$token = api("POST", "/guest", ["slug" => "story-forge"])["token"];
var guest = await SkillSafe.ApiAsync(HttpMethod.Post, "/guest",
    new { slug = "story-forge" });
var token = guest.GetProperty("token").GetString();

The app stores this browser's token under the localStorage key skillsafe_app_token:story-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 planning document.

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 worst-case cost. Nothing is charged and no job is created, so estimating is free — useful when you are piping a long brief in and want a ceiling before spending credits. The input object is the request body itself — it is not wrapped in {"input": …}.

Input fieldTypeNotes
materialstring, requiredThe feature brief as pasted: a feature description, a requirements list, a planning-doc section, design notes, the thread where the feature was discussed, user feedback. Messy, partial and out of order is fine. This is the model's only evidence — no ticket tracker, no design file, no wiki is read. If you clip a long brief, mark the cut in-band with [material truncated - N characters (~M lines) removed from the MIDDLE of the brief. The opening and the end of the paste are intact; the middle is missing, so treat anything it would have shown as unknown and raise it under Open questions.] so the stories report 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 brief carries its edge cases, its late decisions and its design links at the end. A head-only slice(0, 60000) would throw away exactly what the acceptance criteria are built from.
contextstring, optionalThe product's name, who its users are, key assumptions, links to design files, team conventions — anything the team knows that the brief does not say. It sharpens which product the stories are written for and which roles are real; it never licenses invention. 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: which role-shaped words appear, how many lines look requirement-shaped, how many concrete limits and question marks were found, how many design links were detected, and any wording a tester could not check. Pure pattern-matching, offered as a hint to cross-check against, never a verdict: where the scan and the material disagree, the material wins. 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 a story as a requirement, a role or a criterion. Leave it out of ordinary calls, and put anything you want the stories to reflect in context.

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

Mechanical scan of the pasted brief (pattern-matching, not judgement):
- 198 words over 11 non-empty lines.
- Role-shaped words found: shopper, shoppers, customers, guests.
- 7 lines match requirement patterns (must/should/so-that and similar), 3 concrete limits, 0 question marks.
- 2 design links or references detected.

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'
Feature brief - "Recently viewed" section for Northwind Supply

Customers keep telling us they lose track of the products they looked at earlier in the week and end up
searching for them all over again. The plan is a "Recently viewed" section listing the products a shopper
has opened, newest first, on the home page and at the foot of every product page. Mockups for both surfaces
are in Figma: https://figma.com/file/NW-recently-viewed. The section shows at most 10 items and hides itself
completely when there is nothing to show.

Open points from the thread: signed-in shoppers should see the same list on phone and laptop, but nobody has
decided what guests get - Priya thinks browser-local is fine, Marcus wants it kept for 30 days either way.
Shoppers also want to drop an item from the list; the mockup puts an x on each card but the thread never says
whether that hides it once or for good. Out-of-stock products still appear, greyed out, per the 14 March
design review.
MATERIAL

# the input object IS the body - no {"input": ...} wrapper
jq -n --rawfile material material.txt \
  '{material: $material,
    context: "Northwind Supply, a web shop for workshop and trade supplies; most shoppers are returning trade customers with accounts, and the team grooms in two-week sprints."}' > 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'
MATERIAL = """Feature brief - "Recently viewed" section for Northwind Supply

Customers keep telling us they lose track of the products they looked at earlier in the week and end up
searching for them all over again. The plan is a "Recently viewed" section listing the products a shopper
has opened, newest first, on the home page and at the foot of every product page. Mockups for both surfaces
are in Figma: https://figma.com/file/NW-recently-viewed. The section shows at most 10 items and hides itself
completely when there is nothing to show.

Open points from the thread: signed-in shoppers should see the same list on phone and laptop, but nobody has
decided what guests get - Priya thinks browser-local is fine, Marcus wants it kept for 30 days either way.
Shoppers also want to drop an item from the list; the mockup puts an x on each card but the thread never says
whether that hides it once or for good. Out-of-stock products still appear, greyed out, per the 14 March
design review.
"""

CONTEXT = ("Northwind Supply, a web shop for workshop and trade supplies; most shoppers are "
           "returning trade customers with accounts, and the team grooms in two-week sprints.")

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

est = api("POST", "/estimate", payload)
print("worst case:", est.get("hold_credits", est.get("credits")), "credits", "on", est.get("model"))
const material = [
  "Feature brief - \"Recently viewed\" section for Northwind Supply",
  "",
  "Customers keep telling us they lose track of the products they looked at earlier in the week and end up",
  "searching for them all over again. The plan is a \"Recently viewed\" section listing the products a shopper",
  "has opened, newest first, on the home page and at the foot of every product page. Mockups for both surfaces",
  "are in Figma: https://figma.com/file/NW-recently-viewed. The section shows at most 10 items and hides itself",
  "completely when there is nothing to show.",
  "",
  "Open points from the thread: signed-in shoppers should see the same list on phone and laptop, but nobody has",
  "decided what guests get - Priya thinks browser-local is fine, Marcus wants it kept for 30 days either way.",
  "Shoppers also want to drop an item from the list; the mockup puts an x on each card but the thread never says",
  "whether that hides it once or for good. Out-of-stock products still appear, greyed out, per the 14 March",
  "design review.",
].join("\n");

const context =
  "Northwind Supply, a web shop for workshop and trade supplies; most shoppers are returning " +
  "trade customers with accounts, and the team grooms in two-week sprints.";

// 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("worst case:", est.hold_credits ?? est.credits, "credits on", est.model);
const material = "Feature brief - \"Recently viewed\" section for Northwind Supply\n" +
	"\n" +
	"Customers keep telling us they lose track of the products they looked at earlier in the week and end up\n" +
	"searching for them all over again. The plan is a \"Recently viewed\" section listing the products a shopper\n" +
	"has opened, newest first, on the home page and at the foot of every product page. Mockups for both surfaces\n" +
	"are in Figma: https://figma.com/file/NW-recently-viewed. The section shows at most 10 items and hides itself\n" +
	"completely when there is nothing to show.\n" +
	"\n" +
	"Open points from the thread: signed-in shoppers should see the same list on phone and laptop, but nobody has\n" +
	"decided what guests get - Priya thinks browser-local is fine, Marcus wants it kept for 30 days either way.\n" +
	"Shoppers also want to drop an item from the list; the mockup puts an x on each card but the thread never says\n" +
	"whether that hides it once or for good. Out-of-stock products still appear, greyed out, per the 14 March\n" +
	"design review.\n"

const briefContext = "Northwind Supply, a web shop for workshop and trade supplies; most shoppers are " +
	"returning trade customers with accounts, and the team grooms in two-week sprints."

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

var est struct {
	HoldCredits int64  `json:"hold_credits"`
	Model       string `json:"model"`
}
err := call("POST", "/estimate", payload, &est)
String material = """
    Feature brief - "Recently viewed" section for Northwind Supply

    Customers keep telling us they lose track of the products they looked at earlier in the week and end up
    searching for them all over again. The plan is a "Recently viewed" section listing the products a shopper
    has opened, newest first, on the home page and at the foot of every product page. Mockups for both surfaces
    are in Figma: https://figma.com/file/NW-recently-viewed. The section shows at most 10 items and hides itself
    completely when there is nothing to show.

    Open points from the thread: signed-in shoppers should see the same list on phone and laptop, but nobody has
    decided what guests get - Priya thinks browser-local is fine, Marcus wants it kept for 30 days either way.
    Shoppers also want to drop an item from the list; the mockup puts an x on each card but the thread never says
    whether that hides it once or for good. Out-of-stock products still appear, greyed out, per the 14 March
    design review.
    """;

String briefContext = """
    Northwind Supply, a web shop for workshop and trade supplies; most shoppers are returning \
    trade customers with accounts, and the team grooms in two-week sprints.""";

// 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(briefContext));

String envelope = api("POST", "/estimate", jsonPayload);
// worst-case cost is at data.hold_credits
MATERIAL = <<~MATERIAL
  Feature brief - "Recently viewed" section for Northwind Supply

  Customers keep telling us they lose track of the products they looked at earlier in the week and end up
  searching for them all over again. The plan is a "Recently viewed" section listing the products a shopper
  has opened, newest first, on the home page and at the foot of every product page. Mockups for both surfaces
  are in Figma: https://figma.com/file/NW-recently-viewed. The section shows at most 10 items and hides itself
  completely when there is nothing to show.

  Open points from the thread: signed-in shoppers should see the same list on phone and laptop, but nobody has
  decided what guests get - Priya thinks browser-local is fine, Marcus wants it kept for 30 days either way.
  Shoppers also want to drop an item from the list; the mockup puts an x on each card but the thread never says
  whether that hides it once or for good. Out-of-stock products still appear, greyed out, per the 14 March
  design review.
MATERIAL

BRIEF_CONTEXT = "Northwind Supply, a web shop for workshop and trade supplies; most shoppers are " \
                "returning trade customers with accounts, and the team grooms in two-week sprints."

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

est = api("POST", "/estimate", payload)
puts "worst case: #{est["hold_credits"] || est["credits"]} credits on #{est["model"]}"
$material = <<<'MATERIAL'
Feature brief - "Recently viewed" section for Northwind Supply

Customers keep telling us they lose track of the products they looked at earlier in the week and end up
searching for them all over again. The plan is a "Recently viewed" section listing the products a shopper
has opened, newest first, on the home page and at the foot of every product page. Mockups for both surfaces
are in Figma: https://figma.com/file/NW-recently-viewed. The section shows at most 10 items and hides itself
completely when there is nothing to show.

Open points from the thread: signed-in shoppers should see the same list on phone and laptop, but nobody has
decided what guests get - Priya thinks browser-local is fine, Marcus wants it kept for 30 days either way.
Shoppers also want to drop an item from the list; the mockup puts an x on each card but the thread never says
whether that hides it once or for good. Out-of-stock products still appear, greyed out, per the 14 March
design review.
MATERIAL;

$briefContext = "Northwind Supply, a web shop for workshop and trade supplies; most shoppers are "
              . "returning trade customers with accounts, and the team grooms in two-week sprints.";

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

$est = api("POST", "/estimate", $payload);
echo "worst case: " . ($est["hold_credits"] ?? $est["credits"]) . " credits\n";
var material = """
    Feature brief - "Recently viewed" section for Northwind Supply

    Customers keep telling us they lose track of the products they looked at earlier in the week and end up
    searching for them all over again. The plan is a "Recently viewed" section listing the products a shopper
    has opened, newest first, on the home page and at the foot of every product page. Mockups for both surfaces
    are in Figma: https://figma.com/file/NW-recently-viewed. The section shows at most 10 items and hides itself
    completely when there is nothing to show.

    Open points from the thread: signed-in shoppers should see the same list on phone and laptop, but nobody has
    decided what guests get - Priya thinks browser-local is fine, Marcus wants it kept for 30 days either way.
    Shoppers also want to drop an item from the list; the mockup puts an x on each card but the thread never says
    whether that hides it once or for good. Out-of-stock products still appear, greyed out, per the 14 March
    design review.
    """;

var context = "Northwind Supply, a web shop for workshop and trade supplies; most shoppers are "
            + "returning trade customers with accounts, and the team grooms in two-week sprints.";

// 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($"worst case: {est.GetProperty("hold_credits")} credits");

facts is a hint, not an instruction: if your prescan names a role the brief does not support, the material wins. Its real value is the list of role-shaped words — a story whose role never appears in that list, and never appears in your brief, is worth a question before the backlog is groomed, because it usually means a persona was assumed rather than read.

Step 4 — Write the stories 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 brief, longer for a full PRD). Always send an Idempotency-Key header so a network retry can't start a second, double-charged run. The reply is in output — usually nested as 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.

JOB_ID=$(curl -s -X POST "$API/run" \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: sf-$(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 backlog is plain text - -r keeps it readable
echo "$JOB" | jq -r '.data.output.output' > stories.md

head -5 stories.md                                    # the five header lines
grep '^## Story ' stories.md                          # every story title

# the brief carried no feature at all
grep -qx 'STORIES: 0' stories.md \
  && { echo "no feature in the brief - read Open questions"; exit 1; }

# stories the brief gave no design reference for
grep -c '^- Design: Not provided$' stories.md || true
import time

job_id = api("POST", "/run", payload,
             **{"Idempotency-Key": "sf-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"))

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

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

head, stories, extra = parse_stories(stories_text)   # see the next section
print(head["feature"], "|", head["product"], "|", head["confidence"])
for s in stories:
    print(f'  Story {s["n"]}: {s["title"]}  ({len(s["ac"])} AC, design: {s["design"]})')

if head["stories_declared"] == 0:
    raise SystemExit("the brief carried no feature - read Open questions")
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");

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

const backlog = parseStories(storiesText);          // see the next section
console.log(`${backlog.feature} | ${backlog.product} | ${backlog.confidence}`);
for (const s of backlog.stories) {
  console.log(`  Story ${s.n}: ${s.title}  (${s.ac.length} AC, design: ${s.design})`);
}
const undesigned = backlog.stories.filter((s) => s.design === "Not provided");
if (undesigned.length) console.warn(`${undesigned.length} story(s) have no design reference`);
if (backlog.declared === 0) throw new Error("the brief carried no feature - read Open questions");
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"`
	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)
}

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

os.WriteFile("stories.md", []byte(storiesText), 0o644)

b := parseStories(storiesText) // see the next section
fmt.Printf("%s | %s | %d\n", b.Feature, b.Product, b.Confidence)
for _, s := range b.Stories {
	fmt.Printf("  Story %d: %s  (%d AC, design: %s)\n", s.N, s.Title, len(s.AC), s.Design)
}
if b.Declared == 0 {
	log.Fatal("the brief carried no feature - read Open questions")
}
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 backlog as PLAIN TEXT - no second JSON parse.
String storiesText = /* data.output.output */;
Files.writeString(Path.of("stories.md"), storiesText);

// Header lines first (FEATURE:, PRODUCT:, STORIES:, CONFIDENCE:, SUMMARY:),
// then one "## Story N: <title>" section per story, then "## Open questions"
// and "## Sequencing notes" in that order. Inside a story the first bullet is
// the "As a ..., I want ..., so that ..." description, the second is
// "Design: ...", and the rest are 3 to 6 "AC: ..." criteria.
// 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"

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

b = parse_stories(stories_text)  # see the next section
puts "#{b[:feature]} | #{b[:product]} | #{b[:confidence]}"
b[:stories].each { |s| puts "  Story #{s[:n]}: #{s[:title]}  (#{s[:ac].size} AC, design: #{s[:design]})" }
abort "the brief carried no feature - read Open questions" if b[:declared].zero?
$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");
}

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

$b = parse_stories($storiesText);   // see the next section
echo "{$b['feature']} | {$b['product']} | {$b['confidence']}\n";
foreach ($b["stories"] as $s) {
    echo "  Story {$s['n']}: {$s['title']}  (" . count($s["ac"]) . " AC, design: {$s['design']})\n";
}
if ($b["declared"] === 0) {
    fwrite(STDERR, "the brief carried no feature - read Open questions\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 storiesText = job.GetProperty("output").GetProperty("output").GetString()!;
await File.WriteAllTextAsync("stories.md", storiesText);

var b = ParseStories(storiesText);   // see the next section
Console.WriteLine($"{b.Feature} | {b.Product} | {b.Confidence}");
foreach (var s in b.Stories)
    Console.WriteLine($"  Story {s.N}: {s.Title}  ({s.AC.Count} AC, design: {s.Design})");
if (b.Declared == 0)
    Console.Error.WriteLine("the brief carried no feature - read Open questions");

The model is asked for the bare document and nothing else, but a stray code fence is always possible. Strip a leading ``` line and a 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 brief content.

The stories — output contract

The reply is plain text, not JSON. It always has the same shape: five header lines, then one ## Story N section per story, then ## Open questions and ## Sequencing notes in that order. Every role, behaviour, constraint and design reference in it comes from the material and context you sent — no limit, flow or platform constraint is invented, no design link is fabricated, no persona the brief does not support is introduced, and a gap is named as an open question rather than filled with plausible product behaviour.

The five header lines

LineValue
FEATURE:First line. The feature named in one line, from the material. Never wraps.
PRODUCT:Second line. The product or system name as the material or context words it, or exactly Not stated when neither names it.
STORIES:Third line. A bare integer equal to the number of ## Story sections that follow — no words, no range. This is the field to gate automation on: 0 is the legitimate insufficient-brief branch, described below.
CONFIDENCE:Fourth line. A bare integer 0–100 — no percent sign, no range, no words. How confident the reply is that these stories faithfully cover the brief: high for a full PRD with flows and edge cases, low for two sentences where the split had to be inferred.
SUMMARY:Fifth line onwards. Two to four sentences: what the feature is, how it was split, and the one or two open questions that most need an answer. It may wrap over several lines and ends at the first blank line.

The story sections

ElementDetail
## Story N: <title>One heading per story, with N counting up from 1 with no gaps and a short plain-language title after the colon. A normal brief yields 2 to 7 stories; a brief that genuinely carries one story gets one, and nothing is padded to hit a count.
First bullet — the descriptionExactly - As a <role>, I want <capability>, so that <benefit>. — one sentence with all three clauses present. The role comes from the material or context; the benefit is a real outcome, never a restatement of the capability.
Second bullet — the design referenceExactly - Design: … — the design link or reference the material carries for this story's surface, or exactly - Design: Not provided. A link is never fabricated, so Not provided is common and is not an error.
Remaining bullets — the criteriaEvery one is - AC: …, and there are 3 to 6 of them. Each is independently testable by someone who has read only that story: a concrete condition, an observable behaviour, an edge case, an empty state, a validation rule. No criterion restates the description, none contains a pipe, and each is one or two sentences.
INVESTEach story is independently shippable, sized for one sprint and testable through its criteria. Where the brief hides several stories in one sentence they are split; where splitting would leave a story with nothing testable it is kept whole and the reason appears under ## Sequencing notes.

The two trailing sections, and the empty branch

RuleDetail
## Open questionsAlways present, after the last story. One - bullet per decision the brief leaves unmade that changes what gets built — which roles are in scope, what happens at a limit, whether a behaviour is per-device or per-account, and any contradiction the brief carries rather than the friendlier reading of it.
## Sequencing notesAlways present, and always last. One - bullet per real dependency: which story unlocks which, what can be built in parallel, what to cut first if the sprint is tight.
Empty sectionsA trailing section with nothing to report contains the single bullet - None stated. Treat that as an empty list, not as an item called None.
Every body line is a bulletEach line inside any section starts with - . A long bullet may wrap onto indented continuation lines — fold those into the preceding bullet when parsing.
The insufficient-brief branchWhen the material carries no feature at all — it is empty, or nothing in it describes behaviour to build — the reply is STORIES: 0 with no ## Story sections at all, a low CONFIDENCE, a summary saying what was supplied instead, one bullet per thing a usable brief would need to say under ## Open questions, and - None stated. under ## Sequencing notes. This is a valid reply, not a failure: no feature is invented to split. Gate your automation on STORIES: being greater than zero.
The count ruleSTORIES: must equal the number of ## Story sections, and their numbers must run 1..N with no gaps. A mismatch is a broken reply — the app rejects it and retries with retry_note, and your parser should refuse it too.

A small, realistic reply for the brief above:

FEATURE: A "Recently viewed" section listing the products a shopper has opened, on the home page and on product pages
PRODUCT: Northwind Supply
STORIES: 4
CONFIDENCE: 72
SUMMARY: The brief describes one surface pattern shown in two places, plus a way to remove an item and a
rule for out-of-stock products. It splits into four stories: the home-page section, the product-page
section, removing an item, and the out-of-stock treatment. Two decisions are missing and both change
what gets built: what guests get, and whether removing an item hides it once or for good.

## Story 1: See recently viewed products on the home page
- As a returning shopper, I want the products I opened recently listed on the home page, so that I can carry on from where I stopped without searching again.
- Design: https://figma.com/file/NW-recently-viewed
- AC: The home page shows a `Recently viewed` section listing products the shopper has opened, newest first.
- AC: The section lists at most `10` products; opening an eleventh drops the oldest from the list.
- AC: When the shopper has opened no products, the section is not rendered at all - no heading, no empty box.
- AC: A signed-in shopper sees the same list on a phone and on a laptop.

## Story 2: See recently viewed products at the foot of a product page
- As a returning shopper, I want the same recently viewed list at the foot of a product page, so that I can compare it with what I am looking at now.
- Design: https://figma.com/file/NW-recently-viewed
- AC: Every product page shows the `Recently viewed` section below the product detail.
- AC: The section follows the same rules as the home page: newest first, at most `10` items, hidden when empty.
- AC: The product currently being viewed is not listed in its own section.

## Story 3: Remove a product from the recently viewed list
- As a shopper, I want to drop a product from my recently viewed list, so that a thing I opened by mistake stops following me around the shop.
- Design: https://figma.com/file/NW-recently-viewed
- AC: Each card in the section carries the `x` control shown in the mockup.
- AC: Choosing `x` removes that product from the list and the section re-renders without it.
- AC: Removing the last remaining product hides the section, matching the empty rule.

## Story 4: Keep out-of-stock products visible but marked
- As a shopper, I want out-of-stock products to stay in the list, marked as unavailable, so that I do not think they have disappeared from the shop.
- Design: https://figma.com/file/NW-recently-viewed
- AC: A product that is out of stock still appears in the section, in its usual position by recency.
- AC: An out-of-stock card is shown greyed out, as agreed in the 14 March design review.
- AC: The card still links through to the product page.

## Open questions
- What do guests get? The brief records two positions - browser-local only, or kept for 30 days either way - and does not settle between them.
- Does removing an item hide it once, or for good? The mockup shows the control; the thread never says what it means.
- Over what window does "recently" run, and does an item ever fall out on age rather than only on the 10-item limit?
- Does the greyed-out treatment for out-of-stock products come with any text label, or is colour the only signal?

## Sequencing notes
- Story 1 establishes the list itself; Story 2 reuses it on another surface and should follow it rather than run in parallel.
- Story 3 needs the list from Story 1 to exist before it can be tested.
- Story 4 is independent of Story 3 and can be built alongside it.
- If the sprint is tight, cut Story 4 first: the list is useful without the out-of-stock treatment.

A parser is about forty lines: match the header lines, accumulate SUMMARY: until the first blank line, open a new story on ## Story N:, switch to a trailing section on any other ## heading, and sort each story's - bullets into the description, the Design: line and the AC: list. Treat a section holding the single item None stated. as empty, and check the count and the criteria bounds before you trust the result.

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

grep '^STORIES: ' stories.md | cut -d' ' -f2              # bare integer, 0 for no feature
grep '^CONFIDENCE: ' stories.md | cut -d' ' -f2           # bare integer 0-100
grep '^## Story ' stories.md | sed 's/^## //'             # every story title

# one story, without its heading
story() { sed -n "/^## Story $1: /,/^## /p" stories.md | sed '1d;$d' | sed '/^$/d'; }

story 1                                                   # description, Design:, then the AC lines
story 1 | sed -n '1p'                                     # the As a / I want / so that line
story 1 | grep '^- AC: ' | sed 's/^- AC: //'              # the acceptance criteria alone
story 1 | grep '^- AC: ' | wc -l                          # 3 to 6, or the reply is broken

# the two trailing sections
sed -n '/^## Open questions$/,/^## /p' stories.md | sed '1d;$d'
sed -n '/^## Sequencing notes$/,$p'   stories.md | sed '1d'

# "- None stated." means the section is empty, not that there is an item called None
sed -n '/^## Open questions$/,/^## /p' stories.md | grep -qx -- '- None stated.' \
  && echo "(the brief left nothing open)"
import re

STORY_RE = re.compile(r"^##\s+Story\s+(\d+):\s*(.*)$")
DESC_RE = re.compile(r"^As an?\s+(.+?),\s*I want\s+(.+?),\s*so that\s+(.+?)\.?$", re.I)
TAIL = ("Open questions", "Sequencing notes")

def parse_stories(text):
    text = re.sub(r"^```[^\n]*\n|\n```\s*$", "", text.strip())
    head, summary, stories, tail = {}, [], [], {}
    current = None            # None | "__summary__" | ("story", s) | ("tail", name)

    for line in text.splitlines():
        m = re.match(r"^(FEATURE|PRODUCT|STORIES|CONFIDENCE)\s*:\s*(.*)$", line)
        if m:
            head[m.group(1).lower()] = m.group(2).strip()
            current = None
            continue
        m = re.match(r"^SUMMARY\s*:\s*(.*)$", line)
        if m:
            summary.append(m.group(1).strip())
            current = "__summary__"
            continue
        m = STORY_RE.match(line)
        if m:
            stories.append({"n": int(m.group(1)), "title": m.group(2).strip(),
                            "description": "", "role": "", "capability": "",
                            "benefit": "", "design": "", "ac": []})
            current = ("story", stories[-1])
            continue
        m = re.match(r"^##\s+(.*?)\s*$", line)
        if m:
            tail[m.group(1)] = []
            current = ("tail", m.group(1))
            continue

        if current == "__summary__":
            if not line.strip():
                current = None
            else:
                summary.append(line.strip())
            continue
        if not line.startswith("- ") or current is None:
            continue

        item = line[2:].strip()
        kind, where = current
        if kind == "tail":
            tail[where].append(item)
        elif item.startswith("AC:"):
            where["ac"].append(item[3:].strip())
        elif item.startswith("Design:"):
            where["design"] = item[7:].strip()
        elif not where["description"]:
            where["description"] = item
            d = DESC_RE.match(item)
            if d:
                where["role"], where["capability"], where["benefit"] = (
                    g.strip() for g in d.groups())

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

    # contract checks - a reply that fails one of these is not usable
    if head["declared"] != len(stories):
        raise ValueError("STORIES says %d, %d sections follow" % (head["declared"], len(stories)))
    if [s["n"] for s in stories] != list(range(1, len(stories) + 1)):
        raise ValueError("story numbers are not 1..N with no gaps")
    for s in stories:
        if len(s["ac"]) not in range(3, 7):
            raise ValueError("story %d has %d acceptance criteria" % (s["n"], len(s["ac"])))
        if not s["design"]:
            raise ValueError("story %d has no Design: bullet" % s["n"])
    for name in TAIL:
        if name not in tail:
            raise ValueError("missing section: " + name)
        if tail[name] == ["None stated."]:
            tail[name] = []

    return head, stories, tail
const STORY_RE = /^##\s+Story\s+(\d+):\s*(.*)$/;
const DESC_RE = /^As an?\s+(.+?),\s*I want\s+(.+?),\s*so that\s+(.+?)\.?$/i;
const TAIL = ["Open questions", "Sequencing notes"];

function parseStories(text) {
  const clean = text.trim().replace(/^```[^\n]*\n/, "").replace(/\n```\s*$/, "");
  const head = {}, stories = [], tail = {}, summary = [];
  let current = null;                 // null | "__summary__" | {story} | {tailName}

  for (const line of clean.split(/\r?\n/)) {
    let m = /^(FEATURE|PRODUCT|STORIES|CONFIDENCE)\s*:\s*(.*)$/.exec(line);
    if (m) { head[m[1].toLowerCase()] = m[2].trim(); current = null; continue; }
    m = /^SUMMARY\s*:\s*(.*)$/.exec(line);
    if (m) { summary.push(m[1].trim()); current = "__summary__"; continue; }
    m = STORY_RE.exec(line);
    if (m) {
      const s = { n: Number(m[1]), title: m[2].trim(), description: "", role: "",
                  capability: "", benefit: "", design: "", ac: [] };
      stories.push(s);
      current = { story: s };
      continue;
    }
    m = /^##\s+(.*?)\s*$/.exec(line);
    if (m) { tail[m[1]] = []; current = { tail: m[1] }; continue; }

    if (current === "__summary__") {
      if (!line.trim()) current = null;
      else summary.push(line.trim());
      continue;
    }
    if (!line.startsWith("- ") || !current) continue;

    const item = line.slice(2).trim();
    if (current.tail) { tail[current.tail].push(item); continue; }

    const s = current.story;
    if (item.startsWith("AC:")) s.ac.push(item.slice(3).trim());
    else if (item.startsWith("Design:")) s.design = item.slice(7).trim();
    else if (!s.description) {
      s.description = item;
      const d = DESC_RE.exec(item);
      if (d) { s.role = d[1].trim(); s.capability = d[2].trim(); s.benefit = d[3].trim(); }
    }
  }

  const declared = Number(head.stories);

  // contract checks - a reply that fails one of these is not usable
  if (declared !== stories.length) {
    throw new Error(`STORIES says ${declared}, ${stories.length} sections follow`);
  }
  stories.forEach((s, i) => {
    if (s.n !== i + 1) throw new Error("story numbers are not 1..N with no gaps");
    if (s.ac.length < 3 || s.ac.length > 6) {
      throw new Error(`story ${s.n} has ${s.ac.length} acceptance criteria`);
    }
    if (!s.design) throw new Error(`story ${s.n} has no Design: bullet`);
  });
  for (const name of TAIL) {
    if (!tail[name]) throw new Error("missing section: " + name);
    if (tail[name].length === 1 && tail[name][0] === "None stated.") tail[name] = [];
  }

  return {
    feature: head.feature, product: head.product,
    confidence: Number(head.confidence), declared,
    summary: summary.join(" ").trim(),
    stories,
    openQuestions: tail["Open questions"],
    sequencing: tail["Sequencing notes"],
  };
}
type Story struct {
	N                                    int
	Title, Description, Design           string
	Role, Capability, Benefit            string
	AC                                   []string
}

type Backlog struct {
	Feature, Product, Summary string
	Confidence, Declared      int
	Stories                   []Story
	OpenQuestions             []string
	Sequencing                []string
}

var headRe = regexp.MustCompile(`^(FEATURE|PRODUCT|STORIES|CONFIDENCE|SUMMARY):\s*(.*)$`)
var storyRe = regexp.MustCompile(`^##\s+Story\s+(\d+):\s*(.*)$`)
var descRe = regexp.MustCompile(`(?i)^As an? (.+?), I want (.+?), so that (.+?)\.?$`)

func parseStories(text string) Backlog {
	b := Backlog{}
	var summary []string
	mode := "" // "", "summary", "story", "open", "seq"

	for _, line := range strings.Split(strings.TrimSpace(text), "\n") {
		if h := headRe.FindStringSubmatch(line); h != nil {
			v := strings.TrimSpace(h[2])
			mode = ""
			switch h[1] {
			case "FEATURE":
				b.Feature = v
			case "PRODUCT":
				b.Product = v
			case "STORIES":
				b.Declared, _ = strconv.Atoi(v)
			case "CONFIDENCE":
				b.Confidence, _ = strconv.Atoi(v)
			case "SUMMARY":
				summary = append(summary, v)
				mode = "summary"
			}
			continue
		}
		if s := storyRe.FindStringSubmatch(line); s != nil {
			n, _ := strconv.Atoi(s[1])
			b.Stories = append(b.Stories, Story{N: n, Title: strings.TrimSpace(s[2])})
			mode = "story"
			continue
		}
		if line == "## Open questions" {
			mode = "open"
			continue
		}
		if line == "## Sequencing notes" {
			mode = "seq"
			continue
		}
		if mode == "summary" {
			if strings.TrimSpace(line) == "" {
				mode = ""
			} else {
				summary = append(summary, strings.TrimSpace(line))
			}
			continue
		}
		if !strings.HasPrefix(line, "- ") {
			continue
		}
		item := strings.TrimSpace(line[2:])
		switch mode {
		case "open":
			b.OpenQuestions = append(b.OpenQuestions, item)
		case "seq":
			b.Sequencing = append(b.Sequencing, item)
		case "story":
			cur := &b.Stories[len(b.Stories)-1]
			switch {
			case strings.HasPrefix(item, "AC:"):
				cur.AC = append(cur.AC, strings.TrimSpace(item[3:]))
			case strings.HasPrefix(item, "Design:"):
				cur.Design = strings.TrimSpace(item[7:])
			case cur.Description == "":
				cur.Description = item
				if d := descRe.FindStringSubmatch(item); d != nil {
					cur.Role, cur.Capability, cur.Benefit = d[1], d[2], d[3]
				}
			}
		}
	}
	b.Summary = strings.Join(summary, " ")

	// contract checks - a reply that fails one of these is not usable
	if b.Declared != len(b.Stories) {
		log.Fatalf("STORIES says %d, %d sections follow", b.Declared, len(b.Stories))
	}
	for i, s := range b.Stories {
		if s.N != i+1 {
			log.Fatal("story numbers are not 1..N with no gaps")
		}
		if len(s.AC) < 3 || len(s.AC) > 6 {
			log.Fatalf("story %d has %d acceptance criteria", s.N, len(s.AC))
		}
		if s.Design == "" {
			log.Fatalf("story %d has no Design: bullet", s.N)
		}
	}
	if len(b.OpenQuestions) == 1 && b.OpenQuestions[0] == "None stated." {
		b.OpenQuestions = nil
	}
	if len(b.Sequencing) == 1 && b.Sequencing[0] == "None stated." {
		b.Sequencing = nil
	}
	return b
}
// Java 17+.
// record Story(int n, String title, String description, String design, List<String> ac) {}
static final Pattern HEAD_RE =
    Pattern.compile("^(FEATURE|PRODUCT|STORIES|CONFIDENCE):\\s*(.*)$");
static final Pattern STORY_RE = Pattern.compile("^##\\s+Story\\s+(\\d+):\\s*(.*)$");
static final List<String> TAIL = List.of("Open questions", "Sequencing notes");

static Map<String, Object> parseStories(String text) {
    var head = new LinkedHashMap<String, Object>();
    var stories = new ArrayList<Map<String, Object>>();
    var tail = new LinkedHashMap<String, List<String>>();
    var summary = new StringBuilder();
    String mode = null;            // null | "__summary__" | "__story__" | tail section name

    for (String line : text.strip().split("\\R")) {
        var h = HEAD_RE.matcher(line);
        if (h.matches()) {
            head.put(h.group(1).toLowerCase(), h.group(2).strip());
            mode = null;
            continue;
        }
        if (line.startsWith("SUMMARY:")) {
            summary.append(line.substring(8).strip());
            mode = "__summary__";
            continue;
        }
        var s = STORY_RE.matcher(line);
        if (s.matches()) {
            var story = new LinkedHashMap<String, Object>();
            story.put("n", Integer.parseInt(s.group(1)));
            story.put("title", s.group(2).strip());
            story.put("description", "");
            story.put("design", "");
            story.put("ac", new ArrayList<String>());
            stories.add(story);
            mode = "__story__";
            continue;
        }
        if (line.startsWith("## ")) {
            mode = line.substring(3).strip();
            tail.put(mode, new ArrayList<>());
            continue;
        }
        if ("__summary__".equals(mode)) {
            if (line.isBlank()) mode = null;
            else summary.append(" ").append(line.strip());
            continue;
        }
        if (!line.startsWith("- ") || mode == null) continue;

        String item = line.substring(2).strip();
        if (tail.containsKey(mode)) {
            tail.get(mode).add(item);
        } else {
            var story = stories.get(stories.size() - 1);
            @SuppressWarnings("unchecked")
            var ac = (List<String>) story.get("ac");
            if (item.startsWith("AC:")) ac.add(item.substring(3).strip());
            else if (item.startsWith("Design:")) story.put("design", item.substring(7).strip());
            else if (((String) story.get("description")).isEmpty()) story.put("description", item);
        }
    }

    int declared = Integer.parseInt((String) head.get("stories"));
    if (declared != stories.size()) {
        throw new IllegalStateException("STORIES says " + declared + ", " + stories.size() + " follow");
    }
    for (var story : stories) {
        @SuppressWarnings("unchecked")
        var ac = (List<String>) story.get("ac");
        if (ac.size() < 3 || ac.size() > 6) {
            throw new IllegalStateException("story " + story.get("n") + " has " + ac.size() + " criteria");
        }
    }
    for (String name : TAIL) {
        var items = tail.get(name);
        if (items == null) throw new IllegalStateException("missing section: " + name);
        if (items.equals(List.of("None stated."))) items.clear();
    }

    head.put("confidence", Integer.parseInt((String) head.get("confidence")));
    head.put("declared", declared);
    head.put("summary", summary.toString().strip());
    head.put("stories", stories);
    head.put("tail", tail);
    return head;
}
STORY_RE = /^##\s+Story\s+(\d+):\s*(.*)$/.freeze
DESC_RE = /^As an?\s+(.+?),\s*I want\s+(.+?),\s*so that\s+(.+?)\.?$/i.freeze
TAIL = ["Open questions", "Sequencing notes"].freeze

def parse_stories(text)
  head = {}
  stories = []
  tail = {}
  summary = []
  current = nil

  text.strip.sub(/\A```[^\n]*\n/, "").sub(/\n```\s*\z/, "").each_line do |raw|
    line = raw.chomp
    if (m = line.match(/^(FEATURE|PRODUCT|STORIES|CONFIDENCE)\s*:\s*(.*)$/))
      head[m[1].downcase.to_sym] = m[2].strip
      current = nil
    elsif (m = line.match(/^SUMMARY\s*:\s*(.*)$/))
      summary << m[1].strip
      current = :__summary__
    elsif (m = line.match(STORY_RE))
      story = { n: m[1].to_i, title: m[2].strip, description: "", role: "",
                capability: "", benefit: "", design: "", ac: [] }
      stories << story
      current = [:story, story]
    elsif (m = line.match(/^##\s+(.*?)\s*$/))
      tail[m[1]] = []
      current = [:tail, m[1]]
    elsif current == :__summary__
      line.strip.empty? ? (current = nil) : (summary << line.strip)
    elsif current.is_a?(Array) && line.start_with?("- ")
      item = line[2..].strip
      kind, where = current
      if kind == :tail
        tail[where] << item
      elsif item.start_with?("AC:")
        where[:ac] << item[3..].strip
      elsif item.start_with?("Design:")
        where[:design] = item[7..].strip
      elsif where[:description].empty?
        where[:description] = item
        if (d = item.match(DESC_RE))
          where[:role] = d[1].strip
          where[:capability] = d[2].strip
          where[:benefit] = d[3].strip
        end
      end
    end
  end

  head[:confidence] = head[:confidence].to_i
  head[:declared] = head[:stories].to_i
  head[:summary] = summary.join(" ").strip

  # contract checks - a reply that fails one of these is not usable
  raise "STORIES says #{head[:declared]}, #{stories.size} sections follow" if head[:declared] != stories.size
  stories.each_with_index do |s, i|
    raise "story numbers are not 1..N with no gaps" if s[:n] != i + 1
    raise "story #{s[:n]} has #{s[:ac].size} acceptance criteria" unless (3..6).cover?(s[:ac].size)
    raise "story #{s[:n]} has no Design: bullet" if s[:design].empty?
  end
  TAIL.each do |name|
    raise "missing section: #{name}" unless tail.key?(name)
    tail[name] = [] if tail[name] == ["None stated."]
  end

  head[:stories] = stories
  head[:open_questions] = tail["Open questions"]
  head[:sequencing] = tail["Sequencing notes"]
  head
end
const SF_TAIL = ["Open questions", "Sequencing notes"];

function parse_stories(string $text): array {
    $text = preg_replace('/^```[^\n]*\n|\n```\s*$/', "", trim($text));
    $head = [];
    $stories = [];
    $tail = [];
    $summary = [];
    $mode = null;                 // null | "__summary__" | "__story__" | tail section name

    foreach (preg_split('/\R/', $text) as $line) {
        if (preg_match('/^(FEATURE|PRODUCT|STORIES|CONFIDENCE)\s*:\s*(.*)$/', $line, $m)) {
            $head[strtolower($m[1])] = trim($m[2]);
            $mode = null;
        } elseif (preg_match('/^SUMMARY\s*:\s*(.*)$/', $line, $m)) {
            $summary[] = trim($m[1]);
            $mode = "__summary__";
        } elseif (preg_match('/^##\s+Story\s+(\d+):\s*(.*)$/', $line, $m)) {
            $stories[] = ["n" => (int) $m[1], "title" => trim($m[2]),
                          "description" => "", "design" => "", "ac" => []];
            $mode = "__story__";
        } elseif (preg_match('/^##\s+(.*?)\s*$/', $line, $m)) {
            $tail[$m[1]] = [];
            $mode = $m[1];
        } elseif ($mode === "__summary__") {
            if (trim($line) === "") { $mode = null; } else { $summary[] = trim($line); }
        } elseif ($mode !== null && str_starts_with($line, "- ")) {
            $item = trim(substr($line, 2));
            if (isset($tail[$mode])) {
                $tail[$mode][] = $item;
            } else {
                $i = count($stories) - 1;
                if (str_starts_with($item, "AC:")) {
                    $stories[$i]["ac"][] = trim(substr($item, 3));
                } elseif (str_starts_with($item, "Design:")) {
                    $stories[$i]["design"] = trim(substr($item, 7));
                } elseif ($stories[$i]["description"] === "") {
                    $stories[$i]["description"] = $item;
                }
            }
        }
    }

    $head["confidence"] = (int) $head["confidence"];
    $head["declared"] = (int) $head["stories"];
    $head["summary"] = trim(implode(" ", $summary));

    // contract checks - a reply that fails one of these is not usable
    if ($head["declared"] !== count($stories)) {
        throw new Exception("STORIES says {$head['declared']}, " . count($stories) . " sections follow");
    }
    foreach ($stories as $i => $s) {
        if ($s["n"] !== $i + 1) { throw new Exception("story numbers are not 1..N with no gaps"); }
        $n = count($s["ac"]);
        if ($n < 3 || $n > 6) { throw new Exception("story {$s['n']} has $n acceptance criteria"); }
        if ($s["design"] === "") { throw new Exception("story {$s['n']} has no Design: bullet"); }
    }
    foreach (SF_TAIL as $name) {
        if (!isset($tail[$name])) { throw new Exception("missing section: $name"); }
        if ($tail[$name] === ["- None stated."] || $tail[$name] === ["None stated."]) { $tail[$name] = []; }
    }

    $head["stories"] = $stories;
    $head["open_questions"] = $tail["Open questions"];
    $head["sequencing"] = $tail["Sequencing notes"];
    return $head;
}
// .NET 8+
record Story(int N, string Title)
{
    public string Description { get; set; } = "";
    public string Design { get; set; } = "";
    public List<string> AC { get; } = new();
}

record Backlog(string Feature, string Product, int Confidence, int Declared, string Summary,
               List<Story> Stories, List<string> OpenQuestions, List<string> Sequencing);

static readonly string[] TailNames = { "Open questions", "Sequencing notes" };

static Backlog ParseStories(string text)
{
    var head = new Dictionary<string, string>();
    var stories = new List<Story>();
    var tail = new Dictionary<string, List<string>>();
    var summary = new List<string>();
    var headRe = new Regex(@"^(FEATURE|PRODUCT|STORIES|CONFIDENCE)\s*:\s*(.*)$");
    var storyRe = new Regex(@"^##\s+Story\s+(\d+):\s*(.*)$");
    string? mode = null;             // null | "__summary__" | "__story__" | tail section name

    foreach (var line in text.Trim().Split('\n').Select(l => l.TrimEnd('\r')))
    {
        var h = headRe.Match(line);
        if (h.Success) { head[h.Groups[1].Value.ToLower()] = h.Groups[2].Value.Trim(); mode = null; continue; }
        if (line.StartsWith("SUMMARY:")) { summary.Add(line[8..].Trim()); mode = "__summary__"; continue; }

        var s = storyRe.Match(line);
        if (s.Success)
        {
            stories.Add(new Story(int.Parse(s.Groups[1].Value), s.Groups[2].Value.Trim()));
            mode = "__story__";
            continue;
        }
        if (line.StartsWith("## ")) { mode = line[3..].Trim(); tail[mode] = new(); continue; }

        if (mode == "__summary__")
        {
            if (line.Trim().Length == 0) mode = null; else summary.Add(line.Trim());
            continue;
        }
        if (!line.StartsWith("- ") || mode is null) continue;

        var item = line[2..].Trim();
        if (tail.TryGetValue(mode, out var bucket)) { bucket.Add(item); continue; }

        var cur = stories[^1];
        if (item.StartsWith("AC:")) cur.AC.Add(item[3..].Trim());
        else if (item.StartsWith("Design:")) cur.Design = item[7..].Trim();
        else if (cur.Description.Length == 0) cur.Description = item;
    }

    var declared = int.Parse(head["stories"]);

    // contract checks - a reply that fails one of these is not usable
    if (declared != stories.Count)
        throw new Exception($"STORIES says {declared}, {stories.Count} sections follow");
    for (var i = 0; i < stories.Count; i++)
    {
        if (stories[i].N != i + 1) throw new Exception("story numbers are not 1..N with no gaps");
        if (stories[i].AC.Count < 3 || stories[i].AC.Count > 6)
            throw new Exception($"story {stories[i].N} has {stories[i].AC.Count} criteria");
        if (stories[i].Design.Length == 0)
            throw new Exception($"story {stories[i].N} has no Design: bullet");
    }
    foreach (var name in TailNames)
    {
        if (!tail.TryGetValue(name, out var items)) throw new Exception("missing section: " + name);
        if (items.Count == 1 && items[0] == "None stated.") items.Clear();
    }

    return new Backlog(head["feature"], head["product"], int.Parse(head["confidence"]), declared,
        string.Join(" ", summary).Trim(), stories,
        tail["Open questions"], tail["Sequencing notes"]);
}

These are AI-drafted stories built from text you supplied, not a groomed backlog and not a commitment. Read CONFIDENCE: and ## Open questions first — a low confidence means the brief was too thin to split cleanly, and every open question is a decision someone still has to make. Take the stories to refinement and check each criterion against the brief before anything is estimated or pulled into a sprint.

Step 5 — Stream the stories as they are 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 a backlog built from a full PRD 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 — counting ## Story headings as they arrive, against the STORIES: number that came in the first few deltas, makes a good progress indicator, and it is exactly what the app's own stage list does.
done{job_id, status, charged_credits, output}The final, authoritative result — read the document from output.output rather than trusting concatenated deltas (the SSE tail can drop), and the settled price from charged_credits.
error{code, message}Replaces done when the run fails.
# -N disables buffering so events print as they arrive
curl -N -s -X POST "$API/run-stream" \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: sf-$(date +%s)" \
  -d @input.json

# event: job
# data: {"job_id":"job_...","status":"running"}
#
# event: delta
# data: {"text":"FEATURE: A \"Recently viewed\" section"}
# ...
# event: done
# data: {"job_id":"job_...","status":"succeeded","charged_credits":286,"output":{"output":"FEATURE: ..."}}
import json, requests

result = None
with requests.post(
    API + "/run-stream",
    headers={"Authorization": f"Bearer {TOKEN}",
             "Idempotency-Key": "sf-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"))

stories_text = result["output"]["output"]                 # authoritative, plain text
print("\ncharged:", result["charged_credits"])
head, stories, extra = parse_stories(stories_text)
print(head["feature"], "-", head["product"], head["confidence"])
for s in stories:
    print(f'  Story {s["n"]}: {s["title"]}  ({len(s["ac"])} AC)')
with open("stories.md", "w", encoding="utf-8") as fh:
    fh.write(stories_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 storiesText = done.output.output;                // authoritative, plain text
const backlog = parseStories(storiesText);
console.log(`\n${done.charged_credits} credits - ${backlog.feature} [${backlog.declared} stories]`);
for (const s of backlog.stories) {
  console.log(`  Story ${s.n}: ${s.title}  (${s.ac.length} AC)`);
}
writeFileSync("stories.md", storiesText);
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", "sf-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 backlog is plain text at final["output"]["output"] - no JSON parse.
storiesText := final["output"].(map[string]any)["output"].(string)
os.WriteFile("stories.md", []byte(storiesText), 0o644)
b := parseStories(storiesText)
fmt.Printf("\n%s [%d stories, confidence %d]\n", b.Feature, b.Declared, b.Confidence)
for _, s := range b.Stories {
	fmt.Printf("  Story %d: %s  (%d AC)\n", s.N, s.Title, len(s.AC))
}
// 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", "sf-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 backlog as PLAIN TEXT.
// Feed it to parseStories() from the previous section, then:
//   Files.writeString(Path.of("stories.md"), storiesText);
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"] = "sf-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

stories_text = done["output"]["output"]        # authoritative, plain text
File.write("stories.md", stories_text)
b = parse_stories(stories_text)
puts "\n#{done["charged_credits"]} credits - #{b[:feature]} [#{b[:declared]} stories]"
b[:stories].each { |s| puts "  Story #{s[:n]}: #{s[:title]}  (#{s[:ac].size} AC)" }
$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: sf-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);

$storiesText = $done["output"]["output"];      // authoritative, plain text
file_put_contents("stories.md", $storiesText);
$b = parse_stories($storiesText);
echo "\n{$done['charged_credits']} credits - {$b['feature']} [{$b['declared']} stories]\n";
foreach ($b["stories"] as $s) {
    echo "  Story {$s['n']}: {$s['title']}  (" . count($s["ac"]) . " AC)\n";
}
var req = new HttpRequestMessage(HttpMethod.Post, Api + "/run-stream") {
    Content = JsonContent.Create(payload),
};
req.Headers.Add("Idempotency-Key", "sf-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 storiesText = final.RootElement.GetProperty("output").GetProperty("output").GetString()!;
await File.WriteAllTextAsync("stories.md", storiesText);

var b = ParseStories(storiesText);
Console.WriteLine($"\n{b.Feature} [{b.Declared} stories, confidence {b.Confidence}]");
foreach (var s in b.Stories)
    Console.WriteLine($"  Story {s.N}: {s.Title}  ({s.AC.Count} AC)");

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.