Build an audience reaction experience: scorecards and chat
Kumkuat shows an audience's reaction in two ways. A scorecard is structured: a grade per persona, the judgments behind it and the passages that drove them. A chat is a conversation: you ask, the audience answers in its own voice. In the app these are Audience Scorecards and Audience Chat. This guide shows how to build both into your own product with the same API calls the app uses.
It assumes you have a key and persona ids from the Quickstart. The scoring call itself is covered field by field in Score a draft against your audiences; this guide summarises it and concentrates on what to build around it.
The pieces
| What the user sees | Call | Notes |
|---|---|---|
| A panel picker | GET /api/v1/audience-groups, GET /api/v1/personas?summary=1, optionally GET /api/v1/audience-panels | Cache for a few minutes |
| A grade per persona | POST /api/v1/personas/reaction-insights | Synchronous for small panels |
| Large panels | POST /api/v1/personas/reaction-insights/async, GET /api/v1/jobs/{job_id} | Stored documents and stored personas only |
| A group view | Computed on your side from the insights | Optional narrative from POST /api/v1/personas/interact |
| One-to-one chat | POST /api/v1/personas/interact with interaction_type: "conversation" | Stateless: you send the history |
| A focus group | POST /api/v1/personas/interact, once for the room or once per persona | See Focus groups |
| Chat about a document | context_document_ids on the same call | Up to six stored documents |
Every call in this guide accepts either kind of key except the persona listing, which needs a personal key.
1. Build the panel picker
Group personas by category in the order of GET /api/v1/audience-groups, and leave out personas with include_in_audience: false or has_template: false. The Quickstart shows both calls.
If the workspace uses the Audience Library, it also has saved panels, named sets of audiences such as "Trade press" or "Q3 earnings panel". Offering them in your picker lets people reuse the sets their team already maintains:
curl https://api.kumkuat.ai/api/v1/audience-panels \
-H "X-API-Key: $KUMKUAT_API_KEY"
{
"panels": [
{
"id": "b3d4c1e2-5f6a-4b7c-8d9e-0f1a2b3c4d5e",
"workspaceId": "ws_9738dfeb738a44debb10",
"name": "Subscriber pricing panel",
"description": "Consumers and critics we test every pricing message against.",
"personaIds": ["8e049a2e-92dd-4077-b6ca-e7154edc7de4", "afd6efb0-90f2-4fae-99a4-4c366bb57897"],
"groupNames": ["Consumer"],
"visibility": "workspace",
"createdBy": "…",
"createdAt": "2026-09-18T14:02:11+00:00",
"updatedAt": "2026-09-18T14:02:11+00:00"
}
]
}
A panel is stored by reference. To get its members, take personaIds and add every persona whose category is in groupNames, then remove duplicates. Panel objects use camelCase keys, unlike the rest of the API.
Saved panels come with the Audience Library, which is being switched on workspace by workspace. Where it is off, the call returns 403 with {"error": "Audience panels are not enabled for this workspace"}. Treat that as "no panels" and show categories only. The panel endpoints are not yet in the API reference; Build and manage an audience library covers creating and editing them.
2. Scorecard: a grade per persona
Post the content and the panel. For drafts, send the text inline with skip_save: true so nothing is added to the workspace:
{
"document": {
"title": "Tudum Weekly: your fall watchlist, plus a change to the ads plan",
"content_type": "newsletter",
"content": "Fall is stacking up. … 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
}
POST /api/v1/personas/reaction-insights returns one insight per persona, in the order you sent them. This is how the app's scorecard maps onto the response:
| On the scorecard | From each insight |
|---|---|
| Grade A to F | grade |
| Resonates, Mixed, Doesn't land | status_counts.good, .warn, .bad |
| The summary paragraph | output_json.overall_take |
| The judgments, coloured | output_json.key_judgments[]: status, tag, judgment, confidence |
| The Evidence link on each judgment | output_json.evidence_and_signals.items[]: highlight quote in the text, linked to judgments by maps_to |
| The recommendation on each judgment | comms_title and comms on the judgment |
| Potential pushback | output_json.counterpoints_and_risks.items[] |
| An unavailable persona | status of failed or timed_out, with error_message |
Panel size. The app keeps panels of up to three personas on the synchronous call and switches to the background job from four. The synchronous call accepts up to fifty, but it only returns when every persona has answered, and personas are generated up to four at a time. For a panel of more than a handful, use POST /api/v1/personas/reaction-insights/async on a stored document and poll GET /api/v1/jobs/{job_id}; Score a draft walks through it.
Audience history. For a stored document, GET /api/v1/personas/reaction-insights/history?persona_id=…&document_id=… returns earlier reactions newest first, so you can show the grade moving between revisions, as the app's Audience history does.
3. A group view
The app's Generate group scorecard view is computed from the per-persona insights; there is no separate endpoint for it. Build yours the same way: tally across the panel, then surface what the audiences agree on.
from collections import Counter, defaultdict
GRADE_POINTS = {"A": 4, "B": 3, "C": 2, "D": 1, "F": 0}
def group_view(insights):
done = [i for i in insights if i["status"] == "completed"]
totals = Counter()
by_tag = defaultdict(list)
for i in done:
totals.update(i["status_counts"])
for j in i["output_json"]["key_judgments"]:
by_tag[(j["tag"], j["status"])].append(i["persona"]["name"])
avg = sum(GRADE_POINTS[i["grade"]] for i in done) / len(done) if done else None
shared = sorted(((tag, status, names) for (tag, status), names in by_tag.items() if len(names) > 1),
key=lambda t: -len(t[2]))
return {
"personas_scored": len(done),
"average_points": avg, # 4 = all A, 0 = all F
"status_counts": dict(totals), # resonates / mixed / doesn't land across the panel
"shared_judgments": shared, # the same tag and status raised by more than one persona
}
Tags are short labels chosen by each persona, so two personas can describe the same issue with different words. If you want a written synthesis instead of, or alongside, the tally, ask a neutral analyst through the conversation call. persona_override lets you describe an ad-hoc persona inline:
{
"persona_id": "persona_override",
"persona_override": {
"name": "Focus group analyst",
"description": "A neutral research analyst who summarises how a panel of audiences received a piece of content.",
"template": "Summarise where the panel agrees, where it splits, and the two changes that would most improve the reception. Refer to audiences by name. Do not invent reactions that are not in the notes."
},
"interaction_type": "conversation",
"input_content": "Content: Tudum Weekly newsletter.\n\nPrice-Sensitive Subscriber (grade F): Price Increase [bad] The announcement of a price increase without upfront value justification …\nTV Critics (grade D): …"
}
The analyst's reply is in persona_response. Build input_content from each insight's grade and key_judgments so the synthesis rests on what the panel actually said.
4. One-to-one chat
A chat turn is one call to POST /api/v1/personas/interact with interaction_type: "conversation". The persona answers in its own voice, grounded in its profile, its linked sources and the workspace's grounded context.
| Field | Required | Meaning |
|---|---|---|
persona_id | yes | The persona to talk to. |
interaction_type | yes | conversation for a chat reply. (communication returns the structured snapshot as text instead; use the scoring call for that.) |
input_content | yes | The message, with the conversation so far. See below. |
context_document_ids | no | Up to six stored documents for the persona to read. See Chat about a document. |
ground_with_google_search | no | true lets the model run web searches derived from the message to bring in recent context. The app sends true. |
enable_follow_up_questions | no | true lets the persona end its reply with one question back to the user. By default it ends on a statement. |
voice_profile_id | no | Pin one of the persona's voice profiles instead of its default. |
response_language | no | Override the reply language. By default the persona answers in the workspace's market language. |
The call is stateless. Kumkuat does not keep the conversation between calls, so your product holds the transcript and sends the recent turns with every message. The app sends the last eight turns, labelled by speaker:
- Python
- Node
import os
import requests
BASE = "https://api.kumkuat.ai"
HEADERS = {"X-API-Key": os.environ["KUMKUAT_API_KEY"]}
class PersonaChat:
def __init__(self, persona_id, persona_name, document_ids=None, max_turns=8):
self.persona_id = persona_id
self.persona_name = persona_name
self.document_ids = document_ids or []
self.max_turns = max_turns
self.turns = [] # [(speaker, text)]
def send(self, message):
history = "\n\n".join(f"{who}: {text}" for who, text in self.turns[-self.max_turns:])
prompt = f"{history}\n\nUser: {message}" if history else f"User: {message}"
resp = requests.post(f"{BASE}/api/v1/personas/interact", headers=HEADERS, timeout=120, json={
"persona_id": self.persona_id,
"interaction_type": "conversation",
"input_content": prompt,
"context_document_ids": self.document_ids,
"ground_with_google_search": True,
})
resp.raise_for_status()
reply = resp.json()["persona_response"]
self.turns += [("User", message), (self.persona_name, reply)]
return reply
chat = PersonaChat("8e049a2e-92dd-4077-b6ca-e7154edc7de4", "Price-Sensitive Subscriber")
print(chat.send("We're thinking of adding a cheaper annual plan. Would that change how you feel about the price rise?"))
print(chat.send("What if the annual plan didn't include downloads?"))
const BASE = "https://api.kumkuat.ai";
const headers = { "X-API-Key": process.env.KUMKUAT_API_KEY, "Content-Type": "application/json" };
export function personaChat(personaId, personaName, { documentIds = [], maxTurns = 8 } = {}) {
const turns = []; // [{ who, text }]
return async function send(message) {
const history = turns.slice(-maxTurns).map((t) => `${t.who}: ${t.text}`).join("\n\n");
const input = history ? `${history}\n\nUser: ${message}` : `User: ${message}`;
const resp = await fetch(`${BASE}/api/v1/personas/interact`, {
method: "POST",
headers,
body: JSON.stringify({
persona_id: personaId,
interaction_type: "conversation",
input_content: input,
context_document_ids: documentIds,
ground_with_google_search: true,
}),
signal: AbortSignal.timeout(120_000),
});
if (!resp.ok) throw new Error(`${resp.status} ${await resp.text()}`);
const { persona_response: reply } = await resp.json();
turns.push({ who: "User", text: message }, { who: personaName, text: reply });
return reply;
};
}
{
"message": "Persona interaction completed",
"interaction_id": "ephem-int-b5bd3c42-6b69-42cd-a0b7-8490e43c9977",
"persona_response": "An annual plan would help, honestly. If I can lock in a lower monthly equivalent, the dollar increase stings less …",
"response": "An annual plan would help, honestly. …",
"output_content": "An annual plan would help, honestly. …",
"response_language": "en",
"grounded_context_suggestion": null
}
Read the reply from persona_response; response and output_content repeat it for older clients. interaction_id is a trace token, not a stored record: there is nothing to fetch with it later. A reply usually takes a few seconds; each model call is capped at 60 seconds, so set your client timeout to at least two minutes.
Errors to expect. An unknown persona_id, like a model failure, returns 500 with {"error": "Failed to generate persona response"} rather than a 404. Check ids against your cached persona listing before you send, and treat a 500 on a known id as retryable. A malformed body returns 400.
5. Chat about a document
To let a persona read a document, pass its id in context_document_ids. Up to six documents are read per call, the first 6,000 characters of each. Ids the key cannot see (another workspace's document, or one private to another user) are skipped without an error, so if a persona seems unaware of a document, check the id first.
context_document_ids takes stored documents. A draft scored with skip_save: true has no id; to discuss a draft, put the passage in input_content itself, or store the document first with POST /api/v1/documents.
Hand off from a scorecard to a chat
The app's Chat with focus group button opens a chat with the same panel and document. You can do better than the app here: carry the persona's scorecard into the first message, so the conversation starts from what it already said.
def open_chat_from_insight(insight, document_id):
j = insight["output_json"]
notes = "; ".join(f'{k["tag"]} ({k["status"]})' for k in j["key_judgments"])
chat = PersonaChat(insight["persona"]["id"], insight["persona"]["name"], document_ids=[document_id])
chat.turns.append(("Context", f'You read this document and graded it {insight["grade"]}. '
f'Your overall take: {j["overall_take"]} Your judgments: {notes}.'))
return chat
6. Focus groups
A focus group puts several audiences in one conversation. There are two ways to build it, and they trade grounding for speed.
| One call for the room | One call per persona | |
|---|---|---|
| How | One interact call with an inline focus-group persona that lists the members | One interact call per member, in parallel, then an optional synthesis |
| Personas react to each other | Yes, within the turn | No, unless you add a second round |
| What each member draws on | Its name, category and description only | Its full profile, linked sources, statements and voice |
| Cost and latency | One model call per turn | One call per member per turn |
| Used by | The Kumkuat app |
One call for the room
This is what Audience Chat does. Describe the room inline with persona_override and let one model call speak for every member. The app limits a room to between two and ten members; keep to the same range, as a longer roster gets thin replies.
def focus_group_override(members):
roster = "\n".join(f'{n}. {m["name"]} ({m["category"]}): {m["description"]}'
for n, m in enumerate(members, 1))
return {
"name": f"Focus group ({len(members)} personas)",
"description": f"A moderated focus group of {len(members)} audience personas.",
"template": (
"You are simulating a live focus group with these audience personas:\n\n" + roster + "\n\n"
"Rules for every response:\n"
"- Each persona speaks once per turn, in their own voice, grounded in their description.\n"
'- Format each contribution as "**<Persona name>:** <response>" in its own paragraph.\n'
"- Keep each contribution to 1-3 sentences unless asked for more.\n"
"- Personas may agree, disagree or build on each other.\n"
'- End every turn with "**Group takeaway:** <one sentence>".\n'
"- Stay in character."
),
}
def focus_group_turn(members, history_text, message, document_ids=()):
resp = requests.post(f"{BASE}/api/v1/personas/interact", headers=HEADERS, timeout=120, json={
"persona_id": "persona_override",
"persona_override": focus_group_override(members),
"interaction_type": "conversation",
"input_content": f"{history_text}\n\nUser: {message}" if history_text else f"User: {message}",
"context_document_ids": list(document_ids),
"ground_with_google_search": True,
})
resp.raise_for_status()
return resp.json()["persona_response"]
Take name, category and description from the persona listing. Split the reply into speakers on the **Name:** markers, and show the **Group takeaway:** line as the room's summary.
One call per persona
When each member's answer needs to rest on everything Kumkuat knows about it, such as its linked articles, its public statements and its voice, send the turn to each persona separately with its own persona_id. Run the calls in parallel, about four at a time, and render each answer as it arrives:
from concurrent.futures import ThreadPoolExecutor
def panel_turn(chats, message):
"""chats: a PersonaChat per member, each keeping its own history."""
with ThreadPoolExecutor(max_workers=4) as pool:
replies = pool.map(lambda c: (c.persona_name, c.send(message)), chats)
return dict(replies)
The members do not hear each other this way. If you want a debate, follow up with a second round that shows each persona the others' answers in input_content, or close the turn with the analyst synthesis from section 3.
7. Keep the transcript
Conversations over the API are not saved in Kumkuat and do not appear in the app's Audience Chat history. Store the transcript, the persona ids and any document ids with your own records. To reopen a conversation, load the turns back into your history and carry on; each call only ever sees what you send.
Production checklist
- Keys. On the server only. The listing needs a personal key; every scoring and chat call accepts a workspace key.
- Timeouts. At least two minutes on chat turns, five on synchronous scoring calls.
- History length. Send the last six to ten turns. Longer histories cost more and dilute the current question.
- Grounding.
ground_with_google_search: truebrings in recent public context, which suits questions about current events. Leave it off for questions that should be answered only from the persona's profile and your documents. See Model provider controls. - Language. Replies follow the workspace's market language;
response_languageon the response tells you which. - Budget. Every turn and every fresh reaction is a model call against the workspace's run budget. Identical scoring requests are cached; chat turns are not.
- Present it as simulation. Personas are models of an audience, not the audience. Label replies with the persona's name and keep the grade and judgments visible next to any chat, so people weigh both.