Scenario
Your Typeform survey includes open-ended questions that generated hundreds of free-text responses. Reading them all is impractical, and keyword analysis misses nuance. This job extracts all open-ended answers, sends them to Mavera Chat for theme identification, then creates a Focus Group with targeted questions based on the discovered themes. The result is a deep-dive into the why behind each theme, with synthetic personas probing the nuances your survey couldn’t capture. Flow: Typeform responses → Filter open-ended fields → Mavera Chat: “Identify themes” → Parse themes →POST /api/v1/focus-groups with theme-specific questions → Qualitative depth on each theme
Architecture
Code
import os, json, requests, time
from openai import OpenAI
TF = os.environ["TYPEFORM_TOKEN"]
MV = os.environ["MAVERA_API_KEY"]
TF_BASE = "https://api.typeform.com"
MB = "https://app.mavera.io/api/v1"
TF_H = {"Authorization": f"Bearer {TF}"}
MV_H = {"Authorization": f"Bearer {MV}", "Content-Type": "application/json"}
FORM_ID = os.environ.get("TYPEFORM_FORM_ID", "your_form_id")
PERSONA_IDS = os.environ.get("PERSONA_IDS", "").split(",")
# 1. Get form and identify open-ended fields
form = requests.get(f"{TF_BASE}/forms/{FORM_ID}", headers=TF_H).json()
open_fields = [f for f in form.get("fields", [])
if f.get("type") in ("long_text", "short_text")]
print(f"Open-ended fields: {len(open_fields)}")
# 2. Pull responses
responses = []
params = {"page_size": 1000}
while True:
r = requests.get(f"{TF_BASE}/forms/{FORM_ID}/responses",
headers=TF_H, params=params)
if r.status_code == 429:
time.sleep(1)
else:
r.raise_for_status()
data = r.json()
responses.extend(data.get("items", []))
if len(data.get("items", [])) < 1000:
break
params["before"] = data["items"][-1]["token"]
time.sleep(0.6)
# 3. Extract open-ended answers grouped by field
open_field_ids = {f["id"] for f in open_fields}
field_titles = {f["id"]: f.get("title", f["id"]) for f in open_fields}
answers_by_field = {fid: [] for fid in open_field_ids}
for resp in responses:
for ans in resp.get("answers", []):
fid = ans.get("field", {}).get("id", "")
if fid in open_field_ids and ans.get("type") == "text":
text = ans.get("text", "").strip()
if text and len(text) > 10:
answers_by_field[fid].append(text)
# 4. Identify themes with Mavera Chat
mavera = OpenAI(api_key=MV, base_url=MB)
all_text = []
for fid, answers in answers_by_field.items():
title = field_titles[fid]
for ans in answers[:50]:
all_text.append(f"[{title}] {ans[:200]}")
theme_result = mavera.responses.create(
model="mavera-1",
input=[{"role": "user", "content": f"""Analyze these {len(all_text)} open-ended survey responses.
Identify 5-7 distinct themes. For each theme:
- Theme name (2-4 words)
- Frequency estimate (what % of responses mention it)
- Representative quotes (3 examples)
- Underlying sentiment (positive, negative, mixed)
- Key insight
RESPONSES:
{chr(10).join(all_text[:100])}
Return as JSON: {{"themes": [...]}}"""}],
)
theme_content = theme_result.output[0].content[0].text
print("=== Discovered Themes ===")
print(theme_content[:1500])
# 5. Parse themes and create focus group questions
try:
json_str = theme_content[theme_content.find("{"):theme_content.rfind("}")+1]
themes = json.loads(json_str).get("themes", [])
except (json.JSONDecodeError, ValueError):
themes = []
focus_questions = []
for theme in themes[:5]:
name = theme.get("name", "Unknown")
sentiment = theme.get("sentiment", "mixed")
quotes = theme.get("representative_quotes", theme.get("quotes", []))
quote_sample = quotes[0] if quotes else "N/A"
focus_questions.append(
f'Survey respondents mentioned "{name}" — e.g., "{quote_sample[:100]}". '
f"How does this resonate with your experience? What would you add?"
)
focus_questions.append("Which of these themes matters most to you? Why?")
focus_questions.append("What's missing from these themes? What topic should we have asked about?")
# 6. Run Focus Group
if not PERSONA_IDS or PERSONA_IDS == [""]:
p = requests.post(f"{MB}/personas", headers=MV_H, json={
"name": "TF Survey Respondent",
"description": "Represents the typical respondent of this Typeform survey.",
}).json()
PERSONA_IDS = [p["id"]]
fg = requests.post(f"{MB}/focus-groups", headers=MV_H, json={
"name": f"Theme Deep-Dive: {form.get('title', 'Survey')}",
"persona_ids": PERSONA_IDS,
"questions": focus_questions,
"context": f"Based on analysis of {len(responses)} survey responses. Themes discovered: {', '.join(t.get('name','') for t in themes[:5])}",
"responses_per_persona": 3,
}).json()
# 7. Poll for results
for _ in range(20):
time.sleep(5)
result = requests.get(f"{MB}/focus-groups/{fg['id']}",
headers=MV_H).json()
if result.get("status") == "completed":
break
print(f"\nFocus Group: {fg['id']}")
for resp in result.get("responses", []):
print(f"\n[{resp.get('persona_name', '?')}] {resp.get('question', '')[:70]}...")
print(f" → {resp.get('answer', '')[:250]}")
import OpenAI from "openai";
const TF = process.env.TYPEFORM_TOKEN;
const MV = process.env.MAVERA_API_KEY;
const TF_BASE = "https://api.typeform.com";
const MB = "https://app.mavera.io/api/v1";
const tfH = { Authorization: `Bearer ${TF}` };
const mvH = { Authorization: `Bearer ${MV}`, "Content-Type": "application/json" };
const FORM_ID = process.env.TYPEFORM_FORM_ID || "your_form_id";
let personaIds = (process.env.PERSONA_IDS || "").split(",").filter(Boolean);
// 1. Form structure
const form = await fetch(`${TF_BASE}/forms/${FORM_ID}`, { headers: tfH }).then(r => r.json());
const openFields = (form.fields || []).filter(f => ["long_text", "short_text"].includes(f.type));
const fieldTitles = Object.fromEntries(openFields.map(f => [f.id, f.title || f.id]));
// 2. Pull responses
const responses = [];
const params = new URLSearchParams({ page_size: "1000" });
while (true) {
let res = await fetch(`${TF_BASE}/forms/${FORM_ID}/responses?${params}`, { headers: tfH });
if (res.status === 429) { await new Promise(r => setTimeout(r, 1000)); continue; }
const data = await res.json();
responses.push(...(data.items || []));
if ((data.items || []).length < 1000) break;
params.set("before", data.items[data.items.length - 1].token);
await new Promise(r => setTimeout(r, 600));
}
// 3. Extract open-ended
const openFieldIds = new Set(openFields.map(f => f.id));
const answersByField = {};
for (const fid of openFieldIds) answersByField[fid] = [];
for (const resp of responses) {
for (const ans of resp.answers || []) {
const fid = ans.field?.id;
if (openFieldIds.has(fid) && ans.type === "text" && (ans.text || "").trim().length > 10)
answersByField[fid].push(ans.text.trim());
}
}
const allText = [];
for (const [fid, answers] of Object.entries(answersByField)) {
for (const ans of answers.slice(0, 50))
allText.push(`[${fieldTitles[fid] || fid}] ${ans.slice(0, 200)}`);
}
// 4. Theme identification
const mavera = new OpenAI({ apiKey: MV, baseURL: MB });
const themeResult = await mavera.responses.create({
model: "mavera-1",
input: [{ role: "user", content: `Analyze these ${allText.length} open-ended responses.
Identify 5-7 themes. For each: name, frequency %, representative quotes (3), sentiment, key insight.
Return JSON: {"themes": [...]}
RESPONSES:
${allText.slice(0, 100).join("\n")}` }],
});
const themeContent = themeResult.output[0].content[0].text;
console.log("=== Discovered Themes ===");
console.log(themeContent.slice(0, 1500));
// 5. Parse themes
let themes = [];
try {
const jsonStr = themeContent.slice(themeContent.indexOf("{"), themeContent.lastIndexOf("}") + 1);
themes = JSON.parse(jsonStr).themes || [];
} catch { themes = []; }
const focusQuestions = themes.slice(0, 5).map(t => {
const quote = (t.representative_quotes || t.quotes || ["N/A"])[0];
return `Respondents mentioned "${t.name}" — e.g., "${(quote || "").slice(0, 100)}". How does this resonate? What would you add?`;
});
focusQuestions.push("Which theme matters most to you? Why?");
focusQuestions.push("What's missing? What topic should we have asked about?");
// 6. Ensure personas
if (!personaIds.length) {
const p = await fetch(`${MB}/personas`, { method: "POST", headers: mvH,
body: JSON.stringify({ name: "TF Survey Respondent",
description: "Typical respondent of this Typeform survey." }),
}).then(r => r.json());
personaIds = [p.id];
}
// 7. Focus Group
const fg = await fetch(`${MB}/focus-groups`, { method: "POST", headers: mvH,
body: JSON.stringify({
name: `Theme Deep-Dive: ${form.title}`,
persona_ids: personaIds, questions: focusQuestions,
context: `Based on ${responses.length} responses. Themes: ${themes.slice(0, 5).map(t => t.name).join(", ")}`,
responses_per_persona: 3,
}),
}).then(r => r.json());
let result;
for (let i = 0; i < 20; i++) {
await new Promise(r => setTimeout(r, 5000));
result = await fetch(`${MB}/focus-groups/${fg.id}`, { headers: mvH }).then(r => r.json());
if (result.status === "completed") break;
}
console.log(`\nFocus Group: ${fg.id}`);
for (const resp of result.responses || []) {
console.log(`\n[${resp.persona_name || "?"}] ${(resp.question || "").slice(0, 70)}...`);
console.log(` → ${(resp.answer || "").slice(0, 250)}`);
}
Example Output
=== Discovered Themes ===
1. "Tool Consolidation" (42%) — "I use 7 different tools and none talk to each other"
2. "Time to Value" (38%) — "We spent 3 months onboarding our last platform"
3. "Pricing Transparency" (31%) — "Hidden fees killed our budget mid-year"
4. "Team Adoption" (27%) — "I love it but my team won't switch from spreadsheets"
5. "Data Security" (19%) — "SOC 2 is table stakes, we need more"
Focus Group: fg_tf_themes_4k
[Growth-Stage Operator] Respondents mentioned "Tool Consolidation"...
→ Absolutely. The cognitive overhead of context-switching between 7 tools
is worse than any single tool's limitations. What I'd add: it's not just
about features — it's about having one place to think.
[Enterprise Evaluator] Which theme matters most to you? Why?
→ Data Security, hands down. Tool consolidation is a nice-to-have, but
a security incident is existential. I'd also add "Vendor Risk Assessment
Burden" — every new tool means another 6-week security review.
Error Handling
Short responses add noise
Short responses add noise
Responses under 10 characters (e.g., “N/A”, “none”) are filtered out. Adjust the threshold based on your survey’s typical response quality.
Theme count depends on response volume
Theme count depends on response volume
With fewer than 50 open-ended responses, Mave may only find 2-3 themes. The code handles variable theme counts gracefully.
Focus Group question length
Focus Group question length
Questions derived from themes can be long. Keep the quote excerpt under 100 characters to prevent focus group prompt overflow.