Demand Desk — API

Forecast methods, safety stock and reorder points — from your history, with the assumptions stated.

API tokens Open the app

Build a demand plan from your own scripts

Send one paste — per-SKU demand history like SKU-A: 120, 118, 131, ... (oldest period first) plus the scenario prose around it: lead times, MOQs, planned promotions, service targets, a new item with no history — and get back the plan a senior demand planner would write. Plain text, in a fixed shape: a coverage call, the horizon, a confidence integer, a summary, then a pattern read per series, a forecast method chosen to fit each pattern, safety stock and reorder points with the z-value, lead time and standard deviation stated in the same row, plus risks, actions and open questions. Every computable safety stock and reorder point carries its arithmetic inline in backticks, so a caller can re-run the expression and confirm it reproduces the number printed beside it — the check the web app runs in the browser, and the one step 7 shows you how to run yourself. One paste in, one plan out, no follow-up calls and no session state to carry.

StatusMeaning
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 planning a very large paste).
404Unknown job or record id.
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. The base URL is https://api.skillsafe.ai/v1/app-api and the paths are /guest, /me, /estimate, /run, /run-stream and /jobs/{job_id} — there is no per-app path segment, because the app slug is bound to the token when you mint it in step 1.

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

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

API = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN"  # see step 1 — or os.environ["SKILLSAFE_TOKEN"] in real code

def api(method, path, body=None, **headers):
    res = requests.request(method, API + path, json=body,
                           headers={"Authorization": f"Bearer {TOKEN}", **headers})
    payload = res.json()
    if not res.ok:
        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["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 plan 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. The slug in the body is what binds the token to this app, which is why no later path carries the app name.

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

The app stores this browser's token under the localStorage key skillsafe_app_token:demand-desk, 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 planning a long paste or sweeping a whole category of SKUs.

curl -s "$API/me" -H "Authorization: Bearer $TOKEN" | jq '.data'
me = api("GET", "/me")
print(me["subject_type"], me["credits"])
const me = await api("GET", "/me");
console.log(me.subject_type, me.credits);
var me struct {
	SubjectType string `json:"subject_type"`
	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 — The input object, and what a run will cost

POST /estimate

The request body for /estimate, /run and /run-stream is the input object itself — not wrapped in an input key. Send exactly what 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 about to plan a whole category and want a ceiling before spending credits. The estimate also carries model, model_alias, markup_bps and min_credits, which is what the app's own cost meter renders under the paste box.

Input fieldTypeNotes
datastring, requiredThe paste. Per-item demand history lines like SKU-A: 120, 118, 131, ... with the oldest period first, one line per item, plus the scenario prose around them: lead times and lead-time changes, MOQs and pack sizes, planned promotions, service-level targets, new items with no history. Freeform notes with numbers work too. The web client clips this at 40,000 characters and leaves a marker where text was removed; text either side of the marker is verbatim, and the plan treats the removed span as unknown rather than guessing at it. An API caller can send up to the same limit.
notesstring, optionalBusiness context and constraints, up to 6,000 characters: what you actually need out of the run, budget or open-to-buy caps, warehouse space, "the supplier will not budge on the MOQ", the failure you are most worried about. When the notes say you already know about an issue, the plan still lists the risk but attaches your caveat to it.
taskstring, requiredExactly one of Full plan, Forecast review, Safety stock and reorder, Promotion planning or New item launch. Full plan covers everything the data supports; the narrower tasks shift depth of attention but never suppress a serious risk from another area — a stockout the numbers predict is still reported on a Promotion planning run.
factsstring, optionalA plain-text mechanical scan of the paste: one line per detected series with its period count, mean, standard deviation, coefficient of variation, volatility class, zero-period count and a naive trend read. The web client computes this in the browser and ships it as a cross-check hint, never a verdict — where the plan's own reading of the paste disagrees with a scanned series (the scanner misparsed a line), the reading wins. API callers may omit the field entirely or supply their own scan in the same shape.
retry_notestring, optionalA formatting-only instruction the app sends on an automatic second attempt after a first reply failed to parse. It restates the required output shape and nothing else: it never changes the planning task, the data, the method or the grounding rules — only the shape of the reply. Leave it out on a first call.
cat > input.json <<'JSON'
{
  "data": "Weekly units, Wk 1 (oldest) through Wk 12, all channels, net of returns.\n\nKIB-SALM-24: 872, 905, 861, 893, 878, 866, 899, 884, 870, 891, 875, 882\nGPS-TRK-COLLAR: 120, 134, 128, 145, 152, 161, 178, 190, 203, 215, 229, 241\nAQ-HEAT-200W: 0, 0, 42, 0, 0, 18, 0, 0, 0, 65, 0, 7\nCHW-ELK-6IN: 198, 205, 196, 202, 207, 560, 148, 199, 203, 196, 208, 201\n\nScenario:\n1. Northbrook (KIB-SALM-24 and CHW-ELK-6IN) moves from a 2 week to a 3 week lead time next month. Nothing else changes lead time.\n2. GPS-TRK-COLLAR has a supplier MOQ of 480 units per PO.\n3. A 20 percent sitewide price cut on KIB-SALM-24 is locked in 3 weeks from now.\n4. Service target is 97.5 percent for A items (KIB-SALM-24, CHW-ELK-6IN) and 95 percent for the rest.\n5. GPS-TRK-COLLAR-XL launches in 4 weeks with zero history; buying wants GPS-TRK-COLLAR as the analog.\n\nCHW-ELK-6IN week 6 was a buy-one-get-one email promo; week 7 is the post-promo trough.",
  "notes": "I need a 13-week forecast by SKU plus a buy plan: order quantities, order timing and the safety stock assumed for each item. Hard constraint is 240,000 dollars of open-to-buy for this category through end of quarter. Call out explicitly how the 2-to-3 week lead time move changes the cover I need on the two Northbrook items. If the elk chew history needs the week 6 promo cleaned out before you fit a baseline, do that and say so.",
  "task": "Full plan",
  "facts": "Mechanical scan of the paste (arithmetic, not judgement):\n- KIB-SALM-24: 12 periods, mean 881.3, sd 13.6, CV 0.02, class stable; half-over-half trend +0%; naive 4-period MA 882/period.\n- GPS-TRK-COLLAR: 12 periods, mean 174.7, sd 40.1, CV 0.23, class variable; half-over-half trend +59%; naive 4-period MA 222/period."
}
JSON

curl -s -X POST "$API/estimate" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d @input.json | jq '.data | {model, model_alias, markup_bps, hold_credits, min_credits}'
history = (
    "Weekly units, Wk 1 (oldest) through Wk 12, all channels, net of returns.\n\n"
    "KIB-SALM-24: 872, 905, 861, 893, 878, 866, 899, 884, 870, 891, 875, 882\n"
    "GPS-TRK-COLLAR: 120, 134, 128, 145, 152, 161, 178, 190, 203, 215, 229, 241\n"
    "AQ-HEAT-200W: 0, 0, 42, 0, 0, 18, 0, 0, 0, 65, 0, 7\n"
    "CHW-ELK-6IN: 198, 205, 196, 202, 207, 560, 148, 199, 203, 196, 208, 201\n\n"
    "Scenario:\n"
    "1. Northbrook (KIB-SALM-24 and CHW-ELK-6IN) moves from a 2 week to a 3 week "
    "lead time next month. Nothing else changes lead time.\n"
    "2. GPS-TRK-COLLAR has a supplier MOQ of 480 units per PO.\n"
    "3. A 20 percent sitewide price cut on KIB-SALM-24 is locked in 3 weeks from now.\n"
    "4. Service target is 97.5 percent for A items (KIB-SALM-24, CHW-ELK-6IN) and "
    "95 percent for the rest.\n"
    "5. GPS-TRK-COLLAR-XL launches in 4 weeks with zero history; buying wants "
    "GPS-TRK-COLLAR as the analog.\n\n"
    "CHW-ELK-6IN week 6 was a buy-one-get-one email promo; week 7 is the post-promo trough."
)

payload = {
    "data": history,
    "notes": "I need a 13-week forecast by SKU plus a buy plan: order quantities, order "
             "timing and the safety stock assumed for each item. Hard constraint is "
             "240,000 dollars of open-to-buy for this category through end of quarter. "
             "Call out explicitly how the 2-to-3 week lead time move changes the cover I "
             "need on the two Northbrook items. If the elk chew history needs the week 6 "
             "promo cleaned out before you fit a baseline, do that and say so.",
    "task": "Full plan",
    # optional — omit it and the plan simply reads the paste itself
    "facts": "Mechanical scan of the paste (arithmetic, not judgement):\n"
             "- KIB-SALM-24: 12 periods, mean 881.3, sd 13.6, CV 0.02, class stable.\n"
             "- GPS-TRK-COLLAR: 12 periods, mean 174.7, sd 40.1, CV 0.23, class variable.",
}

est = api("POST", "/estimate", payload)
print(est["model"], est.get("model_alias"), "markup", est.get("markup_bps"), "bps")
print("worst case:", est.get("hold_credits", est.get("credits")),
      "credits; floor", est.get("min_credits"))
const history = [
  "Weekly units, Wk 1 (oldest) through Wk 12, all channels, net of returns.",
  "",
  "KIB-SALM-24: 872, 905, 861, 893, 878, 866, 899, 884, 870, 891, 875, 882",
  "GPS-TRK-COLLAR: 120, 134, 128, 145, 152, 161, 178, 190, 203, 215, 229, 241",
  "AQ-HEAT-200W: 0, 0, 42, 0, 0, 18, 0, 0, 0, 65, 0, 7",
  "CHW-ELK-6IN: 198, 205, 196, 202, 207, 560, 148, 199, 203, 196, 208, 201",
  "",
  "Scenario:",
  "1. Northbrook (KIB-SALM-24 and CHW-ELK-6IN) moves from a 2 week to a 3 week lead time next month.",
  "2. GPS-TRK-COLLAR has a supplier MOQ of 480 units per PO.",
  "3. A 20 percent sitewide price cut on KIB-SALM-24 is locked in 3 weeks from now.",
  "4. Service target is 97.5 percent for A items (KIB-SALM-24, CHW-ELK-6IN), 95 percent for the rest.",
  "5. GPS-TRK-COLLAR-XL launches in 4 weeks with zero history; buying wants GPS-TRK-COLLAR as the analog.",
  "",
  "CHW-ELK-6IN week 6 was a buy-one-get-one email promo; week 7 is the post-promo trough.",
].join("\n");

const payload = {
  data: history,
  notes:
    "I need a 13-week forecast by SKU plus a buy plan: order quantities, order timing and " +
    "the safety stock assumed for each item. Hard constraint is 240,000 dollars of " +
    "open-to-buy for this category through end of quarter. Call out explicitly how the " +
    "2-to-3 week lead time move changes the cover I need on the two Northbrook items. If " +
    "the elk chew history needs the week 6 promo cleaned out before you fit a baseline, " +
    "do that and say so.",
  task: "Full plan",
  // optional: a mechanical scan of the paste, used only as a cross-check hint
  facts:
    "Mechanical scan of the paste (arithmetic, not judgement):\n" +
    "- KIB-SALM-24: 12 periods, mean 881.3, sd 13.6, CV 0.02, class stable.\n" +
    "- GPS-TRK-COLLAR: 12 periods, mean 174.7, sd 40.1, CV 0.23, class variable.",
};

const est = await api("POST", "/estimate", payload);
console.log(est.model, est.model_alias, "markup", est.markup_bps, "bps");
console.log("worst case:", est.hold_credits ?? est.credits, "credits; floor", est.min_credits);
history := strings.Join([]string{
	"Weekly units, Wk 1 (oldest) through Wk 12, all channels, net of returns.",
	"",
	"KIB-SALM-24: 872, 905, 861, 893, 878, 866, 899, 884, 870, 891, 875, 882",
	"GPS-TRK-COLLAR: 120, 134, 128, 145, 152, 161, 178, 190, 203, 215, 229, 241",
	"AQ-HEAT-200W: 0, 0, 42, 0, 0, 18, 0, 0, 0, 65, 0, 7",
	"CHW-ELK-6IN: 198, 205, 196, 202, 207, 560, 148, 199, 203, 196, 208, 201",
	"",
	"Scenario:",
	"1. Northbrook (KIB-SALM-24 and CHW-ELK-6IN) moves from a 2 week to a 3 week lead time next month.",
	"2. GPS-TRK-COLLAR has a supplier MOQ of 480 units per PO.",
	"3. A 20 percent sitewide price cut on KIB-SALM-24 is locked in 3 weeks from now.",
	"4. Service target is 97.5 percent for A items (KIB-SALM-24, CHW-ELK-6IN), 95 percent for the rest.",
	"5. GPS-TRK-COLLAR-XL launches in 4 weeks with zero history; buying wants GPS-TRK-COLLAR as the analog.",
	"",
	"CHW-ELK-6IN week 6 was a buy-one-get-one email promo; week 7 is the post-promo trough.",
}, "\n")

payload := map[string]any{
	"data": history,
	"notes": "I need a 13-week forecast by SKU plus a buy plan: order quantities, order timing " +
		"and the safety stock assumed for each item. Hard constraint is 240,000 dollars of " +
		"open-to-buy for this category through end of quarter. Call out explicitly how the " +
		"2-to-3 week lead time move changes the cover I need on the two Northbrook items.",
	"task": "Full plan",
	// "facts" is optional — a mechanical scan used only as a cross-check hint.
	"facts": "Mechanical scan of the paste (arithmetic, not judgement):\n" +
		"- KIB-SALM-24: 12 periods, mean 881.3, sd 13.6, CV 0.02, class stable.",
}

var est struct {
	Model       string `json:"model"`
	ModelAlias  string `json:"model_alias"`
	MarkupBps   int64  `json:"markup_bps"`
	HoldCredits int64  `json:"hold_credits"`
	MinCredits  int64  `json:"min_credits"`
}
err := call("POST", "/estimate", payload, &est)
// A text block keeps the JSON readable; \\n stays a JSON escape, not a real newline.
String jsonPayload = """
    {"data": "Weekly units, Wk 1 (oldest) through Wk 12.\\n\\nKIB-SALM-24: 872, 905, 861, 893, 878, 866, 899, 884, 870, 891, 875, 882\\nGPS-TRK-COLLAR: 120, 134, 128, 145, 152, 161, 178, 190, 203, 215, 229, 241\\nAQ-HEAT-200W: 0, 0, 42, 0, 0, 18, 0, 0, 0, 65, 0, 7\\nCHW-ELK-6IN: 198, 205, 196, 202, 207, 560, 148, 199, 203, 196, 208, 201\\n\\nScenario:\\n1. Northbrook (KIB-SALM-24 and CHW-ELK-6IN) moves from a 2 week to a 3 week lead time next month.\\n2. GPS-TRK-COLLAR has a supplier MOQ of 480 units per PO.\\n3. A 20 percent sitewide price cut on KIB-SALM-24 is locked in 3 weeks from now.\\n4. Service target is 97.5 percent for A items (KIB-SALM-24, CHW-ELK-6IN), 95 percent for the rest.\\n5. GPS-TRK-COLLAR-XL launches in 4 weeks with zero history; buying wants GPS-TRK-COLLAR as the analog.\\n\\nCHW-ELK-6IN week 6 was a buy-one-get-one email promo; week 7 is the post-promo trough.",
     "notes": "I need a 13-week forecast by SKU plus a buy plan. Hard constraint is 240,000 dollars of open-to-buy through end of quarter. Call out how the 2-to-3 week lead time move changes the cover on the two Northbrook items.",
     "task": "Full plan",
     "facts": "Mechanical scan of the paste (arithmetic, not judgement):\\n- KIB-SALM-24: 12 periods, mean 881.3, sd 13.6, CV 0.02, class stable."}
    """;

String envelope = api("POST", "/estimate", jsonPayload);
// data.model, data.model_alias, data.markup_bps, data.hold_credits, data.min_credits
// hold_credits is the worst-case reservation; nothing is charged by /estimate.
history = [
  "Weekly units, Wk 1 (oldest) through Wk 12, all channels, net of returns.",
  "",
  "KIB-SALM-24: 872, 905, 861, 893, 878, 866, 899, 884, 870, 891, 875, 882",
  "GPS-TRK-COLLAR: 120, 134, 128, 145, 152, 161, 178, 190, 203, 215, 229, 241",
  "AQ-HEAT-200W: 0, 0, 42, 0, 0, 18, 0, 0, 0, 65, 0, 7",
  "CHW-ELK-6IN: 198, 205, 196, 202, 207, 560, 148, 199, 203, 196, 208, 201",
  "",
  "Scenario:",
  "1. Northbrook (KIB-SALM-24 and CHW-ELK-6IN) moves from a 2 week to a 3 week lead time next month.",
  "2. GPS-TRK-COLLAR has a supplier MOQ of 480 units per PO.",
  "3. A 20 percent sitewide price cut on KIB-SALM-24 is locked in 3 weeks from now.",
  "4. Service target is 97.5 percent for A items (KIB-SALM-24, CHW-ELK-6IN), 95 percent for the rest.",
  "5. GPS-TRK-COLLAR-XL launches in 4 weeks with zero history; the analog is GPS-TRK-COLLAR.",
  "",
  "CHW-ELK-6IN week 6 was a buy-one-get-one email promo; week 7 is the post-promo trough."
].join("\n")

payload = {
  data: history,
  notes: "I need a 13-week forecast by SKU plus a buy plan: order quantities, order timing " \
         "and the safety stock assumed for each item. Hard constraint is 240,000 dollars of " \
         "open-to-buy through end of quarter. Call out explicitly how the 2-to-3 week lead " \
         "time move changes the cover I need on the two Northbrook items.",
  task: "Full plan",
  facts: "Mechanical scan of the paste (arithmetic, not judgement):\n" \
         "- KIB-SALM-24: 12 periods, mean 881.3, sd 13.6, CV 0.02, class stable."
}

est = api("POST", "/estimate", payload)
puts "#{est["model"]} (#{est["model_alias"]}), markup #{est["markup_bps"]} bps"
puts "worst case: #{est["hold_credits"] || est["credits"]} credits, floor #{est["min_credits"]}"
$history = implode("\n", [
    "Weekly units, Wk 1 (oldest) through Wk 12, all channels, net of returns.",
    "",
    "KIB-SALM-24: 872, 905, 861, 893, 878, 866, 899, 884, 870, 891, 875, 882",
    "GPS-TRK-COLLAR: 120, 134, 128, 145, 152, 161, 178, 190, 203, 215, 229, 241",
    "AQ-HEAT-200W: 0, 0, 42, 0, 0, 18, 0, 0, 0, 65, 0, 7",
    "CHW-ELK-6IN: 198, 205, 196, 202, 207, 560, 148, 199, 203, 196, 208, 201",
    "",
    "Scenario:",
    "1. Northbrook (KIB-SALM-24 and CHW-ELK-6IN) moves from a 2 week to a 3 week lead time next month.",
    "2. GPS-TRK-COLLAR has a supplier MOQ of 480 units per PO.",
    "3. A 20 percent sitewide price cut on KIB-SALM-24 is locked in 3 weeks from now.",
    "4. Service target is 97.5 percent for A items (KIB-SALM-24, CHW-ELK-6IN), 95 percent for the rest.",
    "5. GPS-TRK-COLLAR-XL launches in 4 weeks with zero history; the analog is GPS-TRK-COLLAR.",
    "",
    "CHW-ELK-6IN week 6 was a buy-one-get-one email promo; week 7 is the post-promo trough.",
]);

$payload = [
    "data"  => $history,
    "notes" => "I need a 13-week forecast by SKU plus a buy plan: order quantities, order "
             . "timing and the safety stock assumed for each item. Hard constraint is 240,000 "
             . "dollars of open-to-buy through end of quarter. Call out explicitly how the "
             . "2-to-3 week lead time move changes the cover on the two Northbrook items.",
    "task"  => "Full plan",
    "facts" => "Mechanical scan of the paste (arithmetic, not judgement):\n"
             . "- KIB-SALM-24: 12 periods, mean 881.3, sd 13.6, CV 0.02, class stable.",
];

$est = api("POST", "/estimate", $payload);
echo "{$est['model']} ({$est['model_alias']}), markup {$est['markup_bps']} bps\n";
echo "worst case: " . ($est["hold_credits"] ?? $est["credits"]) . " credits, "
   . "floor {$est['min_credits']}\n";
var history = string.Join("\n", new[] {
    "Weekly units, Wk 1 (oldest) through Wk 12, all channels, net of returns.",
    "",
    "KIB-SALM-24: 872, 905, 861, 893, 878, 866, 899, 884, 870, 891, 875, 882",
    "GPS-TRK-COLLAR: 120, 134, 128, 145, 152, 161, 178, 190, 203, 215, 229, 241",
    "AQ-HEAT-200W: 0, 0, 42, 0, 0, 18, 0, 0, 0, 65, 0, 7",
    "CHW-ELK-6IN: 198, 205, 196, 202, 207, 560, 148, 199, 203, 196, 208, 201",
    "",
    "Scenario:",
    "1. Northbrook (KIB-SALM-24 and CHW-ELK-6IN) moves from a 2 week to a 3 week lead time next month.",
    "2. GPS-TRK-COLLAR has a supplier MOQ of 480 units per PO.",
    "3. A 20 percent sitewide price cut on KIB-SALM-24 is locked in 3 weeks from now.",
    "4. Service target is 97.5 percent for A items (KIB-SALM-24, CHW-ELK-6IN), 95 percent for the rest.",
    "5. GPS-TRK-COLLAR-XL launches in 4 weeks with zero history; the analog is GPS-TRK-COLLAR.",
    "",
    "CHW-ELK-6IN week 6 was a buy-one-get-one email promo; week 7 is the post-promo trough.",
});

var payload = new {
    data = history,
    notes = "I need a 13-week forecast by SKU plus a buy plan: order quantities, order timing " +
            "and the safety stock assumed for each item. Hard constraint is 240,000 dollars of " +
            "open-to-buy through end of quarter. Call out explicitly how the 2-to-3 week lead " +
            "time move changes the cover on the two Northbrook items.",
    task = "Full plan",
    facts = "Mechanical scan of the paste (arithmetic, not judgement):\n" +
            "- KIB-SALM-24: 12 periods, mean 881.3, sd 13.6, CV 0.02, class stable.",
};

var est = await SkillSafe.ApiAsync(HttpMethod.Post, "/estimate", payload);
Console.WriteLine($"{est.GetProperty("model")} markup {est.GetProperty("markup_bps")} bps");
Console.WriteLine($"worst case: {est.GetProperty("hold_credits")} credits, " +
                  $"floor {est.GetProperty("min_credits")}");

facts is a hint, not an instruction. It is pure arithmetic on the pasted text — period counts, mean, standard deviation, CV, volatility class, a naive trend read — and the plan is free to disagree with it when your own reading of the paste says the scanner misparsed a line. Omit it and nothing is lost except that cross-check.

Step 4 — Run the plan and wait for the result

POST /run
GET /jobs/{job_id}

/run takes the same body as /estimate — the input object directly — places a credit hold and returns {"data":{"job_id":"…"}}. Poll /jobs/{job_id} every 1–2 seconds until status is succeeded or failed. Always send an Idempotency-Key header so a network retry can't start a second, double-charged run — see step 5 for how to choose the key. The plan text is in output — usually nested as output.output, and it is plain text, not JSON, so keep it as a string and hand it to the parser in step 6.

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

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

# the plan is plain text — write it out verbatim, do not try to parse it as JSON
echo "$JOB" | jq -r '.data.output.output // .data.output' > plan.md

head -4 plan.md                       # COVERAGE / HORIZON / CONFIDENCE / SUMMARY
grep -n '^## ' plan.md                # the six sections, in order

# fail the pipeline when the paste was not plannable
grep -q '^COVERAGE: Not enough to plan' plan.md \
  && { echo "not enough to plan"; exit 1; } || true
import time

job_id = api("POST", "/run", payload,
             **{"Idempotency-Key": "dd-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"]
plan_text = raw if isinstance(raw, str) else str(raw)

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

for line in plan_text.splitlines()[:4]:
    print(line)
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 — keep it as a string
const planText = job.output?.output ?? job.output;
writeFileSync("plan.md", planText);

console.log(planText.split("\n").slice(0, 4).join("\n"));
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)
}

// job.Output is {"output": "<the plan text>"} — the plan is plain text, never JSON.
var wrapper struct{ Output string `json:"output"` }
json.Unmarshal(job.Output, &wrapper)
planText := wrapper.Output

os.WriteFile("plan.md", []byte(planText), 0o644)
for i, line := range strings.SplitN(planText, "\n", 5) {
	if i < 4 {
		fmt.Println(line)
	}
}
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);
}

// The plan is at data.output.output as a PLAIN TEXT string — do not parse it as
// JSON. Pull it out with your JSON library and keep it verbatim:
String planText = /* data.output.output */;
Files.writeString(Path.of("plan.md"), planText);

// The first four lines are COVERAGE:, HORIZON:, CONFIDENCE: and SUMMARY:,
// then the six "## " sections. Step 6 shows the parse.
planText.lines().limit(4).forEach(System.out::println);
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"

raw = job["output"].is_a?(Hash) ? job["output"].fetch("output", job["output"]) : job["output"]
plan_text = raw.to_s   # plain text, not JSON

File.write("plan.md", plan_text)
puts plan_text.lines.first(4).join
$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");
}

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

file_put_contents("plan.md", $planText);
echo implode("\n", array_slice(explode("\n", $planText), 0, 4)), "\n";
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 planText = job.GetProperty("output").GetProperty("output").GetString()!;
await File.WriteAllTextAsync("plan.md", planText);

foreach (var line in planText.Split('\n').Take(4)) Console.WriteLine(line);

A reply that breaks the output contract is discarded by the app rather than half-rendered, and a second attempt is sent carrying retry_note — a formatting-only instruction that restates the required shape and changes nothing about the analysis. If you implement the same fallback, send the identical input object plus retry_note, and treat the second reply exactly like the first.

Step 5 — Stream the plan as it is written

POST /run-stream

/run-stream takes exactly the same body as /run but answers with server-sent events, so you can show progress instead of a spinner. 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.

EventPayloadMeaning
job{job_id, status}Sent once, when the job is accepted — show "starting".
delta{text}A chunk of the reply, in order. Append it; the accumulated length is your only progress signal (the total is not known in advance). The app advances its step list by watching for the ## Demand assessment, ## Forecast plan, ## Inventory parameters and later headings as they arrive.
done{job_id, status, charged_credits, output}The final, authoritative result — read the plan text from output.output rather than trusting concatenated deltas, and the settled price from charged_credits.
error{code, message}Replaces done when the run fails.

The Idempotency-Key header

/run-stream (and /run) accepts an Idempotency-Key header, and the way to build it matters. The app derives the key from a hash of the input object plus a nonce minted once per user gesture — per press of the run button. So every transport-level retry of that one gesture, whether it is your own retry loop or a dropped connection you re-open, carries the same key and is deduplicated: the server returns the original run's result and you are billed once. A deliberate second run of the same input is a new gesture, gets a fresh nonce and therefore a fresh key, and does execute and does charge — which is what you want when you re-run because a supplier just changed a lead time and you want the plan rebuilt. Reuse a key for retries; mint a new one for intentional re-runs.

# One key per gesture: hash the input, then salt it with a nonce you mint once.
NONCE=$(uuidgen)
KEY="dd-$(cat input.json <(echo "$NONCE") | shasum -a 256 | cut -c1-32)"

# -N disables buffering so events print as they arrive.
# Re-running THIS command reuses $KEY and is deduplicated (no second charge).
curl -N -s -X POST "$API/run-stream" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  -d @input.json

# event: job
# data: {"job_id":"job_...","status":"running"}
#
# event: delta
# data: {"text":"COVERAGE: Partial - gaps noted\nHORIZON: next 13 weeks"}
# ...
# event: done
# data: {"job_id":"job_...","status":"succeeded","charged_credits":612,"output":{"output":"COVERAGE: ..."}}
import hashlib, json, uuid, requests

# One key per gesture: input hash + a nonce minted once for this attempt.
nonce = uuid.uuid4().hex
body = json.dumps(payload, sort_keys=True).encode("utf-8")
key = "dd-" + hashlib.sha256(body + nonce.encode()).hexdigest()[:32]

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

plan_text = result["output"]["output"]                  # authoritative
print("\ncharged:", result["charged_credits"])
print("\n".join(plan_text.splitlines()[:4]))
with open("plan.md", "w", encoding="utf-8") as fh:
    fh.write(plan_text)
import { createHash, randomUUID } from "node:crypto";

// One key per gesture: hash the input, salt with a nonce minted once.
const nonce = randomUUID();
const key = "dd-" + createHash("sha256")
  .update(JSON.stringify(payload) + nonce).digest("hex").slice(0, 32);

const res = await fetch(API + "/run-stream", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${TOKEN}`,
    "Content-Type": "application/json",
    "Idempotency-Key": key,
  },
  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 data = /^data:\s*(.+)$/m.exec(frame)?.[1];
    if (!name || !data) continue;
    const payloadJson = JSON.parse(data);
    if (name === "delta") process.stdout.write(".");   // live progress
    if (name === "done") done = payloadJson;
    if (name === "error") throw new Error(payloadJson.message ?? "run failed");
  }
}

const planText = done.output.output;
console.log(`\n${done.charged_credits} credits`);
console.log(planText.split("\n").slice(0, 4).join("\n"));
writeFileSync("plan.md", planText);
// One key per gesture: hash of the body plus a nonce minted once.
bodyBytes, _ := json.Marshal(payload)
nonce := fmt.Sprintf("%d", time.Now().UnixNano())
sum := sha256.Sum256(append(bodyBytes, []byte(nonce)...))
key := "dd-" + hex.EncodeToString(sum[:])[:32]

req, _ := http.NewRequest("POST", API+"/run-stream", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key)

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"])
		}
	}
}

planText := final["output"].(map[string]any)["output"].(string)
os.WriteFile("plan.md", []byte(planText), 0o644)
// Java 17+ — read the stream line by line instead of buffering the body.
// One key per gesture: hash the body, salt with a nonce minted once.
String nonce = java.util.UUID.randomUUID().toString();
var digest = java.security.MessageDigest.getInstance("SHA-256")
    .digest((jsonPayload + nonce).getBytes(java.nio.charset.StandardCharsets.UTF_8));
String key = "dd-" + java.util.HexFormat.of().formatHex(digest).substring(0, 32);

var req = HttpRequest.newBuilder(URI.create(API + "/run-stream"))
    .header("Authorization", "Bearer " + TOKEN)
    .header("Content-Type", "application/json")
    .header("Idempotency-Key", key)
    .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`, then read data.output.output — a PLAIN TEXT plan, not JSON.
// Retrying this whole block with the SAME key returns the first run's result
// and is not billed again; mint a new nonce for a deliberate re-run.
require "digest"
require "net/http"
require "json"
require "securerandom"

# One key per gesture: input hash salted with a nonce minted once.
nonce = SecureRandom.hex(16)
key = "dd-" + Digest::SHA256.hexdigest(payload.to_json + nonce)[0, 32]

uri = URI(API + "/run-stream")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = key
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

plan_text = done["output"]["output"]
puts "\n#{done["charged_credits"]} credits"
File.write("plan.md", plan_text)
// One key per gesture: input hash salted with a nonce minted once.
$nonce = bin2hex(random_bytes(16));
$key = "dd-" . substr(hash("sha256", json_encode($payload) . $nonce), 0, 32);

$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: $key",
    ],
    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);

$planText = $done["output"]["output"];
echo "\n{$done['charged_credits']} credits\n";
file_put_contents("plan.md", $planText);
using System.Security.Cryptography;
using System.Text;

// One key per gesture: input hash salted with a nonce minted once.
var nonce = Guid.NewGuid().ToString("N");
var bodyJson = JsonSerializer.Serialize(payload);
var hash = Convert.ToHexString(
    SHA256.HashData(Encoding.UTF8.GetBytes(bodyJson + nonce)));
var key = "dd-" + hash[..32].ToLowerInvariant();

var req = new HttpRequestMessage(HttpMethod.Post, Api + "/run-stream") {
    Content = JsonContent.Create(payload),
};
req.Headers.Add("Idempotency-Key", key);

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!);
var planText = final.RootElement.GetProperty("output").GetProperty("output").GetString()!;
Console.WriteLine($"\n{final.RootElement.GetProperty("charged_credits")} credits");
await File.WriteAllTextAsync("plan.md", planText);

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.

Step 6 — The output contract, and how to parse it

The reply is plain text, not JSON, and it always has the same shape. The first four lines are tag lines; then six ## sections in a fixed order, each containing only - bullets. A bullet may wrap onto indented continuation lines. A section with nothing to report contains the single bullet - None.

LineValue
COVERAGE:The first line. Exactly one of Sufficient, Partial - gaps noted or Not enough to plan. Sufficient means the paste supports the requested task end to end; Partial - gaps noted means some items or some of the task are plannable and the gaps are named under Open questions; Not enough to plan means there is no usable demand signal at all.
HORIZON:The horizon the forecast covers — taken from the paste or notes when stated ("plan the next 6 weeks"), otherwise the shortest horizon the data honestly supports, or exactly Not stated when no forecasting was possible.
CONFIDENCE:A bare integer from 0 to 100. No percent sign, no range, no words.
SUMMARY:Two to four sentences: what was supplied, the headline reads and what the plan says to do. It may wrap over several lines and ends at the first blank line — join the wrapped lines with a space.

The six sections, in exactly this order:

HeadingBullet shape
## Demand assessmentFree-text bullets, one per item or demand signal: the pattern read and the evidence for it. When real series were supplied, every one of them is named here.
## Forecast planExactly four | -separated fields: item (as named in the paste) | method ("4-week moving average", "exponential smoothing with trend", "Croston for intermittent demand", "analog to SKU-X") | why that method fits the pattern | expected demand over the horizon, with its unit. No pipes inside a field.
## Inventory parametersExactly four | -separated fields: item | safety stock, with the assumption that produced it | reorder point | order quantity, with its constraint applied ("480 - MOQ binds; cycle need is about 320"). Any field the paste cannot support reads not computable, and what is missing appears under Open questions.
## Risks and watchoutsFree-text bullets, one per risk the data or scenario exposes.
## Recommended actionsFree-text bullets, each starting with a verb, with its timing where one is stated or implied.
## Open questionsFree-text bullets, one per thing that must be supplied or confirmed before the plan is executable.

Two cross-rules are worth asserting on in a pipeline: when COVERAGE is Not enough to plan, both ## Forecast plan and ## Inventory parameters are - None. and ## Open questions carries at least one bullet saying what to paste; and conversely, a real row under either table means COVERAGE is not Not enough to plan.

A real reply for the pet-hardlines paste above, trimmed for length:

COVERAGE: Partial - gaps noted
HORIZON: next 13 weeks
CONFIDENCE: 55
SUMMARY: Four SKUs with 12 weeks of weekly sell-through plus a Q3 scenario were supplied,
along with a $240,000 OTB cap. `KIB-SALM-24` is flat and easy to forecast, `GPS-TRK-COLLAR`
is on a real upward trend that its 480-unit MOQ will eventually outrun, `AQ-HEAT-200W` is
intermittent, and `CHW-ELK-6IN`'s history is clean once the wk6 promo and wk7 trough are
pulled out. The plan cannot compute safety stock for `GPS-TRK-COLLAR` or `AQ-HEAT-200W`
because their current lead time is never stated.

## Demand assessment
- `KIB-SALM-24`: flat, low-variability demand - 12 weeks averaging 881/week with sd 13.6
  (CV 0.02) and no visible trend.
- `AQ-HEAT-200W`: intermittent demand with real zeros - 8 of 12 weeks at zero, with four
  lumpy events of 42, 18, 65 and 7 units; a plain average would smear these into a phantom
  steady baseline.

## Forecast plan
- `KIB-SALM-24` | simple exponential smoothing / short moving average | 12 weeks show no
  trend and negligible variance, so the simplest method that tracks a flat mean fits |
  about 11,450 units baseline over 13 weeks (881/week)
- `AQ-HEAT-200W` | Croston-style intermittent (event size x interval) | 8 of 12 weeks are
  zero with four lumpy events averaging 33 units roughly every 3 weeks | about 140 units
  over 13 weeks across roughly 4 restock events

## Inventory parameters
- `KIB-SALM-24` | 38 units at 97.5% service, z=1.96, current 2-week lead time
  (`1.96 x 13.6 x sqrt(2)`), rising to 46 units once the lead time moves to 3 weeks
  (`1.96 x 13.6 x sqrt(3)`) | 1,800 units at the current 2-week lead time
  (`881.3 x 2 + 38`), rising to 2,690 units at the new 3-week lead time
  (`881.3 x 3 + 46`) | not computable - no MOQ, pack size or per-unit cost is stated
- `GPS-TRK-COLLAR` | not computable - current lead time for this SKU is never stated |
  not computable - reorder point needs the same missing lead time | 480 - MOQ binds every
  order; at 250-380 units/week this covers roughly 1.3-1.9 weeks per order

## Risks and watchouts
- OTB exposure unverifiable: the paste has no per-unit cost for any SKU, so this unit-based
  plan cannot be reconciled against the $240,000 cap.

## Recommended actions
- Confirm the current lead time for `GPS-TRK-COLLAR` and `AQ-HEAT-200W` so their safety
  stock and reorder points can be computed.

## Open questions
- Current lead time for `GPS-TRK-COLLAR` and `AQ-HEAT-200W`.
- Per-unit cost for all lines, to check the plan against the $240,000 OTB cap.

A parser for this is a few dozen lines in any language: read the four tag lines, then walk the rest switching on ## headings and collecting - bullets, joining wrapped continuation lines into the bullet above them. Split the two table sections on | .

# The four tag lines
sed -n '1,4p' plan.md

# Every heading, in the order they appear — expect exactly these six
grep -n '^## ' plan.md

# One section's bullets (awk keeps wrapped continuation lines attached)
awk '/^## Inventory parameters$/{f=1;next} /^## /{f=0} f && NF' plan.md

# The inventory table as TSV: item, safety stock, reorder point, order quantity
awk '/^## Inventory parameters$/{f=1;next} /^## /{f=0}
     f && /^- /{if(b)print b; b=substr($0,3); next}
     f && NF{b=b" "$0; next}
     END{if(b)print b}' plan.md \
| awk -F' \\| ' '{printf "%s\t%s\t%s\t%s\n", $1, $2, $3, $4}'

# Rows the plan could not compute
grep -c 'not computable' plan.md
import re

SECTIONS = ["Demand assessment", "Forecast plan", "Inventory parameters",
            "Risks and watchouts", "Recommended actions", "Open questions"]

def parse_plan(text):
    plan = {"coverage": "", "horizon": "", "confidence": None, "summary": "",
            "sections": {name: [] for name in SECTIONS}}
    current, summary, bullets = None, [], None
    for line in text.replace("\r\n", "\n").split("\n"):
        head = re.match(r"^##\s+(.*?)\s*$", line)
        if head:
            current = head.group(1) if head.group(1) in SECTIONS else None
            bullets = plan["sections"].get(current) if current else None
            continue
        for tag, key in (("COVERAGE", "coverage"), ("HORIZON", "horizon")):
            m = re.match(rf"^{tag}\s*:\s*(.*)$", line)
            if m and not plan[key]:
                plan[key], current, bullets = m.group(1).strip(), None, None
        m = re.match(r"^CONFIDENCE\s*:\s*(\d{1,3})\s*$", line)
        if m and plan["confidence"] is None:
            plan["confidence"], current, bullets = int(m.group(1)), None, None
            continue
        m = re.match(r"^SUMMARY\s*:\s*(.*)$", line)
        if m and not summary:
            summary.append(m.group(1).strip())
            current, bullets = "__summary__", None
            continue
        if current == "__summary__":
            if not line.strip():
                current = None
            else:
                summary.append(line.strip())
            continue
        if bullets is None:
            continue
        if line.startswith("- "):
            bullets.append(line[2:].strip())
        elif line.strip() and bullets:
            bullets[-1] += " " + line.strip()          # wrapped continuation
    plan["summary"] = " ".join(summary).strip()
    for key in ("Forecast plan", "Inventory parameters"):
        rows = plan["sections"][key]
        plan[key] = [] if rows == ["None."] else [
            [f.strip() for f in row.split(" | ")] for row in rows]
    return plan

plan = parse_plan(plan_text)
print(plan["coverage"], "|", plan["horizon"], "|", plan["confidence"])
print(plan["summary"])
for item, ss, rop, oq in plan["Inventory parameters"]:
    print(f"  {item}\n    ss:  {ss}\n    rop: {rop}\n    oq:  {oq}")

assert plan["coverage"] in ("Sufficient", "Partial - gaps noted", "Not enough to plan")
if plan["coverage"] == "Not enough to plan":
    assert not plan["Forecast plan"] and not plan["Inventory parameters"]
const SECTIONS = ["Demand assessment", "Forecast plan", "Inventory parameters",
                  "Risks and watchouts", "Recommended actions", "Open questions"];

function parsePlan(text) {
  const plan = { coverage: "", horizon: "", confidence: null, summary: "", sections: {} };
  for (const name of SECTIONS) plan.sections[name] = [];
  let current = null, bullets = null;
  const summary = [];

  for (const line of text.replace(/\r\n/g, "\n").split("\n")) {
    const head = /^##\s+(.*?)\s*$/.exec(line);
    if (head) {
      current = SECTIONS.includes(head[1]) ? head[1] : null;
      bullets = current ? plan.sections[current] : null;
      continue;
    }
    let m;
    if (!plan.coverage && (m = /^COVERAGE\s*:\s*(.*)$/.exec(line))) {
      plan.coverage = m[1].trim(); current = bullets = null; continue;
    }
    if (!plan.horizon && (m = /^HORIZON\s*:\s*(.*)$/.exec(line))) {
      plan.horizon = m[1].trim(); current = bullets = null; continue;
    }
    if (plan.confidence === null && (m = /^CONFIDENCE\s*:\s*(\d{1,3})\s*$/.exec(line))) {
      plan.confidence = Number(m[1]); current = bullets = null; continue;
    }
    if (!summary.length && (m = /^SUMMARY\s*:\s*(.*)$/.exec(line))) {
      if (m[1].trim()) summary.push(m[1].trim());
      current = "__summary__"; bullets = null; continue;
    }
    if (current === "__summary__") {
      if (!line.trim()) current = null; else summary.push(line.trim());
      continue;
    }
    if (!bullets) continue;
    if (line.startsWith("- ")) bullets.push(line.slice(2).trim());
    else if (line.trim() && bullets.length) bullets[bullets.length - 1] += " " + line.trim();
  }
  plan.summary = summary.join(" ").trim();

  for (const key of ["Forecast plan", "Inventory parameters"]) {
    const rows = plan.sections[key];
    plan[key] = rows.length === 1 && /^none\.?$/i.test(rows[0])
      ? []
      : rows.map((r) => r.split(" | ").map((f) => f.trim()));
  }
  return plan;
}

const plan = parsePlan(planText);
console.log(`${plan.coverage} | ${plan.horizon} | ${plan.confidence}`);
console.log(plan.summary);
for (const [item, ss, rop, oq] of plan["Inventory parameters"]) {
  console.log(`  ${item}\n    ss: ${ss}\n    rop: ${rop}\n    oq: ${oq}`);
}
var sectionNames = []string{
	"Demand assessment", "Forecast plan", "Inventory parameters",
	"Risks and watchouts", "Recommended actions", "Open questions",
}

type Plan struct {
	Coverage   string
	Horizon    string
	Confidence int
	Summary    string
	Sections   map[string][]string
}

var (
	reHead = regexp.MustCompile(`^##\s+(.*?)\s*$`)
	reCov  = regexp.MustCompile(`^COVERAGE\s*:\s*(.*)$`)
	reHor  = regexp.MustCompile(`^HORIZON\s*:\s*(.*)$`)
	reConf = regexp.MustCompile(`^CONFIDENCE\s*:\s*(\d{1,3})\s*$`)
	reSum  = regexp.MustCompile(`^SUMMARY\s*:\s*(.*)$`)
)

func parsePlan(text string) Plan {
	p := Plan{Confidence: -1, Sections: map[string][]string{}}
	for _, n := range sectionNames {
		p.Sections[n] = []string{}
	}
	known := func(n string) bool {
		for _, s := range sectionNames {
			if s == n {
				return true
			}
		}
		return false
	}

	current, inSummary := "", false
	var summary []string
	for _, line := range strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n") {
		if m := reHead.FindStringSubmatch(line); m != nil {
			inSummary = false
			if known(m[1]) {
				current = m[1]
			} else {
				current = ""
			}
			continue
		}
		if m := reCov.FindStringSubmatch(line); m != nil && p.Coverage == "" {
			p.Coverage, current, inSummary = strings.TrimSpace(m[1]), "", false
			continue
		}
		if m := reHor.FindStringSubmatch(line); m != nil && p.Horizon == "" {
			p.Horizon, current, inSummary = strings.TrimSpace(m[1]), "", false
			continue
		}
		if m := reConf.FindStringSubmatch(line); m != nil && p.Confidence < 0 {
			p.Confidence, _ = strconv.Atoi(m[1])
			current, inSummary = "", false
			continue
		}
		if m := reSum.FindStringSubmatch(line); m != nil && len(summary) == 0 {
			summary = append(summary, strings.TrimSpace(m[1]))
			current, inSummary = "", true
			continue
		}
		if inSummary {
			if strings.TrimSpace(line) == "" {
				inSummary = false
			} else {
				summary = append(summary, strings.TrimSpace(line))
			}
			continue
		}
		if current == "" {
			continue
		}
		if strings.HasPrefix(line, "- ") {
			p.Sections[current] = append(p.Sections[current], strings.TrimSpace(line[2:]))
		} else if strings.TrimSpace(line) != "" && len(p.Sections[current]) > 0 {
			last := len(p.Sections[current]) - 1
			p.Sections[current][last] += " " + strings.TrimSpace(line)
		}
	}
	p.Summary = strings.TrimSpace(strings.Join(summary, " "))
	return p
}

// rows("Inventory parameters") -> [item, safety stock, reorder point, order quantity]
func rows(p Plan, section string) [][]string {
	src := p.Sections[section]
	if len(src) == 1 && strings.EqualFold(strings.TrimSuffix(src[0], "."), "None") {
		return nil
	}
	out := make([][]string, 0, len(src))
	for _, b := range src {
		fields := strings.Split(b, " | ")
		for i := range fields {
			fields[i] = strings.TrimSpace(fields[i])
		}
		out = append(out, fields)
	}
	return out
}
// Java 17+ — the whole contract is line-oriented, so no dependencies are needed.
import java.util.*;
import java.util.regex.*;

record Plan(String coverage, String horizon, int confidence, String summary,
            Map<String, List<String>> sections) {}

static final List<String> SECTIONS = List.of(
    "Demand assessment", "Forecast plan", "Inventory parameters",
    "Risks and watchouts", "Recommended actions", "Open questions");

static Plan parsePlan(String text) {
    Map<String, List<String>> sections = new LinkedHashMap<>();
    SECTIONS.forEach(s -> sections.put(s, new ArrayList<>()));

    String coverage = "", horizon = "", current = null;
    int confidence = -1;
    boolean inSummary = false;
    var summary = new ArrayList<String>();

    for (String line : text.replace("\r\n", "\n").split("\n", -1)) {
        Matcher m;
        if ((m = Pattern.compile("^##\\s+(.*?)\\s*$").matcher(line)).matches()) {
            current = SECTIONS.contains(m.group(1)) ? m.group(1) : null;
            inSummary = false;
            continue;
        }
        if (coverage.isEmpty() && line.startsWith("COVERAGE:")) {
            coverage = line.substring(9).trim(); current = null; inSummary = false; continue;
        }
        if (horizon.isEmpty() && line.startsWith("HORIZON:")) {
            horizon = line.substring(8).trim(); current = null; inSummary = false; continue;
        }
        if (confidence < 0 && line.startsWith("CONFIDENCE:")) {
            confidence = Integer.parseInt(line.substring(11).trim());
            current = null; inSummary = false; continue;
        }
        if (summary.isEmpty() && line.startsWith("SUMMARY:")) {
            summary.add(line.substring(8).trim()); current = null; inSummary = true; continue;
        }
        if (inSummary) {
            if (line.isBlank()) inSummary = false; else summary.add(line.trim());
            continue;
        }
        if (current == null) continue;
        var bullets = sections.get(current);
        if (line.startsWith("- ")) bullets.add(line.substring(2).trim());
        else if (!line.isBlank() && !bullets.isEmpty())
            bullets.set(bullets.size() - 1, bullets.get(bullets.size() - 1) + " " + line.trim());
    }
    return new Plan(coverage, horizon, confidence, String.join(" ", summary).trim(), sections);
}

// Split a table section into its four fields:
//   for (String b : plan.sections().get("Inventory parameters"))
//       String[] f = b.split(" \\| ");   // item, safety stock, reorder point, order qty
// A section holding the single bullet "None." has nothing to report.
SECTIONS = ["Demand assessment", "Forecast plan", "Inventory parameters",
            "Risks and watchouts", "Recommended actions", "Open questions"].freeze

def parse_plan(text)
  plan = { coverage: "", horizon: "", confidence: nil, summary: "",
           sections: SECTIONS.to_h { |s| [s, []] } }
  current = nil
  summary = []

  text.gsub("\r\n", "\n").split("\n").each do |line|
    if (m = line.match(/\A##\s+(.*?)\s*\z/))
      current = SECTIONS.include?(m[1]) ? m[1] : nil
      next
    end
    if plan[:coverage].empty? && (m = line.match(/\ACOVERAGE\s*:\s*(.*)\z/))
      plan[:coverage] = m[1].strip; current = nil; next
    end
    if plan[:horizon].empty? && (m = line.match(/\AHORIZON\s*:\s*(.*)\z/))
      plan[:horizon] = m[1].strip; current = nil; next
    end
    if plan[:confidence].nil? && (m = line.match(/\ACONFIDENCE\s*:\s*(\d{1,3})\s*\z/))
      plan[:confidence] = m[1].to_i; current = nil; next
    end
    if summary.empty? && (m = line.match(/\ASUMMARY\s*:\s*(.*)\z/))
      summary << m[1].strip
      current = "__summary__"
      next
    end
    if current == "__summary__"
      line.strip.empty? ? (current = nil) : (summary << line.strip)
      next
    end
    next unless current

    bullets = plan[:sections][current]
    if line.start_with?("- ")
      bullets << line[2..].strip
    elsif !line.strip.empty? && !bullets.empty?
      bullets[-1] = "#{bullets[-1]} #{line.strip}"
    end
  end

  plan[:summary] = summary.join(" ").strip
  ["Forecast plan", "Inventory parameters"].each do |key|
    rows = plan[:sections][key]
    plan[key] = rows == ["None."] ? [] : rows.map { |r| r.split(" | ").map(&:strip) }
  end
  plan
end

plan = parse_plan(plan_text)
puts "#{plan[:coverage]} | #{plan[:horizon]} | #{plan[:confidence]}"
plan["Inventory parameters"].each do |item, ss, rop, oq|
  puts "  #{item}\n    ss: #{ss}\n    rop: #{rop}\n    oq: #{oq}"
end
const SECTIONS = ["Demand assessment", "Forecast plan", "Inventory parameters",
                  "Risks and watchouts", "Recommended actions", "Open questions"];

function parse_plan(string $text): array {
    $plan = ["coverage" => "", "horizon" => "", "confidence" => null,
             "summary" => "", "sections" => []];
    foreach (SECTIONS as $s) { $plan["sections"][$s] = []; }

    $current = null;
    $summary = [];

    foreach (explode("\n", str_replace("\r\n", "\n", $text)) as $line) {
        if (preg_match('/^##\s+(.*?)\s*$/', $line, $m)) {
            $current = in_array($m[1], SECTIONS, true) ? $m[1] : null;
            continue;
        }
        if ($plan["coverage"] === "" && preg_match('/^COVERAGE\s*:\s*(.*)$/', $line, $m)) {
            $plan["coverage"] = trim($m[1]); $current = null; continue;
        }
        if ($plan["horizon"] === "" && preg_match('/^HORIZON\s*:\s*(.*)$/', $line, $m)) {
            $plan["horizon"] = trim($m[1]); $current = null; continue;
        }
        if ($plan["confidence"] === null && preg_match('/^CONFIDENCE\s*:\s*(\d{1,3})\s*$/', $line, $m)) {
            $plan["confidence"] = (int) $m[1]; $current = null; continue;
        }
        if (!$summary && preg_match('/^SUMMARY\s*:\s*(.*)$/', $line, $m)) {
            $summary[] = trim($m[1]); $current = "__summary__"; continue;
        }
        if ($current === "__summary__") {
            if (trim($line) === "") { $current = null; } else { $summary[] = trim($line); }
            continue;
        }
        if ($current === null) { continue; }

        if (str_starts_with($line, "- ")) {
            $plan["sections"][$current][] = trim(substr($line, 2));
        } elseif (trim($line) !== "" && $plan["sections"][$current]) {
            $last = count($plan["sections"][$current]) - 1;
            $plan["sections"][$current][$last] .= " " . trim($line);
        }
    }

    $plan["summary"] = trim(implode(" ", $summary));
    foreach (["Forecast plan", "Inventory parameters"] as $key) {
        $rows = $plan["sections"][$key];
        $plan[$key] = $rows === ["None."]
            ? []
            : array_map(fn($r) => array_map("trim", explode(" | ", $r)), $rows);
    }
    return $plan;
}

$plan = parse_plan($planText);
echo "{$plan['coverage']} | {$plan['horizon']} | {$plan['confidence']}\n";
foreach ($plan["Inventory parameters"] as [$item, $ss, $rop, $oq]) {
    echo "  $item\n    ss: $ss\n    rop: $rop\n    oq: $oq\n";
}
using System.Text.RegularExpressions;

static readonly string[] Sections = {
    "Demand assessment", "Forecast plan", "Inventory parameters",
    "Risks and watchouts", "Recommended actions", "Open questions" };

record Plan(string Coverage, string Horizon, int Confidence, string Summary,
            Dictionary<string, List<string>> SectionBullets);

static Plan ParsePlan(string text)
{
    var sections = Sections.ToDictionary(s => s, _ => new List<string>());
    string coverage = "", horizon = "", current = null!;
    int confidence = -1;
    bool inSummary = false;
    var summary = new List<string>();

    foreach (var line in text.Replace("\r\n", "\n").Split('\n'))
    {
        var head = Regex.Match(line, @"^##\s+(.*?)\s*$");
        if (head.Success)
        {
            current = Sections.Contains(head.Groups[1].Value) ? head.Groups[1].Value : null!;
            inSummary = false;
            continue;
        }
        Match m;
        if (coverage.Length == 0 && (m = Regex.Match(line, @"^COVERAGE\s*:\s*(.*)$")).Success)
        { coverage = m.Groups[1].Value.Trim(); current = null!; inSummary = false; continue; }
        if (horizon.Length == 0 && (m = Regex.Match(line, @"^HORIZON\s*:\s*(.*)$")).Success)
        { horizon = m.Groups[1].Value.Trim(); current = null!; inSummary = false; continue; }
        if (confidence < 0 && (m = Regex.Match(line, @"^CONFIDENCE\s*:\s*(\d{1,3})\s*$")).Success)
        { confidence = int.Parse(m.Groups[1].Value); current = null!; inSummary = false; continue; }
        if (summary.Count == 0 && (m = Regex.Match(line, @"^SUMMARY\s*:\s*(.*)$")).Success)
        { summary.Add(m.Groups[1].Value.Trim()); current = null!; inSummary = true; continue; }

        if (inSummary)
        {
            if (line.Trim().Length == 0) inSummary = false; else summary.Add(line.Trim());
            continue;
        }
        if (current is null) continue;

        var bullets = sections[current];
        if (line.StartsWith("- ")) bullets.Add(line[2..].Trim());
        else if (line.Trim().Length > 0 && bullets.Count > 0)
            bullets[^1] += " " + line.Trim();
    }

    return new Plan(coverage, horizon, confidence,
                    string.Join(" ", summary).Trim(), sections);
}

// Table rows: one bullet, four fields.
static List<string[]> Rows(Plan plan, string section)
{
    var src = plan.SectionBullets[section];
    if (src.Count == 1 && src[0].TrimEnd('.').Equals("None", StringComparison.OrdinalIgnoreCase))
        return new();
    return src.Select(b => b.Split(" | ").Select(f => f.Trim()).ToArray()).ToList();
}

A stray code fence around the whole reply 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. The tag lines also survive a bold or italic wrapper (**COVERAGE:** Sufficient), so trim leading and trailing asterisks before matching if you want to be as forgiving as the app is.

Step 7 — Verify the arithmetic yourself

This is the app's headline feature, and it is available to API callers too. Whenever a safety stock or reorder point is computable, the reply states the arithmetic inline in backticks, in a fixed form:

FieldExpression formExample field text
Safety stockz x sd x sqrt(lead time)38 units at 97.5% service, z=1.96, current 2-week lead time (`1.96 x 13.6 x sqrt(2)`)
Reorder pointmean x lead time + safety stock1,800 units at the current 2-week lead time (`881.3 x 2 + 38`)

The multiplication sign is a literal x, numbers are plain decimals (thousands separators may appear in the stated result), and sqrt(...) is spelled that way. A field may carry two expressions when two lead times are planned — a current one and a changed one — each with its own stated number. Where a value is not computable, there is no expression at all.

The app re-runs every one of these expressions in the reader's browser and flags any that does not reproduce the number printed beside it. Do the same in four moves: pull the backticked spans out of the field, match them against the two expression forms, evaluate, and check that some number stated in the same field lands within tolerance of the result. Tolerance is max(1, 2% of the computed value) — the reply states rounded integers derived from decimals. Matching against any number in the field rather than a positionally-nearest one is deliberate: a field routinely carries two lead-time cases.

This is a self-consistency check on the reply's own stated formula and inputs. It confirms the arithmetic, not the provenance: it cannot prove the standard deviation came from your paste. That is by design — a good plan often uses a promo-cleaned sd that a raw scan of the paste never sees, and checking against the raw scan would fail exactly the careful work you want.

# Extract every backticked expression from the Inventory parameters section and
# re-run it with awk. Prints FAIL when the expression does not reproduce a number
# stated in the same field (tolerance: max(1, 2%)).
awk '/^## Inventory parameters$/{f=1;next} /^## /{f=0}
     f && /^- /{if(b)print b; b=substr($0,3); next}
     f && NF{b=b" "$0; next}
     END{if(b)print b}' plan.md \
| while IFS= read -r row; do
    item=${row%%" | "*}
    # each " | " field, checked on its own
    echo "$row" | awk -F' \\| ' -v item="$item" '{
      for (i = 2; i <= 3; i++) {
        field = $i
        # every number stated in this field, for the comparison pool
        n = 0; tmp = field
        while (match(tmp, /[0-9][0-9,]*(\.[0-9]+)?/)) {
          v = substr(tmp, RSTART, RLENGTH); gsub(",", "", v)
          pool[++n] = v + 0
          tmp = substr(tmp, RSTART + RLENGTH)
        }
        # every `backticked` span
        tmp = field
        while (match(tmp, /`[^`]+`/)) {
          expr = substr(tmp, RSTART + 1, RLENGTH - 2)
          tmp  = substr(tmp, RSTART + RLENGTH)
          e = expr; gsub(",", "", e); gsub(/ /, "", e)
          got = ""
          if (match(e, /^[0-9.]+x[0-9.]+xsqrt\([0-9.]+\)$/)) {
            split(e, p, /x?sqrt\(|x|\)/)
            got = p[1] * p[2] * sqrt(p[3])
          } else if (match(e, /^[0-9.]+x[0-9.]+\+[0-9.]+$/)) {
            split(e, p, /x|\+/)
            got = p[1] * p[2] + p[3]
          }
          if (got == "") continue
          tol = (got * 0.02 > 1) ? got * 0.02 : 1
          best = 1e18
          for (k = 1; k <= n; k++) { d = (pool[k] - got); if (d < 0) d = -d; if (d < best) { best = d; claim = pool[k] } }
          printf "%s  `%s` = %.1f  stated %s  %s\n", item, expr, got, claim, (best <= tol ? "ok" : "FAIL")
        }
        delete pool
      }
    }'
  done
import math, re

SS_EXPR  = re.compile(r"^\s*([\d.,]+)\s*x\s*([\d.,]+)\s*x\s*sqrt\(\s*([\d.,]+)\s*\)\s*$", re.I)
ROP_EXPR = re.compile(r"^\s*([\d.,]+)\s*x\s*([\d.,]+)\s*\+\s*([\d.,]+)\s*$")
NUMBER   = re.compile(r"\d[\d,]*(?:\.\d+)?")
BACKTICK = re.compile(r"`([^`]+)`")

def num(s):
    return float(str(s).replace(",", ""))

def check_field(text, kind):
    """Re-run every backticked expression in one field. -> [{expr, expected, claimed, ok}]"""
    pool = [num(m.group(0)) for m in NUMBER.finditer(text)]
    pattern = ROP_EXPR if kind == "rop" else SS_EXPR
    out = []
    for expr in BACKTICK.findall(text):
        m = pattern.match(expr)
        if not m:
            continue
        a, b, c = num(m.group(1)), num(m.group(2)), num(m.group(3))
        expected = a * b + c if kind == "rop" else a * b * math.sqrt(c)
        tol = max(1.0, abs(expected) * 0.02)
        claimed = min(pool, key=lambda v: abs(v - expected)) if pool else None
        out.append({"expr": expr, "kind": kind, "expected": expected,
                    "claimed": claimed,
                    "ok": claimed is not None and abs(claimed - expected) <= tol})
    return out

def verify(plan):
    checked = failed = 0
    for item, ss, rop, _oq in plan["Inventory parameters"]:
        for c in check_field(ss, "ss") + check_field(rop, "rop"):
            checked += 1
            if not c["ok"]:
                failed += 1
            print(f'  {"ok  " if c["ok"] else "FAIL"} {item}: `{c["expr"]}` '
                  f'= {c["expected"]:.1f}, stated {c["claimed"]}')
    return checked, failed

checked, failed = verify(plan)
print(f"{checked} expressions re-run, {failed} disagree with the number beside them")
if failed:
    raise SystemExit("recheck the flagged rows before ordering")
const SS_EXPR  = /^\s*([\d.,]+)\s*x\s*([\d.,]+)\s*x\s*sqrt\(\s*([\d.,]+)\s*\)\s*$/i;
const ROP_EXPR = /^\s*([\d.,]+)\s*x\s*([\d.,]+)\s*\+\s*([\d.,]+)\s*$/;

const num = (s) => parseFloat(String(s).replace(/,/g, ""));
const numbersIn = (t) => (String(t).match(/\d[\d,]*(?:\.\d+)?/g) ?? []).map(num);
const backticked = (t) => [...String(t).matchAll(/`([^`]+)`/g)].map((m) => m[1]);

// Re-run every backticked expression in one field.
function checkField(text, kind) {
  const re = kind === "rop" ? ROP_EXPR : SS_EXPR;
  const pool = numbersIn(text);
  const out = [];
  for (const expr of backticked(text)) {
    const m = re.exec(expr);
    if (!m) continue;
    const [a, b, c] = [num(m[1]), num(m[2]), num(m[3])];
    const expected = kind === "rop" ? a * b + c : a * b * Math.sqrt(c);
    if (!isFinite(expected)) continue;
    const tol = Math.max(1, Math.abs(expected) * 0.02);
    let claimed = null, best = Infinity;
    for (const v of pool) {
      const d = Math.abs(v - expected);
      if (d < best) { best = d; claimed = v; }
    }
    out.push({ expr, kind, expected, claimed, ok: best <= tol });
  }
  return out;
}

let checked = 0, failed = 0;
for (const [item, ss, rop] of plan["Inventory parameters"]) {
  for (const c of [...checkField(ss, "ss"), ...checkField(rop, "rop")]) {
    checked++;
    if (!c.ok) failed++;
    console.log(`  ${c.ok ? "ok  " : "FAIL"} ${item}: \`${c.expr}\` ` +
                `= ${c.expected.toFixed(1)}, stated ${c.claimed}`);
  }
}
console.log(`${checked} expressions re-run, ${failed} disagree`);
if (failed) process.exitCode = 1;
var (
	ssExpr  = regexp.MustCompile(`(?i)^\s*([\d.,]+)\s*x\s*([\d.,]+)\s*x\s*sqrt\(\s*([\d.,]+)\s*\)\s*$`)
	ropExpr = regexp.MustCompile(`^\s*([\d.,]+)\s*x\s*([\d.,]+)\s*\+\s*([\d.,]+)\s*$`)
	numRe   = regexp.MustCompile(`\d[\d,]*(?:\.\d+)?`)
	tickRe  = regexp.MustCompile("`([^`]+)`")
)

func toNum(s string) float64 {
	v, _ := strconv.ParseFloat(strings.ReplaceAll(s, ",", ""), 64)
	return v
}

type Check struct {
	Expr     string
	Expected float64
	Claimed  float64
	OK       bool
}

// checkField re-runs every backticked expression in one field.
func checkField(text, kind string) []Check {
	re := ssExpr
	if kind == "rop" {
		re = ropExpr
	}
	var pool []float64
	for _, s := range numRe.FindAllString(text, -1) {
		pool = append(pool, toNum(s))
	}
	var out []Check
	for _, m := range tickRe.FindAllStringSubmatch(text, -1) {
		expr := m[1]
		p := re.FindStringSubmatch(expr)
		if p == nil {
			continue
		}
		a, b, c := toNum(p[1]), toNum(p[2]), toNum(p[3])
		expected := a * b * math.Sqrt(c)
		if kind == "rop" {
			expected = a*b + c
		}
		tol := math.Max(1, math.Abs(expected)*0.02)
		best, claimed := math.Inf(1), 0.0
		for _, v := range pool {
			if d := math.Abs(v - expected); d < best {
				best, claimed = d, v
			}
		}
		out = append(out, Check{expr, expected, claimed, best <= tol})
	}
	return out
}

checked, failed := 0, 0
for _, row := range rows(plan, "Inventory parameters") {
	if len(row) < 4 {
		continue
	}
	checks := append(checkField(row[1], "ss"), checkField(row[2], "rop")...)
	for _, c := range checks {
		checked++
		status := "ok  "
		if !c.OK {
			failed++
			status = "FAIL"
		}
		fmt.Printf("  %s %s: `%s` = %.1f, stated %.0f\n", status, row[0], c.Expr, c.Expected, c.Claimed)
	}
}
fmt.Printf("%d expressions re-run, %d disagree\n", checked, failed)
// Java 17+ — re-run the reply's own arithmetic and compare with what it printed.
static final Pattern SS_EXPR = Pattern.compile(
    "^\\s*([\\d.,]+)\\s*x\\s*([\\d.,]+)\\s*x\\s*sqrt\\(\\s*([\\d.,]+)\\s*\\)\\s*$",
    Pattern.CASE_INSENSITIVE);
static final Pattern ROP_EXPR = Pattern.compile(
    "^\\s*([\\d.,]+)\\s*x\\s*([\\d.,]+)\\s*\\+\\s*([\\d.,]+)\\s*$");
static final Pattern NUMBER   = Pattern.compile("\\d[\\d,]*(?:\\.\\d+)?");
static final Pattern BACKTICK = Pattern.compile("`([^`]+)`");

static double num(String s) { return Double.parseDouble(s.replace(",", "")); }

record Check(String expr, double expected, Double claimed, boolean ok) {}

static List<Check> checkField(String text, String kind) {
    Pattern re = kind.equals("rop") ? ROP_EXPR : SS_EXPR;
    var pool = NUMBER.matcher(text).results().map(r -> num(r.group())).toList();
    var out = new ArrayList<Check>();
    var ticks = BACKTICK.matcher(text);
    while (ticks.find()) {
        String expr = ticks.group(1);
        Matcher m = re.matcher(expr);
        if (!m.matches()) continue;
        double a = num(m.group(1)), b = num(m.group(2)), c = num(m.group(3));
        double expected = kind.equals("rop") ? a * b + c : a * b * Math.sqrt(c);
        double tol = Math.max(1, Math.abs(expected) * 0.02);
        Double claimed = null;
        double best = Double.MAX_VALUE;
        for (double v : pool) {
            double d = Math.abs(v - expected);
            if (d < best) { best = d; claimed = v; }
        }
        out.add(new Check(expr, expected, claimed, best <= tol));
    }
    return out;
}

int checked = 0, failed = 0;
for (String bullet : plan.sections().get("Inventory parameters")) {
    String[] f = bullet.split(" \\| ");
    if (f.length < 4) continue;
    var checks = new ArrayList<Check>(checkField(f[1], "ss"));
    checks.addAll(checkField(f[2], "rop"));
    for (Check c : checks) {
        checked++;
        if (!c.ok()) failed++;
        System.out.printf("  %s %s: `%s` = %.1f, stated %s%n",
            c.ok() ? "ok  " : "FAIL", f[0], c.expr(), c.expected(), c.claimed());
    }
}
System.out.printf("%d expressions re-run, %d disagree%n", checked, failed);
SS_EXPR  = /\A\s*([\d.,]+)\s*x\s*([\d.,]+)\s*x\s*sqrt\(\s*([\d.,]+)\s*\)\s*\z/i
ROP_EXPR = /\A\s*([\d.,]+)\s*x\s*([\d.,]+)\s*\+\s*([\d.,]+)\s*\z/
NUMBER   = /\d[\d,]*(?:\.\d+)?/

def to_num(s) = s.to_s.delete(",").to_f

# Re-run every backticked expression in one field.
def check_field(text, kind)
  re   = kind == "rop" ? ROP_EXPR : SS_EXPR
  pool = text.scan(NUMBER).map { |s| to_num(s) }
  text.scan(/`([^`]+)`/).flatten.filter_map do |expr|
    m = expr.match(re)
    next unless m

    a, b, c = to_num(m[1]), to_num(m[2]), to_num(m[3])
    expected = kind == "rop" ? a * b + c : a * b * Math.sqrt(c)
    tol = [1.0, expected.abs * 0.02].max
    claimed = pool.min_by { |v| (v - expected).abs }
    { expr: expr, expected: expected, claimed: claimed,
      ok: !claimed.nil? && (claimed - expected).abs <= tol }
  end
end

checked = 0
failed  = 0
plan["Inventory parameters"].each do |item, ss, rop, _oq|
  (check_field(ss, "ss") + check_field(rop, "rop")).each do |c|
    checked += 1
    failed += 1 unless c[:ok]
    printf("  %s %s: `%s` = %.1f, stated %s\n",
           c[:ok] ? "ok  " : "FAIL", item, c[:expr], c[:expected], c[:claimed])
  end
end
puts "#{checked} expressions re-run, #{failed} disagree"
exit 1 if failed.positive?
const SS_EXPR  = '/^\s*([\d.,]+)\s*x\s*([\d.,]+)\s*x\s*sqrt\(\s*([\d.,]+)\s*\)\s*$/i';
const ROP_EXPR = '/^\s*([\d.,]+)\s*x\s*([\d.,]+)\s*\+\s*([\d.,]+)\s*$/';

function to_num(string $s): float { return (float) str_replace(",", "", $s); }

/** Re-run every backticked expression in one field. */
function check_field(string $text, string $kind): array {
    $re = $kind === "rop" ? ROP_EXPR : SS_EXPR;
    preg_match_all('/\d[\d,]*(?:\.\d+)?/', $text, $nums);
    $pool = array_map("to_num", $nums[0]);

    preg_match_all('/`([^`]+)`/', $text, $ticks);
    $out = [];
    foreach ($ticks[1] as $expr) {
        if (!preg_match($re, $expr, $m)) { continue; }
        [$a, $b, $c] = [to_num($m[1]), to_num($m[2]), to_num($m[3])];
        $expected = $kind === "rop" ? $a * $b + $c : $a * $b * sqrt($c);
        $tol = max(1.0, abs($expected) * 0.02);
        $claimed = null;
        $best = INF;
        foreach ($pool as $v) {
            $d = abs($v - $expected);
            if ($d < $best) { $best = $d; $claimed = $v; }
        }
        $out[] = ["expr" => $expr, "expected" => $expected,
                  "claimed" => $claimed, "ok" => $best <= $tol];
    }
    return $out;
}

$checked = 0;
$failed  = 0;
foreach ($plan["Inventory parameters"] as [$item, $ss, $rop, $oq]) {
    foreach (array_merge(check_field($ss, "ss"), check_field($rop, "rop")) as $c) {
        $checked++;
        if (!$c["ok"]) { $failed++; }
        printf("  %s %s: `%s` = %.1f, stated %s\n",
               $c["ok"] ? "ok  " : "FAIL", $item, $c["expr"], $c["expected"], $c["claimed"]);
    }
}
echo "$checked expressions re-run, $failed disagree\n";
using System.Text.RegularExpressions;

static readonly Regex SsExpr = new(
    @"^\s*([\d.,]+)\s*x\s*([\d.,]+)\s*x\s*sqrt\(\s*([\d.,]+)\s*\)\s*$", RegexOptions.IgnoreCase);
static readonly Regex RopExpr = new(@"^\s*([\d.,]+)\s*x\s*([\d.,]+)\s*\+\s*([\d.,]+)\s*$");
static readonly Regex NumberRe = new(@"\d[\d,]*(?:\.\d+)?");
static readonly Regex TickRe = new("`([^`]+)`");

static double Num(string s) => double.Parse(s.Replace(",", ""));

record Check(string Expr, double Expected, double? Claimed, bool Ok);

// Re-run every backticked expression in one field.
static List<Check> CheckField(string text, string kind)
{
    var re = kind == "rop" ? RopExpr : SsExpr;
    var pool = NumberRe.Matches(text).Select(m => Num(m.Value)).ToList();
    var outp = new List<Check>();

    foreach (Match tick in TickRe.Matches(text))
    {
        var expr = tick.Groups[1].Value;
        var m = re.Match(expr);
        if (!m.Success) continue;
        double a = Num(m.Groups[1].Value), b = Num(m.Groups[2].Value), c = Num(m.Groups[3].Value);
        var expected = kind == "rop" ? a * b + c : a * b * Math.Sqrt(c);
        var tol = Math.Max(1, Math.Abs(expected) * 0.02);
        double? claimed = null;
        var best = double.MaxValue;
        foreach (var v in pool)
        {
            var d = Math.Abs(v - expected);
            if (d < best) { best = d; claimed = v; }
        }
        outp.Add(new Check(expr, expected, claimed, best <= tol));
    }
    return outp;
}

int checked = 0, failed = 0;
foreach (var row in Rows(plan, "Inventory parameters"))
{
    if (row.Length < 4) continue;
    var checks = CheckField(row[1], "ss").Concat(CheckField(row[2], "rop"));
    foreach (var c in checks)
    {
        checked++;
        if (!c.Ok) failed++;
        Console.WriteLine($"  {(c.Ok ? "ok  " : "FAIL")} {row[0]}: `{c.Expr}` " +
                          $"= {c.Expected:F1}, stated {c.Claimed}");
    }
}
Console.WriteLine($"{checked} expressions re-run, {failed} disagree");

This is AI-generated demand planning from the text you pasted, not a guarantee of service level or margin: it sees only what you sent, never your live on-hand, your open POs or your supplier's real capacity. A failed arithmetic check means the reply contradicted itself and the row must be rechecked before anyone orders against it; a passed check means the stated formula reproduces the stated number, nothing more. Read ## Open questions first — anything the paste could not support arrives there rather than as a silently assumed industry default — and keep a planner in the loop.