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.
| Status | Meaning |
|---|---|
400 | Malformed body — e.g. assets missing, empty, or longer than twelve entries. |
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 running a twelve-asset corpus). |
404 | Unknown job or record id. |
429 | Too many requests — 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
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
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
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 field | Type | Notes |
|---|---|---|
goal | string | general | 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_context | string, may be empty | What 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. |
assets | array of 1–12, required | The 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_facts | object, optional | What 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_note | string, optional | Only 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
/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.
| Field | Type | Meaning |
|---|---|---|
report_name | string | A short name for this synthesis, taken from the corpus — for example Churned-SMB research — Q3. |
posture | string | Exactly 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). |
verdict | string | One or two sentences naming the single most important thing this research says — or the most important reason it cannot say it yet. |
overview | string | Two to three paragraphs, separated by blank lines: what was supplied, how strong the signal is, what stands out, and where the bias sits. |
asset_readout | array | Exactly 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. |
themes | array 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. |
jtbd | object | {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. |
pains | array | {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. |
triggers | string[] | The events that pushed customers to look for a solution at all. |
desired_outcomes | array | {outcome, verbatim} — what customers want to be true afterwards, with the exact customer phrasing where it exists ("" otherwise) rather than a paraphrase. |
vocabulary | string[] (5–15) | Exact words and phrases customers use, for reuse in copy — we were drowning in spreadsheets, not manual process inefficiency. |
alternatives | string[] | What customers tried, considered or compared — including do nothing, hiring someone, and built a spreadsheet style answers. |
contradictions | string[], may be empty | Score-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. |
personas | array of 0–3 | Emitted 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. |
gaps | array, never empty | {unknown, how_to_find} — what the research cannot yet answer, and the concrete next research step that would answer it. |
coverage_check | array | {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_steps | string[] (2–5) | Ordered: what the team should do with this synthesis, first item first. |
The confidence guardrails, applied to every theme and every pain:
| confidence | What earns it |
|---|---|
high | Appears in 3 or more independent assets, mentioned unprompted, and consistent across segments. |
medium | Appears in 2 assets, or only in answers to a prompt, or limited to a single segment. |
low | A 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
/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.
| 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 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.