Hindi Daily Word — Setup & Operations Runbook¶
This guide is for engineers onboarding a new tutoring organisation to the Hindi Daily Word feature, or maintaining the existing Hindi-Tutoring tenant. It covers setup end-to-end, day-to-day operations, and how to extend the feature in future.
For the architecture and behaviour reference, see Hindi Daily Word feature.
Prerequisites¶
Before starting, ensure you have:
- [ ] SSH access to the staging VPS (
94.136.188.221) — and prod VPS (89.116.31.109) when going live - [ ] Editor admin login for the target org
- [ ] Firebase Admin SDK service account JSON (already mounted on the VPSes — same file the editor uses)
- [ ] Google AI / Gemini API key with Live API enabled
- [ ] An assigned DID for the org (Tata NNI or other) — this is the outbound caller_id for the daily call
- [ ] MSG91 account with WhatsApp Business templates approved (success template required, missed template optional)
- [ ] Hindi word list (CSV or JSON) covering the curriculum
Step 1: Create the Tutoring Organisation¶
Why: Each tutoring tenant is fully isolated — its learners, words, lesson queue, call logs, and progress live under one Firestore root keyed by the org id, and its outbound calls use the org's own DID, trunk, and MSG91 credentials.
What: A standard AstraPBX organisation, plus a Firestore document tree under astradial/<org_id> that the editor and bot read.
How:
Follow Onboard a New Client up to and including the outbound route step. You need:
- Org id, API key
- A SIP trunk (Tata NNI via NUC, or whatever the customer's provider is)
- An outbound route that allows the trunk to dial Indian mobile numbers
- A DID assigned to the org (this becomes the outbound caller_id on the daily call — when the learner calls back, the IVR should route the return call to a human or queue)
The Hindi-Tutoring reference org is 9dede1af-c88d-454d-b2a5-d837788a71c8 with DID +918065978021.
Step 2: Seed the Firestore Word Bank¶
Why: The daily-builder cron picks tomorrow's word per learner using SM-2 spaced repetition over this word bank. Without words, the cron skips every learner with skipped_no_words.
What: A words subcollection under the org root. Each doc represents one teachable word.
How:
The minimal word document shape (Devanagari + transliteration + meaning + example sentence in both languages):
{
"hindi_word": "नमस्ते",
"transliteration": "namaste",
"english_meaning": "hello / greetings",
"example_sentence_hindi": "नमस्ते, आप कैसे हैं?",
"example_english": "Hello, how are you?",
"level": 1,
"active": true,
"created_at": "2026-04-26T00:00:00Z"
}
Bulk-seed via the firebase-admin SDK from any machine that has the service account key mounted. There is no editor UI for word management yet — adding one is on the future-work list. For now, run a one-off Node script:
// scripts/seed-hindi-words.js
const admin = require("firebase-admin");
admin.initializeApp({ credential: admin.credential.cert(require("./firebase-sa-key.json")) });
const db = admin.firestore();
const ORG_ID = "9dede1af-c88d-454d-b2a5-d837788a71c8";
const words = require("./hindi-words.json"); // your CSV-converted list
(async () => {
const root = db.collection("astradial").doc(ORG_ID).collection("words");
for (const w of words) {
await root.add({ ...w, active: true, created_at: new Date() });
}
console.log(`Seeded ${words.length} words`);
})();
Verify in Firestore: astradial/<org_id>/words/ should now have one doc per word.
Step 3: Configure Per-Org Settings in MySQL¶
Why: The bot's WhatsApp recap reads MSG91 credentials from the org's settings JSON column in MySQL — same source the ticket system uses. Without these, the recap step logs would_send_to=... and skips.
What: Two values in the organizations.settings JSON:
| Key | Used by | Source |
|---|---|---|
msg91_authkey | Hindi WhatsApp recap, ticket system | MSG91 dashboard → API & Webhooks → Auth Key |
whatsapp.sender_number | Hindi WhatsApp recap, ticket system | MSG91 dashboard → WhatsApp → Integrated Number |
How:
The PUT /api/v1/settings/msg91 endpoint silently no-ops
Sequelize's auto-generated PUT for nested JSON columns returns configured: true but does not actually persist the change. Use a direct MySQL update:
Substitute <MSG91_AUTH_KEY> with the real value
Real key is in macOS Keychain (msg91-hindi-tutoring-authkey). Never commit a real authkey to this doc — see docs/reference/credentials.md.
-- set authkey
UPDATE organizations
SET settings = JSON_SET(settings, '$.msg91_authkey', '<MSG91_AUTH_KEY>')
WHERE id = '9dede1af-c88d-454d-b2a5-d837788a71c8';
-- set sender number (the integrated number registered with MSG91)
UPDATE organizations
SET settings = JSON_SET(settings, '$.whatsapp', JSON_OBJECT('sender_number', '15558897024'))
WHERE id = '9dede1af-c88d-454d-b2a5-d837788a71c8';
Verify the editor reads it back:
curl -X POST 'https://stagepbx.astradial.com/api/v1/settings/msg91/key' \
-H 'X-Internal-Key: <INTERNAL_API_KEY>' \
-H 'Content-Type: application/json' \
-d '{"org_id":"9dede1af-c88d-454d-b2a5-d837788a71c8"}'
# expect: {"authkey":"<key-prefix>..."}
Step 4: Approve MSG91 WhatsApp Templates¶
Why: WhatsApp Business requires Meta-approved templates for all outbound non-session messages. The recap sender uses positional template variables; field order must match the dashboard exactly.
What: Two templates in the MSG91 dashboard. Both share a 6-variable positional body.
| Template name | Outcome | Status |
|---|---|---|
hindi_daily_lesson_summary | Learner repeated the word correctly | Required (default for success path) |
hindi_daily_lesson_missed | Learner hung up early or never picked up | Optional — if missing, missed-path logs and skips |
Body variables (must be in this order):
- Learner first name
- Hindi word in Devanagari
- Transliteration
- English meaning
- Example sentence (Hindi)
- Example sentence (English)
How:
- Log in to
control.msg91.com→ WhatsApp → Templates → Create Template. - Category: Marketing or Utility (Marketing is faster to approve for these).
- Body example (success template):
- Submit for Meta approval. Approval typically takes 1–24 hours.
- Note the template namespace (shown in dashboard); this goes into
MSG91_HINDI_TEMPLATE_NAMESPACEenv var.
The template name + namespace are referenced by the editor's whatsapp route (editor/app/api/hindi/bot/whatsapp/route.ts) — no code change needed when creating new templates as long as the env var points at the right name.
Step 5: Register the Pipecat Tutor Bot¶
Why: The bot module hindi_teacher is a Python module under pipecat-flow/bots/, not a JSON flow. The editor's bot-builder UI only handles JSON flows, so module bots must be registered via the admin API.
What: A row in the pipecat_bots table that points at the hindi_teacher module path and selects the LLM voice/model.
How:
curl -X POST 'https://stagebots.astradial.com/admin/orgs/9dede1af-c88d-454d-b2a5-d837788a71c8/bots' \
-H 'Authorization: Bearer <PIPECAT_ADMIN_TOKEN>' \
-H 'Content-Type: application/json' \
-d '{
"name": "Hindi Teacher",
"module_path": "hindi_teacher",
"voice": "Puck",
"llm_model": "gemini-3.1-flash-live-preview",
"llm_provider": "google"
}'
The response includes a bot_id UUID. Save it — Step 6 wires this bot to the workflow.
The reference Hindi-Tutoring bot id is 34feab4b-8d30-42a3-86de-6cd25453e2b6.
Step 6: Create the Workflow Template¶
Why: The daily-builder cron doesn't dial directly. It POSTs to /trigger/<workflow_id> and the workflow engine schedules a place_call step at the learner's preferred_call_time. This decouples scheduling from dialling and lets us reuse the workflow engine's retry, rate-limit, and concurrency machinery.
What: One workflow per tutoring org with two steps:
- Webhook trigger — receives
{ phone, learner_id, scheduled_call_time }from the daily-builder. - place_call — dials the phone via the org's outbound trunk and bridges to the bot's WSS URL.
How:
Use the JSON template at workflow-engine/templates/hindi_daily_lesson.json as the starting point. Update these fields for your org:
| Field | Value |
|---|---|
org_id | The new org's UUID |
place_call.bot_id | The pipecat_bots.id from Step 5 |
place_call.caller_id | The org's DID (e.g., +918065978021) |
place_call.timing | scheduled (so the workflow waits until scheduled_call_time) |
POST to /api/v1/workflows:
curl -X POST 'https://stagepbx.astradial.com/api/v1/workflows' \
-H 'X-API-Key: <ORG_API_KEY>' \
-H 'Content-Type: application/json' \
-d @hindi_daily_lesson.json
Save the response id — this is your <workflow_id>. The reference workflow id is c237e8bb-e7bc-406e-99e0-adb7668fb764.
Step 7: Wire the Workflow to the Daily-Builder¶
Why: The daily-builder needs to know which workflow to trigger per org. Currently this is one-to-one (the cron auto-discovers the workflow named Hindi Daily Lesson under the org), but you can override via env if multiple workflows exist.
What: Two env vars on the workflow-engine VPS.
How:
Edit /opt/workflow-engine/.env on the staging VPS:
HINDI_ORG_IDS=9dede1af-c88d-454d-b2a5-d837788a71c8,<new-org-id>
HINDI_INTERNAL_API_KEY=<INTERNAL_API_KEY matching editor's value>
Then restart:
The daily-builder cron (00:00 IST) and missed-sweeper cron (every 5 min IST) both read HINDI_ORG_IDS to know which orgs to process.
Step 8: Configure the Editor¶
Why: The "Learn" sidebar entry, learner CRUD, and Call-now button all live in the editor. The sidebar is currently hard-scoped to the Hindi-Tutoring org id; for a second tutoring org, generalise.
What: Two env vars + (for a second org) a code change.
How:
/opt/pipecat-flow-editor/.env on the staging VPS:
INTERNAL_API_KEY=<same as workflow-engine>
WORKFLOW_ENGINE_URL=http://127.0.0.1:7860
MSG91_HINDI_SUCCESS_TEMPLATE=hindi_daily_lesson_summary
MSG91_HINDI_MISSED_TEMPLATE=hindi_daily_lesson_missed
MSG91_HINDI_TEMPLATE_NAMESPACE=ab7728b6_9e3c_4160_b51e_958e57f151e0
For a second tutoring org, edit editor/components/layout/app-sidebar.tsx — change the hardcoded id check to either a list or a per-org feature flag stored in MySQL organizations.settings.
Rebuild + restart:
Step 9: Add Learners¶
Why: No learners → daily-builder skips org. This is also the only step the org admin will need to do regularly after onboarding.
What: One Firestore doc per learner under astradial/<org_id>/users/, plus optional progress docs created lazily as lessons run.
How — preferred path (UI):
- Log in to the editor as an admin of the tutoring org.
- Navigate to
https://stageeditor.astradial.com/dashboard/<org_id>/learn. - Click "Add learner". Fill: name, phone (any format — the server normalises), preferred_call_time (HH:MM IST), consent checkbox.
- The learner appears as
active: trueimmediately.
How — programmatic path:
curl -X POST 'https://stageeditor.astradial.com/api/hindi/learners' \
-H 'Cookie: <admin session cookie>' \
-H 'Content-Type: application/json' \
-d '{
"org_id": "9dede1af-c88d-454d-b2a5-d837788a71c8",
"name": "Test Learner",
"phone_number": "+919042321214",
"preferred_call_time": "09:00",
"consent_given": true
}'
The phone is normalised to 10-digit local before insertion. 11/12-digit forms with country code or leading zero are accepted.
Verification — End-to-End Smoke Test¶
After all steps above, verify with the "Call now" path (which doesn't wait until 00:00 IST):
- Open the editor at
/dashboard/<org_id>/learn. - Click the kebab menu on a learner → "Call now".
- Within ~30 seconds the learner's phone should ring. Pick up.
- Bot greets in Hindi, teaches one word, prompts for repetition.
- Repeat the word correctly. Bot says "Shabaash! Great job. We'll learn another word tomorrow. Happy learning!" then hangs up within ~3 seconds.
- WhatsApp recap arrives within 5–10 seconds of hangup.
Check progress was recorded:
# Firestore: astradial/<org_id>/lesson_queue/<today's row>.status = "completed"
# Firestore: astradial/<org_id>/progress/<learner_id>.last_word_id = the word taught
# Editor call-logs page shows one new entry tagged "hindi-tutor"
Day-to-day Operations¶
Adding/removing learners¶
Use the editor's Learn page. Deactivating a learner immediately stops them being picked by the next daily-builder run (the SM-2 selector filters on active: true). Reactivate via the kebab menu — they'll be back in tomorrow's queue.
Adding new Hindi words¶
Run the seed script in Step 2 against the same org. The daily-builder picks the next-untaught word per learner using SM-2's "due today, easiest first" ordering, so new words enter rotation naturally as old ones are mastered.
Pausing the daily call (e.g., learner on vacation)¶
Toggle active: false in the editor. They'll skip until reactivated. The hindi_lesson_queue table is not retroactively cleaned, but missed-sweeper marks any pending row as missed after its scheduled time.
Manually triggering the daily-builder¶
curl -X POST 'https://stage-workflow-engine.astradial.com/hindi/run-daily-queue' \
-H 'Content-Type: application/json' \
-d '{"org_id":"9dede1af-c88d-454d-b2a5-d837788a71c8"}'
Returns counts: inserted, skipped_existing, skipped_no_words, errors.
Manually triggering the missed-sweeper¶
Checking the Bull queue state¶
Look for lines like [Hindi] daily-builder running for 1 org(s): ... (00:00 IST) and [Hindi sweeper] org=... found=N sent=N (every 5 min).
Troubleshooting¶
Daily-builder ran but no calls dialled¶
Check workflow-engine logs for the org:
Likely causes:
| Symptom in logs | Cause | Fix |
|---|---|---|
skipped_no_words=N for every learner | Word bank empty | Run Step 2 seed |
inserted=N but no workflow trigger | Workflow id not found for org | Re-run Step 6 and confirm workflow exists in Postgres |
fireWorkflow error: 401 | HINDI_INTERNAL_API_KEY mismatch | Ensure editor and workflow-engine .env have the same value |
fireWorkflow error: 404 from NUC | Phone number not normalised | Confirm runDailyQueue.normalizeIndianPhone is stripping the + (commit 0c4f997 and after) |
Bot answers but no audio / cuts off mid-word¶
Check pipecat-flow logs:
Common: NUC's WireGuard tunnel went down — Tata accepts the call but RTP can't traverse. Check the NUC's tunnel status in Cloudflare Zero Trust dashboard. If "Down", recovery requires LAN access at the NUC site (cloud-side ops can't fix a downed tunnel). See NUC Runbook.
Bot keeps talking after praise / call doesn't hang up¶
The fix for this is flow_manager.transport._client.disconnect() after the praise frame, plus AstraPBX's bridge.destroy() + hangupChannel() in ws.on('close'). If symptoms recur:
ssh root@94.136.188.221
grep -n 'disconnect()' /opt/pipecat-flow/bots/hindi_teacher/__init__.py
grep -n 'bridges.destroy' /opt/astrapbx/src/services/asterisk/ariClient.js
Both should match the latest commits (4c2c80b and 5b55101 respectively).
WhatsApp recap not delivered¶
| Editor log message | Meaning | Fix |
|---|---|---|
would-send to=... — msg91 not configured for org | msg91_authkey or whatsapp.sender_number missing in MySQL | Re-run Step 3 — note the silent-no-op warning |
msg91 400: to_and_components received is Invalid | Wrong payload shape | The whatsapp route already uses the correct shape; check you haven't downgraded |
msg91 401 | Authkey wrong | Re-fetch from MSG91 dashboard, re-run Step 3 |
no missed template configured — skipping | MSG91_HINDI_MISSED_TEMPLATE env unset | Either set the env var or accept the missed path is silent until template is approved |
Learner can't be added via UI ("Missing or insufficient permissions")¶
Firestore client SDK writes are blocked by security rules. The editor falls back to POST /api/hindi/learners server-side (admin SDK). If this also errors:
- Confirm the route file exists:
editor/app/api/hindi/learners/route.ts - Confirm the admin SDK service account JSON is mounted on the editor VPS at the path the route reads (default:
/opt/pipecat-flow-editor/firebase-sa-key.json)
Workflow execution UI shows "failed" on success cases¶
Cosmetic only. The immediate-execution path marks timing="scheduled" place_call steps as deferred and the UI surfaces that as failed. Real call behaviour and Firestore state are correct. Tracked as known limitation in the feature page.
Going to prod¶
The setup above is for the staging VPS (94.136.188.221). To promote to prod:
- Repeat Step 3 (MSG91 settings) on prod's MySQL.
- Repeat Step 5 (register bot) against
gateway.astradial.cominstead ofstagebots.. - Repeat Step 6 (workflow) against
devpbx.astradial.cominstead ofstagepbx.. - Repeat Step 7 (env vars) on
/opt/workflow-engine/.envon prod (89.116.31.109). - Repeat Step 8 (editor env + rebuild) on prod.
- Approve the same MSG91 templates against the prod MSG91 account if it's different from staging.
Per the Operating principles, confirm with the user before any prod change and back up /opt/<app>/ and the affected MySQL rows first.
Future work¶
- Per-org sidebar flag. The Learn entry is currently hardcoded to the Hindi-Tutoring org id in
app-sidebar.tsx. Generalise to a per-org feature flag inorganizations.settingsso a second tutoring org can onboard without a code change. - Word-bank UI. Currently word management is firebase-admin-script-only. Adding a CRUD UI under the Learn section would let the org admin curate the curriculum themselves.
- Pre-warming the LLM session. Initial greeting has ~2–3 s latency from Gemini Live setup + first TTS token. Establishing the LLM session before the call answers would cut most of it; needs a gateway architecture change to decouple session start from media start.
- Retry on no-answer. Currently a missed call goes straight to the missed-WhatsApp path. Adding a single retry 30 min later (configurable per-org) would cover transient unavailability without nagging.