Build a message creation flow
Content Writing is Kumkuat's drafting studio: set up a piece, generate a draft, test it with the audiences that matter, and rewrite it with their feedback. Every step is available over the API, so you can build the same loop into your own editor or publishing tool. This guide walks through it in the order a writer uses it.
It assumes you have a key from the Quickstart. Testing the draft with audiences uses the scoring call from Score a draft against your audiences.
The loop
| Step | Call | Key |
|---|---|---|
| Pick a voice | GET /api/v1/personas?summary=1, GET /api/v1/executives/voices | Personal |
| Pick a source | GET /api/v1/content-creation/source-documents | Any |
| Load key messages | GET /api/workspaces/{workspace_id}/pull_through_priorities | Any |
| Generate | POST /api/v1/content-creation/generate | Any |
| Check key messages | POST /api/v1/content-creation/analyze-pull-through-coverage, /enhance-pull-through | Any |
| Test with audiences | POST /api/v1/personas/reaction-insights | Any |
| Rewrite from feedback | POST /api/v1/content-creation/rewrite-phrases, /rewrite-segments | Any |
| Polish | POST /api/v1/content-creation/check-quality, /deaiify | Any |
| Save drafts | POST /api/v1/content-creation/drafts, GET /drafts | Any (deleting needs a personal key) |
| Export | POST /api/v1/content-creation/export | Any |
1. Choose the content type
content_type sets the shape of the draft. These values are recognised; anything else gets a general-purpose draft:
content_type | Draft shape |
|---|---|
press-release | Headline, dateline, body paragraphs, one quote, short closing paragraph |
blog | Headline, introduction, section headers, conclusion |
linkedin | An executive LinkedIn post with short paragraphs and one hashtag line |
twitter | A thread of four to six posts |
email | Subject line, greeting, short paragraphs, a specific call to action |
memo | An internal memo with a subject line and a next-steps section |
speech | Talking points or a full speech with an opening hook and a close |
qa | Anticipated questions grouped by theme, each with an answer |
content_type_label is the name shown to the model, such as "Investor update" or "Store-manager briefing". Use it to name formats that are not in the list.
If you send template_instructions, they replace the shape above: use them for your own house formats, up to 2,000 characters, with an optional template_reference_excerpt of up to 4,000 characters showing an example of the format.
2. Choose the source
A draft can be written from the brief alone, or grounded in one source document.
- From the brief:
source_mode: "freeform"and the brief inprompt. - From a document: any other
source_mode(the app uses"communication"for library documents and"uploaded"for files uploaded on the spot) withsource_document_id. Kumkuat reads up to the first 6,000 characters of the document. A non-freeform mode without a source returns400.
The workspace's source documents are listed by:
curl "https://api.kumkuat.ai/api/v1/content-creation/source-documents?limit=50" \
-H "X-API-Key: $KUMKUAT_API_KEY"
{
"knowledgebase": "content-sources",
"count": 2,
"source_documents": [
{
"id": "c41f0a6e-2d8b-4f3a-9e17-5b6c8d9a0f21",
"title": "Q3 product launch fact sheet",
"content_type": "pdf",
"word_count": 1840,
"excerpt": "The new Standard with ads plan adds downloads on two devices …",
"created_at": "2026-09-30T13:12:04+00:00",
"…": "…"
}
]
}
To add one, upload it with POST /api/v1/content-creation/source-documents/upload (multipart field file; needs a personal key from a member or above), or mark an existing workspace document as a source with POST /api/v1/content-creation/source-documents/{document_id}/attach. Any stored document id works as source_document_id, whether or not it is marked as a source; the source list is a convenience for your picker.
The generator also adds the workspace's company name and description, so drafts are written for the right company without you repeating it.
3. Choose the voice
A draft is written in a voice: your brand's, or a named executive's. Voices are personas with include_in_voice_list: true in the persona listing. You describe the voice to the generator in two fields:
| Field | What to send |
|---|---|
tone_persona_label | The voice's name, such as "Sarah Chen, CFO". Defaults to "Default brand voice". |
tone_persona_helper | A short description of the voice. The app builds it from the persona's description, then Tone: … and Style: … from tone and communication_style. |
tone_persona_id and voice_profile_id | Optional, and only together. Adds that voice profile's detailed style (tone pillars, vocabulary, rhetorical patterns) to the prompt. Take the ids from the persona's id and default_voice_profile_id. |
def voice_fields(persona):
helper = " ".join(filter(None, [
persona.get("description", "").strip(),
f"Tone: {persona['tone']}." if persona.get("tone") else "",
f"Style: {persona['communication_style']}." if persona.get("communication_style") else "",
]))
fields = {"tone_persona_label": persona["name"], "tone_persona_helper": helper}
if persona.get("default_voice_profile_id"):
fields |= {"tone_persona_id": persona["id"], "voice_profile_id": persona["default_voice_profile_id"]}
return fields
Executive voices. Kumkuat can build a voice for an executive from their statements in your library. GET /api/v1/executives/voices lists the executives it knows, with persona_id, enabled and tuned. PUT /api/v1/executives/voice with {"executive": "Sarah Chen", "enabled": true} creates and tunes the voice persona if needed, in one synchronous call, and returns its persona_id. Both need a personal key.
A persona's profiles are listed at GET /api/v1/personas/{persona_id}/voice-profiles, which returns voice_profiles and default_voice_profile_id.
4. Load the key messages
Pull-through priorities are the messages a piece must carry, each with a level: must, should or nice. The workspace keeps a default list:
curl https://api.kumkuat.ai/api/workspaces/ws_9738dfeb738a44debb10/pull_through_priorities \
-H "X-API-Key: $KUMKUAT_API_KEY"
{
"priorities": [
{ "text": "The ads plan now includes downloads on two devices", "level": "must" },
{ "text": "Most members see no change to their plan", "level": "should" }
]
}
The path takes the key's own workspace id, from GET /api/v1/workspace; any other id returns 403. PUT the same path with {"priorities": […]} to replace the list: up to 12 items of up to 400 characters each. Since this list is shared by the whole workspace, only let workspace admins change it from your tool.
Pass the priorities for this piece, the workspace's list or your own, in pull_through_priorities on the generate call, and the draft is written to carry them.
5. Generate the draft
POST /api/v1/content-creation/generate
{
"prompt": "Announce the updated Standard with ads plan to existing members. Lead with what they gain. Keep it under 250 words.",
"content_type": "email",
"content_type_label": "Member announcement",
"source_mode": "communication",
"source_document_id": "c41f0a6e-2d8b-4f3a-9e17-5b6c8d9a0f21",
"tone_persona_label": "Default brand voice",
"tone_persona_helper": "Warm, plain-spoken, confident. Short sentences.",
"audiences": ["Price-Sensitive Subscriber", "Streaming Comparison Consumer"],
"themes": ["value", "pricing"],
"pull_through_priorities": [
{ "text": "The ads plan now includes downloads on two devices", "level": "must" },
{ "text": "Most members see no change to their plan", "level": "should" }
]
}
| Field | Meaning |
|---|---|
prompt | The brief. Required for freeform drafts. |
content_type, content_type_label | See section 1. |
source_mode, source_document_id | See section 2. |
tone_persona_label, tone_persona_helper, tone_persona_id, voice_profile_id | See section 3. |
audiences | Who the piece is for, as names. Up to eight. These steer the draft; they do not score it. |
themes | Topics to emphasise. Up to eight. |
pull_through_priorities | Key messages, each {text, level}. Up to 12. |
template_instructions, template_reference_excerpt | Your own format. Replaces the content-type shape. |
preferred_title_length | Target title length in characters, 10 to 200. Default 80. |
There is no length field: ask for a length in the brief.
The response:
{
"content": "## More from your plan, starting this month\n\nHi there,\n\nYour Standard with ads plan now includes downloads on two devices …",
"model": "gemini/gemini-2.5-flash",
"truncated": false
}
content is Markdown limited to ## headings, bold, italic and lists. truncated: true means the draft hit the output limit; ask for something shorter.
Streaming. Send Accept: text/event-stream to receive the draft as it is written, the way the app does. Each event is data: {"token": "…"}; the last is data: {"done": true, "model": "…", "truncated": false, "content": "…"}, where content is the full cleaned draft: use it rather than your concatenated tokens. An error part-way arrives as data: {"error": "…", "done": true} on an HTTP 200, so check every event for error.
- Python
- Node
import json, os, requests
BASE = "https://api.kumkuat.ai"
HEADERS = {"X-API-Key": os.environ["KUMKUAT_API_KEY"]}
def generate_streaming(body, on_token):
with requests.post(f"{BASE}/api/v1/content-creation/generate", json=body, stream=True, timeout=300,
headers={**HEADERS, "Accept": "text/event-stream"}) as resp:
resp.raise_for_status()
for line in resp.iter_lines(decode_unicode=True):
if not line or not line.startswith("data: "):
continue
event = json.loads(line[6:])
if event.get("error"):
raise RuntimeError(event["error"])
if event.get("done"):
return event["content"]
on_token(event.get("token", ""))
export async function generateStreaming(body, onToken) {
const resp = await fetch("https://api.kumkuat.ai/api/v1/content-creation/generate", {
method: "POST",
headers: { "X-API-Key": process.env.KUMKUAT_API_KEY, "Content-Type": "application/json", Accept: "text/event-stream" },
body: JSON.stringify(body),
});
if (!resp.ok) throw new Error(`${resp.status} ${await resp.text()}`);
const reader = resp.body.pipeThrough(new TextDecoderStream()).getReader();
let buffer = "";
for (;;) {
const { value, done } = await reader.read();
if (done) throw new Error("stream ended without a final event");
buffer += value;
const lines = buffer.split("\n");
buffer = lines.pop();
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
const event = JSON.parse(line.slice(6));
if (event.error) throw new Error(event.error);
if (event.done) return event.content;
onToken(event.token ?? "");
}
}
}
Errors: 400 when the brief or source is missing, 404 when source_document_id is not in the workspace, 502 when the model returns nothing usable. A 502 is worth one retry.
6. Check the key messages
Check whether the draft carries each priority:
{
"content": "## More from your plan, starting this month\n\n…",
"pull_through_priorities": [
{ "text": "The ads plan now includes downloads on two devices", "level": "must" },
{ "text": "Most members see no change to their plan", "level": "should" }
]
}
POST /api/v1/content-creation/analyze-pull-through-coverage returns a status per priority, with the passage that carries it:
{
"coverage": [
{ "text": "The ads plan now includes downloads on two devices", "level": "must", "status": "covered",
"reason": "Stated in the opening paragraph.", "quote": "Your Standard with ads plan now includes downloads on two devices" },
{ "text": "Most members see no change to their plan", "level": "should", "status": "missing",
"reason": "The draft does not say which members are unaffected.", "quote": "" }
],
"model": "gemini/gemini-2.5-flash"
}
status is covered, partial or missing. To fix the gaps, send the draft and the unmet priorities to POST /api/v1/content-creation/enhance-pull-through:
{
"current_draft": "## More from your plan, starting this month\n\n…",
"unmet_priorities": [{ "text": "Most members see no change to their plan", "level": "should" }],
"content_type": "email",
"tone_persona_label": "Default brand voice",
"tone_persona_helper": "Warm, plain-spoken, confident. Short sentences."
}
It returns a revised draft in the same shape as generate, {content, model, truncated}, and streams the same way. Show it as a diff for the writer to accept.
7. Test the draft with audiences
Score the draft against the audiences it is for. Send it inline with skip_save: true, so every revision is tested exactly as written and nothing is added to the workspace library:
{
"document": { "title": "More from your plan, starting this month", "content_type": "email", "content": "…the current draft…" },
"personas": [{ "id": "8e049a2e-92dd-4077-b6ca-e7154edc7de4" }, { "id": "0c2d8cfc-0c98-446b-91e8-330dafa3875f" }],
"skip_save": true
}
POST /api/v1/personas/reaction-insights returns a grade, judgments, evidence and suggested rewrites per persona; Score a draft describes every field. Unlike audiences on the generate call, this takes persona ids.
If you score a saved draft by its id, {"document": {"id": "…"}}, Kumkuat scores the stored copy, which is whatever you last saved with POST /drafts, not the text in your editor. Send the text inline unless you have just saved it.
8. Rewrite from the feedback
Turn the audiences' judgments into concrete edits. Both rewrite calls take the full draft and up to six segments, one per audience reaction you want addressed:
| Segment field | From the reaction insight |
|---|---|
persona_name | persona.name |
grade | grade |
summary | The most important key_judgments[].judgment, bad first, then warn (200 characters) |
actions | Up to three implications_and_actions.actions[].detail |
risks | Up to three counterpoints_and_risks.items[].text |
segment_text | Required. The passage to rewrite: use the judgment's evidence quote. rewrite-phrases ignores it, so an empty string is fine there. |
def segments_from(insights, limit=6):
out = []
for i in insights:
if i["status"] != "completed":
continue
j = i["output_json"]
ranked = sorted(j["key_judgments"], key=lambda k: {"bad": 0, "warn": 1}.get(k["status"], 2))
quotes = {e["maps_to"][0]: e["quote"] for e in j["evidence_and_signals"]["items"] if e.get("maps_to")}
top = ranked[0] if ranked else {}
out.append({
"persona_name": i["persona"]["name"],
"grade": i["grade"],
"summary": top.get("judgment", "")[:200],
"segment_text": quotes.get(top.get("id"), ""),
"actions": [a["detail"] for a in j["implications_and_actions"]["actions"][:3]],
"risks": [r["text"] for r in j["counterpoints_and_risks"]["items"][:3]],
})
return out[:limit]
POST /api/v1/content-creation/rewrite-phrases is what the app tries first:
{
"full_draft": "…the current draft…",
"segments": [
{
"segment_text": "",
"persona_name": "Price-Sensitive Subscriber",
"grade": "D",
"summary": "The price increase is announced before any value justification.",
"actions": ["Lead with the added downloads before mentioning the new price."],
"risks": ["Some members will compare against cheaper ad-supported rivals."]
}
],
"tone_persona_label": "Default brand voice",
"tone_persona_helper": "Warm, plain-spoken, confident. Short sentences.",
"content_type_label": "Member announcement"
}
It returns up to twelve phrase-level suggestions, each an exact passage from the draft and its replacement, ready to show as tracked changes:
{
"suggestions": [
{
"original": "the plan moves from $6.99 to $7.99 a month",
"replacement": "for $1 more a month, you now get downloads on two devices",
"persona_name": "Price-Sensitive Subscriber",
"rationale": "Leads with the gain before the cost.",
"severity": "high"
}
],
"model": "gemini/gemini-2.5-flash"
}
POST /api/v1/content-creation/rewrite-segments rewrites each segment_text and returns {"rewrites": [{segment_text, rewrite, type}]}, where type is direct_rewrite or general_advice. Use it when you want a rewrite of a whole passage, or when the writer selects text and types an instruction: send one segment with that text, persona_name: null and the instruction as summary.
Every original returned by rewrite-phrases appears verbatim in the draft you sent, so a find-and-replace applies it safely. Send the same tone_persona_label and tone_persona_helper as on the generate call so rewrites stay in voice.
9. Polish
Grammar, spelling and AI detection. POST /api/v1/content-creation/check-quality with {"text": "…"}. Switch parts off with include_grammar, include_spelling or include_ai_detection set to false.
{
"provider": "sapling",
"grammar": { "error_count": 1, "edits": [{ "id": "…", "sentence": "…", "replacement": "…", "error_type": "…", "start": 112, "end": 118 }] },
"spelling": { "…": "…" },
"ai_detection": { "overall_score": 0.62, "sentence_scores": [{ "sentence": "…", "score": 0.91 }] }
}
ai_detection.overall_score runs from 0 to 1; higher means the text reads as more machine-written. This check runs on a third-party language service rather than Kumkuat's model provider; see the sub-processor list.
Remove the tells of machine-written prose. POST /api/v1/content-creation/deaiify with {"text": "…"}, plus the voice fields to keep it in voice. It returns up to fifteen suggestions:
{
"suggestions": [
{ "original": "In today's fast-paced streaming landscape,", "replacement": "", "category": "ai_phrase",
"rationale": "A stock opener that adds nothing.", "severity": "high" }
],
"model": "gemini/gemini-2.5-flash"
}
category is ai_phrase, word_choice, rhythm, information_density or structural. As with the rewrites, every original appears verbatim in the text. Send selection_only: true when the text is a selection rather than the whole draft.
10. Save and list drafts
Kumkuat can keep drafts for you, so they appear in the workspace's content drafts alongside those written in the app:
{
"title": "More from your plan, starting this month",
"content": "…",
"content_type_value": "email",
"prompt": "Announce the updated Standard with ads plan to existing members …",
"source_document_id": "c41f0a6e-2d8b-4f3a-9e17-5b6c8d9a0f21",
"audience_persona_ids": ["8e049a2e-92dd-4077-b6ca-e7154edc7de4", "0c2d8cfc-0c98-446b-91e8-330dafa3875f"],
"pull_through_priorities": [{ "text": "The ads plan now includes downloads on two devices", "level": "must" }]
}
POST /api/v1/content-creation/drafts creates the draft (201) and returns {"message", "draft"}. Send draft_id to update an existing one (200). GET /api/v1/content-creation/drafts?limit=20&include_content=true lists the newest drafts, and GET /drafts/{draft_id} returns one with its content. Deleting, DELETE /drafts/{draft_id} or POST /drafts/bulk-delete with {"draft_ids": […]}, needs a personal key from a member or above.
Saving is optional. If your tool has its own storage, keep drafts there and use Kumkuat only for generation, testing and rewriting; nothing in sections 5 to 9 requires a saved draft.
11. Export
POST /api/v1/content-creation/export returns a file, not JSON:
curl -X POST https://api.kumkuat.ai/api/v1/content-creation/export \
-H "X-API-Key: $KUMKUAT_API_KEY" \
-H "Content-Type: application/json" \
-d '{"title": "More from your plan", "content_text": "…", "format": "docx"}' \
-o more-from-your-plan.docx
format is pdf (the default), docx or txt. Send content_text, or content_html if your editor holds HTML. The file name comes from title.
Production checklist
- Keys. A workspace key covers generation, checks, rewrites, scoring, saving and export. Listing personas and executive voices, uploading sources and deleting drafts need a personal key.
- Timeouts. Generation and rewrites are model calls that take from a few seconds to about a minute. Stream generation, and set at least two minutes on the others.
- Budget. Every generate, coverage, rewrite and de-AI-ify call is a model call against the workspace's run budget. Run them on the writer's request rather than on every keystroke.
- Exact matches. Rewrite and de-AI-ify suggestions match the text you sent. If the writer edits in between, re-run before applying, or skip suggestions whose
originalis no longer found. - Keep the same panel. Test every revision against the same personas, so grades are comparable from one revision to the next.