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.
| 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 brief). |
404 | Unknown job id. |
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 {"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
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
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
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 field | Type | Notes |
|---|---|---|
material | string, required | The 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. |
context | string, optional | The 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. |
facts | string, optional | Plain 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_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 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
/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
| Line | Value |
|---|---|
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
| Element | Detail |
|---|---|
## 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 description | Exactly - 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 reference | Exactly - 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 criteria | Every 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. |
| INVEST | Each 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
| Rule | Detail |
|---|---|
## Open questions | Always 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 notes | Always 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 sections | A 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 bullet | Each 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 branch | When 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 rule | STORIES: 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
/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.
| 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 — 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.