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": {…}}.
| Status | Meaning |
|---|---|
400 | Malformed body — usually a missing or non-string data field, or a body wrapped in an extra input key. |
401 | Missing or expired token — create a new session. |
402 | Not enough credits — top up at skillsafe.ai/account/credits. |
403 | The token isn't allowed to do this (e.g. a guest token submitting a very large table). |
404 | Unknown job or record id. |
409 | An Idempotency-Key was replayed with a different body. |
429 | Too many runs in flight — back off and retry. |
5xx | Transient platform error — retry with backoff. |
Browsers enforce CORS for this API, so run these examples from a server, script or terminal — not from another website's frontend.
Step 0 — A tiny client
Every task below is a single HTTP call, so start with a short helper that adds the auth
header, sends JSON and unwraps the data envelope. The later steps reuse it.
export API="https://api.skillsafe.ai/v1/app-api"
export 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
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
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
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
| Field | Type | Notes |
|---|---|---|
focus | string, optional | The 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. |
data | string, required | The 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. |
spec | string, optional | A 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. |
notes | string, optional | Your 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_note | string, conditional | Present 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_note | string, conditional | Present 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
/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.
| Part | Rule |
|---|---|
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:
| Verdict | What it means |
|---|---|
Well shaped | The 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 data | The 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 data | There 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.
| Key | Rules |
|---|---|
title | Required, non-empty string. The dashboard's own title (may differ from TITLE:). |
kpis | 1 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. |
charts | An 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. |
filters | 0 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. |
table | Optional 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:
| type | Rules |
|---|---|
line | Trend. 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. |
bar | Vertical bars over a categorical dimension. Optional topN. A date dimension still needs a bucket. |
hbar | Horizontal bars — the right choice when labels are long or categories exceed about six. series is not supported. |
stacked | Stacked vertical bars. series is required and must be categorical. |
donut | Composition, 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
/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.
| Event | Payload | Meaning |
|---|---|---|
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.