
Updated Sep 28, 2026. An earlier version of this post described the architecture I planned in the first week (an on-device Gemma model, Firestore, WebSockets). This version describes what actually shipped in v1.5.10 on Google Play.
The problem: WhatsApp voice notes
In Bocas del Toro, almost everything runs on WhatsApp: the water taxi, the plumber, the A/C technician, the landlord. And a lot of it happens in voice notes, not text. Local workers often don't read long messages; they listen.
For expats who only have textbook Spanish, that is hard. Translation apps give you formal Spanish text, and then you still have to read it out loud into your phone. PoquitoTalk skips that step: you type or say what you need in English, and it sends a natural Panamanian Spanish voice note to WhatsApp.
The second half is the reply. Most two-way translation apps expect both people to install the app. A boat captain at the tiller is not going to do that, so the contractor side had to work from a link in a browser.
What the system looks like
There are three parts, and none of them need a database server:
- The mobile app: Expo SDK 57 and React Native 0.86, published on Google Play.
- The contractor page: a single HTML page at
/talkthat the contractor opens from a WhatsApp link. No install, no account. - A small PHP API on the same shared LiteSpeed hosting as the website. It proxies translation and speech-to-text, stores voice notes as files, and keeps each walkie-talkie room as a JSON file.
Expat's phone (Expo app) Contractor's phone (any browser)
┌──────────────────────────┐ ┌──────────────────────────────┐
│ 1. Speak or type English │ │ /talk?r=room_xxxx │
│ 2. Speech-to-text │ │ Tap Poquito to talk │
│ 3. Translate + Panama │ └──────────────┬───────────────┘
│ dialect rules │ │
│ 4. Spanish voice (TTS) │ │
│ 5. Share to WhatsApp ───┼── link ──────────────────────▶│
└────────────┬─────────────┘ │
│ poll every 2.5 s │
▼ ▼
┌───────────────────────────────────────────────────────────────────────┐
│ PHP API on shared LiteSpeed hosting (poquitotalk.hero-apps.com/api) │
│ translate.php · transcribe.php · audio.php · walkie.php │
│ Rooms = JSON files · voice notes = audio files on disk │
└───────────────────────────────────────────────────────────────────────┘
Step 1: Speech-to-text with fallbacks
Island internet is unreliable and every speech API has bad days, so transcription tries up to four engines in order and stops at the first one that works:
// src/services/transcriptionService.ts (simplified)
// 1. ElevenLabs Scribe (scribe_v1)
// 2. Groq Whisper (whisper-large-v3), if a key is set
// 3. OpenAI Whisper (whisper-1), if a key is set
// 4. Own PHP proxy: /api/transcribe.php
// 5. Give up and let the user type
const BOCAS_WHISPER_PROMPT =
"Bocas del Toro, Isla Colón, Bastimentos, Carenero, Isla Solarte, " +
"Red Frog, Old Bank, Playa Bluff, Bocas Town, lancha, muelle Taxi 25, " +
"capitán, dólares, WhatsApp";
The prompt lists local place names and island words like lancha and muelle, so the engine expects them instead of guessing. After transcription, two small cleanup steps run: one removes stutters and repeats ("I, I need the the boat"), and one normalizes local place names.
Step 2: Translation with a phrase map and dialect rules
The first plan was an on-device LLM; the translation file was called gemma.ts until v1.5.11, when it became translation.ts. What shipped is simpler and more predictable for short service messages:
- A hand-written phrase map of about 85 common island requests (water pressure, Starlink outages, table reservations, plants for the garden), each with a natural Panamanian version.
- Language direction detection, so an English sentence never gets "translated" from Spanish to English by accident.
- Machine translation with fallbacks: MyMemory first, then my own PHP proxy, then Google Translate.
- Panamanian dialect rules that run on every result. Generic Spanish says barco or taxi acuático; in Bocas everyone says lancha.
// src/services/translation.ts: polishPanamaSpanish() (excerpt)
text = text.replace(/\bel\s+barco\b/gi, 'la lancha');
text = text.replace(/\btaxi\s+acuático\b/gi, 'lancha');
text = text.replace(/\bwater\s+taxi\b/gi, 'lancha');
text = text.replace(/\bwater truck\b/gi, 'camión cisterna');
text = text.replace(/\bwater tank\b/gi, 'tanque de reserva');
If every online service fails, a small local fallback still returns something usable, so the user is never left with an empty screen.
Step 3: A voice that sounds like a person
Spanish text becomes audio with ElevenLabs (eleven_multilingual_v2) in one of two voices: Diego (male) or Sofia (female). The user picks the voice that matches them. If ElevenLabs is unavailable, Google Cloud Text-to-Speech takes over.
The ready-made phrases in the Presets tab don't call any API at all. Their audio is generated once and served as files (over 300 clips on the server, plus a set bundled in the app), so the most common requests play instantly, even on a weak connection.
Step 4: A walkie-talkie the contractor can use without installing anything
When the expat starts a live conversation, the app creates a room ID and a link like poquitotalk.hero-apps.com/talk?r=room_…, which goes out in WhatsApp. The contractor taps it and sees Poquito with one instruction: "Toca a Poquito para hablar" (tap Poquito to talk). Their voice is recorded in the browser with the standard MediaRecorder API.
There are no WebSockets. Both sides post messages to walkie.php and poll for new ones every 2.5 seconds:
// talk.html: the contractor's side
async function pollServerMessages() {
const res = await fetch(
`/api/walkie.php?action=poll&room=${encodeURIComponent(roomId)}&since=${lastPollTimestamp}`
);
// ...append new messages, play incoming audio
}
setInterval(pollServerMessages, 2500);
On cheap shared hosting, polling is the boring option that works. Each room is a JSON file; each voice note is an audio file next to it.
Because live sessions cost real API money, they have limits. A session only starts counting when the first voice message is sent (opening the link does nothing), and then:
// src/services/walkieTalkie.ts
const SESSION_MAX_DURATION_MS = 15 * 60 * 1000; // 15 minutes max active session
const SESSION_INACTIVITY_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes silence auto-close
const SESSION_MAX_TURNS = 15; // 15 voice exchanges per session
Pricing that covers the API costs
Every voice note costs money to generate, so nothing is truly unlimited without a plan behind it. The current plans on Google Play:
| Plan | Price | What you get |
|---|---|---|
| Annual Explorer Pass | $39.99 / year (7 days free) | Unlimited voice notes and live sessions |
| Monthly Resident Pass | $9.99 / month | Unlimited voice notes and live sessions |
| 7-Day Travel Pass | $4.99, non-renewing | 100 voice notes and 20 live sessions |
| 50 Credits Pack | $4.99 once, never expires | 50 voice notes or 10 live sessions (1 live session = 5 credits) |
Subscriptions run through RevenueCat. The credits pack is also sold on a web funnel through Stripe, and web purchases are redeemed in the app through RevenueCat.
Small detail: WhatsApp link previews
WhatsApp caches link preview images for days. When I updated the logo, old previews kept showing up. The fix is a version number on the image URL:
<meta property="og:image" content="https://poquitotalk.hero-apps.com/logo_v6_og.png?v=1.0.4" />
Tech stack
- App: Expo SDK 57, React Native 0.86, expo-audio.
- Speech-to-text: ElevenLabs Scribe, with Groq Whisper, OpenAI Whisper and a PHP proxy as fallbacks.
- Translation: phrase map, MyMemory, Google Translate, and custom Panamanian dialect rules.
- Voices: ElevenLabs (Diego, Sofia), with Google Cloud Text-to-Speech as fallback.
- Backend: plain PHP on Namecheap LiteSpeed hosting. No database server.
- Payments: RevenueCat, Google Play Billing, Stripe.
What I'd tell other builders
- Ship the boring version. I planned an LLM, a cloud database and WebSockets. A phrase map, dialect rules, JSON files and polling shipped instead, and they work.
- Design for the person on the other end. The contractor never installs anything. That one decision shaped the whole walkie-talkie.
- Always have a fallback. On an island with spotty internet, every external API in this app has a second or third option behind it.
Follow the build on X: @DorienVibecodes