Skip to main content

Build and manage an audience library

A workspace's audiences are its personas: the journalists, investors, regulators, customer segments and employees that content is tested against. This guide shows how to build and maintain that library from your own system, for instance to sync stakeholders from a CRM, to build personas for a new market in one go, or to give your own product an audience manager like the app's Audience Library.

It assumes you have read the Quickstart, which covers listing categories and personas.

1. How a library is organised​

ConceptWhat it isCalls
CategoryA group such as Journalists or Investors & Capital Markets. Each persona has one. The workspace keeps the display order./api/v1/audience-groups, GET /api/v1/personas/categories
PersonaOne audience: a named person, an organisation or a segment, with a profile it reacts from./api/v1/personas, /api/v1/personas/{persona_id}
SourcesArticles, files and profiles a persona is grounded in./api/v1/personas/{persona_id}/link-*
RefinementsStanding corrections to how a persona behaves./api/v1/personas/{persona_id}/refinements
PanelA saved, named set of personas and categories, reused across pickers./api/v1/audience-panels

Which key. Reading and changing personas needs a personal key, because these calls check the permissions of the person the key belongs to: members can read and change personas by default, viewers can only read. A workspace key gets 403 with "code": "permission_denied". The category calls accept either kind.

2. Categories​

GET /api/v1/audience-groups returns the workspace's categories in display order:

{
"groups": [
{ "id": "59a69729-01e7-42b3-a6a8-bbb6f28e9c55", "name": "Regulators & Legislators", "display_name": null, "sort_order": 0 },
{ "id": "83cfea06-3058-4daa-b5c7-bd3f568841eb", "name": "Journalists", "display_name": null, "sort_order": 1 }
]
}

A persona belongs to a category by its category text, matched to name. display_name, when set, is the label to show.

Add a category. POST /api/v1/audience-groups with {"name": "Energy trade press"} appends one category at the end of the order and returns 201 with the new group. Names are up to 80 characters; a name that already exists, in any letter case, returns 409 with the existing group. This is the safe way to add a category: it never touches the others.

Reorder or rename. PUT /api/v1/audience-groups takes the whole list:

{
"groups": [
{ "name": "Journalists", "sort_order": 0 },
{ "name": "Regulators & Legislators", "sort_order": 1, "display_name": "Policy makers" },
{ "name": "Energy trade press", "original_name": "Trade press", "sort_order": 2 }
]
}

To rename, send the new name with original_name: every persona in the old category moves to the new one.

PUT replaces the list

Any category missing from the PUT payload is removed from the workspace's list. Always GET the current list, change it, and send it back whole. Use POST to add one.

GET /api/v1/personas/categories returns {"categories": [...]}, only the categories that have at least one audience persona in them. Use it to hide empty categories in a picker. A category added with POST appears there once a persona is filed under it.

3. Create personas​

From a full profile​

POST /api/v1/personas creates a persona straight away:

{
"name": "Dana Okafor (Grid & Power Weekly)",
"description": "Senior reporter covering grid reliability, storage and utility regulation in North America.",
"category": "Journalists",
"entity_kind": "person",
"tone": "professional",
"interests": "Hard numbers on capacity and reliability; regulatory filings; who pays.",
"concerns": "Announcements without independent data; vague timelines.",
"communication_style": "Direct and sceptical. Wants the story in two sentences and the data behind it.",
"voice_visibility": "workspace"
}
{ "persona_id": "5c1e9f0a-3b7d-4e2a-9c61-2f8d4a7b0e13", "message": "Persona created successfully" }
FieldMeaning
nameRequired. For individuals, add the organisation in parentheses so they are recognised as a person.
descriptionWho this audience is. The single most important field for how it reacts.
categoryMatched to a category name. Defaults to general.
entity_kindperson, organization or archetype (a segment). Inferred from the name when you leave it out.
toneprofessional, casual, formal, technical or friendly.
interests, concerns, communication_styleFree text that shapes what the persona notices and how it answers.
templateOptional full instructions, if you want to write the persona's prompt yourself.
include_in_audienceDefaults to true. Set false for a persona used only as a writing voice.
voice_visibilityworkspace to share with the team, or private. Setting workspace needs the content sharing permission, which members have by default.
response_languageOptional language tag, such as fr. Otherwise the persona answers in the workspace's market language.
social_links, external_sourcesOptional lists of links. See section 8.

A persona is usable as soon as it is created: it comes back from the listing with has_template: true and can be scored against at once. Creating a persona with the same name and category as an existing one returns 409 with existing_persona_id, so a retry after a timeout cannot create a duplicate.

From a name, with the profile drafted for you​

POST /api/v1/personas/generate-draft drafts the missing fields from as little as a name, and saves nothing. Review the draft, then create it:

{ "name": "Independent power producers in Texas", "category": "Industry" }
{
"success": true,
"persona": {
"name": "Independent power producers in Texas",
"description": "Owners and operators of merchant generation in the ERCOT market …",
"category": "Industry",
"tone": "professional",
"interests": "Market design, capacity revenue, interconnection queues …",
"concerns": "Rule changes that favour regulated utilities …",
"communication_style": "Analytical and commercial …",
"template": "You are …"
},
"generated_fields": ["description", "tone", "interests", "concerns", "communication_style", "template"],
"used_model": "gemini/gemini-2.5-flash"
}

Values are illustrative. Fields you send are kept; only missing ones are drafted, unless you set overwrite_existing: true. The draft takes the workspace's existing categories into account and is written in its market language. To create the persona, post persona from the response to POST /api/v1/personas, with any edits.

This is the flow the app's persona editor uses: draft, review, save.

4. Build personas in the background​

For a list of names, POST /api/v1/personas/build drafts and creates each persona as a background job, so your request returns at once:

{
"personas": [
{ "name": "Dana Okafor", "organisation": "Grid & Power Weekly", "context": "Covers storage and grid reliability." },
{ "name": "State utility commission staff", "context": "Staff who review rate cases and resource plans." }
]
}
{
"requested": 2,
"jobs": [
{ "job_id": "2f8e6a1c-…", "name": "Dana Okafor" },
{ "job_id": "7b3d9c04-…", "name": "State utility commission staff" }
]
}

Up to 100 names per request; organisation and context are optional and improve the draft. The response is 202. A row that could not be queued comes back with "job_id": null and an error. Repeating a request for a name already being built reuses its job.

Poll each job with GET /api/v1/jobs/{job_id} every few seconds until status is completed or failed. result is a JSON-encoded string; parse it:

import json, time, requests

def wait_for_build(job_id):
while True:
job = requests.get(f"{BASE}/api/v1/jobs/{job_id}", headers=HEADERS, timeout=60).json()
if job["status"] in ("completed", "failed", "cancelled"):
result = json.loads(job["result"]) if job.get("result") else {}
return job["status"], result
time.sleep(3)

status, result = wait_for_build("2f8e6a1c-…")
# result: {"status": "created", "persona_id": "…", "name": "Dana Okafor", "category": "Journalists", …}
# or {"status": "exists", "persona_id": "…", "name": "Dana Okafor"}

"status": "exists" means a persona with that name was already in the library; its id is returned and nothing new is created.

Built personas start private

A persona created by a background build is saved as private to the person whose key started it. To share it with the workspace, update it with PUT /api/v1/personas/{persona_id} and {"voice_visibility": "workspace"}, using the same key.

5. Import and update in bulk​

Import from a spreadsheet or CRM. POST /api/v1/personas/bulk creates many personas in one synchronous call:

{
"personas": [
{ "name": "Dana Okafor (Grid & Power Weekly)", "category": "Journalists", "description": "…" },
{ "name": "Regional utility CFOs", "category": "Industry" }
],
"skip_existing": true,
"auto_generate_missing": true
}

auto_generate_missing: true drafts any empty fields, one row at a time, in the same request. That makes the call slow for long lists: with drafting on, send five rows per request, or use background builds instead. skip_existing (default true) leaves personas that already exist alone; update_existing: true updates them instead.

The response reports every row:

{
"success": true,
"summary": { "requested": 2, "created": 1, "updated": 0, "skipped": 1, "errors": 0 },
"created": [{ "row": 2, "persona_id": "…", "name": "Regional utility CFOs", "auto_generated": true, "generated_fields": ["description", "…"] }],
"skipped": [{ "row": 1, "name": "Dana Okafor (Grid & Power Weekly)", "reason": "…", "message": "…" }],
"errors": [],
"warnings": []
}

warnings flags rows that look like a person already in the library under a slightly different name (possible_duplicate_person, with matches). Show them to whoever ran the import.

Change many personas at once. POST /api/v1/personas/bulk-update applies field changes to up to 200 personas. Send dry_run: true first to see each change as before and after without saving:

{
"dry_run": true,
"updates": [
{ "persona_id": "5c1e9f0a-…", "fields": { "category": "Energy trade press" } },
{ "persona_id": "9a40d2b7-…", "fields": { "concerns": "Timelines that slip; costs passed to ratepayers." } }
]
}

It accepts description, category, tone, interests, concerns, communication_style and template. Each result has a status such as preview, applied, unchanged or not_found. Run it again without dry_run to apply.

6. Edit, refine and retire​

Edit. PUT /api/v1/personas/{persona_id} updates the fields you send and returns {"message": "Persona updated successfully"}. It does not return the persona; GET it again to show the result. Two list fields behave differently: social_links and external_sources replace the stored list, while linked_knowledgebase_ids is added to it.

Personas installed from the Kumkuat Marketplace stay linked to the catalog and receive its updates. Editing a profile field on one breaks that link, and the persona becomes your own copy.

Refine. A refinement is a standing instruction added to a persona, for corrections that should apply to every future answer:

{ "content": "Dana has covered the Texas market since 2019 and distrusts claims that compare ERCOT to other grids without adjusting for its isolation." }

POST /api/v1/personas/{persona_id}/refinements adds one (up to 4,000 characters) and returns 201 with the refinement. GET the same path lists them; PATCH /refinements/{refinement_id} changes the content or switches it off with is_active: false; DELETE removes it.

Feedback. If your product lets people rate a persona's answer, send it to POST /api/v1/personas/{persona_id}/feedback with feedback_type (positive or negative) and a description of what was right or wrong. Kumkuat records it and turns it into a refinement automatically, returned in the response.

Retire. DELETE /api/v1/personas/{persona_id} removes a persona from the library. To keep a persona but mark it inactive, PUT {"is_active": false} instead, and leave inactive personas out of your own pickers.

7. Saved panels​

Panels are named sets of audiences, such as "Trade press" or "Q3 earnings panel", that appear in every picker in the app. Keeping them in sync with your own system means your users and the communications team in Kumkuat pick from the same sets.

Enabled per workspace

Panels come with the Audience Library, which is being switched on workspace by workspace. Where it is off, every panel call returns 403 with {"error": "Audience panels are not enabled for this workspace"}. The panel endpoints are not yet in the API reference.

POST /api/v1/audience-panels:

{
"name": "Energy trade press",
"description": "Reporters and outlets we pitch grid and storage news to.",
"personaIds": ["5c1e9f0a-3b7d-4e2a-9c61-2f8d4a7b0e13", "9a40d2b7-61e5-4c8f-b3a2-7d0e5f1c6b98"],
"groupNames": [],
"visibility": "workspace"
}
{
"panel": {
"id": "e1c7a9b2-…",
"workspaceId": "ws_…",
"name": "Energy trade press",
"description": "Reporters and outlets we pitch grid and storage news to.",
"personaIds": ["5c1e9f0a-3b7d-4e2a-9c61-2f8d4a7b0e13", "9a40d2b7-61e5-4c8f-b3a2-7d0e5f1c6b98"],
"groupNames": [],
"visibility": "workspace",
"createdBy": "…",
"createdAt": "2026-10-02T16:04:51+00:00",
"updatedAt": "2026-10-02T16:04:51+00:00",
"usedInProjects": 0
}
}

Panel objects use camelCase keys, unlike the rest of the API.

FieldMeaning
nameRequired, up to 120 characters.
personaIdsUp to 500 persona ids.
groupNamesUp to 50 category names. Every persona in those categories is a member, including ones added later.
visibilityworkspace (default) or private to the key's owner.
descriptionUp to 2,000 characters.

A panel needs at least one persona id or category. To resolve its members, take personaIds plus every persona whose category is in groupNames, and remove duplicates.

GET /api/v1/audience-panels lists the panels the key can see: the workspace's shared panels and its own private ones. GET, PATCH and DELETE /api/v1/audience-panels/{panel_id} read, change (send only the fields to change) and archive one. Only the panel's creator or a workspace admin can change or archive it. There is no call to restore an archived panel, so archive only what you mean to retire.

8. Ground personas in their sources​

A persona answers from its profile, the workspace's grounded context and the sources linked to it. Linking a named person's own words is what makes their persona sound like them.

CallUse it forNotes
POST /api/v1/personas/{persona_id}/link-fileAn article, report, transcript or deck by or about the personaMultipart file: PDF, Word, PowerPoint, HTML, text, Markdown or CSV. Needs the upload permission.
POST /api/v1/personas/{persona_id}/unlink-fileRemoving a linked file{"document_id": "…"}. Deletes the document.
POST /api/v1/personas/{persona_id}/discover-linkedinFinding a person's LinkedIn profile by nameReturns candidates[] with url, headline and match. Changes nothing.
POST /api/v1/personas/{persona_id}/link-linkedinFollowing a LinkedIn profile{"linkedin_url": "…"}. Posts sync daily in the background.
POST /api/v1/personas/{persona_id}/quick-linkRecording a website or social profile{"url": "…"}. Sorted into social_links or external_sources. Stored for reference, not crawled.

Uploaded files and LinkedIn posts feed the persona's answers and its reactions to content. For a worked example with reporters, see Build a media pitching workflow.

9. Track how each audience is reacting​

Once content is being scored, two calls give an audience manager something to show beside each persona:

  • GET /api/v1/persona-summaries?persona_id=…&days=30 returns the persona's cached summary: grade, grade_trend, status_counts, and a summary_json with a headline, top_themes, top_actions and top_risks. A persona with no reactions in the window returns {"summary": null}.
  • GET /api/v1/personas/grade-timeline?persona_ids=…,…&granularity=monthly returns each persona's grade per month (or quarterly), for a sparkline.

Both read from stored reactions, so they reflect content scored as stored documents rather than ephemeral drafts.

A sync job from your own system​

import os, requests

BASE = "https://api.kumkuat.ai"
HEADERS = {"X-API-Key": os.environ["KUMKUAT_PERSONAL_KEY"]}

def api(method, path, **kw):
r = requests.request(method, f"{BASE}{path}", headers=HEADERS, timeout=120, **kw)
if r.status_code == 409:
return r.json()
r.raise_for_status()
return r.json()

def sync_stakeholders(rows):
"""rows: [{"name", "organisation", "category", "description"}] from your CRM."""
existing = {p["name"].lower(): p for p in api("GET", "/api/v1/personas", params={"summary": 1})["personas"]}
known = {g["name"].lower() for g in api("GET", "/api/v1/audience-groups")["groups"]}

for category in {r["category"] for r in rows}:
if category.lower() not in known:
api("POST", "/api/v1/audience-groups", json={"name": category})
known.add(category.lower())

for row in rows:
name = f'{row["name"]} ({row["organisation"]})' if row.get("organisation") else row["name"]
if name.lower() in existing:
continue
draft = api("POST", "/api/v1/personas/generate-draft", json={
"name": name, "category": row["category"], "description": row.get("description", ""),
})["persona"]
created = api("POST", "/api/v1/personas", json={**draft, "voice_visibility": "workspace"})
print(name, "->", created.get("persona_id") or created.get("existing_persona_id"))

Good practice​

  • Description first. A persona's description does most of the work. One specific paragraph about who they are and what they care about beats a long list of adjectives.
  • One category per audience type. Pickers, panels, summaries and the dashboard all group by category. Agree the list with the communications team before syncing.
  • Ground named people. A persona for a real person should be linked to their own work. Without it, it is a description of them, not a model of them.
  • Never overwrite in a loop. PUT /audience-groups replaces the list and social_links replaces a persona's links. Read, change, write.
  • Personal keys for library work. Hold the personal key of a service account with the member role or above, so library changes do not depend on one employee's account.