Skip to main content

Kumkuat API guide

A quick introduction to APIs​

An API (application programming interface) lets your own software do what you would otherwise do by clicking in the Kumkuat web app. Anything that can send an HTTPS request can use it: a Python or JavaScript script, a spreadsheet macro, Postman, Zapier, or an AI assistant such as Claude or ChatGPT.

Five ideas cover almost everything in this guide:

  • Base URL — every request goes to https://api.kumkuat.ai.
  • Endpoint — the path after the base URL that names the thing you are working with, such as /api/v1/personas.
  • Method — the verb. GET reads, POST creates or runs something, PUT replaces a record, PATCH changes part of one, DELETE removes it.
  • Authentication — every request carries your API key in a header called X-API-Key. The key decides which workspace you are acting in.
  • JSON — requests and responses are plain-text JSON objects, for example {"name": "Jane Doe", "category": "Journalists"}.

In Kumkuat, an audience is a set of personas (synthetic versions of real stakeholders such as journalists, investors, or legislators) grouped by category. The three workflows below map to three parts of the API:

WorkflowWhat you doMain endpoints
1. Add or edit audiencesCreate personas, tune their voice, sources, and behavior/api/v1/personas, /api/v1/personas/{id}/…
2. Audience scorecardsGrade how personas react to a document or message/api/v1/personas/reaction-insights, /api/v1/audience-summaries
3. Audience chatAsk personas questions and get replies/api/v1/personas/interact

The full, always-current reference is the interactive Swagger page at api.kumkuat.ai/docs, with the machine-readable spec at api.kumkuat.ai/openapi.json. The same operations are documented on this site in the API reference.

Getting set up (5 minutes)​

  1. Create an API key. In Kumkuat, go to Workspace → API → Manage API Keys and create a key. Copy it once; treat it like a password.
  2. Pick the right workspace first. Each workspace has its own personas and its own keys. A key from the wrong workspace is the most common reason nothing matches.
  3. Store the key outside your code. Put it in an environment variable, for example export KUMKUAT_KEY="<your-key>" on Mac/Linux or setx KUMKUAT_KEY "<your-key>" on Windows.
  4. Test the connection. A 200 response with your workspace name means you are ready.
curl -s https://api.kumkuat.ai/api/v1/workspace \
-H "X-API-Key: $KUMKUAT_KEY"

The same call in Python, which the rest of this guide builds on:

import os, requests

BASE = "https://api.kumkuat.ai"
HEADERS = {"X-API-Key": os.environ["KUMKUAT_KEY"], "Content-Type": "application/json"}

def kk(method, path, **kwargs):
r = requests.request(method, BASE + path, headers=HEADERS, timeout=120, **kwargs)
r.raise_for_status()
return r.json() if r.content else None

print(kk("GET", "/api/v1/workspace"))

You can revoke or rotate keys at any time in the app or through DELETE /api/v1/auth/keys/{key_id} and POST /api/v1/auth/keys/{key_id}/rotate.

Personal or workspace key?

Kumkuat has two kinds of key, and both are sent the same way. Two calls in this guide, listing personas (GET /api/v1/personas) and creating a document (POST /api/v1/documents), currently need a personal key; every other call accepts either kind. The quickstart explains where each kind is created and when to use which.

Use case 1: Add or edit audiences​

Personas are the building blocks of an audience; the category field decides which audience a persona belongs to. Most teams follow the same loop: find what exists, create what is missing, then tune.

Step 1 — List the personas you already have​

page = kk("GET", "/api/v1/personas", params={"limit": 500, "offset": 0})
print(page["total"], "personas")
for p in page["personas"]:
print(p["id"], p["name"], p.get("category"))
  • The list is paged: limit is 200 by default and 500 at most. Raise offset until you have total rows.
  • Add summary=1 for a lightweight list (id, name, category) when you only need to match names.
  • Large workspaces return several MB; send Accept-Encoding: gzip (most HTTP libraries do this for you).

Step 2 — Create new personas​

Pick the option that matches how much you already know about each person.

You haveEndpointBehavior
A full profile for one personaPOST /api/v1/personasCreates it immediately
Full profiles for many personasPOST /api/v1/personas/bulkCreates them in one call; skips names that already exist
Just names (plus organization and context)POST /api/v1/personas/buildKumkuat researches and writes each profile in the background (1–100 per call)
A name and a few fieldsPOST /api/v1/personas/generate-draftReturns AI-drafted fields for you to review, then save with POST /api/v1/personas

The fastest route from a list of names to a working audience is build:

jobs = kk("POST", "/api/v1/personas/build", json={"personas": [
{"name": "Jane Doe", "organisation": "Financial Times", "context": "Covers EU fintech regulation"},
{"name": "John Roe", "organisation": "Bloomberg", "context": "Banking and payments reporter"},
]})
# -> 202 {"jobs": [{"job_id": "...", "name": "Jane Doe"}, ...], "requested": 2}

for job in jobs["jobs"]:
print(kk("GET", f"/api/v1/jobs/{job['job_id']}")) # poll until complete

Note the British spelling organisation. Sending the same name twice shares one job, so re-running a list is safe.

Step 3 — Edit a persona​

PUT /api/v1/personas/{id} replaces the whole record. Always read the persona first, change only the fields you mean to change, and send the full object back; a partial body can blank out other fields.

p = next(x for x in kk("GET", "/api/v1/personas", params={"limit": 500})["personas"]
if x["name"] == "Jane Doe (Financial Times)")
p["category"] = "EU Fintech Press"
p["description"] += " Increasingly skeptical of crypto-asset rules."
kk("PUT", f"/api/v1/personas/{p['id']}", json=p)

Use the object from the list endpoint. GET /api/v1/personas/{id} wraps it as {"persona": {…}}, so unwrap it before sending it back.

Step 4 — Tune how a persona thinks and sounds​

Tuning is where personas go from generic to accurate. Each lever has its own endpoint, so you never have to rewrite the whole record.

LeverEndpointBody
Voice (tone, vocabulary, rhetoric) from real writingPOST /api/v1/personas/{id}/analyze-voice{"samples": ["…", "…"], "profile_name": "Professional Voice"} — up to 7 samples
Knowledge from a website or publication pagePOST /api/v1/personas/{id}/link-external-source{"source_url": "https://…", "label": "Financial Times"}
Knowledge from LinkedInPOST /api/v1/personas/{id}/link-linkedin{"linkedin_url": "https://www.linkedin.com/in/…"}
Profile links (X, Bluesky, LinkedIn)POST /api/v1/personas/{id}/quick-link{"url": "https://x.com/…", "platform": "x"}
A tuning note ("always asks about timelines")POST /api/v1/personas/{id}/refinementsthe note text — see Swagger
A trusted example answerPOST /api/v1/personas/{id}/golden-responses{"prompt": "…", "golden_response": "…"}
Profile photoPOST /api/v1/personas/avatar/upload, then PUTmultipart field file; then set avatar_url and avatar_source: "upload" on the persona
Re-crawl linked sources nowPOST /api/v1/personas/{id}/refreshnone (runs in the background)

Two cautions:

  • Each analyze-voice call creates a new voice profile. Check voice_profiles on the persona first so a retry does not leave duplicates.
  • link-external-source replaces the persona's current source. Skip it when external_source_kb_id is already set, unless you mean to replace it.

Step 5 — Apply one tuning change to many personas​

POST /api/v1/personas/bulk-update takes one explicit entry per persona and returns a per-persona report. Send it with dry_run: true first to see the before/after without writing anything, review it, then send the same body with dry_run: false.

Organize audiences​

GET /api/v1/personas/categories lists your audience categories. POST /api/v1/audience-groups adds one, and PUT /api/v1/audience-groups sets the order they appear in the app.

Use case 2: Create audience scorecards​

A scorecard is Kumkuat's graded read of how each persona would react to a piece of content: a press release, a pitch, talking points, or a draft an AI assistant just wrote for you. You get one scorecard per persona, then a roll-up for the whole audience.

The flow has four steps:

  1. Put the content into Kumkuat as a document.
  2. Choose the personas who should react.
  3. Generate their scorecards.
  4. Read the audience-level summary and grade.

Step 1 — Add the content as a document​

Upload a file (PDF, DOCX, TXT) and Kumkuat ingests it:

with open("press_release.docx", "rb") as f:
doc = requests.post(f"{BASE}/api/v1/documents/upload",
headers={"X-API-Key": os.environ["KUMKUAT_KEY"]}, # no Content-Type: requests sets multipart
files={"file": f}, timeout=300).json()
doc_id = doc["id"] # check the response for the exact id field

If the content was written by an AI assistant or a script, skip the file and send the text directly. title and content are both required:

doc = kk("POST", "/api/v1/documents", json={
"title": "Q3 launch press release — draft B",
"content": draft_text,
})

For a web page or video, use POST /api/v1/ingest/auto-detect or POST /api/v1/ingest/youtube.

Step 2 — Choose the personas​

Use the persona list from Use case 1 and filter by category to score a whole audience, or pick individual ids.

all_personas = kk("GET", "/api/v1/personas", params={"limit": 500})["personas"]
audience = [p for p in all_personas if p.get("category") == "EU Fintech Press"]

Step 3 — Generate the scorecards​

Audience sizeEndpointHow it returns
Fewer than 10 personasPOST /api/v1/personas/reaction-insightsWaits and returns the scorecards in the response
10 or more personasPOST /api/v1/personas/reaction-insights/asyncReturns a job_id right away; poll GET /api/v1/jobs/{job_id}

The body takes the full objects, not just ids: personas is a list of persona objects (at least id, name, description) and document is a document object (id, title, content). The easiest way to get both is to fetch them:

document = kk("GET", f"/api/v1/documents/{doc_id}")
document = document.get("document", document) # unwrap if needed

body = {
"personas": [{"id": p["id"], "name": p["name"], "description": p.get("description", "")} for p in audience],
"document": {"id": document["id"], "title": document["title"], "content": document["content"]},
"force_refresh": False,
}

if len(audience) < 10:
result = kk("POST", "/api/v1/personas/reaction-insights", json=body)
for card in result["insights"]:
print(card["persona"]["name"], card["grade"], card["status_counts"])
else:
job = kk("POST", "/api/v1/personas/reaction-insights/async", json=body)
# poll kk("GET", f"/api/v1/jobs/{job['job_id']}") until it reports complete

Each scorecard in insights has:

FieldWhat it holds
gradeLetter grade, A to F
status_countsHow many judgments landed well, mixed, or badly: {"good": 5, "warn": 0, "bad": 0}
output_json.overall_takeThe persona's one-paragraph verdict
output_json.key_judgmentsIndividual judgments, each with a status, tag, and 1–5 confidence
output_json.evidence_and_signalsQuotes from your content and why they matter to this persona
output_json.implications_and_actionsRecommended next steps
output_json.counterpoints_and_risksWhere the reaction could go wrong

Scorecards are cached per persona and document text. Calling again with the same content returns the saved result instantly (cache_hit: true); set force_refresh: true only after you re-tune a persona. Send real content: an empty document still gets graded, and grades an F.

To read scorecards back later, including ones created in the app:

rows = kk("GET", "/api/v1/personas/reaction-insights", params={"latest_only": "true"})

latest_only=true keeps the newest scorecard for each persona–document pair. Leave it off only when you want run history; GET /api/v1/personas/reaction-insights/history gives the history for one pair.

Step 4 — Get the audience grade and summary​

  • GET /api/v1/audience-summaries?category=EU%20Fintech%20Press — an overall grade plus themes, recommended actions, and risks, synthesized from that audience's recent scorecards. Add force_refresh=true to regenerate.
  • GET /api/v1/audience-summaries/grades — cached grade and trend for every audience in one fast call (no regeneration). Good for dashboards.
  • GET /api/v1/persona-summaries — the same summary for a single persona.
  • GET /api/v1/personas/grade-timeline — grades over time, for trend charts.

Using an AI assistant to drive scorecards​

The API does not care who wrote the content, so a common pattern is: ask your assistant for three versions of a message, upload each as a document, score each against the same audience, and keep the version with the best grade. To improve a weak draft, POST /api/v1/content-creation/rewrite-segments and POST /api/v1/content-creation/rewrite-phrases suggest edits driven by the audience's feedback. Using AI assistants with the Kumkuat API has copy-paste prompts for this.

Use case 3: Set up an audience chat​

Audience chat means asking a persona a question and getting an in-character reply. You do this with POST /api/v1/personas/interact, one persona per call. To chat with a whole audience, loop over its personas.

Step 1 — Ask one persona a question​

reply = kk("POST", "/api/v1/personas/interact", json={
"persona_id": persona["id"],
"input_content": "What is the first question you would ask our CFO about these results?",
"interaction_type": "conversation",
})
print(reply["response"])
FieldRequiredNotes
persona_idYesFrom the persona list (Use case 1)
input_contentYesYour question, or the full text you want the persona to react to
interaction_typeNo"conversation" for chat-style answers; the default, "communication", treats the input as content to react to
voice_profile_idNoAnswer in a specific voice profile instead of the persona's default
response_languageNoReply language, e.g. "en", "de"

The reply comes back in the same response, usually within a few seconds, in the response field (also returned as persona_response and output_content).

Step 2 — Keep a conversation going​

Each interact call stands alone; the persona does not remember earlier calls. For a follow-up, include the earlier exchange in input_content:

history = []

def ask(persona_id, question):
transcript = "\n".join(f"{who}: {text}" for who, text in history)
prompt = f"Conversation so far:\n{transcript}\n\nNew question: {question}" if history else question
answer = kk("POST", "/api/v1/personas/interact", json={
"persona_id": persona_id, "input_content": prompt, "interaction_type": "conversation",
})["response"]
history.extend([("Us", question), ("Persona", answer)])
return answer

ask(persona["id"], "Would you cover this announcement?")
ask(persona["id"], "What would change your mind?")

Step 3 — Ask a whole audience the same question​

import csv

question = "In one sentence, what is your biggest concern about this launch?"
with open("audience_answers.csv", "w", newline="") as f:
w = csv.writer(f)
w.writerow(["persona", "answer"])
for p in audience: # from Use case 2, Step 2
r = kk("POST", "/api/v1/personas/interact", json={
"persona_id": p["id"], "input_content": question, "interaction_type": "conversation"})
w.writerow([p["name"], r["response"]])

Each reply calls a language model, so set a timeout of 60–120 seconds. For large audiences, run a few requests in parallel.

About /api/chat/sessions​

The chat-session endpoints power the chat screen in the app, and you can read them through the API. GET /api/chat/sessions lists past sessions, and GET /api/chat/sessions/{session_id} returns the full transcript, including persona replies people had in the app. Use them to pull app conversations into your own reports.

Sending a message through the API (POST /api/chat/sessions/{session_id}/messages with {"message": "…"}) reaches Kumkuat's general workspace assistant, not a persona. For persona replies from your own code, use interact as shown above.

Close the loop​

If a reply is off, say so: POST /api/v1/personas/{id}/feedback records a rating. Strong answers can become golden responses (Use case 1, Step 4) that anchor the persona in future chats.

Troubleshooting and good habits​

SymptomLikely causeFix
401 "API key or bearer token required"Missing or misspelled headerSend X-API-Key: <key> on every request
Names you expect are not foundKey belongs to a different workspaceCheck GET /api/v1/workspace; use that workspace's key
A name almost matchesPersona names often carry a suffix, e.g. Jane Doe (Financial Times)Match on the name with any trailing ( … ) removed
Fields went blank after an editPUT was sent with a partial bodyRead the full persona, change it, send it all back
Duplicate voice profilesanalyze-voice retried after a timeoutCheck voice_profiles before calling; delete extras with DELETE /api/v1/personas/{id}/voice-profiles/{profile_id}
422 Unprocessable EntityBody field missing or misnamedThe response lists the field; compare with Swagger
Request times outModel-backed calls take seconds eachRaise the timeout, use the async / build endpoints, run a few in parallel

Habits that save time:

  • Dry-run first. Print what you would change before writing, and use dry_run: true where offered.
  • Make scripts resumable. Write each finished persona id to a file and skip it on the next run, so a crash never means starting over.
  • Keep keys secret. Never paste a key into code you share, a spreadsheet, or a chat you do not control. Rotate it when a project ends.

Making it easier with Claude skills​

Everything above works from any language or tool. If your team uses Claude (in the Claude desktop app, Cowork, or Claude Code), skills remove most of the typing. A skill is a packaged set of instructions and scripts that teaches Claude a repeatable workflow: which endpoints to call, in what order, and which mistakes to avoid. Once installed, you describe the goal in plain English and Claude runs the calls.

Kumkuat workflows that already exist as skills:

SkillSay something likeWhat it does
Persona voices"Tune the voices for these personas from this sheet"Reads writing samples from a spreadsheet, calls analyze-voice per persona, skips and removes duplicates
Persona links"Add these X and LinkedIn handles and ingest the publication pages"Adds social_links, links sources for ingest, skips curated sources
Persona photos"Upload these headshots to my Senators audience"Extracts images from a spreadsheet or folder, uploads, and attaches them

How to use them:

  1. Create a workspace API key (see Getting set up) and give it to Claude when asked. Revoke it when the job is done.
  2. Attach the spreadsheet or folder and name the audience or workspace.
  3. Review the dry-run match report Claude shows you, fix any unmatched names, then approve the write.

To build your own, ask Claude to "create a skill" from a workflow you have just run together, such as scoring every new press release against your investor audience. The same patterns in this guide (read first, dry-run, resumable, verify) make good skill instructions. Other assistants that support tools or function calling can use the same endpoints; point them at api.kumkuat.ai/openapi.json.

Next steps​