Dash Forge — API

Paste the data, get the dashboard design.

API tokens Open the app

Design dashboards over your own tables, from your own tools

Send one pasted table — CSV, TSV, semicolon- or pipe-separated, or a markdown table — plus, optionally, the question the dashboard should answer. What comes back is a design, not a picture and not a computed report: a title, a verdict, a one-line summary, one SPEC JSON object describing the KPI cards, the charts, the filters and the drill-down table in terms of your column names, then insights and notes in markdown. The model never does arithmetic — the app's own engine parses the table, applies the SPEC and computes every number locally, and you can do exactly the same in your pipeline. That is what makes the output safe to automate: a SPEC either references real columns and validates, or it is rejected outright. 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. 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 design is produced by the gpt-terra model. Estimates are free; runs are metered against your credit balance. There is a single run task — one table in, one design out, no follow-up calls and no session state to carry.

The routes have no app segment in them. They are /v1/app-api/guest, /v1/app-api/me, /v1/app-api/estimate, /v1/app-api/run, /v1/app-api/run-stream and /v1/app-api/jobs/{job_id}. There is no /apps/{slug}/ path segment anywhere: the app slug (dash-forge) is bound to the token when the token is minted at POST /guest, and every later call is scoped by that token alone. Likewise the /run and /estimate body is the input object itself — {"focus": …, "data": …} — not {"input": {…}}.

StatusMeaning
400Malformed body — usually a missing or non-string data field, or a body wrapped in an extra input key.
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 token submitting a very large table).
404Unknown job or record id.
409An Idempotency-Key was replayed with a different body.
429Too many runs in flight — back off and retry.
5xxTransient platform error — retry with backoff.

Browsers enforce CORS for this API, so run these examples from a server, script or terminal — not from another website's frontend.

Step 0 — A tiny client

Every task below is a single HTTP call, so start with a short helper that adds the auth header, sends JSON and unwraps the data envelope. The later steps reuse it.

export API="https://api.skillsafe.ai/v1/app-api"
export 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 — read it from your shell environment 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.fetch("SKILLSAFE_TOKEN") # see step 1

def api(method, path, body = nil)
  uri = URI(API + path)
  req = Net::HTTP.const_get(method.capitalize).new(uri)
  req["Authorization"] = "Bearer #{TOKEN}"
  req["Content-Type"] = "application/json"
  req.body = body.to_json if body
  res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
  payload = JSON.parse(res.body)
  raise (payload.dig("error", "message") || res.message) unless res.is_a?(Net::HTTPSuccess)
  payload["data"]
end
<?php
const API = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = getenv("SKILLSAFE_TOKEN"); // see step 1

function api(string $method, string $path, ?array $body = null): mixed {
    global $TOKEN;
    $ch = curl_init(API . $path);
    curl_setopt_array($ch, [
        CURLOPT_CUSTOMREQUEST  => $method,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER     => [
            "Authorization: Bearer $TOKEN",
            "Content-Type: application/json",
        ],
        CURLOPT_POSTFIELDS     => $body === null ? null : json_encode($body),
    ]);
    $payload = json_decode(curl_exec($ch), true);
    $status  = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    curl_close($ch);
    if ($status >= 400) {
        throw new Exception($payload["error"]["message"] ?? "HTTP $status");
    }
    return $payload["data"];
}
// .NET 8+
using System.Net.Http.Json;
using System.Text.Json;

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

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

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

Step 1 — Get a token

POST /guest

This is the one and only place the app slug appears. POST /guest takes {"slug": "dash-forge"} and returns a token that is bound to this app; from then on the routes carry no slug at all. A guest token lets you check balances and estimate costs for free. For metered design 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.

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

The app stores this browser's token under the localStorage key skillsafe_app_token:dash-forge, on the app's own origin. The token page reads and manages it for you — you never need to open developer tools.

Step 2 — Check who you are and your balance

GET /me

Returns subject_type ("user" or "guest"), subject_id and your credits balance. Check this before sending a wide table — cost scales with how much of the table you send.

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 — Estimate the cost

POST /estimate

Send exactly the input you would send to /run — the input object directly, with no input wrapper — and the response's hold_credits is the worst-case cost. Nothing is charged and no job is created, so estimating is free. This is worth doing before you pipe a long table in.

The input object

FieldTypeNotes
focusstring, optionalThe question the dashboard should answer, in the user's words — "is our growth real, and what is driving it?". May be the empty string; then the design targets the most decision-relevant story the columns support. It is what orders the insights, so it is the cheapest way to change the shape of the answer. The app clips it to 300 characters, keeping the beginning and the end.
datastring, requiredThe pasted table, as one string. CSV, TSV, semicolon- or pipe-separated, or a markdown table; the delimiter is detected. The first row is treated as the header, and every column name in the returned SPEC is copied from it character for character. Messy values are expected and handled: currency strings ("$1,240.50"), percent strings ("10%"), blanks and inconsistent casing. Quoted fields containing the delimiter are parsed correctly. With an empty or unusable data you still get a valid reply — a Not enough data verdict.
specstring, optionalA SPEC JSON object you already have, as a string — from a prior run or hand-edited. When present it is treated as your own design: your choices are kept unless the data makes them impossible, and whatever had to change is named under NOTES. Send "" to let the run design from scratch. Clipped to 8000 characters.
notesstring, optionalYour guidance in prose, for anything the table alone does not say: "fiscal year starts in October", "treat emea as EMEA", "revenue is in USD", "flag the blank discounts rather than counting them as zero". Followed where the data allows. Clipped to 2000 characters.
data_notestring, conditionalPresent only when the table was too large to send whole and was clipped middle-out. The app sets it automatically when data exceeds 40,000 characters: it keeps the header, the opening rows and the most recent rows, replaces the middle with an in-band marker line ([... N middle rows omitted to fit the limit; this line is not data ...]), and sets data_note to say how many rows were profiled, how many were sent and how many were dropped — and to instruct the model to describe the shape and the story rather than presenting its own row counts or totals as complete. If you clip a table yourself, set this field the same way; if you send the whole table, omit it.
retry_notestring, conditionalPresent only on the app's automatic reformat retry, when a first reply did not follow the contract. It restates the required shape and says what was wrong. Omit it on a first attempt. If you implement the same retry, note that it is a genuinely different request: give it a different Idempotency-Key, or the platform replays the original malformed job.
# One table, header first. CSV here; TSV, semicolons, pipes and markdown
# tables all work. Quoted fields may contain the delimiter.
cat > data.csv <<'CSV'
order_date,region,channel,plan,revenue,units
2025-10-01,North America,web,Starter,"$1,240.50",14
2025-10-07,North America,partner,Scale,"$3,280.00",22
2025-11-05,EMEA,web,Starter,"$1,520.00",14
2025-11-24,LATAM,field,Scale,"$2,010.00",15
2025-12-19,APAC,web,Growth,"$1,080.00",11
2026-01-12,North America,partner,Growth,"$4,180.00",26
2026-02-13,EMEA,partner,Growth,"$2,880.00",20
2026-03-31,North America,field,Growth,"$4,180.00",26
CSV

# NOTE: the body IS the input object. No {"input": {...}} wrapper.
jq -n --rawfile data data.csv \
  '{focus: "Is the revenue trend real, and which channels and regions drive it?",
    data:  $data,
    spec:  "",
    notes: "Fiscal year starts in October. Revenue is USD; strip the $ and commas."}' > input.json

curl -s -X POST "$API/estimate" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d @input.json | jq '.data.hold_credits'
DATA = "\n".join([
    "order_date,region,channel,plan,revenue,units",
    '2025-10-01,North America,web,Starter,"$1,240.50",14',
    '2025-10-07,North America,partner,Scale,"$3,280.00",22',
    '2025-11-05,EMEA,web,Starter,"$1,520.00",14',
    '2025-11-24,LATAM,field,Scale,"$2,010.00",15',
    '2025-12-19,APAC,web,Growth,"$1,080.00",11',
    '2026-01-12,North America,partner,Growth,"$4,180.00",26',
    '2026-02-13,EMEA,partner,Growth,"$2,880.00",20',
    '2026-03-31,North America,field,Growth,"$4,180.00",26',
])

# The body IS this object — there is no {"input": ...} wrapper.
payload = {
    "focus": "Is the revenue trend real, and which channels and regions drive it?",
    "data": DATA,
    "spec": "",
    "notes": "Fiscal year starts in October. Revenue is USD; strip the $ and commas.",
}
# Only when you clipped the table yourself:
# payload["data_note"] = "The table was too large to send whole. Profiled all 48,000 rows ..."

est = api("POST", "/estimate", payload)
print("worst case:", est.get("hold_credits", est.get("credits")), "credits")
const data = [
  "order_date,region,channel,plan,revenue,units",
  '2025-10-01,North America,web,Starter,"$1,240.50",14',
  '2025-10-07,North America,partner,Scale,"$3,280.00",22',
  '2025-11-05,EMEA,web,Starter,"$1,520.00",14',
  '2025-11-24,LATAM,field,Scale,"$2,010.00",15',
  '2025-12-19,APAC,web,Growth,"$1,080.00",11',
  '2026-01-12,North America,partner,Growth,"$4,180.00",26',
  '2026-02-13,EMEA,partner,Growth,"$2,880.00",20',
  '2026-03-31,North America,field,Growth,"$4,180.00",26',
].join("\n");

// The body IS this object — there is no { input: ... } wrapper.
const payload = {
  focus: "Is the revenue trend real, and which channels and regions drive it?",
  data,
  spec: "",
  notes: "Fiscal year starts in October. Revenue is USD; strip the $ and commas.",
};
// Only when you clipped the table yourself:
// payload.data_note = "The table was too large to send whole. Profiled all 48,000 rows ...";

const est = await api("POST", "/estimate", payload);
console.log("worst case:", est.hold_credits ?? est.credits, "credits");
const data = "order_date,region,channel,plan,revenue,units\n" +
	"2025-10-01,North America,web,Starter,\"$1,240.50\",14\n" +
	"2025-10-07,North America,partner,Scale,\"$3,280.00\",22\n" +
	"2025-11-05,EMEA,web,Starter,\"$1,520.00\",14\n" +
	"2025-11-24,LATAM,field,Scale,\"$2,010.00\",15\n" +
	"2025-12-19,APAC,web,Growth,\"$1,080.00\",11\n" +
	"2026-01-12,North America,partner,Growth,\"$4,180.00\",26\n" +
	"2026-02-13,EMEA,partner,Growth,\"$2,880.00\",20\n" +
	"2026-03-31,North America,field,Growth,\"$4,180.00\",26"

// The body IS this map. No "input" wrapper.
payload := map[string]any{
	"focus": "Is the revenue trend real, and which channels and regions drive it?",
	"data":  data,
	"spec":  "",
	"notes": "Fiscal year starts in October. Revenue is USD; strip the $ and commas.",
}
// Only when you clipped the table yourself:
// payload["data_note"] = "The table was too large to send whole. Profiled all 48,000 rows ..."

var est struct{ HoldCredits int64 `json:"hold_credits"` }
err := call("POST", "/estimate", payload, &est)
// One table as one string; \n between rows.
String data = String.join("\n",
    "order_date,region,channel,plan,revenue,units",
    "2025-10-01,North America,web,Starter,\"$1,240.50\",14",
    "2025-10-07,North America,partner,Scale,\"$3,280.00\",22",
    "2025-11-05,EMEA,web,Starter,\"$1,520.00\",14",
    "2025-11-24,LATAM,field,Scale,\"$2,010.00\",15",
    "2025-12-19,APAC,web,Growth,\"$1,080.00\",11",
    "2026-01-12,North America,partner,Growth,\"$4,180.00\",26",
    "2026-02-13,EMEA,partner,Growth,\"$2,880.00\",20",
    "2026-03-31,North America,field,Growth,\"$4,180.00\",26");

// The body IS the input object — no "input" wrapper. toJsonString() is your
// JSON library's string encoder.
String jsonPayload = """
    {"focus": "Is the revenue trend real, and which channels and regions drive it?",
     "data": %s,
     "spec": "",
     "notes": "Fiscal year starts in October. Revenue is USD; strip the $ and commas."}
    """.formatted(toJsonString(data));
// Add "data_note" only if you clipped the table; "retry_note" only on a reformat retry.

String envelope = api("POST", "/estimate", jsonPayload);
// worst-case cost is at data.hold_credits
DATA = [
  "order_date,region,channel,plan,revenue,units",
  '2025-10-01,North America,web,Starter,"$1,240.50",14',
  '2025-10-07,North America,partner,Scale,"$3,280.00",22',
  '2025-11-05,EMEA,web,Starter,"$1,520.00",14',
  '2025-11-24,LATAM,field,Scale,"$2,010.00",15',
  '2025-12-19,APAC,web,Growth,"$1,080.00",11',
  '2026-01-12,North America,partner,Growth,"$4,180.00",26',
  '2026-02-13,EMEA,partner,Growth,"$2,880.00",20',
  '2026-03-31,North America,field,Growth,"$4,180.00",26'
].join("\n")

# The body IS this hash. No :input wrapper.
payload = {
  focus: "Is the revenue trend real, and which channels and regions drive it?",
  data:  DATA,
  spec:  "",
  notes: "Fiscal year starts in October. Revenue is USD; strip the $ and commas."
}
# payload[:data_note] = "..." only when you clipped the table yourself.

est = api("POST", "/estimate", payload)
puts "worst case: #{est["hold_credits"] || est["credits"]} credits"
$data = implode("\n", [
    "order_date,region,channel,plan,revenue,units",
    '2025-10-01,North America,web,Starter,"$1,240.50",14',
    '2025-10-07,North America,partner,Scale,"$3,280.00",22',
    '2025-11-05,EMEA,web,Starter,"$1,520.00",14',
    '2025-11-24,LATAM,field,Scale,"$2,010.00",15',
    '2025-12-19,APAC,web,Growth,"$1,080.00",11',
    '2026-01-12,North America,partner,Growth,"$4,180.00",26',
    '2026-02-13,EMEA,partner,Growth,"$2,880.00",20',
    '2026-03-31,North America,field,Growth,"$4,180.00",26',
]);

// The body IS this array. No "input" wrapper.
$payload = [
    "focus" => "Is the revenue trend real, and which channels and regions drive it?",
    "data"  => $data,
    "spec"  => "",
    "notes" => "Fiscal year starts in October. Revenue is USD; strip the $ and commas.",
];
// $payload["data_note"] = "..."; only when you clipped the table yourself.

$est = api("POST", "/estimate", $payload);
echo "worst case: " . ($est["hold_credits"] ?? $est["credits"]) . " credits\n";
var rows = new[] {
    "order_date,region,channel,plan,revenue,units",
    "2025-10-01,North America,web,Starter,\"$1,240.50\",14",
    "2025-10-07,North America,partner,Scale,\"$3,280.00\",22",
    "2025-11-05,EMEA,web,Starter,\"$1,520.00\",14",
    "2025-11-24,LATAM,field,Scale,\"$2,010.00\",15",
    "2025-12-19,APAC,web,Growth,\"$1,080.00\",11",
    "2026-01-12,North America,partner,Growth,\"$4,180.00\",26",
    "2026-02-13,EMEA,partner,Growth,\"$2,880.00\",20",
    "2026-03-31,North America,field,Growth,\"$4,180.00\",26",
};
var data = string.Join("\n", rows);

// The body IS this object. No { input = ... } wrapper.
var payload = new {
    focus = "Is the revenue trend real, and which channels and regions drive it?",
    data,
    spec = "",
    notes = "Fiscal year starts in October. Revenue is USD; strip the $ and commas.",
};
// Add data_note only when you clipped the table; retry_note only on a reformat retry.

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

focus and notes are the two fields that actually change the design. focus decides which story the charts tell and how the insights are ordered; notes settles what the table cannot say for itself — fiscal calendars, units, values you want normalised, rows you want flagged rather than smoothed. Both are optional, and both are cheap compared with a second run.

Step 4 — Run the design 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 a job_id. Poll /jobs/{job_id} every 1–2 seconds until status is succeeded or failed (a run typically takes 20–60 s). 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: the TITLE: / VERDICT: / SUMMARY: / SPEC: / INSIGHTS: / NOTES: shape documented below. Only the SPEC block is JSON. The samples below split it into its sections, parse the SPEC, print the verdict and the design, and save the SPEC to spec.json.

JOB_ID=$(curl -s -X POST "$API/run" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: df-$(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 reply is plain text, not JSON. Unwrap it once, then slice it up.
echo "$JOB" | jq -r '.data.output.output' > reply.txt

grep -m1 '^TITLE:'   reply.txt
grep -m1 '^VERDICT:' reply.txt
grep -m1 '^SUMMARY:' reply.txt

# Everything strictly between the "SPEC:" line and the "INSIGHTS:" line is the JSON.
awk '/^SPEC:[ \t]*$/{f=1;next} /^INSIGHTS:[ \t]*$/{f=0} f' reply.txt > spec.json
jq -e . spec.json > /dev/null || { echo "SPEC is not valid JSON"; exit 1; }

jq -r '"KPIs:", (.kpis[] | "  \(.label) = \(.agg)(\(.field))"),
       "Charts:", (.charts[] | "  [\(.type)] \(.title) — \(.dimension) x \(.agg)(\(.measure))"),
       "Filters: \((.filters // []) | join(", "))"' spec.json

awk '/^INSIGHTS:[ \t]*$/{f=1;next} /^NOTES:[ \t]*$/{f=0} f' reply.txt > insights.md
awk '/^NOTES:[ \t]*$/{f=1;next} f'                          reply.txt > notes.md

# Gate a pipeline on the verdict
grep -qx 'VERDICT: Well shaped' reply.txt \
  || { echo "the design needed judgement — read notes.md before publishing"; exit 1; }
import time

VERDICTS = ("Well shaped", "Check the data", "Not enough data")

def parse_reply(text):
    """The whole contract, decoded. Raises on anything off-shape."""
    lines = text.replace("\r\n", "\n").split("\n")
    out = {"title": "", "verdict": "", "summary": ""}
    for ln in lines:
        if not out["title"] and ln.startswith("TITLE:"):
            out["title"] = ln[len("TITLE:"):].strip()
        elif not out["verdict"] and ln.startswith("VERDICT:"):
            out["verdict"] = ln[len("VERDICT:"):].strip().rstrip(".!")
        elif not out["summary"] and ln.startswith("SUMMARY:"):
            out["summary"] = ln[len("SUMMARY:"):].strip()
    if not out["title"]:
        raise ValueError("no TITLE: line")
    if out["verdict"] not in VERDICTS:
        raise ValueError(f'VERDICT is not one of {VERDICTS}: {out["verdict"]!r}')

    def index_of(label):
        for i, ln in enumerate(lines):
            if ln.strip() == label:
                return i
        return -1

    si, ii, ni = index_of("SPEC:"), index_of("INSIGHTS:"), index_of("NOTES:")
    if si == -1 or ii == -1:
        raise ValueError("missing a SPEC: or INSIGHTS: line")
    out["spec"] = json.loads("\n".join(lines[si + 1:ii]).strip())
    end = ni if ni != -1 else len(lines)
    out["insights"] = "\n".join(lines[ii + 1:end]).strip()
    out["notes"] = "\n".join(lines[ni + 1:]).strip() if ni != -1 else ""
    return out

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

res = parse_reply(raw)
print(f'{res["title"]} [{res["verdict"]}]')
print(res["summary"])
for k in res["spec"]["kpis"]:
    print(f'  KPI {k["label"]}: {k["agg"]}({k["field"]})')
for c in res["spec"]["charts"]:
    print(f'  {c["type"]:<8} {c["title"]} — {c["dimension"]} x {c["agg"]}({c["measure"]})')
print("  filters:", ", ".join(res["spec"].get("filters", [])) or "none")
print(res["insights"])
print(res["notes"])

with open("spec.json", "w", encoding="utf-8") as fh:
    json.dump(res["spec"], fh, indent=2)

if res["verdict"] == "Not enough data":
    raise SystemExit("no usable table — nothing to build")
import { writeFileSync } from "node:fs";

const VERDICTS = ["Well shaped", "Check the data", "Not enough data"];

function parseReply(text) {
  const lines = text.replace(/\r\n/g, "\n").split("\n");
  const first = (p) => lines.find((l) => l.startsWith(p))?.slice(p.length).trim() ?? "";
  const title = first("TITLE:");
  const verdict = first("VERDICT:").replace(/[.!]+$/, "");
  const summary = first("SUMMARY:");
  if (!title) throw new Error("no TITLE: line");
  if (!VERDICTS.includes(verdict)) throw new Error(`bad VERDICT: ${verdict}`);

  const at = (label) => lines.findIndex((l) => l.trim() === label);
  const si = at("SPEC:"), ii = at("INSIGHTS:"), ni = at("NOTES:");
  if (si === -1 || ii === -1) throw new Error("missing a SPEC: or INSIGHTS: line");

  const spec = JSON.parse(lines.slice(si + 1, ii).join("\n").trim());
  const end = ni === -1 ? lines.length : ni;
  return {
    title, verdict, summary, spec,
    insights: lines.slice(ii + 1, end).join("\n").trim(),
    notes: ni === -1 ? "" : lines.slice(ni + 1).join("\n").trim(),
  };
}

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");

const res = parseReply(job.output?.output ?? job.output);
console.log(`${res.title} [${res.verdict}]`);
console.log(res.summary);
for (const k of res.spec.kpis) console.log(`  KPI ${k.label}: ${k.agg}(${k.field})`);
for (const c of res.spec.charts) {
  console.log(`  ${c.type} ${c.title} — ${c.dimension} x ${c.agg}(${c.measure})`);
}
console.log("  filters:", (res.spec.filters ?? []).join(", ") || "none");
console.log(res.insights);
console.log(res.notes);

writeFileSync("spec.json", JSON.stringify(res.spec, null, 2));

if (res.verdict === "Not enough data") process.exitCode = 1;
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": "<plain text reply>"} — unwrap, then slice by label.
var wrapper struct{ Output string `json:"output"` }
json.Unmarshal(job.Output, &wrapper)

type Spec struct {
	Title string `json:"title"`
	KPIs  []struct {
		ID, Label, Agg, Field, Format string
	} `json:"kpis"`
	Charts []struct {
		ID, Type, Title, Dimension, Bucket, Series, Agg, Measure, Format string
		TopN                                                            int `json:"topN"`
	} `json:"charts"`
	Filters []string `json:"filters"`
	Table   *struct {
		Columns []string `json:"columns"`
		Sort    *struct{ Field, Dir string } `json:"sort"`
		Limit   int `json:"limit"`
	} `json:"table"`
}

lines := strings.Split(strings.ReplaceAll(wrapper.Output, "\r\n", "\n"), "\n")
first := func(p string) string {
	for _, l := range lines {
		if strings.HasPrefix(l, p) {
			return strings.TrimSpace(strings.TrimPrefix(l, p))
		}
	}
	return ""
}
at := func(label string) int {
	for i, l := range lines {
		if strings.TrimSpace(l) == label {
			return i
		}
	}
	return -1
}

title, verdict, summary := first("TITLE:"), first("VERDICT:"), first("SUMMARY:")
si, ii, ni := at("SPEC:"), at("INSIGHTS:"), at("NOTES:")
if si == -1 || ii == -1 {
	log.Fatal("reply is missing a SPEC: or INSIGHTS: line")
}
var spec Spec
if err := json.Unmarshal([]byte(strings.Join(lines[si+1:ii], "\n")), &spec); err != nil {
	log.Fatal("SPEC block is not valid JSON: ", err)
}
end := len(lines)
if ni != -1 {
	end = ni
}
insights := strings.TrimSpace(strings.Join(lines[ii+1:end], "\n"))
notes := ""
if ni != -1 {
	notes = strings.TrimSpace(strings.Join(lines[ni+1:], "\n"))
}

fmt.Printf("%s [%s]\n%s\n", title, verdict, summary)
for _, k := range spec.KPIs {
	fmt.Printf("  KPI %s: %s(%s)\n", k.Label, k.Agg, k.Field)
}
for _, c := range spec.Charts {
	fmt.Printf("  %s %s — %s x %s(%s)\n", c.Type, c.Title, c.Dimension, c.Agg, c.Measure)
}
fmt.Println(insights)
fmt.Println(notes)
os.WriteFile("spec.json", []byte(strings.Join(lines[si+1:ii], "\n")), 0o644)
String envelope = api("POST", "/run", jsonPayload);
String jobId = /* data.job_id via your JSON library */;

String job;
while (true) {
    job = api("GET", "/jobs/" + jobId, null);
    String status = /* data.status */;
    if (status.equals("succeeded") || status.equals("failed")) break;
    Thread.sleep(1500);
}

// data.output.output is PLAIN TEXT, not JSON. Slice it by label:
String reply = /* data.output.output */;
String[] lines = reply.replace("\r\n", "\n").split("\n", -1);

java.util.function.Function<String, String> first = p -> {
    for (String l : lines) if (l.startsWith(p)) return l.substring(p.length()).trim();
    return "";
};
java.util.function.Function<String, Integer> at = label -> {
    for (int i = 0; i < lines.length; i++) if (lines[i].trim().equals(label)) return i;
    return -1;
};

String title   = first.apply("TITLE:");
String verdict = first.apply("VERDICT:");   // Well shaped | Check the data | Not enough data
String summary = first.apply("SUMMARY:");
int si = at.apply("SPEC:"), ii = at.apply("INSIGHTS:"), ni = at.apply("NOTES:");
if (si < 0 || ii < 0) throw new IllegalStateException("missing SPEC: or INSIGHTS:");

String specJson = String.join("\n",
    java.util.Arrays.copyOfRange(lines, si + 1, ii)).trim();
// Parse specJson with Jackson/Gson: title, kpis[] (id/label/agg/field/format),
// charts[] (id/type/title/dimension/bucket/series/agg/measure/topN/format),
// filters[] and the optional table {columns[], sort{field,dir}, limit}.

int end = ni >= 0 ? ni : lines.length;
String insights = String.join("\n", java.util.Arrays.copyOfRange(lines, ii + 1, end)).trim();
String notes = ni >= 0
    ? String.join("\n", java.util.Arrays.copyOfRange(lines, ni + 1, lines.length)).trim()
    : "";

System.out.printf("%s [%s]%n%s%n", title, verdict, summary);
// Files.writeString(Path.of("spec.json"), specJson);
VERDICTS = ["Well shaped", "Check the data", "Not enough data"].freeze

def parse_reply(text)
  lines = text.gsub("\r\n", "\n").split("\n", -1)
  first = ->(p) { (lines.find { |l| l.start_with?(p) } || "")[p.length..].to_s.strip }
  at    = ->(label) { lines.index { |l| l.strip == label } }

  title   = first.call("TITLE:")
  verdict = first.call("VERDICT:").sub(/[.!]+\z/, "")
  summary = first.call("SUMMARY:")
  raise "no TITLE: line" if title.empty?
  raise "bad VERDICT: #{verdict}" unless VERDICTS.include?(verdict)

  si, ii, ni = at.call("SPEC:"), at.call("INSIGHTS:"), at.call("NOTES:")
  raise "missing SPEC: or INSIGHTS:" if si.nil? || ii.nil?

  spec = JSON.parse(lines[(si + 1)...ii].join("\n").strip)
  fin  = ni || lines.length
  { title: title, verdict: verdict, summary: summary, spec: spec,
    insights: lines[(ii + 1)...fin].join("\n").strip,
    notes: ni ? lines[(ni + 1)..].join("\n").strip : "" }
end

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"]
res = parse_reply(raw)

puts "#{res[:title]} [#{res[:verdict]}]"
puts res[:summary]
res[:spec]["kpis"].each { |k| puts "  KPI #{k["label"]}: #{k["agg"]}(#{k["field"]})" }
res[:spec]["charts"].each do |c|
  puts "  #{c["type"]} #{c["title"]} - #{c["dimension"]} x #{c["agg"]}(#{c["measure"]})"
end
puts res[:insights]
puts res[:notes]

File.write("spec.json", JSON.pretty_generate(res[:spec]))
exit 1 if res[:verdict] == "Not enough data"
function parse_reply(string $text): array {
    $lines = explode("\n", str_replace("\r\n", "\n", $text));
    $first = function (string $p) use ($lines): string {
        foreach ($lines as $l) {
            if (str_starts_with($l, $p)) return trim(substr($l, strlen($p)));
        }
        return "";
    };
    $at = function (string $label) use ($lines): int {
        foreach ($lines as $i => $l) if (trim($l) === $label) return $i;
        return -1;
    };

    $title   = $first("TITLE:");
    $verdict = rtrim($first("VERDICT:"), ".!");
    $summary = $first("SUMMARY:");
    if ($title === "") throw new Exception("no TITLE: line");
    if (!in_array($verdict, ["Well shaped", "Check the data", "Not enough data"], true)) {
        throw new Exception("bad VERDICT: $verdict");
    }

    $si = $at("SPEC:"); $ii = $at("INSIGHTS:"); $ni = $at("NOTES:");
    if ($si < 0 || $ii < 0) throw new Exception("missing SPEC: or INSIGHTS:");

    $specText = trim(implode("\n", array_slice($lines, $si + 1, $ii - $si - 1)));
    $spec = json_decode($specText, true, 512, JSON_THROW_ON_ERROR);
    $end = $ni >= 0 ? $ni : count($lines);
    return [
        "title" => $title, "verdict" => $verdict, "summary" => $summary, "spec" => $spec,
        "insights" => trim(implode("\n", array_slice($lines, $ii + 1, $end - $ii - 1))),
        "notes" => $ni >= 0 ? trim(implode("\n", array_slice($lines, $ni + 1))) : "",
    ];
}

$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"];
$res = parse_reply($raw);

echo "{$res['title']} [{$res['verdict']}]\n{$res['summary']}\n";
foreach ($res["spec"]["kpis"] as $k) {
    echo "  KPI {$k['label']}: {$k['agg']}({$k['field']})\n";
}
foreach ($res["spec"]["charts"] as $c) {
    echo "  {$c['type']} {$c['title']} - {$c['dimension']} x {$c['agg']}({$c['measure']})\n";
}
echo $res["insights"] . "\n" . $res["notes"] . "\n";

file_put_contents("spec.json", json_encode($res["spec"], JSON_PRETTY_PRINT));
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);
}

// output.output is PLAIN TEXT, not JSON.
var reply = job.GetProperty("output").GetProperty("output").GetString()!;
var lines = reply.Replace("\r\n", "\n").Split('\n');

string First(string p) =>
    lines.FirstOrDefault(l => l.StartsWith(p))?[p.Length..].Trim() ?? "";
int At(string label) => Array.FindIndex(lines, l => l.Trim() == label);

var title = First("TITLE:");
var verdict = First("VERDICT:").TrimEnd('.', '!');
var summary = First("SUMMARY:");
string[] verdicts = { "Well shaped", "Check the data", "Not enough data" };
if (!verdicts.Contains(verdict)) throw new Exception($"bad VERDICT: {verdict}");

int si = At("SPEC:"), ii = At("INSIGHTS:"), ni = At("NOTES:");
if (si < 0 || ii < 0) throw new Exception("missing SPEC: or INSIGHTS:");

var specText = string.Join("\n", lines[(si + 1)..ii]).Trim();
using var specDoc = JsonDocument.Parse(specText);
var spec = specDoc.RootElement;

var end = ni >= 0 ? ni : lines.Length;
var insights = string.Join("\n", lines[(ii + 1)..end]).Trim();
var notes = ni >= 0 ? string.Join("\n", lines[(ni + 1)..]).Trim() : "";

Console.WriteLine($"{title} [{verdict}]\n{summary}");
foreach (var k in spec.GetProperty("kpis").EnumerateArray())
{
    Console.WriteLine($"  KPI {k.GetProperty("label")}: " +
                      $"{k.GetProperty("agg")}({k.GetProperty("field")})");
}
foreach (var c in spec.GetProperty("charts").EnumerateArray())
{
    Console.WriteLine($"  {c.GetProperty("type")} {c.GetProperty("title")} — " +
                      $"{c.GetProperty("dimension")} x {c.GetProperty("agg")}({c.GetProperty("measure")})");
}
Console.WriteLine(insights);
Console.WriteLine(notes);

await File.WriteAllTextAsync("spec.json", specText);

The model is asked for plain text in exactly this shape, with no code fence around the whole response, but a stray fence is always possible. Strip a leading ``` line and a trailing one before you split, and if the SPEC block itself arrives fenced, strip that fence too — that is what the app does before it falls back to a retry_note reformat run. Give the reformat run a different Idempotency-Key: it is a different request, and replaying the first key hands you back the same malformed reply.

The reply — output contract

Plain text, always the same six-part shape, in this order and nothing before the first line:

TITLE: Bramblewick Revenue Pulse — Oct 2025 to Mar 2026
VERDICT: Check the data
SUMMARY: Revenue roughly triples over six months on a clean six-column order table; one region label is inconsistently cased.

SPEC:
{ ...one JSON object, and nothing else, in this block... }

INSIGHTS:
- markdown bullets, three to six of them

NOTES:
**Mapped:** ...
**Assumed:** ...
**Ignored:** ...
Design confidence: 84%, clean typed columns and a clear trend.
PartRule
TITLE:The first line. Required and non-empty — a dashboard title naming the subject and the period. An empty title fails the parse.
VERDICT:Exactly one of Well shaped, Check the data, Not enough data. Any other value fails the parse. See the table below.
SUMMARY:One line, no markdown: what the dashboard shows and how trustworthy the data is. Optional in practice — a missing SUMMARY yields an empty string rather than an error.
SPEC:A line reading exactly SPEC: (nothing after the colon but whitespace), then one JSON object and nothing else until the INSIGHTS: line. No fence, no prose, no comments, no trailing commas. Schema below.
INSIGHTS:A line reading exactly INSIGHTS:, then three to six markdown bullets. Each is a finding checkable against the pasted rows alone — a trend, a comparison, a composition shift, an outlier, a data-quality caveat — with the figures derived from your rows, approximate but never invented. No external benchmarks or industry facts. Ordered by how much they matter to focus. Empty INSIGHTS fails the parse unless the verdict is Not enough data.
NOTES:A line reading exactly NOTES:, then markdown in a fixed order: **Mapped:** (each column the SPEC uses, its role and the type it was read as), **Assumed:** (every judgement call, or None.), **Ignored:** (columns deliberately left out and why, or None.), and a final plain line Design confidence: NN%, <short clause>.

The three VERDICT values:

VerdictWhat it means
Well shapedThe header parsed cleanly, every column used was typed confidently, and no column meaning had to be guessed. It requires **Assumed:** None. in NOTES — the consistency rule the app checks and flags. This is the case to gate an automated handover on.
Check the dataThe dashboard is buildable but something needed judgement: a column whose meaning was assumed, blank or inconsistent values you should know about, a numeric column that only mostly parses. NOTES must name each such item under **Assumed:**, so read that section before publishing the numbers.
Not enough dataThere is no usable table — no header, fewer than two data rows, or no column that works as a measure or dimension. The reply is still valid and still parses: TITLE comes from focus or is Untitled dashboard, and the SPEC is the minimal legal one (kpis: [{"id":"k1","label":"Rows","agg":"count","field":"*"}], charts: [], filters: [], no table). This is the only verdict under which charts may be empty.

The SPEC JSON schema

Every column name in the SPEC is copied from your pasted header exactly, character for character after trimming, case included — names are never invented, renamed or "cleaned up". The app validates the whole SPEC against the header it parsed and rejects the entire reply if any rule below is broken, so you can treat a SPEC that validates as safe to execute.

KeyRules
titleRequired, non-empty string. The dashboard's own title (may differ from TITLE:).
kpis1 to 4 entries. Each is {id, label, agg, field, format?}. id unique across the whole SPEC; label non-empty. agg is one of sum, avg, median, min, max, count, count_distinct. field is a column name, or "*" — which is legal only with agg: "count". sum/avg/median/min/max require a column typed numeric. format is optional: number (default), currency or percent.
chartsAn array of 0 to 4 entries — and 0 only under Not enough data. Each is {id, type, title, dimension, bucket?, series?, agg, measure, topN?, format?}. id unique, title non-empty, dimension an existing column. agg/measure follow the same rules as KPIs (count with measure: "*" is fine). topN, when present, is an integer 3–12. format as for KPIs. No two charts may share the same type + dimension + series + measure + agg.
filters0 to 3 column names, each categorical — a string column with at most 30 distinct values. These become the filter chips; the engine recomputes every KPI, chart and table row against the filtered rows.
tableOptional drill-down. columns is 2 to 8 existing column names; sort, if present, is {field, dir} where field is one of columns and dir is asc or desc; limit is an integer 5–100.

The five chart type values, and what each requires:

typeRules
lineTrend. dimension is a date column — and then bucket is required and is one of day, week, month, quarter, year — or a numeric column. A string dimension is allowed only if it has at most 30 distinct values. Optional series (categorical) draws one line per value, at most 6.
barVertical bars over a categorical dimension. Optional topN. A date dimension still needs a bucket.
hbarHorizontal bars — the right choice when labels are long or categories exceed about six. series is not supported.
stackedStacked vertical bars. series is required and must be categorical.
donutComposition, at most 8 slices. series is not supported. Over a string column with more than 8 distinct values, topN is required — the engine rolls the rest into "Other".

bucket is only ever valid on a date column, and a date dimension always needs one — on any chart type, not just line. Those two rules together account for most rejected specs.

A complete, valid reply for the table in step 3 (the SPEC block shown formatted; on the wire it is one object):

TITLE: Bramblewick Revenue Pulse — Oct 2025 to Mar 2026
VERDICT: Check the data
SUMMARY: Revenue roughly triples across six months of order-level data; the region column is
         inconsistently cased, so regional splits need a caveat.

SPEC:
{
  "title": "Bramblewick Revenue Pulse",
  "kpis": [
    { "id": "k1", "label": "Total revenue", "agg": "sum", "field": "revenue", "format": "currency" },
    { "id": "k2", "label": "Orders", "agg": "count", "field": "*" },
    { "id": "k3", "label": "Avg order value", "agg": "avg", "field": "revenue", "format": "currency" },
    { "id": "k4", "label": "Units", "agg": "sum", "field": "units" }
  ],
  "charts": [
    { "id": "c1", "type": "line", "title": "Revenue by month", "dimension": "order_date",
      "bucket": "month", "agg": "sum", "measure": "revenue", "format": "currency" },
    { "id": "c2", "type": "stacked", "title": "Revenue by month and plan", "dimension": "order_date",
      "bucket": "month", "series": "plan", "agg": "sum", "measure": "revenue", "format": "currency" },
    { "id": "c3", "type": "hbar", "title": "Revenue by region", "dimension": "region",
      "agg": "sum", "measure": "revenue", "topN": 8, "format": "currency" },
    { "id": "c4", "type": "donut", "title": "Orders by channel", "dimension": "channel",
      "agg": "count", "measure": "*" }
  ],
  "filters": ["region", "plan"],
  "table": { "columns": ["order_date", "region", "plan", "revenue", "units"],
             "sort": { "field": "revenue", "dir": "desc" }, "limit": 25 }
}

INSIGHTS:
- Revenue climbs steadily from about $4.5k in October to roughly $4.2k on the single March order
  in this extract — on the full paste the monthly total roughly triples over the six months.
- Growth is concentrated in partner and field channels: the largest orders in every month are
  partner or field, while web orders stay near the $1–2k band throughout.
- The plan mix shifts from Starter toward Growth: October is Starter-dominated, and by
  February and March most non-web orders are Growth or Scale.
- North America contributes the largest single orders in every month; APAC stays smallest
  throughout, so a regional filter is worth having.
- Data-quality caveat: one row spells the region "emea" in lower case, which the engine treats
  as a separate category from "EMEA" unless you normalise it first.

NOTES:
**Mapped:**
- order_date — date dimension, bucketed by month; used by c1 and c2.
- revenue — numeric measure (currency strings, coerced); used by k1, k3, c1, c2, c3.
- plan — categorical; series for c2 and a filter.
- region — categorical; dimension for c3 and a filter.
- channel — categorical; dimension for c4.
- units — numeric measure; used by k4.

**Assumed:**
- "revenue" is a per-order amount in one currency, so summing it is meaningful.
- The lower-case "emea" is the same region as "EMEA"; it is left unmerged in the SPEC because
  the engine matches values literally.

**Ignored:** None.

Design confidence: 84%, clean typed columns and a clear trend; the region casing is the one
thing that would change a regional read.

This is an AI-designed dashboard over text you pasted, not analysis of your warehouse: the model sees only the rows you sent (and, when data_note is present, only part of them), knows nothing about the company or the season the data came from, and computes nothing. Every number your users see comes from applying the SPEC to your own rows. Read the **Assumed:** bullets before you publish anything derived from the design.

Step 5 — Stream the design as it is written

POST /run-stream

/run-stream takes exactly the same body as /run — the input object directly — 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 section labels are your progress signal, and they arrive in a fixed order: the app advances its stage list the moment a line matching ^TITLE:, ^VERDICT: or ^SUMMARY: ("designing"), then ^SPEC: ("laying out"), then ^INSIGHTS: ("reading"), then ^NOTES: ("noting") appears in the accumulated text.
done{job_id, status, charged_credits, output}The final, authoritative result — read the reply from output.output rather than trusting concatenated deltas, 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 $TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: df-$(date +%s)" \
  -d @input.json

# event: job
# data: {"job_id":"job_...","status":"running"}
#
# event: delta
# data: {"text":"TITLE: Bramblewick Revenue Pulse"}
# ...
# event: done
# data: {"job_id":"job_...","status":"succeeded","charged_credits":291,"output":{"output":"TITLE: ..."}}
import json, requests

result = None
with requests.post(
    API + "/run-stream",
    headers={"Authorization": f"Bearer {TOKEN}",
             "Idempotency-Key": "df-001"},
    json=payload,                                  # the input object, directly
    stream=True,
) as r:
    r.raise_for_status()
    event, acc = 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":
                acc += data["text"]
                for label, stage in (("\nNOTES:", "noting"), ("\nINSIGHTS:", "reading"),
                                     ("\nSPEC:", "laying out"), ("TITLE:", "designing")):
                    if label in acc:
                        print(f"\r{stage}...", end="", flush=True)
                        break
            elif event == "done":
                result = data
            elif event == "error":
                raise RuntimeError(data.get("message", "run failed"))

res = parse_reply(result["output"]["output"])       # authoritative; parser from step 4
print("\ncharged:", result["charged_credits"], "-", res["title"], f'[{res["verdict"]}]')
for c in res["spec"]["charts"]:
    print(f'  {c["type"]}: {c["title"]}')
with open("spec.json", "w", encoding="utf-8") as fh:
    json.dump(res["spec"], fh, indent=2)
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),      // the input object, directly
});

const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "", acc = "", 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") {
      acc += data.text;
      const stage = /^NOTES:/m.test(acc) ? "noting"
        : /^INSIGHTS:/m.test(acc) ? "reading"
        : /^SPEC:/m.test(acc) ? "laying out"
        : "designing";
      process.stdout.write(`\r${stage}...   `);
    }
    if (name === "done") done = data;
    if (name === "error") throw new Error(data.message ?? "run failed");
  }
}

const out = parseReply(done.output.output);   // parser from step 4
console.log(`\n${done.charged_credits} credits - ${out.title} [${out.verdict}]`);
for (const c of out.spec.charts) console.log(`  ${c.type}: ${c.title}`);
writeFileSync("spec.json", JSON.stringify(out.spec, null, 2));
body, _ := json.Marshal(payload) // the input object, directly
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", "df-001")

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

var event string
var acc strings.Builder
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":
			acc.WriteString(data["text"].(string))
			s := acc.String()
			stage := "designing"
			switch {
			case strings.Contains(s, "\nNOTES:"):
				stage = "noting"
			case strings.Contains(s, "\nINSIGHTS:"):
				stage = "reading"
			case strings.Contains(s, "\nSPEC:"):
				stage = "laying out"
			}
			fmt.Printf("\r%s...   ", stage)
		case "done":
			final = data
		case "error":
			log.Fatal(data["message"])
		}
	}
}
// final["output"].(map[string]any)["output"].(string) is the plain-text reply —
// slice it by label exactly as in step 4, then write the SPEC block to spec.json.
// 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", "df-001")
    .POST(HttpRequest.BodyPublishers.ofString(jsonPayload))   // the input object, directly
    .build();

var res = HTTP.send(req, HttpResponse.BodyHandlers.ofLines());
String event = null, done = null;
var acc = new StringBuilder();
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)) {
            acc.append(data);                                  // {"text": "..."} — decode it
            String s = acc.toString();
            String stage = s.contains("NOTES:") ? "noting"
                         : s.contains("INSIGHTS:") ? "reading"
                         : s.contains("SPEC:") ? "laying out" : "designing";
            System.out.print("\r" + stage + "...   ");
        } else if ("done".equals(event)) {
            done = data;
        } else if ("error".equals(event)) {
            throw new RuntimeException(data);
        }
    }
}
// Parse `done`, take data.output.output — plain text — and slice it by
// TITLE:/VERDICT:/SUMMARY:/SPEC:/INSIGHTS:/NOTES: exactly as in step 4.
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"] = "df-001"
req.body = payload.to_json          # the input object, directly

event = nil
acc = +""
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"
            acc << data["text"]
            stage = if acc.include?("\nNOTES:")      then "noting"
                    elsif acc.include?("\nINSIGHTS:") then "reading"
                    elsif acc.include?("\nSPEC:")     then "laying out"
                    else "designing" end
            print "\r#{stage}...   "
          when "done"  then done = data
          when "error" then raise (data["message"] || "run failed")
          end
        end
      end
    end
  end
end

res = parse_reply(done["output"]["output"])   # parser from step 4
puts "\n#{done["charged_credits"]} credits - #{res[:title]} [#{res[:verdict]}]"
res[:spec]["charts"].each { |c| puts "  #{c["type"]}: #{c["title"]}" }
File.write("spec.json", JSON.pretty_generate(res[:spec]))
$event = null;
$acc   = "";
$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: df-001",
    ],
    CURLOPT_POSTFIELDS => json_encode($payload),   // the input object, directly
    CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$event, &$acc, &$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") {
                    $acc .= $data["text"];
                    $stage = str_contains($acc, "\nNOTES:") ? "noting"
                        : (str_contains($acc, "\nINSIGHTS:") ? "reading"
                        : (str_contains($acc, "\nSPEC:") ? "laying out" : "designing"));
                    echo "\r$stage...   ";
                } elseif ($event === "done") { $done = $data; }
                elseif ($event === "error") { throw new Exception($data["message"] ?? "run failed"); }
            }
        }
        return strlen($chunk);
    },
]);
curl_exec($ch);
curl_close($ch);

$res = parse_reply($done["output"]["output"]);   // parser from step 4
echo "\n{$done['charged_credits']} credits - {$res['title']} [{$res['verdict']}]\n";
foreach ($res["spec"]["charts"] as $c) {
    echo "  {$c['type']}: {$c['title']}\n";
}
file_put_contents("spec.json", json_encode($res["spec"], JSON_PRETTY_PRINT));
var req = new HttpRequestMessage(HttpMethod.Post, Api + "/run-stream") {
    Content = JsonContent.Create(payload),        // the input object, directly
};
req.Headers.Add("Idempotency-Key", "df-001");

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

string? evt = null, done = null;
var acc = new System.Text.StringBuilder();
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")
        {
            using var d = JsonDocument.Parse(data);
            acc.Append(d.RootElement.GetProperty("text").GetString());
            var s = acc.ToString();
            var stage = s.Contains("\nNOTES:") ? "noting"
                : s.Contains("\nINSIGHTS:") ? "reading"
                : s.Contains("\nSPEC:") ? "laying out" : "designing";
            Console.Write($"\r{stage}...   ");
        }
        else if (evt == "done") done = data;
        else if (evt == "error") throw new Exception(data);
    }
}

using var final = JsonDocument.Parse(done!);
var reply = final.RootElement.GetProperty("output").GetProperty("output").GetString()!;
// Slice `reply` by TITLE:/VERDICT:/SUMMARY:/SPEC:/INSIGHTS:/NOTES: exactly as in step 4,
// then: await File.WriteAllTextAsync("spec.json", specText);

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. And if a stream dies mid-SPEC, the prefix is still worth something: close the open JSON containers at the last complete value, drop the entries that no longer validate, and tell the user what you salvaged — that is what the app does rather than charging for a second run.