Score a draft against your audiences
This is the API behind Audience Scorecards. You send a piece of content and a list of personas. Each persona returns a grade from A to F, a set of judgments sorted into what resonates, what is mixed and what doesn't land, the passage in the text that drove each judgment, and a suggested rewrite. The API calls one of these results a reaction insight.
You need an API key and persona ids from the Quickstart.
The call
POST /api/v1/personas/reaction-insights
| Field | Required | Meaning |
|---|---|---|
document | yes | The content to score. Either inline text (title, content, and optionally content_type, source_url, executive for who is speaking) or a stored document as {"id": "…"}. |
personas | yes | One to fifty entries. Each is a stored persona, {"id": "…"}, or an ad-hoc audience described inline with name, description, tone, interests, concerns and communication_style. |
skip_save | no | true for drafts you do not want kept in the workspace. See Drafts versus published content. |
force_refresh | no | true regenerates even when a cached result exists. |
model | no | Overrides the model. Leave it unset to use the workspace default. |
This request scores a newsletter draft against two audiences from the Entertainment workspace used in the Quickstart:
{
"document": {
"title": "Tudum Weekly: your fall watchlist, plus a change to the ads plan",
"content_type": "newsletter",
"content": "Fall is stacking up. This week we're spotlighting three new arrivals … There is also a change to the Standard with ads plan. Starting with your next billing cycle, the plan moves from $6.99 to $7.99 a month in the US …"
},
"personas": [
{ "id": "8e049a2e-92dd-4077-b6ca-e7154edc7de4" },
{ "id": "afd6efb0-90f2-4fae-99a4-4c366bb57897" }
],
"skip_save": true
}
- curl
- Python
- Node
curl -X POST https://api.kumkuat.ai/api/v1/personas/reaction-insights \
-H "X-API-Key: $KUMKUAT_API_KEY" \
-H "Content-Type: application/json" \
--max-time 300 \
--data @request.json
import os
import requests
BASE = "https://api.kumkuat.ai"
HEADERS = {"X-API-Key": os.environ["KUMKUAT_API_KEY"]}
def score(title, text, persona_ids, content_type="newsletter"):
body = {
"document": {"title": title, "content": text, "content_type": content_type},
"personas": [{"id": pid} for pid in persona_ids],
"skip_save": True,
}
resp = requests.post(f"{BASE}/api/v1/personas/reaction-insights",
json=body, headers=HEADERS, timeout=300)
resp.raise_for_status()
return resp.json()
result = score("Tudum Weekly: your fall watchlist", open("draft.txt").read(),
["8e049a2e-92dd-4077-b6ca-e7154edc7de4", "afd6efb0-90f2-4fae-99a4-4c366bb57897"])
for insight in result["insights"]:
counts = insight["status_counts"]
print(f'{insight["persona"]["name"]}: grade {insight["grade"]} '
f'({counts["good"]} resonate, {counts["warn"]} mixed, {counts["bad"]} do not land)')
for j in insight["output_json"]["key_judgments"]:
print(f' [{j["status"]}] {j["tag"]}: {j["judgment"]}')
const BASE = "https://api.kumkuat.ai";
const headers = {
"X-API-Key": process.env.KUMKUAT_API_KEY,
"Content-Type": "application/json",
};
async function score(title, text, personaIds, contentType = "newsletter") {
const resp = await fetch(`${BASE}/api/v1/personas/reaction-insights`, {
method: "POST",
headers,
body: JSON.stringify({
document: { title, content: text, content_type: contentType },
personas: personaIds.map((id) => ({ id })),
skip_save: true,
}),
signal: AbortSignal.timeout(300_000),
});
if (!resp.ok) throw new Error(`${resp.status} ${await resp.text()}`);
return resp.json();
}
const result = await score("Tudum Weekly: your fall watchlist", draftText, [
"8e049a2e-92dd-4077-b6ca-e7154edc7de4",
"afd6efb0-90f2-4fae-99a4-4c366bb57897",
]);
for (const insight of result.insights) {
const c = insight.status_counts;
console.log(`${insight.persona.name}: grade ${insight.grade} (${c.good} resonate, ${c.warn} mixed, ${c.bad} do not land)`);
for (const j of insight.output_json.key_judgments) {
console.log(` [${j.status}] ${j.tag}: ${j.judgment}`);
}
}
The call returns when every persona has answered. This two-persona request took about 30 seconds. Personas are generated up to four at a time, so a panel of eight takes roughly twice as long as a panel of four. Each persona's model call is capped at 60 seconds; a persona that exceeds it comes back with status: "timed_out" instead of failing the whole request.
Reading the result
{
"message": "Persona reaction insights ready",
"generated": 2,
"cache_hits": 0,
"failed": 0,
"timed_out": 0,
"document": {
"id": null,
"title": "Tudum Weekly: your fall watchlist, plus a change to the ads plan",
"document_type": "newsletter",
"document_hash": "74f11ac639ee026d844237ff2a731c9121789589f6d2a2440028613802dea159"
},
"insights": [
{
"insight_id": "6c5a5469-1e3b-42df-bab0-64b5348ab5fb",
"persona": {
"id": "8e049a2e-92dd-4077-b6ca-e7154edc7de4",
"name": "Price-Sensitive Subscriber",
"description": "An individual or household primarily driven by cost-effectiveness …"
},
"grade": "F",
"status_counts": { "good": 1, "warn": 1, "bad": 3 },
"status": "completed",
"cache_hit": false,
"generation_source": "ephemeral_cache",
"generated_at": "2026-09-23T17:42:58.688736",
"model": "gemini/gemini-2.5-flash",
"response_language": "en",
"output_json": {
"title": "Audience snapshot",
"subtitle": "Decision-ready view: what lands, what doesn't, and what to do next",
"context": "Newsletter detailing new content and a price change for an ad-supported plan.",
"overall_take": "This audience will immediately focus on the stated price increase and will be skeptical, looking for a clear, tangible value justification. While the added downloads offer some concrete benefit, the vague 'wider selection' does little to offset the additional cost …",
"key_judgments": [
{
"id": "j1",
"status": "bad",
"confidence": 5,
"tag": "Price Increase",
"judgment": "The immediate announcement of a price increase without strong upfront value justification creates negative sentiment, as the primary focus is on cost reduction.",
"comms_title": "Prioritize Value Proposition",
"comms": "Great news for our Standard with ads members! Starting with your next billing cycle, your plan will now include downloads on two devices and an even wider selection of licensed titles …"
},
{
"id": "j2",
"status": "good",
"confidence": 4,
"tag": "Tangible Value Add",
"judgment": "The addition of downloads on two devices is a concrete, valuable feature that enhances convenience and justifies some of the price increase …",
"comms_title": "Emphasize New Features",
"comms": "We're excited to add downloads on two devices to your Standard with ads plan …"
},
"…"
],
"evidence_and_signals": {
"items": [
{
"id": "e1",
"title": "Direct Price Hike",
"quote": "the plan moves from $6.99 to $7.99 a month in the US.",
"analysis": "This is a clear, unmitigated cost increase that immediately triggers budget concerns for a price-sensitive subscriber.",
"maps_to": ["j1"]
},
"…"
]
},
"implications_and_actions": {
"actions": [
{
"id": "a1",
"title": "Reframe Price Changes",
"detail": "Position price increases as an enhancement to value (more for the money) rather than just a cost hike, leading with the added benefits.",
"tradeoff": "Requires clear, demonstrable value additions to be credible.",
"protects": ["j1", "j2", "j3"]
},
"…"
]
},
"counterpoints_and_risks": {
"items": [
{
"status": "bad",
"text": "The direct announcement of a price increase, even with added features, may prompt some price-sensitive subscribers to cancel or seek cheaper alternatives from competitors, leading to churn."
},
"…"
]
}
},
"output_content": "… the same snapshot as raw text …",
"error_message": null,
"prompt_version": "audience_snapshot_v1"
},
{
"persona": { "id": "afd6efb0-90f2-4fae-99a4-4c366bb57897", "name": "TV Critics (TCA / Metacritic / Rotten Tomatoes)" },
"grade": "D",
"status_counts": { "good": 2, "warn": 2, "bad": 1 },
"…": "…"
}
],
"prompt_version": "audience_snapshot_v1",
"workspace_id": "ws_9738dfeb738a44debb10"
}
The top level counts what happened: generated personas were freshly scored, cache_hits were served from cache, failed and timed_out did not complete. insights comes back in the same order as the personas you sent.
Each insight carries:
| Field | Meaning |
|---|---|
grade | A to F. Kumkuat computes it from the judgments and their confidence, so grades are comparable across audiences and over time. |
status_counts | How many judgments were good, warn and bad. The app shows these as Resonates, Mixed and Doesn't land. |
status | completed, failed or timed_out. Only completed rows have a grade; the others carry an error_message. |
output_json | The structured snapshot, described below. output_content is the same snapshot as raw text; prefer output_json. |
cache_hit, generation_source | Whether this row was generated now or served from cache, and by which path: interactive, manual_regenerate, ephemeral_cache (a skip_save draft) or background_job. |
insight_id | The stored row. Keep it if you want to reference this exact result later. |
response_language | The language the persona answered in, following the workspace's market language. |
Inside output_json:
| Key | What it holds |
|---|---|
overall_take | Two or three sentences on how this audience takes the piece as a whole. |
context | One line on what was read. |
key_judgments[] | The judgments. status is good, warn or bad; confidence runs 1 to 5; tag is a short label; judgment is the reasoning; comms_title and comms are a suggested rewrite of the passage concerned. |
evidence_and_signals.items[] | The passages that drove the judgments: a verbatim quote from the text, an analysis, and maps_to with the judgment ids it supports. Use quote to highlight the passage in your editor. |
implications_and_actions.actions[] | What to do about it: title, detail, the tradeoff of doing it, and protects with the judgment ids it addresses. |
counterpoints_and_risks.items[] | The pushback this audience would raise, each with a status and text. The app shows these as Potential pushback. |
title, subtitle | Fixed labels for the snapshot. |
Two fields you can ignore: translation_target_language and translation_target_language_name tell the app which language its reader prefers.
Caching and re-runs
A result is cached by the exact document text, the persona, and the model. Send the same request again and every persona comes back with cache_hit: true in well under a second. The same two-persona request above returned from cache in 0.2 seconds. This makes the call safe to retry after a timeout or a network error.
Any change to the text produces a new document_hash, and every persona on the panel is scored again. When the document is a stored one, scored by id, each persona anchors on its previous reaction to that document, so the grade moves for real changes rather than for rewording.
Pass force_refresh: true to regenerate a result you already have.
Drafts versus published content
There are two ways to send content, and the difference is what is kept.
Drafts: inline text with skip_save: true. Nothing is added to the workspace's document library. The reactions are kept only as a short-lived cache entry, for seven days, so that an unchanged draft returns the same result instantly. Use this for every revision before publication. The document comes back with "id": null.
Published pieces: a stored document. Create the document once, then score it by id. A stored document is what makes the rest of this guide possible: the background-job variant, reaction history, the Audience Dashboard and Audience Scorecards in the app all work from stored documents. Creating documents requires an admin or owner key.
curl -X POST https://api.kumkuat.ai/api/v1/documents \
-H "X-API-Key: $KUMKUAT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"title": "Tudum Weekly: your fall watchlist, plus a change to the ads plan",
"content": "Fall is stacking up. …",
"content_type": "newsletter",
"source_url": "https://example.com/newsletters/2026-09-23",
"tags": ["newsletter", "fall-2026"],
"media_provenance": "owned"
}'
{
"message": "Document created successfully",
"document": {
"id": "423c1f3e-f631-4b76-825b-420be735e80c",
"title": "Tudum Weekly: your fall watchlist, plus a change to the ads plan",
"content_type": "newsletter",
"word_count": 211,
"character_count": 1186,
"created_at": "2026-09-23T17:43:07.160941"
}
}
content_type is free text; the app uses values such as newsletter, blog, press release and linkedin. media_provenance is owned, earned or paid. source_url should be the published URL when there is one. Then score it with "document": {"id": "423c1f3e-…"} and no skip_save.
Larger panels: run it as a background job
The synchronous call handles up to fifty personas, but for anything beyond a handful the background-job variant is the better fit: it returns at once and lets you render each persona as it lands.
POST /api/v1/personas/reaction-insights/async takes the same document and personas fields, with two conditions: the document must be a stored one ({"id": "…"}) and every persona must be a stored persona. It answers 202 Accepted:
{
"message": "Persona reaction insights job queued",
"job_id": "8e9ec0ac-1702-4298-8cd6-ac0f86c4aabc",
"document_id": "2ce66308-3c2e-468e-b2cb-223d931b0616",
"persona_ids": ["0c2d8cfc-0c98-446b-91e8-330dafa3875f", "4242603b-1b67-45e0-845b-ea4f285ee3e1"],
"requested_personas": 2,
"sync_threshold": 3,
"workspace_id": "ws_9738dfeb738a44debb10"
}
sync_threshold is the panel size at which the app itself switches from the synchronous call to this one.
Poll GET /api/v1/jobs/{job_id} every few seconds:
{
"id": "8e9ec0ac-1702-4298-8cd6-ac0f86c4aabc",
"job_type": "audience_reactions",
"status": "completed",
"document_id": "2ce66308-3c2e-468e-b2cb-223d931b0616",
"parameters": "{\"document_id\": \"2ce66308-…\", \"persona_ids\": [\"0c2d8cfc-…\", \"4242603b-…\"], \"max_personas\": 20, \"force_regenerate\": false, …}",
"result": "{\"requested_personas\": 2, \"generated\": 2, \"failed\": 0, \"persisted\": 2, \"skipped\": 0, \"remaining_personas\": 0, \"continuation_job_id\": null, …}",
"error_message": null,
"created_at": "2026-09-23T17:42:59.788467+00:00",
"started_at": "2026-09-23T17:43:00.007258+00:00",
"completed_at": "2026-09-23T17:43:32.513145+00:00",
"retry_count": 0,
"max_retries": 1
}
status moves through queued, running and then completed or failed. Note that parameters and result are JSON encoded as strings; parse them. A job processes up to 20 personas; for a bigger panel result reports remaining_personas and a continuation_job_id to poll next.
Read the rows as they land with the listing endpoint, filtered to the document and the panel:
curl "https://api.kumkuat.ai/api/v1/personas/reaction-insights?document_id=2ce66308-3c2e-468e-b2cb-223d931b0616&persona_ids=0c2d8cfc-0c98-446b-91e8-330dafa3875f,4242603b-1b67-45e0-845b-ea4f285ee3e1&latest_only=true&include_failed=true" \
-H "X-API-Key: $KUMKUAT_API_KEY"
{
"count": 2,
"latest_only": true,
"insights": [
{ "persona": { "id": "0c2d8cfc-…", "name": "Streaming Comparison Consumer" }, "grade": "C", "status_counts": { "good": 1, "warn": 3, "bad": 0 }, "status": "completed", "generation_source": "background_job", "…": "…" },
{ "persona": { "id": "4242603b-…", "name": "Variety" }, "grade": "C", "status_counts": { "good": 3, "warn": 2, "bad": 1 }, "status": "completed", "generation_source": "background_job", "…": "…" }
]
}
Each item has the same shape as an insight from the synchronous call. latest_only=true keeps one row per persona and document; include_failed=true also returns failed and timed_out rows so you can show why a persona is missing. The list accepts limit (default 25, up to 2000) and sort=recent to order by generation time instead of document date.
Compare revisions
For a stored document, every reaction is kept, so you can show how a persona's grade moved between revisions:
curl "https://api.kumkuat.ai/api/v1/personas/reaction-insights/history?persona_id=0c2d8cfc-0c98-446b-91e8-330dafa3875f&document_id=2ce66308-3c2e-468e-b2cb-223d931b0616&limit=5" \
-H "X-API-Key: $KUMKUAT_API_KEY"
{
"count": 1,
"persona_id": "0c2d8cfc-0c98-446b-91e8-330dafa3875f",
"document_id": "2ce66308-3c2e-468e-b2cb-223d931b0616",
"entries": [
{
"id": "fcad4ecf-4de8-49a4-bf19-e96c39ee8ef9",
"persona_name": "Streaming Comparison Consumer",
"grade": "C",
"status_counts": { "good": 1, "warn": 3, "bad": 0 },
"document_hash": "342a6666d55f45f8ba45cba42df2b0ae8367cda0cea91afd3e45714dd3b589b5",
"generated_at": "2026-09-23T17:43:32.464014+00:00",
"generation_source": "background_job",
"model": "gemini/gemini-2.5-flash",
"output_content": "{ … }"
}
]
}
Entries are newest first, up to 100 per call. A different document_hash between two entries means the text changed between runs.
For drafts scored with skip_save, keep the responses on your side and compare grade and status_counts between revisions yourself.
Ask the audience a follow-up question
A scorecard tells you what landed. When an editor wants to know why, or wants to test a fix before rewriting, put the question to the persona directly:
curl -X POST https://api.kumkuat.ai/api/v1/personas/interact \
-H "X-API-Key: $KUMKUAT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"persona_id": "8e049a2e-92dd-4077-b6ca-e7154edc7de4",
"interaction_type": "conversation",
"input_content": "Here is a draft of our next subscriber newsletter:\n\n… draft text …\n\nWhich line would make you think about cancelling, and what would you need to hear to stay?"
}'
{
"message": "Persona interaction completed",
"interaction_id": "ephem-int-b5bd3c42-6b69-42cd-a0b7-8490e43c9977",
"persona_response": "The line about the Standard with ads plan increasing from $6.99 to $7.99 a month is definitely what would make me think about cancelling. A dollar increase is still a price increase I didn't ask for.\n\nTo stay, I'd need to see a much clearer justification beyond just \"downloads on two devices\" and a \"wider selection\" … are there any loyalty discounts or options to lock in my current rate?",
"response_language": "en",
"grounded_context_suggestion": null
}
| Field | Meaning |
|---|---|
persona_id | The persona to ask. |
interaction_type | conversation for a question and answer in the persona's own voice. |
input_content | The question. Include the draft, or the passage, in the text; the persona sees only what you send. |
context_document_ids | Optional. Stored documents to give the persona as background. |
voice_profile_id | Optional. Pin one of the persona's voice profiles instead of its default. |
The reply is in persona_response (output_content and response repeat it for older clients). This call took about five seconds.
Good practice
- Keep drafts ephemeral. Score every revision with inline text and
skip_save: true. Store a document only when the piece is published. - Show the judgments, not only the grade. The grade is a summary; the value is in
key_judgments, thequotethat each rests on, and thecommsrewrite the editor can apply. - Run the same panel each time. Changing the panel between revisions makes grades incomparable. Keep the persona ids with the piece.
- Treat grades as directional. Identical text returns the identical cached result, but a regenerated reaction can differ from run to run, and a different title is a different document. Compare runs of the same panel on the same text, and read the judgments rather than the letter alone.
- Retry freely, but not in parallel. Identical requests are served from cache, so a retry after a timeout is cheap. Two concurrent identical requests both generate.
- Keep the key on the server. Never call these endpoints from a browser.