VoC Lens — API

Paste your raw research, get a synthesis that quotes the customers verbatim.

API tokens Open the app

Turn a folder of raw research into one evidence-bound synthesis, from your own scripts

Send up to twelve research assets — interview and sales-call transcripts, survey exports, support-ticket threads, NPS verbatims, review dumps, win/loss notes — and get back one JSON object: a rich-signal / usable-with-caveats / insufficient-sample posture, a per-asset readout of what each source actually contributed, themes ranked by frequency times intensity with verbatim quotes and their source asset id, jobs to be done split functional / emotional / social, ranked pains in the customer's own words, trigger events, desired outcomes, a reusable vocabulary list, alternatives considered, contradictions, personas only where five independent data points support them, the gaps that remain and a coverage check that accounts for every asset and every prescan flag you sent. Everything this app does goes through the SkillSafe App API — plain JSON over HTTPS — so you can wire the synthesis into a research repository, a weekly job over the new tickets in your helpdesk, or a script that re-runs the whole corpus after each round of interviews and diffs the themes. 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, app slug voc-lens. 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. Estimates are free; runs are metered against your credit balance. There is a single run task — one corpus in, one synthesis out, no follow-up calls and no session state to carry between runs.

StatusMeaning
400Malformed body — e.g. assets missing, empty, or longer than twelve entries.
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 running a twelve-asset corpus).
404Unknown job or record id.
429Too many requests — 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

A guest token lets you check balances and estimate costs for free. For metered synthesis runs billed to your own account, use your personal token: open the token page, sign in with SkillSafe, and press Copy shell export — it puts export SKILLSAFE_TOKEN="…" on your clipboard, which every example below reads. Treat the token like a password: it can spend your credits. For fully headless scripts, POST /guest mints a guest token with no browser involved.

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

The app stores this browser's token under the localStorage key skillsafe_app_token:voc-lens, 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 pushing a twelve-asset corpus of interview transcripts through a run.

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 — Describe the corpus and estimate the cost

POST /estimate

Send exactly the input you would send to /run; the response's hold_credits is the worst-case cost. Nothing is charged and no job is created, so estimating is free — useful when you are feeding in a dozen hour-long interviews and want a ceiling before spending credits.

Input fieldTypeNotes
goalstringgeneral | messaging | personas | product_gaps | churn — what you want out of the synthesis. general weighs everything the corpus shows; messaging leans into vocabulary, desired outcomes and the money quotes; personas leans into segmentation and evidence counts; product_gaps leans into pains and unmet jobs; churn leans into triggers, alternatives and contradictions. A narrowed goal changes the weighting and depth, never the output shape — every section still comes back.
product_contextstring, may be emptyWhat the product is and who it is believed to be for. It is read as the team's hypothesis, not as evidence — nothing in it can become a finding, and where the assets contradict it the synthesis says so.
assetsarray of 1–12, requiredThe raw research material, one entry per source. Each entry is {id, type, label, text}: id is a short string you choose (a1, a2, …) and is the id quoted back as the source of every quote; type is interview | survey | tickets | nps | reviews | winloss | notes, taken as your declared type and corrected in asset_readout.type_confirmed if the text is clearly something else; label may be empty and carries your segment or source label ("churned SMB, May"), which is what makes segmentation and per-segment confidence possible; text is the raw content. Very long assets may be clipped middle-out with a [... clipped ...] marker — the synthesis never treats that marker as content and never guesses at what was removed.
prescan_factsobject, optionalWhat a client-side scanner mechanically counted before the run. totals is {assets, words, data_points}, where data_points estimates independent evidence units (an interview counts 1; survey rows, tickets, NPS verbatims and reviews count per distinct respondent detected). assets is one {id, type, words, data_points, speakers, nps, quote_candidates, dates_found} entry per asset, where nps is {responses, promoters, passives, detractors, avg} or null and quote_candidates counts sentences with first-person emotional language. flags is an array of {id, note} with ids like small-sample, single-source, promoter-skew, duplicate-asset, thin-asset and stale-sources. These are pattern-matched counts, not judgement: every flag id you send comes back in coverage_check as confirmed, revised or cleared, and where the synthesis's own reading disagrees with a count it says so in the relevant note. The web app computes this client-side; API callers may omit the field entirely — if you omit it the model simply sees no prescan, and coverage_check then covers your asset ids only, with no flag entries.
retry_notestring, optionalOnly set by the app's automatic reformat retry when a first reply was not valid JSON. Leave it out.

A compact, realistic body — two small assets and the prescan a client would have computed for them:

{
  "goal": "churn",
  "product_context": "Reporting tool for SMB finance teams; we believe the buyer is the CFO.",
  "assets": [
    { "id": "a1", "type": "interview", "label": "churned SMB, May",
      "text": "We were drowning in spreadsheets before we tried you. Honestly the
               import was where it fell apart - I spent two evenings mapping columns
               and gave up. My CFO asked what we were paying for and I couldn't
               answer, so we cancelled." },
    { "id": "a2", "type": "nps", "label": "Q2 NPS verbatims",
      "text": "9 - Works great once it's set up.\n4 - Setup took three weeks and
               support answered on day four.\n5 - I still export to Excel every
               Monday because the built-in report doesn't match what my board
               wants." }
  ],
  "prescan_facts": {
    "totals": { "assets": 2, "words": 96, "data_points": 4 },
    "assets": [
      { "id": "a1", "type": "interview", "words": 52, "data_points": 1,
        "speakers": 1, "nps": null, "quote_candidates": 3, "dates_found": 0 },
      { "id": "a2", "type": "nps", "words": 44, "data_points": 3,
        "speakers": 0,
        "nps": { "responses": 3, "promoters": 1, "passives": 0,
                 "detractors": 2, "avg": 6 },
        "quote_candidates": 2, "dates_found": 0 }
    ],
    "flags": [
      { "id": "small-sample", "note": "4 data points across 2 assets" },
      { "id": "single-source", "note": "one interview carries the whole qualitative side" }
    ]
  }
}
read -r -d '' INPUT <<'JSON'
{"goal":"churn",
 "product_context":"Reporting tool for SMB finance teams; we believe the buyer is the CFO.",
 "assets":[
   {"id":"a1","type":"interview","label":"churned SMB, May",
    "text":"We were drowning in spreadsheets before we tried you. Honestly the import was where it fell apart - I spent two evenings mapping columns and gave up. My CFO asked what we were paying for and I couldn't answer, so we cancelled."},
   {"id":"a2","type":"nps","label":"Q2 NPS verbatims",
    "text":"9 - Works great once it's set up.\n4 - Setup took three weeks and support answered on day four.\n5 - I still export to Excel every Monday because the built-in report doesn't match what my board wants."}
 ],
 "prescan_facts":{"totals":{"assets":2,"words":96,"data_points":4},
   "assets":[
     {"id":"a1","type":"interview","words":52,"data_points":1,"speakers":1,"nps":null,"quote_candidates":3,"dates_found":0},
     {"id":"a2","type":"nps","words":44,"data_points":3,"speakers":0,"nps":{"responses":3,"promoters":1,"passives":0,"detractors":2,"avg":6},"quote_candidates":2,"dates_found":0}
   ],
   "flags":[{"id":"small-sample","note":"4 data points across 2 assets"},
            {"id":"single-source","note":"one interview carries the whole qualitative side"}]}}
JSON

curl -s -X POST "$API/estimate" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d "$INPUT" | jq '.data.hold_credits'
A1 = ("We were drowning in spreadsheets before we tried you. Honestly the import "
      "was where it fell apart - I spent two evenings mapping columns and gave up. "
      "My CFO asked what we were paying for and I couldn't answer, so we cancelled.")
A2 = ("9 - Works great once it's set up.\n"
      "4 - Setup took three weeks and support answered on day four.\n"
      "5 - I still export to Excel every Monday because the built-in report doesn't "
      "match what my board wants.")

payload = {
    "goal": "churn",
    "product_context": "Reporting tool for SMB finance teams; we believe the buyer is the CFO.",
    "assets": [
        {"id": "a1", "type": "interview", "label": "churned SMB, May", "text": A1},
        {"id": "a2", "type": "nps", "label": "Q2 NPS verbatims", "text": A2},
    ],
    # optional - omit the whole key and the model simply sees no prescan
    "prescan_facts": {
        "totals": {"assets": 2, "words": 96, "data_points": 4},
        "assets": [
            {"id": "a1", "type": "interview", "words": 52, "data_points": 1,
             "speakers": 1, "nps": None, "quote_candidates": 3, "dates_found": 0},
            {"id": "a2", "type": "nps", "words": 44, "data_points": 3,
             "speakers": 0,
             "nps": {"responses": 3, "promoters": 1, "passives": 0,
                     "detractors": 2, "avg": 6},
             "quote_candidates": 2, "dates_found": 0},
        ],
        "flags": [
            {"id": "small-sample", "note": "4 data points across 2 assets"},
            {"id": "single-source", "note": "one interview carries the whole qualitative side"},
        ],
    },
}

est = api("POST", "/estimate", payload)
print("worst case:", est.get("hold_credits", est.get("credits")), "credits")
const a1 =
  "We were drowning in spreadsheets before we tried you. Honestly the import was " +
  "where it fell apart - I spent two evenings mapping columns and gave up. My CFO " +
  "asked what we were paying for and I couldn't answer, so we cancelled.";
const a2 =
  "9 - Works great once it's set up.\n" +
  "4 - Setup took three weeks and support answered on day four.\n" +
  "5 - I still export to Excel every Monday because the built-in report doesn't " +
  "match what my board wants.";

const payload = {
  goal: "churn",
  product_context: "Reporting tool for SMB finance teams; we believe the buyer is the CFO.",
  assets: [
    { id: "a1", type: "interview", label: "churned SMB, May", text: a1 },
    { id: "a2", type: "nps", label: "Q2 NPS verbatims", text: a2 },
  ],
  // optional - omit the whole key and the model simply sees no prescan
  prescan_facts: {
    totals: { assets: 2, words: 96, data_points: 4 },
    assets: [
      { id: "a1", type: "interview", words: 52, data_points: 1,
        speakers: 1, nps: null, quote_candidates: 3, dates_found: 0 },
      { id: "a2", type: "nps", words: 44, data_points: 3,
        speakers: 0,
        nps: { responses: 3, promoters: 1, passives: 0, detractors: 2, avg: 6 },
        quote_candidates: 2, dates_found: 0 },
    ],
    flags: [
      { id: "small-sample", note: "4 data points across 2 assets" },
      { id: "single-source", note: "one interview carries the whole qualitative side" },
    ],
  },
};

const est = await api("POST", "/estimate", payload);
console.log("worst case:", est.hold_credits ?? est.credits, "credits");
const a1 = "We were drowning in spreadsheets before we tried you. Honestly the " +
	"import was where it fell apart - I spent two evenings mapping columns and " +
	"gave up. My CFO asked what we were paying for and I couldn't answer, so we cancelled."
const a2 = "9 - Works great once it's set up.\n" +
	"4 - Setup took three weeks and support answered on day four.\n" +
	"5 - I still export to Excel every Monday because the built-in report doesn't " +
	"match what my board wants."

payload := map[string]any{
	"goal":            "churn",
	"product_context": "Reporting tool for SMB finance teams; we believe the buyer is the CFO.",
	"assets": []any{
		map[string]any{"id": "a1", "type": "interview", "label": "churned SMB, May", "text": a1},
		map[string]any{"id": "a2", "type": "nps", "label": "Q2 NPS verbatims", "text": a2},
	},
	// optional - drop this key entirely and the model simply sees no prescan
	"prescan_facts": map[string]any{
		"totals": map[string]any{"assets": 2, "words": 96, "data_points": 4},
		"assets": []any{
			map[string]any{"id": "a1", "type": "interview", "words": 52, "data_points": 1,
				"speakers": 1, "nps": nil, "quote_candidates": 3, "dates_found": 0},
			map[string]any{"id": "a2", "type": "nps", "words": 44, "data_points": 3,
				"speakers": 0,
				"nps": map[string]any{"responses": 3, "promoters": 1, "passives": 0,
					"detractors": 2, "avg": 6},
				"quote_candidates": 2, "dates_found": 0},
		},
		"flags": []any{
			map[string]any{"id": "small-sample", "note": "4 data points across 2 assets"},
			map[string]any{"id": "single-source", "note": "one interview carries the whole qualitative side"},
		},
	},
}

var est struct{ HoldCredits int64 `json:"hold_credits"` }
err := call("POST", "/estimate", payload, &est)
String a1 = """
    We were drowning in spreadsheets before we tried you. Honestly the import was \
    where it fell apart - I spent two evenings mapping columns and gave up. My CFO \
    asked what we were paying for and I couldn't answer, so we cancelled.""";
String a2 = """
    9 - Works great once it's set up.
    4 - Setup took three weeks and support answered on day four.
    5 - I still export to Excel every Monday because the built-in report doesn't \
    match what my board wants.""";

// toJsonString() is your JSON library's string escaper (Jackson: writeValueAsString).
String jsonPayload = """
    {"goal": "churn",
     "product_context": "Reporting tool for SMB finance teams; we believe the buyer is the CFO.",
     "assets": [
       {"id": "a1", "type": "interview", "label": "churned SMB, May", "text": %s},
       {"id": "a2", "type": "nps", "label": "Q2 NPS verbatims", "text": %s}
     ],
     "prescan_facts": {
       "totals": {"assets": 2, "words": 96, "data_points": 4},
       "assets": [
         {"id": "a1", "type": "interview", "words": 52, "data_points": 1,
          "speakers": 1, "nps": null, "quote_candidates": 3, "dates_found": 0},
         {"id": "a2", "type": "nps", "words": 44, "data_points": 3, "speakers": 0,
          "nps": {"responses": 3, "promoters": 1, "passives": 0, "detractors": 2, "avg": 6},
          "quote_candidates": 2, "dates_found": 0}
       ],
       "flags": [
         {"id": "small-sample", "note": "4 data points across 2 assets"},
         {"id": "single-source", "note": "one interview carries the whole qualitative side"}
       ]
     }}
    """.formatted(toJsonString(a1), toJsonString(a2));
// prescan_facts is optional - leave the key out and the model sees no prescan.

String envelope = api("POST", "/estimate", jsonPayload);
// worst-case cost is at data.hold_credits
A1 = "We were drowning in spreadsheets before we tried you. Honestly the import " \
     "was where it fell apart - I spent two evenings mapping columns and gave up. " \
     "My CFO asked what we were paying for and I couldn't answer, so we cancelled."
A2 = "9 - Works great once it's set up.\n" \
     "4 - Setup took three weeks and support answered on day four.\n" \
     "5 - I still export to Excel every Monday because the built-in report " \
     "doesn't match what my board wants."

payload = {
  goal: "churn",
  product_context: "Reporting tool for SMB finance teams; we believe the buyer is the CFO.",
  assets: [
    { id: "a1", type: "interview", label: "churned SMB, May", text: A1 },
    { id: "a2", type: "nps", label: "Q2 NPS verbatims", text: A2 }
  ],
  # optional - omit the whole key and the model simply sees no prescan
  prescan_facts: {
    totals: { assets: 2, words: 96, data_points: 4 },
    assets: [
      { id: "a1", type: "interview", words: 52, data_points: 1,
        speakers: 1, nps: nil, quote_candidates: 3, dates_found: 0 },
      { id: "a2", type: "nps", words: 44, data_points: 3, speakers: 0,
        nps: { responses: 3, promoters: 1, passives: 0, detractors: 2, avg: 6 },
        quote_candidates: 2, dates_found: 0 }
    ],
    flags: [
      { id: "small-sample", note: "4 data points across 2 assets" },
      { id: "single-source", note: "one interview carries the whole qualitative side" }
    ]
  }
}

est = api("POST", "/estimate", payload)
puts "worst case: #{est["hold_credits"] || est["credits"]} credits"
$a1 = "We were drowning in spreadsheets before we tried you. Honestly the import "
    . "was where it fell apart - I spent two evenings mapping columns and gave up. "
    . "My CFO asked what we were paying for and I couldn't answer, so we cancelled.";
$a2 = "9 - Works great once it's set up.\n"
    . "4 - Setup took three weeks and support answered on day four.\n"
    . "5 - I still export to Excel every Monday because the built-in report doesn't "
    . "match what my board wants.";

$payload = [
    "goal"            => "churn",
    "product_context" => "Reporting tool for SMB finance teams; we believe the buyer is the CFO.",
    "assets"          => [
        ["id" => "a1", "type" => "interview", "label" => "churned SMB, May", "text" => $a1],
        ["id" => "a2", "type" => "nps",       "label" => "Q2 NPS verbatims",  "text" => $a2],
    ],
    // optional - omit this key and the model simply sees no prescan
    "prescan_facts"   => [
        "totals" => ["assets" => 2, "words" => 96, "data_points" => 4],
        "assets" => [
            ["id" => "a1", "type" => "interview", "words" => 52, "data_points" => 1,
             "speakers" => 1, "nps" => null, "quote_candidates" => 3, "dates_found" => 0],
            ["id" => "a2", "type" => "nps", "words" => 44, "data_points" => 3,
             "speakers" => 0,
             "nps" => ["responses" => 3, "promoters" => 1, "passives" => 0,
                       "detractors" => 2, "avg" => 6],
             "quote_candidates" => 2, "dates_found" => 0],
        ],
        "flags" => [
            ["id" => "small-sample", "note" => "4 data points across 2 assets"],
            ["id" => "single-source", "note" => "one interview carries the whole qualitative side"],
        ],
    ],
];

$est = api("POST", "/estimate", $payload);
echo "worst case: " . ($est["hold_credits"] ?? $est["credits"]) . " credits\n";
var a1 = "We were drowning in spreadsheets before we tried you. Honestly the import " +
         "was where it fell apart - I spent two evenings mapping columns and gave up. " +
         "My CFO asked what we were paying for and I couldn't answer, so we cancelled.";
var a2 = "9 - Works great once it's set up.\n" +
         "4 - Setup took three weeks and support answered on day four.\n" +
         "5 - I still export to Excel every Monday because the built-in report " +
         "doesn't match what my board wants.";

var payload = new {
    goal = "churn",
    product_context = "Reporting tool for SMB finance teams; we believe the buyer is the CFO.",
    assets = new object[] {
        new { id = "a1", type = "interview", label = "churned SMB, May", text = a1 },
        new { id = "a2", type = "nps", label = "Q2 NPS verbatims", text = a2 },
    },
    // optional - omit this member and the model simply sees no prescan
    prescan_facts = new {
        totals = new { assets = 2, words = 96, data_points = 4 },
        assets = new object[] {
            new { id = "a1", type = "interview", words = 52, data_points = 1,
                  speakers = 1, nps = (object?)null, quote_candidates = 3, dates_found = 0 },
            new { id = "a2", type = "nps", words = 44, data_points = 3, speakers = 0,
                  nps = new { responses = 3, promoters = 1, passives = 0,
                              detractors = 2, avg = 6 },
                  quote_candidates = 2, dates_found = 0 },
        },
        flags = new object[] {
            new { id = "small-sample", note = "4 data points across 2 assets" },
            new { id = "single-source", note = "one interview carries the whole qualitative side" },
        },
    },
};

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

prescan_facts is how you make the synthesis answer for the shape of your sample rather than quietly average over it. Every asset id you send comes back in coverage_check as weighed or set-aside with a reason, and every flag id comes back as confirmed, revised or cleared — so a promoter-skew you flagged is either applied to the confidence labels or explicitly dismissed with an explanation. Nothing you flagged is silently dropped. The field is genuinely optional for API callers: the web app fills it from its own client-side scan, and with the key absent coverage_check simply covers your asset ids and nothing else.

Step 4 — Run the synthesis and wait for the result

POST /run
GET /jobs/{job_id}

/run takes the same input as /estimate, 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 30–90 s, since every theme carries verbatim quotes with their source ids and the readout accounts for each asset one by one). Always send an Idempotency-Key header so a network retry can't start a second, double-charged run. The report is in output — usually nested as output.output, and as a JSON string, so parse defensively. The samples below print the posture and verdict, the per-asset readout, the ranked themes with their quotes, the pains and vocabulary, the coverage check and the ordered next steps.

JOB_ID=$(curl -s -X POST "$API/run" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: voc-lens-$(date +%s)" \
  -d "$INPUT" | 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

# unwrap the report once, then read it straight out of the pipeline
echo "$JOB" | jq -r '.data.output.output' | jq -r '
  "\(.report_name) [\(.posture)]: \(.verdict)",
  "",
  "ASSETS",
  (.asset_readout[] | "  \(.id) \(.type_confirmed) dp=\(.data_points) signal=\(.signal) - \(.note)"),
  "",
  "THEMES",
  (.themes[] | "  \(.name) (\(.frequency), intensity \(.intensity), confidence \(.confidence))",
               (.quotes[] | "      [\(.source)] \(.quote)"),
               "      => \(.implications)"),
  "",
  "JTBD functional: \(.jtbd.functional | join("; "))",
  "JTBD emotional:  \(.jtbd.emotional | join("; "))",
  "JTBD social:     \(.jtbd.social | join("; "))",
  "",
  "PAINS",   (.pains[] | "  (\(.confidence)) \(.pain) - \"\(.in_their_words)\""),
  "TRIGGERS", (.triggers[] | "  - \(.)"),
  "OUTCOMES", (.desired_outcomes[] | "  - \(.outcome) | \"\(.verbatim)\""),
  "VOCABULARY: \(.vocabulary | join(" / "))",
  "ALTERNATIVES: \(.alternatives | join(" / "))",
  "CONTRADICTIONS", (.contradictions[] | "  ! \(.)"),
  "PERSONAS", (.personas[] | "  \(.name) (\(.evidence_count) dp, provisional=\(.provisional))"),
  "GAPS",    (.gaps[] | "  ? \(.unknown) -> \(.how_to_find)"),
  "COVERAGE", (.coverage_check[] | "  \(.ref): \(.status) - \(.note)"),
  "NEXT",    (.next_steps[] | "  \(.)")'
import time

job_id = api("POST", "/run", payload,
             **{"Idempotency-Key": "voc-lens-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"]
report = json.loads(raw) if isinstance(raw, str) else raw

print(f'{report["report_name"]} [{report["posture"]}]: {report["verdict"]}')
print(report["overview"])

for a in report["asset_readout"]:
    print(f'  {a["id"]:<4} {a["type_confirmed"]:<10} '
          f'dp={a["data_points"]:<3} signal={a["signal"]:<6} {a["note"]}')

for t in report["themes"]:
    print(f'  {t["name"]} - {t["frequency"]}, '
          f'intensity {t["intensity"]}, confidence {t["confidence"]}')
    print(f'      {t["summary"]}')
    for q in t["quotes"]:
        print(f'      [{q["source"]}] "{q["quote"]}"')
    print(f'      => {t["implications"]}')

for kind in ("functional", "emotional", "social"):
    print(f'  jtbd/{kind}: {"; ".join(report["jtbd"][kind]) or "(not shown by the assets)"}')

for p in report["pains"]:
    print(f'  ({p["confidence"]}) {p["pain"]} - "{p["in_their_words"]}"')
for trg in report["triggers"]:
    print("  trigger:", trg)
for o in report["desired_outcomes"]:
    print(f'  outcome: {o["outcome"]} | "{o["verbatim"]}"')

print("  vocabulary:", " / ".join(report["vocabulary"]))
print("  alternatives:", " / ".join(report["alternatives"]))
for c in report["contradictions"]:
    print("  contradiction:", c)

for persona in report["personas"]:
    print(f'  persona {persona["name"]} ({persona["title_range"]}, '
          f'{persona["company_size"]}) - {persona["evidence_count"]} data points, '
          f'provisional={persona["provisional"]}')
    print(f'      jtbd: {persona["primary_jtbd"]}')
if not report["personas"]:
    print("  personas: none - fewer than 5 data points in any one segment")

for g in report["gaps"]:
    print(f'  gap: {g["unknown"]} -> {g["how_to_find"]}')
for c in report["coverage_check"]:
    print(f'  {c["ref"]}: {c["status"]} - {c["note"]}')
for step in report["next_steps"]:
    print(" -", step)
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 raw = job.output?.output ?? job.output;
const report = typeof raw === "string" ? JSON.parse(raw) : raw;

console.log(`${report.report_name} [${report.posture}]: ${report.verdict}`);
console.log(report.overview);

for (const a of report.asset_readout) {
  console.log(`  ${a.id} ${a.type_confirmed} dp=${a.data_points} signal=${a.signal} - ${a.note}`);
}
for (const t of report.themes) {
  console.log(`  ${t.name} - ${t.frequency}, intensity ${t.intensity}, confidence ${t.confidence}`);
  console.log(`      ${t.summary}`);
  for (const q of t.quotes) console.log(`      [${q.source}] "${q.quote}"`);
  console.log(`      => ${t.implications}`);
}
for (const kind of ["functional", "emotional", "social"]) {
  console.log(`  jtbd/${kind}:`, report.jtbd[kind].join("; ") || "(not shown by the assets)");
}
for (const p of report.pains) console.log(`  (${p.confidence}) ${p.pain} - "${p.in_their_words}"`);
for (const t of report.triggers) console.log("  trigger:", t);
for (const o of report.desired_outcomes) console.log(`  outcome: ${o.outcome} | "${o.verbatim}"`);
console.log("  vocabulary:", report.vocabulary.join(" / "));
console.log("  alternatives:", report.alternatives.join(" / "));
for (const c of report.contradictions) console.log("  contradiction:", c);
for (const p of report.personas) {
  console.log(`  persona ${p.name} (${p.title_range}, ${p.company_size}) - ` +
              `${p.evidence_count} data points, provisional=${p.provisional}`);
}
if (report.personas.length === 0) {
  console.log("  personas: none - fewer than 5 data points in any one segment");
}
for (const g of report.gaps) console.log(`  gap: ${g.unknown} -> ${g.how_to_find}`);
for (const c of report.coverage_check) console.log(`  ${c.ref}: ${c.status} - ${c.note}`);
for (const step of report.next_steps) console.log(" -", step);
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": "<json string>"} - unwrap, then unmarshal:
type Quote struct {
	Quote  string `json:"quote"`
	Source string `json:"source"`
}
type Report struct {
	ReportName string `json:"report_name"`
	Posture    string `json:"posture"`
	Verdict    string `json:"verdict"`
	Overview   string `json:"overview"`
	AssetReadout []struct {
		ID            string `json:"id"`
		TypeConfirmed string `json:"type_confirmed"`
		DataPoints    int    `json:"data_points"`
		Signal        string `json:"signal"`
		Note          string `json:"note"`
	} `json:"asset_readout"`
	Themes []struct {
		Name, Summary, Frequency        string
		Intensity, Confidence           string
		Quotes                          []Quote `json:"quotes"`
		Implications                    string  `json:"implications"`
	} `json:"themes"`
	JTBD struct {
		Functional []string `json:"functional"`
		Emotional  []string `json:"emotional"`
		Social     []string `json:"social"`
	} `json:"jtbd"`
	Pains []struct {
		Pain         string `json:"pain"`
		InTheirWords string `json:"in_their_words"`
		Confidence   string `json:"confidence"`
	} `json:"pains"`
	Triggers        []string `json:"triggers"`
	DesiredOutcomes []struct {
		Outcome  string `json:"outcome"`
		Verbatim string `json:"verbatim"`
	} `json:"desired_outcomes"`
	Vocabulary     []string `json:"vocabulary"`
	Alternatives   []string `json:"alternatives"`
	Contradictions []string `json:"contradictions"`
	Personas       []struct {
		Name, TitleRange, CompanySize, PrimaryJTBD string
		EvidenceCount                              int    `json:"evidence_count"`
		Provisional                                bool   `json:"provisional"`
		ProxyNote                                  string `json:"proxy_note"`
	} `json:"personas"`
	Gaps []struct {
		Unknown   string `json:"unknown"`
		HowToFind string `json:"how_to_find"`
	} `json:"gaps"`
	CoverageCheck []struct {
		Ref, Status, Note string
	} `json:"coverage_check"`
	NextSteps []string `json:"next_steps"`
}
var wrapper struct{ Output string `json:"output"` }
json.Unmarshal(job.Output, &wrapper)
var report Report
json.Unmarshal([]byte(wrapper.Output), &report)

fmt.Printf("%s [%s]: %s\n", report.ReportName, report.Posture, report.Verdict)
for _, a := range report.AssetReadout {
	fmt.Printf("  %s %s dp=%d signal=%s - %s\n",
		a.ID, a.TypeConfirmed, a.DataPoints, a.Signal, a.Note)
}
for _, t := range report.Themes {
	fmt.Printf("  %s - %s, intensity %s, confidence %s\n",
		t.Name, t.Frequency, t.Intensity, t.Confidence)
	for _, q := range t.Quotes {
		fmt.Printf("      [%s] %q\n", q.Source, q.Quote)
	}
	fmt.Printf("      => %s\n", t.Implications)
}
for _, p := range report.Pains {
	fmt.Printf("  (%s) %s - %q\n", p.Confidence, p.Pain, p.InTheirWords)
}
fmt.Println("  vocabulary:", strings.Join(report.Vocabulary, " / "))
if len(report.Personas) == 0 {
	fmt.Println("  personas: none - fewer than 5 data points in any one segment")
}
for _, c := range report.CoverageCheck {
	fmt.Printf("  %s: %s - %s\n", c.Ref, c.Status, c.Note)
}
for _, s := range report.NextSteps {
	fmt.Println("  - " + s)
}
String envelope = api("POST", "/run", jsonPayload);
String jobId = /* data.job_id via your JSON library */;

while (true) {
    String job = api("GET", "/jobs/" + jobId, null);
    String status = /* data.status */;
    if (status.equals("succeeded") || status.equals("failed")) break;
    Thread.sleep(1500);
}
// The report is at data.output.output as a JSON string - parse it again, then read
// report_name, posture (rich-signal | usable-with-caveats | insufficient-sample),
// verdict, overview, asset_readout[] (id / type_confirmed / data_points / signal / note),
// themes[] (name, summary, frequency, intensity, confidence, quotes[quote, source],
// implications), jtbd{functional[], emotional[], social[]},
// pains[] (pain, in_their_words, confidence), triggers[],
// desired_outcomes[] (outcome, verbatim), vocabulary[], alternatives[],
// contradictions[], personas[] (name, title_range, company_size, primary_jtbd,
// trigger_events[], top_pains[], desired_outcomes[], objections[], alternatives[],
// vocabulary[], channels[], evidence_count, provisional, proxy_note),
// gaps[] (unknown, how_to_find), coverage_check[] (ref, status, note) and next_steps[].
// A theme quote prints as:
//   System.out.printf("      [%s] \"%s\"%n", quoteSource, quoteText);
// personas is an empty array whenever no segment reaches 5 independent data points -
// check isEmpty() before iterating and read the reason out of gaps[].
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"]
report = raw.is_a?(String) ? JSON.parse(raw) : raw

puts "#{report["report_name"]} [#{report["posture"]}]: #{report["verdict"]}"
puts report["overview"]

report["asset_readout"].each do |a|
  puts "  #{a["id"]} #{a["type_confirmed"]} dp=#{a["data_points"]} " \
       "signal=#{a["signal"]} - #{a["note"]}"
end
report["themes"].each do |t|
  puts "  #{t["name"]} - #{t["frequency"]}, intensity #{t["intensity"]}, " \
       "confidence #{t["confidence"]}"
  t["quotes"].each { |q| puts "      [#{q["source"]}] \"#{q["quote"]}\"" }
  puts "      => #{t["implications"]}"
end
%w[functional emotional social].each do |kind|
  shown = report["jtbd"][kind]
  puts "  jtbd/#{kind}: #{shown.empty? ? "(not shown by the assets)" : shown.join("; ")}"
end
report["pains"].each { |p| puts "  (#{p["confidence"]}) #{p["pain"]} - \"#{p["in_their_words"]}\"" }
report["triggers"].each { |t| puts "  trigger: #{t}" }
report["desired_outcomes"].each { |o| puts "  outcome: #{o["outcome"]} | \"#{o["verbatim"]}\"" }
puts "  vocabulary: #{report["vocabulary"].join(" / ")}"
puts "  alternatives: #{report["alternatives"].join(" / ")}"
report["contradictions"].each { |c| puts "  contradiction: #{c}" }
if report["personas"].empty?
  puts "  personas: none - fewer than 5 data points in any one segment"
else
  report["personas"].each { |p| puts "  persona #{p["name"]} (#{p["evidence_count"]} dp)" }
end
report["gaps"].each { |g| puts "  gap: #{g["unknown"]} -> #{g["how_to_find"]}" }
report["coverage_check"].each { |c| puts "  #{c["ref"]}: #{c["status"]} - #{c["note"]}" }
report["next_steps"].each { |s| puts " - #{s}" }
$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"];
$report = is_string($raw) ? json_decode($raw, true) : $raw;

echo "{$report['report_name']} [{$report['posture']}]: {$report['verdict']}\n";
echo $report["overview"] . "\n";

foreach ($report["asset_readout"] as $a) {
    echo "  {$a['id']} {$a['type_confirmed']} dp={$a['data_points']} "
       . "signal={$a['signal']} - {$a['note']}\n";
}
foreach ($report["themes"] as $t) {
    echo "  {$t['name']} - {$t['frequency']}, intensity {$t['intensity']}, "
       . "confidence {$t['confidence']}\n";
    foreach ($t["quotes"] as $q) {
        echo "      [{$q['source']}] \"{$q['quote']}\"\n";
    }
    echo "      => {$t['implications']}\n";
}
foreach (["functional", "emotional", "social"] as $kind) {
    $shown = $report["jtbd"][$kind];
    echo "  jtbd/$kind: " . ($shown ? implode("; ", $shown) : "(not shown by the assets)") . "\n";
}
foreach ($report["pains"] as $p) {
    echo "  ({$p['confidence']}) {$p['pain']} - \"{$p['in_their_words']}\"\n";
}
foreach ($report["triggers"] as $t) { echo "  trigger: $t\n"; }
foreach ($report["desired_outcomes"] as $o) {
    echo "  outcome: {$o['outcome']} | \"{$o['verbatim']}\"\n";
}
echo "  vocabulary: " . implode(" / ", $report["vocabulary"]) . "\n";
echo "  alternatives: " . implode(" / ", $report["alternatives"]) . "\n";
foreach ($report["contradictions"] as $c) { echo "  contradiction: $c\n"; }
if (!$report["personas"]) {
    echo "  personas: none - fewer than 5 data points in any one segment\n";
}
foreach ($report["personas"] as $p) {
    echo "  persona {$p['name']} ({$p['evidence_count']} dp, "
       . "provisional=" . ($p["provisional"] ? "true" : "false") . ")\n";
}
foreach ($report["gaps"] as $g) { echo "  gap: {$g['unknown']} -> {$g['how_to_find']}\n"; }
foreach ($report["coverage_check"] as $c) {
    echo "  {$c['ref']}: {$c['status']} - {$c['note']}\n";
}
foreach ($report["next_steps"] as $s) { echo " - $s\n"; }
var started = await SkillSafe.ApiAsync(HttpMethod.Post, "/run", payload);
var jobId = started.GetProperty("job_id").GetString();

JsonElement job;
while (true)
{
    job = await SkillSafe.ApiAsync(HttpMethod.Get, $"/jobs/{jobId}");
    var status = job.GetProperty("status").GetString();
    if (status is "succeeded" or "failed") break;
    await Task.Delay(1500);
}

var rawText = job.GetProperty("output").GetProperty("output").GetString();
using var doc = JsonDocument.Parse(rawText!);
var report = doc.RootElement;

Console.WriteLine($"{report.GetProperty("report_name")} " +
                  $"[{report.GetProperty("posture")}]: {report.GetProperty("verdict")}");
Console.WriteLine(report.GetProperty("overview"));

foreach (var a in report.GetProperty("asset_readout").EnumerateArray())
{
    Console.WriteLine($"  {a.GetProperty("id")} {a.GetProperty("type_confirmed")} " +
                      $"dp={a.GetProperty("data_points")} signal={a.GetProperty("signal")} " +
                      $"- {a.GetProperty("note")}");
}
foreach (var t in report.GetProperty("themes").EnumerateArray())
{
    Console.WriteLine($"  {t.GetProperty("name")} - {t.GetProperty("frequency")}, " +
                      $"intensity {t.GetProperty("intensity")}, " +
                      $"confidence {t.GetProperty("confidence")}");
    foreach (var q in t.GetProperty("quotes").EnumerateArray())
    {
        Console.WriteLine($"      [{q.GetProperty("source")}] {q.GetProperty("quote")}");
    }
    Console.WriteLine($"      => {t.GetProperty("implications")}");
}
foreach (var p in report.GetProperty("pains").EnumerateArray())
{
    Console.WriteLine($"  ({p.GetProperty("confidence")}) {p.GetProperty("pain")} " +
                      $"- {p.GetProperty("in_their_words")}");
}
var jtbd = report.GetProperty("jtbd");
foreach (var kind in new[] { "functional", "emotional", "social" })
{
    var jobs = jtbd.GetProperty(kind).EnumerateArray()
                   .Select(j => j.GetString()).ToArray();
    Console.WriteLine($"  jtbd/{kind}: " +
        (jobs.Length == 0 ? "(not shown by the assets)" : string.Join("; ", jobs)));
}
if (report.GetProperty("personas").GetArrayLength() == 0)
{
    Console.WriteLine("  personas: none - fewer than 5 data points in any one segment");
}
foreach (var c in report.GetProperty("coverage_check").EnumerateArray())
{
    Console.WriteLine($"  {c.GetProperty("ref")}: {c.GetProperty("status")} - {c.GetProperty("note")}");
}
foreach (var s in report.GetProperty("next_steps").EnumerateArray())
{
    Console.WriteLine($"  - {s}");
}

The model is asked for one JSON object and nothing else, but a stray code fence or preamble is always possible. Strip a leading ```json fence, take the text between the first { and the last }, and only then parse — that is what the app does before it falls back to a retry_note reformat run.

The synthesis — output schema

One JSON object, always the same shape. Every field is present and every array is present. Nothing is padded: contradictions is empty when none exist, a job type the assets genuinely do not show stays an empty array, and personas is empty — not thinned — whenever no consistent segment reaches five independent data points, with the reason stated in gaps. gaps is never empty: even rich research has a next question. Quotes are verbatim asset text (lightly elided with at most) and always carry the id of the asset they came from; nothing cited can fail to trace back to an asset you supplied.

FieldTypeMeaning
report_namestringA short name for this synthesis, taken from the corpus — for example Churned-SMB research — Q3.
posturestringExactly one of rich-signal (the assets support confident conclusions in at least three themes), usable-with-caveats (real signal exists, but bias or thin coverage limits it) or insufficient-sample (the material cannot support messaging or persona conclusions yet — the gap analysis is the deliverable, and themes may hold fewer than two entries only in this case).
verdictstringOne or two sentences naming the single most important thing this research says — or the most important reason it cannot say it yet.
overviewstringTwo to three paragraphs, separated by blank lines: what was supplied, how strong the signal is, what stands out, and where the bias sits.
asset_readoutarrayExactly one entry per supplied asset id: {id, type_confirmed, data_points, signal, note}. type_confirmed is the type the text actually is — it differs from the type you declared when the text is clearly something else. data_points is the synthesis's own count of independent evidence units in that asset, which may disagree with the prescan. signal is high | medium | low. note is one sentence on what this asset contributes, or why it contributes little.
themesarray of 2–7{name, summary, frequency, intensity, confidence, quotes, implications}, ranked by frequency times intensity. name is short (Onboarding stalls at data import); summary is one or two sentences; frequency is plain language (5 of 8 assets); intensity is high | medium | low by the emotional weight of the language used; confidence is high (3+ independent assets, unprompted, consistent across segments), medium (2 assets, or only prompted, or one segment only) or low (single source, possibly an outlier, needs validation) — a low-confidence theme is never presented as a headline finding. quotes is 1–3 entries of {quote, source}, where quote is verbatim asset text and source is the asset id with a short descriptor (a2 — churned SMB interview). implications says what the theme means for messaging, product or positioning.
jtbdobject{functional, emotional, social} — three arrays of strings, each string one job stated in the customer's terms. An array is empty only when the assets genuinely do not show that job type, so check for empty before formatting.
painsarray{pain, in_their_words, confidence}, ranked. in_their_words is the verbatim customer phrase, or "" when no quotable phrasing exists. confidence uses the same high / medium / low guardrails as themes; pains mentioned unprompted and with emotional language rank higher.
triggersstring[]The events that pushed customers to look for a solution at all.
desired_outcomesarray{outcome, verbatim} — what customers want to be true afterwards, with the exact customer phrasing where it exists ("" otherwise) rather than a paraphrase.
vocabularystring[] (5–15)Exact words and phrases customers use, for reuse in copy — we were drowning in spreadsheets, not manual process inefficiency.
alternativesstring[]What customers tried, considered or compared — including do nothing, hiring someone, and built a spreadsheet style answers.
contradictionsstring[], may be emptyScore-versus-verbatim conflicts (a 9 whose comment describes a three-week setup), say-versus-do conflicts, and segments that disagree with each other. Empty is a correct answer.
personasarray of 0–3Emitted only where a consistent segment carries at least five independent data points; otherwise the array is empty and the reason is in gaps. Each entry is {name, title_range, company_size, primary_jtbd, trigger_events, top_pains, desired_outcomes, objections, alternatives, vocabulary, channels, evidence_count, provisional, proxy_note}: name is role-based and never cute, primary_jtbd is one sentence, trigger_events / top_pains / desired_outcomes / objections / alternatives / vocabulary / channels are string arrays, evidence_count is the number of data points behind the persona, provisional is true when it is built on the minimum evidence, and proxy_note is "" or names which proxy sources fill the gaps.
gapsarray, never empty{unknown, how_to_find} — what the research cannot yet answer, and the concrete next research step that would answer it.
coverage_checkarray{ref, status, note} — one entry per supplied asset id and one per prescan flag id. Assets use weighed (it was used) or set-aside (with the reason — duplicate, off-topic, empty). Flags use confirmed (the flag is real and shaped the confidence labels), revised (real, but different from what the scanner thought) or cleared (false alarm, with the reason). If you sent no prescan_facts, this array covers your asset ids only.
next_stepsstring[] (2–5)Ordered: what the team should do with this synthesis, first item first.

The confidence guardrails, applied to every theme and every pain:

confidenceWhat earns it
highAppears in 3 or more independent assets, mentioned unprompted, and consistent across segments.
mediumAppears in 2 assets, or only in answers to a prompt, or limited to a single segment.
lowA single source — could be an outlier and needs validation. Never a headline finding.

Sample-bias corrections are applied before confidence is assigned, so expect them in the notes: reviews skew toward power users and strong opinions; support tickets skew toward problems rather than value; promoter-heavy NPS exports overstate satisfaction. Material from the last twelve months is weighted more heavily where dates are visible, and clearly different segments are never averaged together.

A small, realistic result for the two-asset body above, trimmed for length:

{
  "report_name": "Churned SMB + Q2 NPS - import friction read",
  "posture": "insufficient-sample",
  "verdict": "Four data points all point at the same failure - onboarding dies at
              column mapping - but that is one interview and three verbatims, which
              cannot carry messaging or persona conclusions yet.",
  "overview": "Two assets were supplied: one churned-SMB interview from May and three
               Q2 NPS verbatims. Together they hold four independent data points.

               The signal that does exist is unusually consistent: every source that
               mentions setup describes it as the point of failure. ...",
  "asset_readout": [
    { "id": "a1", "type_confirmed": "interview", "data_points": 1, "signal": "high",
      "note": "The only asset with a full arc - trigger, attempt, failure, cancellation
               - and the source of the strongest verbatim." },
    { "id": "a2", "type_confirmed": "nps", "data_points": 3, "signal": "medium",
      "note": "Three short verbatims; useful corroboration on setup time, but too
               terse to show jobs or alternatives." }
  ],
  "themes": [
    { "name": "Onboarding dies at column mapping",
      "summary": "Setup, not the product's value, is where customers stall. Both
                  assets describe multi-evening or multi-week setup, and one ends in
                  cancellation.",
      "frequency": "2 of 2 assets",
      "intensity": "high",
      "confidence": "medium",
      "quotes": [
        { "quote": "the import was where it fell apart - I spent two evenings mapping
                    columns and gave up",
          "source": "a1 - churned SMB interview, May" },
        { "quote": "Setup took three weeks and support answered on day four.",
          "source": "a2 - Q2 NPS verbatim (score 4)" }
      ],
      "implications": "Time-to-first-report is the metric to move; no messaging change
                       will outrun a two-evening mapping task." },
    { "name": "Excel remains the reporting system of record",
      "summary": "Even a satisfied user still exports weekly because the built-in
                  report does not match what the board expects.",
      "frequency": "1 of 2 assets",
      "intensity": "medium",
      "confidence": "low",
      "quotes": [
        { "quote": "I still export to Excel every Monday because the built-in report
                    doesn't match what my board wants.",
          "source": "a2 - Q2 NPS verbatim (score 5)" }
      ],
      "implications": "Board-ready output may be the real job; needs validation before
                       it drives roadmap." }
  ],
  "jtbd": {
    "functional": ["Get our numbers out of spreadsheets and into one report",
                   "Produce something my board will accept without reformatting"],
    "emotional": ["Stop feeling like we are drowning in spreadsheets",
                  "Be able to answer my CFO when he asks what we pay for"],
    "social": []
  },
  "pains": [
    { "pain": "Column mapping during import is manual and defeats new users",
      "in_their_words": "I spent two evenings mapping columns and gave up",
      "confidence": "medium" },
    { "pain": "Support latency compounds a stalled setup",
      "in_their_words": "support answered on day four",
      "confidence": "low" },
    { "pain": "Built-in reports do not match board expectations",
      "in_their_words": "doesn't match what my board wants",
      "confidence": "low" }
  ],
  "triggers": [
    "Spreadsheet reporting became unmanageable for the finance team",
    "A CFO asked what the tool was being paid for"
  ],
  "desired_outcomes": [
    { "outcome": "Reporting that does not require a weekly Excel export",
      "verbatim": "I still export to Excel every Monday" },
    { "outcome": "A defensible answer on what the tool delivers",
      "verbatim": "My CFO asked what we were paying for and I couldn't answer" }
  ],
  "vocabulary": [
    "drowning in spreadsheets", "mapping columns", "fell apart",
    "what we were paying for", "once it's set up", "export to Excel every Monday",
    "what my board wants"
  ],
  "alternatives": [
    "Spreadsheets (the incumbent, and what customers return to)",
    "Manual Excel export alongside the product",
    "Do nothing - cancel and go back to the old process"
  ],
  "contradictions": [
    "A promoter-range score of 9 sits beside 'works great once it's set up' - the
     qualifier, not the score, is the finding.",
    "One score-5 respondent is still an active user while describing a workflow the
     product does not serve."
  ],
  "personas": [],
  "gaps": [
    { "unknown": "Whether import friction is the churn cause or merely its first
                  visible symptom.",
      "how_to_find": "Five churn interviews from the last two quarters, asking what
                      they did in the week before cancelling." },
    { "unknown": "What 'what my board wants' actually contains.",
      "how_to_find": "Collect three real board packs from current SMB customers." },
    { "unknown": "Whether any consistent segment exists at all - no persona is
                  possible from four data points.",
      "how_to_find": "Interview five customers sharing one label (SMB finance,
                      under 50 staff) before attempting a persona." }
  ],
  "coverage_check": [
    { "ref": "a1", "status": "weighed",
      "note": "Carries both themes and most of the vocabulary." },
    { "ref": "a2", "status": "weighed",
      "note": "Corroborates setup friction; counted as 3 data points, matching the
               prescan." },
    { "ref": "small-sample", "status": "confirmed",
      "note": "4 data points is below the persona threshold and caps every theme at
               medium confidence." },
    { "ref": "single-source", "status": "revised",
      "note": "Real, but narrower than flagged - the qualitative depth is
               single-source while the setup finding is corroborated across both." }
  ],
  "next_steps": [
    "Instrument time-to-first-report and treat column mapping as the churn surface.",
    "Run five churn interviews before writing any new positioning.",
    "Collect three customer board packs to define what 'board-ready' means.",
    "Re-run this synthesis once the corpus passes 15 data points and compare themes."
  ]
}

The synthesis is a research starting point, not a substitute for talking to customers: it is written to be internally consistent with the quoted material, but it is AI-generated and it only sees the text you sent — no tone of voice, no account history, no idea who is missing from the sample. Check every quote against the source before it goes in a deck, and treat interview transcripts and ticket threads as the personal data they are.

Step 5 — Stream the synthesis as it is written

POST /run-stream

/run-stream takes exactly the same body as /run but answers with server-sent events, so you can show progress instead of a spinner — useful here because the overview, the per-asset readout and the quoted themes make for a long reply. This app's own progress panel is this endpoint. Events are separated by a blank line; each has an event: line and a data: line carrying JSON.

EventPayloadMeaning
job{job_id, status}Sent once, when the job is accepted — show "starting".
delta{text}A chunk of the reply, in order. Append it; the accumulated length is your only progress signal (the total is not known in advance).
done{job_id, status, charged_credits, output}The final, authoritative result — read the synthesis 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: voc-lens-$(date +%s)" \
  -d "$INPUT"

# event: job
# data: {"job_id":"job_...","status":"running"}
#
# event: delta
# data: {"text":"{\"report_name\":\"Churned SMB + Q2 NPS"}
# ...
# event: done
# data: {"job_id":"job_...","status":"succeeded","charged_credits":610,"output":{"output":"{...}"}}
import json, requests

result = None
with requests.post(
    API + "/run-stream",
    headers={"Authorization": f"Bearer {TOKEN}",
             "Idempotency-Key": "voc-lens-001"},
    json=payload,
    stream=True,
) as r:
    r.raise_for_status()
    event = None
    for line in r.iter_lines(decode_unicode=True):
        if not line:
            continue
        if line.startswith("event:"):
            event = line[len("event:"):].strip()
        elif line.startswith("data:"):
            data = json.loads(line[len("data:"):].strip())
            if event == "delta":
                print(".", end="", flush=True)          # live progress
            elif event == "done":
                result = data
            elif event == "error":
                raise RuntimeError(data.get("message", "run failed"))

report = json.loads(result["output"]["output"])          # authoritative
print("charged:", result["charged_credits"], "-", report["report_name"])
print("posture:", report["posture"])
for t in report["themes"]:
    print(f'  {t["name"]} ({t["confidence"]}) - {t["frequency"]}')
for c in report["coverage_check"]:
    print(f'  {c["ref"]}: {c["status"]}')
const res = await fetch(API + "/run-stream", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${TOKEN}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify(payload),
});

const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "", done = null;

for (;;) {
  const chunk = await reader.read();
  if (chunk.done) break;
  buf += decoder.decode(chunk.value, { stream: true });
  const frames = buf.split("\n\n");
  buf = frames.pop();
  for (const frame of frames) {
    const name = /^event:\s*(.+)$/m.exec(frame)?.[1];
    const body = /^data:\s*(.+)$/m.exec(frame)?.[1];
    if (!name || !body) continue;
    const data = JSON.parse(body);
    if (name === "delta") process.stdout.write(".");   // live progress
    if (name === "done") done = data;
    if (name === "error") throw new Error(data.message ?? "run failed");
  }
}

const report = JSON.parse(done.output.output);
console.log(`\n${done.charged_credits} credits - ${report.report_name}`);
console.log("posture:", report.posture);
for (const t of report.themes) console.log(`  ${t.name} (${t.confidence}) - ${t.frequency}`);
for (const c of report.coverage_check) console.log(`  ${c.ref}: ${c.status}`);
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", API+"/run-stream", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "voc-lens-001")

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

var event string
var final map[string]any
sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
for sc.Scan() {
	line := sc.Text()
	switch {
	case strings.HasPrefix(line, "event:"):
		event = strings.TrimSpace(strings.TrimPrefix(line, "event:"))
	case strings.HasPrefix(line, "data:"):
		var data map[string]any
		json.Unmarshal([]byte(strings.TrimPrefix(line, "data:")), &data)
		switch event {
		case "delta":
			fmt.Print(".") // live progress
		case "done":
			final = data
		case "error":
			log.Fatal(data["message"])
		}
	}
}
// final["output"].(map[string]any)["output"].(string) is the synthesis JSON -
// unmarshal it into the Report struct from step 4, then print report.Themes and
// report.CoverageCheck.
// 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", "voc-lens-001")
    .POST(HttpRequest.BodyPublishers.ofString(jsonPayload))
    .build();

var res = HTTP.send(req, HttpResponse.BodyHandlers.ofLines());
String event = null, done = null;
for (String line : (Iterable<String>) res.body()::iterator) {
    if (line.startsWith("event:")) {
        event = line.substring(6).trim();
    } else if (line.startsWith("data:")) {
        String data = line.substring(5).trim();
        if ("delta".equals(event)) System.out.print(".");   // live progress
        else if ("done".equals(event)) done = data;
        else if ("error".equals(event)) throw new RuntimeException(data);
    }
}
// parse `done`, then parse data.output.output again - it is a JSON string holding
// report_name, posture, verdict, overview, asset_readout[], themes[] with their
// quotes[], jtbd{}, pains[], triggers[], desired_outcomes[], vocabulary[],
// alternatives[], contradictions[], personas[], gaps[], coverage_check[] and
// next_steps[].
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"] = "voc-lens-001"
req.body = payload.to_json

event = nil
done = nil
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
  http.request(req) do |res|
    res.read_body do |chunk|
      chunk.each_line do |line|
        line = line.strip
        if line.start_with?("event:")
          event = line.delete_prefix("event:").strip
        elsif line.start_with?("data:")
          data = JSON.parse(line.delete_prefix("data:").strip)
          case event
          when "delta" then print "."           # live progress
          when "done"  then done = data
          when "error" then raise (data["message"] || "run failed")
          end
        end
      end
    end
  end
end

report = JSON.parse(done["output"]["output"])
puts "\n#{done["charged_credits"]} credits - #{report["report_name"]}"
puts "posture: #{report["posture"]}"
report["themes"].each { |t| puts "  #{t["name"]} (#{t["confidence"]}) - #{t["frequency"]}" }
report["coverage_check"].each { |c| puts "  #{c["ref"]}: #{c["status"]}" }
$event = null;
$done  = null;

$ch = curl_init(API . "/run-stream");
curl_setopt_array($ch, [
    CURLOPT_POST       => true,
    CURLOPT_HTTPHEADER => [
        "Authorization: Bearer $TOKEN",
        "Content-Type: application/json",
        "Idempotency-Key: voc-lens-001",
    ],
    CURLOPT_POSTFIELDS => json_encode($payload),
    CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$event, &$done) {
        foreach (explode("\n", $chunk) as $line) {
            $line = trim($line);
            if (str_starts_with($line, "event:")) {
                $event = trim(substr($line, 6));
            } elseif (str_starts_with($line, "data:")) {
                $data = json_decode(trim(substr($line, 5)), true);
                if ($event === "delta") { echo "."; }        // live progress
                elseif ($event === "done") { $done = $data; }
                elseif ($event === "error") { throw new Exception($data["message"] ?? "run failed"); }
            }
        }
        return strlen($chunk);
    },
]);
curl_exec($ch);
curl_close($ch);

$report = json_decode($done["output"]["output"], true);
echo "\n{$done['charged_credits']} credits - {$report['report_name']}\n";
echo "posture: {$report['posture']}\n";
foreach ($report["themes"] as $t) {
    echo "  {$t['name']} ({$t['confidence']}) - {$t['frequency']}\n";
}
foreach ($report["coverage_check"] as $c) { echo "  {$c['ref']}: {$c['status']}\n"; }
var req = new HttpRequestMessage(HttpMethod.Post, Api + "/run-stream") {
    Content = JsonContent.Create(payload),
};
req.Headers.Add("Idempotency-Key", "voc-lens-001");

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

string? evt = null, done = null;
while (await reader.ReadLineAsync() is { } line)
{
    if (line.StartsWith("event:")) evt = line[6..].Trim();
    else if (line.StartsWith("data:"))
    {
        var data = line[5..].Trim();
        if (evt == "delta") Console.Write(".");            // live progress
        else if (evt == "done") done = data;
        else if (evt == "error") throw new Exception(data);
    }
}

using var final = JsonDocument.Parse(done!);
var text = final.RootElement.GetProperty("output").GetProperty("output").GetString();
using var reportDoc = JsonDocument.Parse(text!);
var report = reportDoc.RootElement;
Console.WriteLine(report.GetProperty("report_name"));
Console.WriteLine($"posture: {report.GetProperty("posture")}");
foreach (var t in report.GetProperty("themes").EnumerateArray())
    Console.WriteLine($"  {t.GetProperty("name")} ({t.GetProperty("confidence")}) " +
                      $"- {t.GetProperty("frequency")}");
foreach (var c in report.GetProperty("coverage_check").EnumerateArray())
    Console.WriteLine($"  {c.GetProperty("ref")}: {c.GetProperty("status")}");

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.