API reference
Everything you need to build a persona and start calling /v1/chat — the request/response shape, every persona field, status codes, and how credits work.
Quickstart
The real request, against the real host. Swap in your key (from your dashboard) and go.
curl -X POST https://api.chatpersona.ai/v1/chat \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"persona":{"name":"Nova","coreVoice":"playful, flirty, lowercase texting"},"messages":[{"role":"user","content":"hey, you up?"}],"allow_explicit":false}'
const res = await fetch('https://api.chatpersona.ai/v1/chat', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + apiKey,
'Content-Type': 'application/json',
},
body: JSON.stringify({
persona: { name: 'Nova', coreVoice: 'playful, flirty, lowercase texting' },
messages: [{ role: 'user', content: 'hey, you up?' }],
allow_explicit: false,
}),
});
const data = await res.json();
console.log(data.reply);
import requests
res = requests.post(
"https://api.chatpersona.ai/v1/chat",
headers={"Authorization": f"Bearer {api_key}"},
json={
"persona": {"name": "Nova", "coreVoice": "playful, flirty, lowercase texting"},
"messages": [{"role": "user", "content": "hey, you up?"}],
"allow_explicit": False,
},
)
data = res.json()
print(data["reply"])
Authentication
Every request to /v1/chat carries your API key as a bearer token:
Authorization: Bearer cp_live_...
Get a key by creating an account — one is issued immediately and shown to you once on your dashboard. Access is invite-only: every new account starts pending and is reviewed by a human before it can call the API. Your key already works the moment you're approved — you never need to regenerate it.
POST /v1/chat
Request body
| Field | Type | Notes |
|---|---|---|
persona | object, required | The full persona object — see the Persona object section below. Not validated for exact shape beyond a few optional fields; unrecognized fields pass through. |
messages | array, required (max 200) | Conversation so far, oldest first. Each item is {role: 'user'|'assistant', content: string}; content is capped at 16000 characters. The ENTIRE request body — persona + messages + everything else combined, as JSON-serialized bytes — is capped at 1,048,576 bytes (1MB), enforced before any field is even parsed. This is SMALLER than the theoretical maximum implied by the per-field limits above (200 messages × 16,000 chars ≈ 3.2MB) — a conversation approaching that theoretical ceiling will not fit through a single call; trim history (or send fewer/shorter messages) to stay under the 1MB total. |
allow_explicit | boolean, default false | Permits explicit adult content for this conversation. |
language | string, default 'auto' | 'auto' detects the reply language from the last message. Any other value (an ISO 639-1 code, e.g. 'es') is an EXPLICIT OVERRIDE that locks the reply to that language, regardless of what was actually written. |
style.explicitness | integer 0-3, optional | Heat level once `allow_explicit` is true (0 = none .. 3 = full). |
style.max_bubbles | integer 1-3, optional (default 3) | Maximum number of chat-style bubbles the reply is split into. |
stream | boolean, default false | Accepted for forward compatibility. Currently ALWAYS ignored — every response is a complete, non-streaming JSON object. |
Response body (200)
| Field | Type | Notes |
|---|---|---|
reply | string | The full reply text (all bubbles joined with newlines). |
bubbles | string[] | The reply split into individual chat-style messages, at most `style.max_bubbles` of them. |
usage.input_tokens | number | Input tokens consumed by this turn. |
usage.output_tokens | number | Output tokens generated by this turn. |
usage.credits | number | Credits charged for this call — 1 on any served reply, including a refusal. |
flagged | boolean | true when the request was refused by the safety floor. `reply`/`bubbles` still contain an in-character decline — the call is still charged. |
The persona object
Personas are not stored anywhere. You pass the full persona object inline on every request — the API is completely stateless and retains nothing about your conversation between calls. Continuity comes entirely from the messages array you send: resend as much of the conversation history as you want the model to remember on every call.
Every field below is optional (an empty {} persona is valid), but the more you give the model, the more convincing it is. examples is the single highest-leverage field — a handful of real message pairs teaches voice far better than any amount of adjectives in coreVoice.
Schema fields
| Field | Description | Example |
|---|---|---|
namestring |
Display name the model role-plays as — read directly off the persona, not part of the formal schema but universally set. | "Nova" |
biostring | string[] |
Free-form "about you" background paragraph(s), folded into the identity block verbatim. | "photographer, loves hiking" |
coreVoicestring |
Free-text tone/voice description. The single most direct lever on how the persona sounds. | "playful, flirty, lowercase texting" |
openersstring[] |
Example conversation-opening lines for this persona. | ["hey stranger 😏", "morning! sleep okay?"] |
endearmentsstring[] |
Affectionate terms the persona uses ("babe", "liebling", …). | ["babe", "love", "trouble"] |
topKinksstring[] |
Topics/kinks the persona leans into. Only surfaces explicitly when the request sets `allow_explicit:true`. | ["lingerie", "praise", "roleplay"] |
bannedWordsstring[] |
Words/phrases the persona must never say, rendered verbatim into the prompt as a hard avoid-list. | ["actually", "moist"] |
examplesArray<{userMessage, agentResponse}> |
THE HIGHEST-LEVERAGE FIELD. Real message pairs the model mimics verbatim — casing, length, emoji choice, typo tolerance, directness. Up to 18 pairs are used; more than that is a no-op. This is what turns a flat character description into a convincing one. | [{"userMessage":"morning!","agentResponse":"morning babe, still in bed lol"}] |
languagesstring[] (ISO 639-1) |
Languages the persona actually speaks. Clamps auto-detection so a reply never drifts into a language the persona was never meant to know. | ["en", "es"] |
langDefaultstring |
Legacy single-language default, kept for backward compatibility. Prefer `languages`. | "en" |
locationGuardstring | null |
Per-persona "never disclose location" rule text, inlined verbatim as a hard constraint. | "never say what city or country you're in" |
bioFactsobject |
Fixed, canonical answers for age / hometown / currentCity / pets / siblings / relationshipStatus, so the model gives the SAME answer every time instead of improvising a different one per session. Any fact not listed here is deflected, never invented. | {"age":27,"hometown":"Portland, OR","pets":"one cat"} |
Additional voice-signature fields
Not part of the formal schema — passed straight through, and genuinely read by the agent's prompt renderer if present.
| Field | Description | Example |
|---|---|---|
emojiFrequency'none' | 'rare' | 'moderate' | 'frequent' |
How often the model reaches for an emoji. Defaults to a moderate rate if unset. | "moderate" |
textingStyle.abbreviationsstring[] |
Texting abbreviations the persona uses naturally. | {"abbreviations":["lol","omg","rn"]} |
styleRulesstring[] |
Extra freeform writing-style rules, appended verbatim to the writing-style block. | ["never use exclamation points"] |
doNotSaystring[] |
A second avoid-list (distinct from `bannedWords`), rendered verbatim as "NEVER SAY THESE WORDS". | ["as an AI"] |
age / location / timezone / appearancenumber | string |
Light identity details folded into the "about you" block. | {"age":27,"appearance":"tall, dark hair"} |
personalityDescription / adjectives / topicsstring / string[] / string[] |
Extra personality framing and discussion topics layered onto `coreVoice`. | {"adjectives":["sarcastic","warm"]} |
endearmentBudget{ terms: [{word}], rate: number } |
Finer-grained pacing control over endearment usage than the plain `endearments` list — a target rate plus a ranked term list. | {"terms":[{"word":"babe"}],"rate":0.2} |
openerVarietystring[] |
The persona's actual observed opening lines, quoted verbatim as voice-signature guidance (distinct from `openers`). | ["hey you 😏", "guess what"] |
sentenceLengthDistribution{ p50: number, p90: number } |
Typical (p50) and occasional-max (p90) per-message character counts. | {"p50":40,"p90":90} |
multiSendRate / questionRatenumber (0-1) |
How often the persona sends multiple bubbles in a row / ends a turn with a question. | {"multiSendRate":0.3,"questionRate":0.4} |
A complete, realistic persona
Most fields together, ready to copy into the sandbox or your own request:
{
"name": "Nova",
"bio": "27, photographer based on the west coast. Loves hiking, film cameras, and late-night diner runs. Sarcastic but warm once she likes you.",
"coreVoice": "playful, flirty, a little sarcastic, lowercase texting, quick with a tease but genuinely warm underneath",
"openers": ["hey stranger 😏", "morning! sleep okay?", "okay I need your opinion on something"],
"endearments": ["babe", "trouble", "love"],
"topKinks": ["praise", "lingerie", "roleplay"],
"bannedWords": ["actually", "moist", "delve"],
"examples": [
{ "userMessage": "hey what are you up to", "agentResponse": "just got out of the shower lol, thinking about you tbh" },
{ "userMessage": "you always know what to say", "agentResponse": "mm i just say what's true babe" },
{ "userMessage": "i had a rough day", "agentResponse": "come here, tell me what happened. i've got you" }
],
"languages": ["en", "es"],
"langDefault": "en",
"locationGuard": "never reveal what city, state, or country you're actually in — deflect playfully if asked",
"bioFacts": {
"age": 27,
"hometown": "Portland, OR",
"currentCity": "Los Angeles, CA",
"pets": "one cat named Biscuit",
"siblings": "younger brother",
"relationshipStatus": "single"
},
"emojiFrequency": "moderate",
"textingStyle": { "abbreviations": ["lol", "omg", "rn", "tbh"] }
}
Status codes
| Status | Error | Meaning |
|---|---|---|
200 | OK | A reply was generated — check `flagged`, a safety-floor refusal is still a 200. |
400 | invalid request / detail | Malformed JSON, or a body that fails schema validation (`detail` explains what failed). |
401 | unauthorized | Missing/malformed `Authorization` header, or the API key is unknown or revoked. |
402 | insufficient_credits | Free allowance and paid credit balance are both exhausted. Checked BEFORE the rate limit below — see the precedence note. |
403 | account_pending / account_suspended | The account has not been approved yet, or has been suspended. |
429 | rate_limited | Exceeded the per-key rate limit (60 requests/minute by default). |
500 | internal error | Something failed while generating the reply. |
Precedence: a request that is both over-budget AND over the rate limit always gets 402, never 429 — the credit reserve is checked, and the request turned away, before the rate limit is even evaluated.
Credits & limits
- 1 reply = 1 credit = 2¢.
- New accounts start with 200 free replies — no card required.
- A refusal (
flagged:true) still consumes 1 credit — the model still did the work of evaluating the request. - Default rate limit: 60 requests/minute per API key.
- Total request body cap: 1,048,576 bytes (1MB), across the whole JSON payload — see the request-body table above for how this interacts with the per-field
messageslimits.
Safety
Adult content is first-class here: set allow_explicit:true (and optionally style.explicitness) and the persona holds frank, explicit conversation without refusing or moralizing.
One hard floor is always enforced, regardless of allow_explicit: content involving minors (or any other illegal material) and bestiality are refused — both on what you send AND on what the model generates (an input check plus an output backstop). A request that trips this returns flagged:true and an in-character decline, never the requested content.