Counterpart API
Counterpart is a conversation-as-a-service API. Your application sends a JSON configuration; Counterpart hosts the practice conversation — counterpart character, deterministic state tracking, coach hints, real-time voice, and a structured post-session report — and returns a session you can either embed in your UI or drive headlessly over HTTP.
This document is the practical reference. For why these conversations are psychologically hard in the first place, read The Psychology of Conversation. The full source-of-truth schema is exported as TypeScript and JSON Schema from lib/conversation/schema.ts in the repository.
Base URL
https://<your-counterpart-host>/api/v1All endpoints accept and return application/json unless otherwise noted.
Quickstart — 60 seconds
Three calls is a full round trip: create, message, end.
# 1. Create a conversation from a built-in scenario
curl -sS -X POST https://your-host/api/v1/conversations \
-H "Authorization: Bearer $COUNTERPART_KEY" \
-H "Content-Type: application/json" \
-d '{
"scenario_id": "interview_pm_case",
"options": { "locale": "en", "metadata": { "external_user_id": "u_123" } }
}'
# -> { "session_id": "...", "embed_url": "...", "messages_url": "...", ... }
# 2. Send a user message
curl -sS -X POST "$MESSAGES_URL" \
-H "Authorization: Bearer $COUNTERPART_KEY" \
-H "Content-Type: application/json" \
-d '{ "text": "Thanks for making the time. Before I dive in, could I ask..." }'
# 3. End and get the KSTAR report
curl -sS -X POST "$END_URL" \
-H "Authorization: Bearer $COUNTERPART_KEY"Or, instead of driving turns yourself, open the returned embed_url in an iframe / popup / redirect — see Embedding.
Authentication
Every request must include a bearer token.
Authorization: Bearer <YOUR_API_KEY>Keys are configured server-side via the VIBESIM_API_KEYS environment variable (a comma-separated allowlist). If the variable is unset, the entire /api/v1/* surface returns 503 public_api_disabled — the public API is explicit opt-in.
Concepts
Scenario
A scenario is the character brief: who the counterpart is, their initial emotional state, the phases the conversation moves through, the evaluation criteria, and the failure patterns to watch for. Counterpart ships with ~30 built-in scenarios across 10 categories (see Built-in scenarios), and you can also supply a full scenario inline.
Session (conversation)
A session is a running instance of a scenario. It has a session_id, an NPC state object, a turn history, coach hints, and a TTL. Sessions are ephemeral by design — fetch the report via /end before the TTL expires if you need durable outcomes.
ConversationConfig
The JSON body you POST to create a session. Either pick a built-in via scenario_id or supply a full scenario object, plus optional session-level options.
ConversationConfig schema
The two top-level shapes — built-in vs. inline — are mutually exclusive and validated server-side.
{
// Option A: use a built-in scenario
"scenario_id": "interview_pm_case",
// --- OR ---
// Option B: supply a full scenario inline
"scenario": {
"scenario_id": "my_custom_scenario",
"title": "Cold Call with a Procurement Director",
"type": "sales",
"domain": "B2B Enterprise",
"context": "You are an AE calling...",
"objectives": {
"task": "Earn a follow-up meeting",
"behavioral": "Ask before pitching",
"emotional": "Stay calm under rejection",
"hidden": "Leave one genuine insight"
},
"npc_profile": {
"name": "Nora",
"role": "Head of Procurement",
"archetype": "Veteran Skeptic",
"avatar": "🧑💼",
"voice": "Kore"
},
"initial_state": {
"trust": 0.4,
"resistance": 0.7,
"commitment_level": 0.1,
"emotion": "skeptical"
},
"evaluation_criteria": {
"empathy": 0.0, "structure": 0.0, "assertiveness": 0.0,
"closure": 0.0, "strategy": 0.0
},
"phases": [
{ "id": "open", "label": "Phase 1 — Open", "description": "..." },
{ "id": "probe", "label": "Phase 2 — Probe", "description": "...", "critical": true }
],
"failure_patterns": [
{ "condition": "feature_first", "description": "...", "flag": "feature_first" }
],
"desired_outcome": "A follow-up meeting booked with a named technical lead",
"key_skills": ["diagnostic discovery", "domain credibility"],
"training_objective": "Sell to skeptical buyers by earning trust through specificity"
},
// Optional: which seat the human plays
"role": "learner", // or "counterpart"
"options": {
"locale": "en", // BCP-47; drives NPC + coach language
"max_user_turns": 20, // auto-end threshold
"coach_enabled": true, // default true
"ttl_seconds": 3600, // 60..86400
"ui": {
"hide_camera": false,
"hide_coach": false,
"mute_tts": false,
"theme": "dark",
"title_override": "Practice: Cold Call"
},
"metadata": { // echoed back in session status + report
"external_user_id": "u_123",
"program_id": "sales_onboarding_q2"
}
}
}Field reference
| Field | Type | Required | Notes |
|---|---|---|---|
| scenario_id | string | one of the two | ID of a built-in scenario. |
| scenario | object | one of the two | Full inline scenario definition. |
| scenario.type | enum | yes | One of: interview, persuasion, coaching, therapy, negotiation, conflict_management, thesis_defense, dating, academic_advising, customer_support, sales, medical. |
| scenario.initial_state.trust | number 0..1 | yes | Initial trust the counterpart has in the user. |
| scenario.initial_state.resistance | number 0..1 | yes | How much they push back against influence. |
| scenario.initial_state.commitment_level | number 0..1 | yes | Starting commitment to the desired outcome. |
| scenario.npc_profile.voice | string | no | Voice name. Legacy Gemini voice names (e.g. Kore, Charon, Aoede) are auto-mapped to OpenAI voices server-side. Defaults sensibly if omitted. |
| role | enum | no | learner (default — AI plays the counterpart) or counterpart (user plays the counterpart; AI rehearses the skilled-practitioner side). |
| options.ttl_seconds | int 60..86400 | no | Session lifetime. Default 3600 (1 h). |
| options.max_user_turns | int 1..200 | no | Hard cap on user turns; session auto-ends when reached. |
| options.metadata | object | no | String / number / boolean values only. Echoed back on the session-status and report responses — handy for external_user_id wiring. |
Endpoints
Create a conversation
/api/v1/conversationsCreate a new session from a config.
POST /api/v1/conversations
Authorization: Bearer <key>
Content-Type: application/json
{ "scenario_id": "interview_pm_case", "options": { "locale": "en" } }Response — 201 Created
{
"session_id": "8f0c1e1c-...",
"embed_url": "https://host/simulation?session=8f0c1e1c-...",
"messages_url": "https://host/api/v1/conversations/8f0c1e1c-.../messages",
"end_url": "https://host/api/v1/conversations/8f0c1e1c-.../end",
"status_url": "https://host/api/v1/conversations/8f0c1e1c-...",
"expires_at": "2026-05-22T15:04:05.000Z",
"scenario": {
"id": "interview_pm_case",
"title": "Product Manager Case Interview",
"type": "interview",
"npc": { "name": "Leila", "role": "Director of Product", "voice": "Callirrhoe" }
},
"opening_message": "Thanks for joining. Let's jump right in..."
}Common errors: 400 invalid_config (with errors[]), 401 invalid_key, 404 scenario_not_found, 502 npc_generation_failed, 503 public_api_disabled.
Send a user message
/api/v1/conversations/{id}/messagesSend a user turn; receive the NPC's response plus the updated state, an optional coach hint, and flags raised by the deterministic state machine.
// Request
{ "text": "Before we dive in, could I ask what prompted the re-org?" }// Response — 200 OK
{
"session_id": "8f0c...",
"npc_response": "Fair question — two things triggered it...",
"updated_state": {
"trust": 0.62, "resistance": 0.55, "commitment_level": 0.18,
"skepticism": 0.40, "engagement": 0.70, "emotion": "opening_up"
},
"coach_hint": {
"type": "hint",
"message": "Nice — you earned airtime by asking before pitching.",
"timestamp": 1713700000000
},
"session_ended": false,
"end_reason": null,
"flags": [],
"turn_count": 1
}Common errors: 400 empty_text, 404 not_found (session expired), 403 forbidden (different key), 409 session_ended, 409 max_turns_reached.
End the session and get the KSTAR report
/api/v1/conversations/{id}/endIdempotent. Returns the full KSTAR report — overall score, outcome, per-skill feedback, the timeline of turns, raised flags, and an improvement plan.
{
"session_id": "8f0c...",
"report": {
"overall_score": 7.8,
"outcome": "Candidate advanced — strong structured thinking.",
"outcome_label": "success",
"conviction_delta": 0.42,
"kstar": {
"situation": "...", "task": "...", "action": "...",
"result": "...", "delta": "..."
},
"skill_scores": [
{ "skill": "empathy", "score": 7.1, "feedback": "..." }
],
"timeline": [
{ "turn_number": 1, "user_action": "...", "npc_response": "...",
"coach_comment": "...", "state_delta": {} }
],
"flags": [],
"improvement_plan": ["Practice asking for specifics before framing solutions."],
"ai_summary": "...",
"duration_seconds": 312
},
"metadata": { "external_user_id": "u_123" }
}Get current session state
/api/v1/conversations/{id}Returns the live state — turns, NPC state, coach hints, flags. Useful for polling an embedded conversation from the parent app.
{
"session_id": "8f0c...",
"npc_state": { "trust": 0.62, "resistance": 0.55, "...": "..." },
"turns": [ { "role": "npc", "text": "...", "timestamp": 1713... } ],
"coach_hints": [],
"flags": [],
"started_at": "2026-05-22T14:00:00.000Z",
"ended_at": null,
"end_reason": null,
"metadata": {},
"expires_at": "2026-05-22T15:00:00.000Z"
}Delete a session
/api/v1/conversations/{id}Explicit cleanup before TTL. Returns { "deleted": true }.
Embedding
The embed_url returned from create can be opened in three ways. The page renders the full Counterpart UI — voice, camera, coach panel, and report — and is fully responsive (collapses into a single column with bottom-sheet panels under the lg breakpoint).
Popup window
const res = await fetch('/api/v1/conversations', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + COUNTERPART_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({ scenario_id: 'interview_pm_case' }),
});
const { embed_url, session_id } = await res.json();
window.open(
embed_url,
'vibesim_conversation',
'popup=yes,width=560,height=820'
);iframe
<iframe
src="https://host/simulation?session=SESSION_ID&embed=1"
allow="camera; microphone; autoplay; fullscreen"
style="width:100%; height:100vh; border:0;"
></iframe>Top-level redirect
Redirect the user to embed_url; have your backend end the session via end_url or poll GET /api/v1/conversations/{id} to know when it's done.
Built-in scenarios
~30 scenarios ship across 10 categories. A live list is available at GET /api/scenarios (no auth required). Category IDs map 1:1 to scenario.type:
| Type | Example scenario IDs |
|---|---|
| persuasion | ai_adoption_trust_boundary, persuasion_budget_cut, persuasion_security_policy |
| coaching | nurse_manager_ehr_adoption, coaching_performance_plan, coaching_career_transition |
| academic_advising | academic_advising_major_choice, academic_advising_dropout_risk, academic_advising_grad_school |
| interview | technical_interview_standard, interview_pm_case, interview_executive_panel |
| sales | sales_discovery_cto, sales_pricing_objection, sales_expansion_upsell |
| negotiation | negotiation_salary_offer, negotiation_vendor_renewal, negotiation_home_purchase |
| customer_support | customer_support_angry_refund, customer_support_technical, customer_support_churn_save |
| medical | medical_difficult_diagnosis, medical_treatment_refusal, medical_end_of_life |
| thesis_defense | thesis_phd_defense, thesis_masters_proposal, thesis_undergrad_honors |
| dating | dating_first_date, dating_expectations_talk, dating_meeting_parents |
Error model
All errors return JSON of the form:
{ "error": "<machine_code>", "message": "Human description" }Or, for validation failures:
{ "error": "invalid_config", "errors": ["scenario.type must be one of: ...", "..."] }| HTTP | Code | Meaning |
|---|---|---|
| 400 | invalid_json | Body was not valid JSON. |
| 400 | invalid_config | Schema validation failed; see errors[]. |
| 400 | empty_text | Empty or missing text on a message POST. |
| 401 | missing_authorization | No bearer token. |
| 401 | invalid_key | Key not in the server allowlist. |
| 403 | forbidden | Session belongs to a different API key. |
| 404 | scenario_not_found | scenario_id unknown. |
| 404 | not_found | Session expired or never existed. |
| 409 | session_ended | Cannot message an ended session. |
| 409 | max_turns_reached | options.max_user_turns exceeded. |
| 502 | npc_generation_failed | Upstream model error on turn generation. |
| 502 | report_generation_failed | Upstream model error on report. |
| 503 | public_api_disabled | Server missing VIBESIM_API_KEYS. |
Limits, guarantees & versioning
- Session TTL: default 1 hour, max 24 hours. Expired sessions are evicted; fetch
/endbefore TTL if you need the report. - Max user turns: soft cap of 200 via
options.max_user_turns. - Durability: sessions are single-instance and in-memory. They do not survive cold starts. Persist reports on your side if you need long-term storage.
- Rate limiting: not enforced in v1 — configure at your edge / load balancer.
- Webhooks: not in v1. Poll
GET /api/v1/conversations/{id}for state changes. - Versioning: versioned via URL prefix (
/api/v1). Breaking changes ship under/api/v2;v1is maintained for at least 6 months afterv2GA.
Full round-trip example
A complete bash script: create a sales-discovery session, send two turns, end and read the score.
#!/usr/bin/env bash
set -euo pipefail
: "${COUNTERPART_KEY:?set COUNTERPART_KEY}"
HOST="${HOST:-https://your-counterpart-host}"
# 1. Create
CREATE=$(curl -sS -X POST "$HOST/api/v1/conversations" \
-H "Authorization: Bearer $COUNTERPART_KEY" \
-H "Content-Type: application/json" \
-d '{"scenario_id":"sales_discovery_cto","options":{"metadata":{"user":"u_123"}}}')
SESSION=$(echo "$CREATE" | jq -r .session_id)
MSG_URL=$(echo "$CREATE" | jq -r .messages_url)
END_URL=$(echo "$CREATE" | jq -r .end_url)
echo "Session: $SESSION"
echo "NPC opened: $(echo "$CREATE" | jq -r .opening_message)"
# 2. Chat a few turns
for MSG in \
"Thanks for making time. Before I pitch anything, mind if I ask about your stack?" \
"We specialize in the problem you just described — can I share one benchmark?"; do
REPLY=$(curl -sS -X POST "$MSG_URL" \
-H "Authorization: Bearer $COUNTERPART_KEY" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg t "$MSG" '{text:$t}')")
echo "You: $MSG"
echo "NPC: $(echo "$REPLY" | jq -r .npc_response)"
done
# 3. End + report
REPORT=$(curl -sS -X POST "$END_URL" -H "Authorization: Bearer $COUNTERPART_KEY")
echo "Score: $(echo "$REPORT" | jq -r .report.overall_score)"
echo "Outcome: $(echo "$REPORT" | jq -r .report.outcome)"Ready to integrate?
Set VIBESIM_API_KEYS on your Counterpart host, share a key with the calling app, and POST a scenario_id.