Scenario
Your quarterly employee engagement survey (eNPS + engagement questions) reveals distinct workforce segments — the Engaged Advocate who scores everything 9+, the Quiet Quitter giving 5-6 across the board, the New Hire still forming opinions. You need to test internal communications (policy announcements, benefit changes, company updates) against these segments before sending. This job pulls engagement data, creates internal personas matching each segment, then runs Focus Groups testing draft comms against each persona type. Flow: Qualtrics eNPS + engagement export → Segment by engagement tier → Create internal personas (Engaged Advocate, Quiet Quitter, New Hire) →POST /api/v1/focus-groups testing internal comms → Segment-validated messaging
Architecture
Code
import os, csv, io, zipfile, requests, time
from collections import defaultdict
QT = os.environ["QUALTRICS_TOKEN"]
DC = os.environ["QUALTRICS_DC"]
MV = os.environ["MAVERA_API_KEY"]
Q_BASE = f"https://{DC}.qualtrics.com/API/v3"
MB = "https://app.mavera.io/api/v1"
Q_H = {"X-API-TOKEN": QT, "Content-Type": "application/json"}
MV_H = {"Authorization": f"Bearer {MV}", "Content-Type": "application/json"}
SURVEY_ID = os.environ.get("ENPS_SURVEY_ID", "SV_xxxxx")
# 1. Export (same async pattern)
export = requests.post(f"{Q_BASE}/surveys/{SURVEY_ID}/export-responses",
headers=Q_H, json={"format": "csv"}).json()
pid = export["result"]["progressId"]
file_id = None
for _ in range(60):
time.sleep(5)
s = requests.get(f"{Q_BASE}/surveys/{SURVEY_ID}/export-responses/{pid}",
headers=Q_H).json()
if s["result"].get("percentComplete") == 100:
file_id = s["result"]["fileId"]; break
zip_data = requests.get(
f"{Q_BASE}/surveys/{SURVEY_ID}/export-responses/{file_id}/file",
headers=Q_H).content
with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
csv_name = [n for n in zf.namelist() if n.endswith(".csv")][0]
with zf.open(csv_name) as f:
rows = list(csv.DictReader(io.TextIOWrapper(f, encoding="utf-8-sig")))
responses = [r for r in rows if r.get("Finished") == "1"]
print(f"Employee responses: {len(responses)}")
# 2. Segment employees
ENPS_COL = os.environ.get("ENPS_COLUMN", "eNPS")
TENURE_COL = os.environ.get("TENURE_COLUMN", "Tenure_Months")
segments = {"engaged_advocate": [], "passive_performer": [],
"quiet_quitter": [], "new_hire": [], "at_risk_veteran": []}
for r in responses:
try:
enps = float(r.get(ENPS_COL, 0) or 0)
except ValueError:
continue
try:
tenure = float(r.get(TENURE_COL, 12) or 12)
except ValueError:
tenure = 12
if tenure < 6:
segments["new_hire"].append(r)
elif enps >= 9:
segments["engaged_advocate"].append(r)
elif enps >= 7:
segments["passive_performer"].append(r)
elif enps >= 4 and tenure > 24:
segments["at_risk_veteran"].append(r)
else:
segments["quiet_quitter"].append(r)
for name, members in segments.items():
print(f" {name}: {len(members)}")
# 3. Create internal personas
PERSONA_PROFILES = {
"engaged_advocate": {
"name": "QX Internal: Engaged Advocate",
"desc": "eNPS 9-10. Enthusiastic about company direction. Shares updates proactively. First to volunteer for initiatives.",
"mindset": "Invested, energized, wants to be recognized and involved",
},
"passive_performer": {
"name": "QX Internal: Passive Performer",
"desc": "eNPS 7-8. Doing their job well but not engaged beyond requirements. Reads comms but rarely responds.",
"mindset": "Content but disengaged from culture. Needs a reason to care more.",
},
"quiet_quitter": {
"name": "QX Internal: Quiet Quitter",
"desc": "eNPS 0-6. Doing minimum required. Skeptical of corporate comms. May be passively job searching.",
"mindset": "Disillusioned. Reads announcements looking for reasons to leave or stay.",
},
"new_hire": {
"name": "QX Internal: New Hire",
"desc": "Under 6 months tenure. Still forming opinions. Observing culture. Comparing to previous employer.",
"mindset": "Observant, optimistic but cautious. Every comm shapes first impression.",
},
"at_risk_veteran": {
"name": "QX Internal: At-Risk Veteran",
"desc": "2+ years tenure, eNPS 4-6. Was once engaged. Something shifted. Institutional knowledge at risk.",
"mindset": "Nostalgic for how things were. Needs to see positive change, not hear about it.",
},
}
persona_ids = {}
for seg_key, profile in PERSONA_PROFILES.items():
members = segments.get(seg_key, [])
if not members:
continue
depts = list({m.get("Department", "") for m in members if m.get("Department")})[:5]
p = requests.post(f"{MB}/personas", headers=MV_H, json={
"name": profile["name"],
"description": f"{profile['desc']} N={len(members)}. Departments: {', '.join(depts[:3])}.",
"psychographic": {"mindset": profile["mindset"], "engagement_tier": seg_key},
})
p.raise_for_status()
persona_ids[seg_key] = p.json()["id"]
time.sleep(0.3)
# 4. Test internal communication
DRAFT_COMMS = [
{"type": "Policy Change", "text": "Starting Q2, we're moving to a hybrid model: 3 days in-office, 2 remote. This balances collaboration and flexibility based on team feedback."},
{"type": "Benefit Update", "text": "We're expanding mental health coverage to include 12 therapy sessions/year (up from 6) and adding a $500 wellness stipend."},
{"type": "Reorg Announcement", "text": "To accelerate our enterprise strategy, we're combining the Sales and CS teams under a new Chief Revenue Officer starting April 1."},
]
for comm in DRAFT_COMMS:
fg = requests.post(f"{MB}/focus-groups", headers=MV_H, json={
"name": f"Internal Comms: {comm['type']}",
"persona_ids": list(persona_ids.values()),
"context": f"Your company just sent this internal announcement:\n\n\"{comm['text']}\"",
"questions": [
"What is your immediate reaction to this announcement?",
"What questions does this raise that aren't answered?",
"How does this affect your engagement or morale?",
"If you were writing this announcement, what would you change?",
],
"responses_per_persona": 2,
}).json()
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"\n{'='*50}")
print(f"COMMS TEST: {comm['type']}")
print(f"{'='*50}")
for resp in result.get("responses", []):
print(f"\n[{resp.get('persona_name', '?')}] {resp.get('question', '')[:50]}...")
print(f" → {resp.get('answer', '')[:250]}")
const QT = process.env.QUALTRICS_TOKEN;
const DC = process.env.QUALTRICS_DC;
const MV = process.env.MAVERA_API_KEY;
const Q_BASE = `https://${DC}.qualtrics.com/API/v3`;
const MB = "https://app.mavera.io/api/v1";
const qH = { "X-API-TOKEN": QT, "Content-Type": "application/json" };
const mvH = { Authorization: `Bearer ${MV}`, "Content-Type": "application/json" };
const SURVEY_ID = process.env.ENPS_SURVEY_ID || "SV_xxxxx";
// 1. Export + parse (same async pattern)
const JSZip = (await import("jszip")).default;
const exp = await fetch(`${Q_BASE}/surveys/${SURVEY_ID}/export-responses`,
{ method: "POST", headers: qH, body: JSON.stringify({ format: "csv" }) }).then(r => r.json());
let fileId = null;
for (let i = 0; i < 60; i++) {
await new Promise(r => setTimeout(r, 5000));
const s = await fetch(`${Q_BASE}/surveys/${SURVEY_ID}/export-responses/${exp.result.progressId}`,
{ headers: qH }).then(r => r.json());
if (s.result?.percentComplete === 100) { fileId = s.result.fileId; break; }
}
const zipBuf = await fetch(`${Q_BASE}/surveys/${SURVEY_ID}/export-responses/${fileId}/file`,
{ headers: qH }).then(r => r.arrayBuffer());
const zip = await JSZip.loadAsync(zipBuf);
const csvFile = Object.keys(zip.files).find(n => n.endsWith(".csv"));
const csvText = await zip.files[csvFile].async("string");
const lines = csvText.split("\n");
const hdrs = lines[0].split(",").map(h => h.replace(/"/g, "").trim());
const responses = lines.slice(3).filter(l => l.trim()).map(line => {
const vals = line.match(/(".*?"|[^,]*)/g) || [];
const obj = {};
hdrs.forEach((h, i) => { obj[h] = (vals[i] || "").replace(/"/g, "").trim(); });
return obj;
}).filter(r => r.Finished === "1");
// 2. Segment
const segs = { engaged_advocate: [], passive_performer: [], quiet_quitter: [], new_hire: [], at_risk_veteran: [] };
const ENPS_COL = process.env.ENPS_COLUMN || "eNPS";
const TENURE_COL = process.env.TENURE_COLUMN || "Tenure_Months";
for (const r of responses) {
const enps = parseFloat(r[ENPS_COL] || "0");
const tenure = parseFloat(r[TENURE_COL] || "12");
if (isNaN(enps)) continue;
if (tenure < 6) segs.new_hire.push(r);
else if (enps >= 9) segs.engaged_advocate.push(r);
else if (enps >= 7) segs.passive_performer.push(r);
else if (enps >= 4 && tenure > 24) segs.at_risk_veteran.push(r);
else segs.quiet_quitter.push(r);
}
// 3. Create personas
const PROFILES = {
engaged_advocate: { name: "QX: Engaged Advocate", desc: "eNPS 9-10. Enthusiastic.", mindset: "Invested, energized" },
passive_performer: { name: "QX: Passive Performer", desc: "eNPS 7-8. Doing well but not engaged.", mindset: "Content, disengaged" },
quiet_quitter: { name: "QX: Quiet Quitter", desc: "eNPS 0-6. Minimum effort.", mindset: "Disillusioned, skeptical" },
new_hire: { name: "QX: New Hire", desc: "<6 months. Still forming opinions.", mindset: "Observant, cautious" },
at_risk_veteran: { name: "QX: At-Risk Veteran", desc: "2+ years, declining engagement.", mindset: "Nostalgic, needs change" },
};
const personaIds = {};
for (const [key, profile] of Object.entries(PROFILES)) {
if (!segs[key].length) continue;
const p = await fetch(`${MB}/personas`, { method: "POST", headers: mvH,
body: JSON.stringify({ name: profile.name, description: `${profile.desc} N=${segs[key].length}.`,
psychographic: { mindset: profile.mindset, tier: key } }),
}).then(r => r.json());
personaIds[key] = p.id;
await new Promise(r => setTimeout(r, 300));
}
// 4. Test comms
const COMMS = [
{ type: "Policy Change", text: "Starting Q2, hybrid model: 3 days office, 2 remote." },
{ type: "Benefit Update", text: "Expanding mental health coverage to 12 sessions/year + $500 wellness stipend." },
{ type: "Reorg", text: "Combining Sales and CS under a new CRO starting April 1." },
];
for (const comm of COMMS) {
const fg = await fetch(`${MB}/focus-groups`, { method: "POST", headers: mvH,
body: JSON.stringify({
name: `Internal: ${comm.type}`, persona_ids: Object.values(personaIds),
context: `Company announcement:\n"${comm.text}"`,
questions: [ "Immediate reaction?", "What questions aren't answered?",
"How does this affect your engagement?", "What would you change about this announcement?" ],
responses_per_persona: 2,
}),
}).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(`\n${"=".repeat(50)}\nCOMMS: ${comm.type}\n${"=".repeat(50)}`);
for (const resp of result.responses || [])
console.log(`[${resp.persona_name}] ${(resp.answer || "").slice(0, 200)}\n`);
}
Example Output
==================================================
COMMS TEST: Reorg Announcement
==================================================
[QX: Engaged Advocate] What is your immediate reaction?
→ I trust leadership's judgment, but I want to understand what
"accelerate enterprise strategy" means for my team specifically.
Who's my new manager? What happens to my current projects?
[QX: Quiet Quitter] What is your immediate reaction?
→ Another reorg. Third in 18 months. The last one was supposed to
"accelerate growth" too. I'll believe it when I see it. Until
then, I'm updating my LinkedIn.
[QX: New Hire] How does this affect your engagement?
→ I literally just learned everyone's names. Now the org chart is
changing? This makes me wonder if the company is stable. I need
my manager to explain what this means for my role specifically.
[QX: At-Risk Veteran] What would you change?
→ Lead with "here's what stays the same." I need to know my job,
my team, and my projects are safe before I can process the big
picture strategy. Also — who decided this? Was anyone consulted?
Error Handling
eNPS column naming
eNPS column naming
Employee surveys use custom column names. Set
ENPS_COLUMN and TENURE_COLUMN env vars to match your survey. Default: eNPS and Tenure_Months.Small segment sizes
Small segment sizes
Some segments (e.g., At-Risk Veteran) may have fewer than 10 members. Personas still work — they represent the archetype, not the statistical average.
Sequential focus groups
Sequential focus groups
Testing 3 announcements × 5 personas sequentially takes 5-10 minutes. For faster results, reduce to the 3 most critical personas per announcement.