Counterpart
All reading
Counterpart — Public v1 API

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

text
https://<your-counterpart-host>/api/v1

All endpoints accept and return application/json unless otherwise noted.


Quickstart — 60 seconds

Three calls is a full round trip: create, message, end.

bash
# 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.

http
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.

jsonc
{
  // 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

FieldTypeRequiredNotes
scenario_idstringone of the twoID of a built-in scenario.
scenarioobjectone of the twoFull inline scenario definition.
scenario.typeenumyesOne of: interview, persuasion, coaching, therapy, negotiation, conflict_management, thesis_defense, dating, academic_advising, customer_support, sales, medical.
scenario.initial_state.trustnumber 0..1yesInitial trust the counterpart has in the user.
scenario.initial_state.resistancenumber 0..1yesHow much they push back against influence.
scenario.initial_state.commitment_levelnumber 0..1yesStarting commitment to the desired outcome.
scenario.npc_profile.voicestringnoVoice name. Legacy Gemini voice names (e.g. Kore, Charon, Aoede) are auto-mapped to OpenAI voices server-side. Defaults sensibly if omitted.
roleenumnolearner (default — AI plays the counterpart) or counterpart (user plays the counterpart; AI rehearses the skilled-practitioner side).
options.ttl_secondsint 60..86400noSession lifetime. Default 3600 (1 h).
options.max_user_turnsint 1..200noHard cap on user turns; session auto-ends when reached.
options.metadataobjectnoString / number / boolean values only. Echoed back on the session-status and report responses — handy for external_user_id wiring.

Endpoints

Create a conversation

POST/api/v1/conversations

Create a new session from a config.

http
POST /api/v1/conversations
Authorization: Bearer <key>
Content-Type: application/json

{ "scenario_id": "interview_pm_case", "options": { "locale": "en" } }

Response — 201 Created

json
{
  "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

POST/api/v1/conversations/{id}/messages

Send a user turn; receive the NPC's response plus the updated state, an optional coach hint, and flags raised by the deterministic state machine.

json
// Request
{ "text": "Before we dive in, could I ask what prompted the re-org?" }
json
// 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

POST/api/v1/conversations/{id}/end

Idempotent. Returns the full KSTAR report — overall score, outcome, per-skill feedback, the timeline of turns, raised flags, and an improvement plan.

json
{
  "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

GET/api/v1/conversations/{id}

Returns the live state — turns, NPC state, coach hints, flags. Useful for polling an embedded conversation from the parent app.

json
{
  "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

DELETE/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

javascript
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

html
<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:

TypeExample scenario IDs
persuasionai_adoption_trust_boundary, persuasion_budget_cut, persuasion_security_policy
coachingnurse_manager_ehr_adoption, coaching_performance_plan, coaching_career_transition
academic_advisingacademic_advising_major_choice, academic_advising_dropout_risk, academic_advising_grad_school
interviewtechnical_interview_standard, interview_pm_case, interview_executive_panel
salessales_discovery_cto, sales_pricing_objection, sales_expansion_upsell
negotiationnegotiation_salary_offer, negotiation_vendor_renewal, negotiation_home_purchase
customer_supportcustomer_support_angry_refund, customer_support_technical, customer_support_churn_save
medicalmedical_difficult_diagnosis, medical_treatment_refusal, medical_end_of_life
thesis_defensethesis_phd_defense, thesis_masters_proposal, thesis_undergrad_honors
datingdating_first_date, dating_expectations_talk, dating_meeting_parents

Error model

All errors return JSON of the form:

json
{ "error": "<machine_code>", "message": "Human description" }

Or, for validation failures:

json
{ "error": "invalid_config", "errors": ["scenario.type must be one of: ...", "..."] }
HTTPCodeMeaning
400invalid_jsonBody was not valid JSON.
400invalid_configSchema validation failed; see errors[].
400empty_textEmpty or missing text on a message POST.
401missing_authorizationNo bearer token.
401invalid_keyKey not in the server allowlist.
403forbiddenSession belongs to a different API key.
404scenario_not_foundscenario_id unknown.
404not_foundSession expired or never existed.
409session_endedCannot message an ended session.
409max_turns_reachedoptions.max_user_turns exceeded.
502npc_generation_failedUpstream model error on turn generation.
502report_generation_failedUpstream model error on report.
503public_api_disabledServer missing VIBESIM_API_KEYS.

Limits, guarantees & versioning

  • Session TTL: default 1 hour, max 24 hours. Expired sessions are evicted; fetch /end before 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; v1 is maintained for at least 6 months after v2 GA.

Full round-trip example

A complete bash script: create a sales-discovery session, send two turns, end and read the score.

bash
#!/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.