Parlane

← Build

Give your agent a voice

September 1, 2026

Your backend from the Express guide chats in text. This guide makes it talk. There are three honest ways to do that, in order of effort, and the first one costs your server nothing: no new endpoint, no model, no audio code. Most backends stop there.

The three paths, side by side:

Path Server work Tier Where reply text goes
Device voice One manifest line Free Nowhere new; speech runs on the phone
Parlane voice None Pro, with a 15-minute free trial A Parlane-operated service
Your own /tts One endpoint Pro to consume Your server only

1. Declare voice, get voice

Voice rides the chat stream. The app captures your speech, turns it into text on the phone, and sends that text to the same POST /chat endpoint you already serve. The reply streams back as SSE and the phone speaks it with the device voice. Your server cannot tell a spoken turn from a typed one, which is the point: if you implemented chat, voice already works.

The one thing your server does is declare the surface, because the app hides tabs a manifest doesn’t declare. Add a single line to src/manifest.js from the Express guide:

// src/manifest.js
surfaces: {
  chat: { endpoint: 'rest', attachments: false },
  voice: {},
  dashboards: [
    { id: 'home', title: 'Home', source: '/ui/home', refresh: { mode: 'manual' } },
  ],
},

An empty object is a complete declaration. The defaults are mode: "ptt" (push-to-talk and tap-to-toggle), stt: "device", and tts: "device", and v1 accepts no other values for the first two. Redeploy, then foreground the app; if the Voice tab doesn’t appear, quit and relaunch, since the manifest cache refreshes at most once per app every five minutes.

Two properties of this path worth stating plainly:

  • Speech-to-text runs on the device. Your audio never leaves the phone; the server receives plain text.
  • It’s free, for you and for your users. Device voice is part of the free tier, hands-free conversation included.

The cost is the voice itself: you get the platform synthesizer, which is serviceable and unmistakably robotic. Paths 2 and 3 exist to fix that one thing.

How a turn works

The same loop drives all three paths; only the speaker changes.

You talk, and a live transcript appears as the on-device recognizer works. When you stop, the app ends your turn (a short silence window) and posts the transcript to /chat. The reply streams back as text, and the app splits it into sentences and speaks them as they arrive, so speech starts before the reply finishes.

Turn-taking supports barge-in: speak over the reply and it yields. The voice stops, the words the recognizer already caught stay in your transcript, and the turn is yours. Echo cancellation keeps the mic from transcribing the phone’s own voice while both are live. If you’d rather hold the button and take strict turns, turn on Classic turn-taking in Settings > Voice.

Every voice exchange persists to the chat thread, so a conversation you had out loud is still there to scroll and search.

2. Turn on Parlane voice

For a natural voice with zero backend changes, there’s Parlane voice, a hosted option the user turns on, not the developer. When it’s active for an app that declares no voice.tts of its own, the app sends each reply sentence to a Parlane-operated service that synthesizes it with Amazon Polly neural voices (Joanna, Matthew, Ruth, or Stephen) and returns audio. Nothing changes in your manifest, and there’s nothing to deploy.

Be clear about what that means for privacy, because it’s the one path where reply text leaves the user’s device for a Parlane server. The service processes text in memory and doesn’t log or store it; its logs carry character counts, latency, and a hashed user id, never content. The service can see the text it synthesizes. Similarly, the push relay can see visible notification previews even when an additional payload is encrypted. The privacy policy explains these data flows. To keep speech generation on your own infrastructure, use path 3.

The economics: anyone can try it free for 15 minutes of speech, once. After that it’s a Pro feature with an allowance of about 4.6 hours of spoken replies per month, and a $4.99 recharge adds roughly 2.8 more hours when a month runs long (see pricing). Running out mid-reply costs quality, never sound: the reply finishes with the device voice.

Users control it per app in the app’s settings, where it’s on by default for Pro subscribers when the connected app hosts no voice of its own.

3. Host the voice yourself

The third path puts a text-to-speech endpoint on your own server, so reply text reaches your infrastructure and nowhere else. This part of the contract is experimental: it’s additive and won’t break existing manifests, but the shape may still change.

Declare it in your manifest’s voice surface:

"voice": {
  "tts": {
    "mode": "server",
    "endpoint": "/tts",
    "format": "wav",
    "voices": ["af_heart", "af_bella", "am_michael"]
  }
}

format accepts "wav" or "mp3" (the default). voices is optional; omit it if your endpoint has one fixed voice.

The wire contract is one POST, carrying the same bearer auth as your other endpoints. The app chunks replies itself and sends one sentence per call, prefetching one ahead of playback:

{ "text": "One sentence of the reply.", "voice": "af_heart" }

voice is optional and only ever one of your declared voices; default sensibly when it’s absent. Respond with 200 and a complete audio file for that text, with a Content-Type of audio/wav or audio/mpeg matching your declared format. There is no streaming contract. Keep per-sentence synthesis under a second; the client aborts at 10 seconds.

Failure semantics make a broken endpoint invisible: any non-200, timeout, or network error finishes the reply with device TTS and retries your server on the next reply. Failures cost quality, never sound. The audible symptom is a voice that pauses and comes back robotic mid-reply, which means your endpoint errored or ran slow.

A complete server in one file

The reference implementation runs Kokoro-82M (Apache-2.0) through kokoro-js: pure Node, no Python, no GPU, and real-time on CPU. The quantized weights are about 90 MB.

server.js
// server.js, quoted from docs/06 §5.6. Copy it as-is
// (npm install express kokoro-js), run it as its own service, or mount the
// /tts handler in your backend behind your existing auth.
import express from 'express';
import { KokoroTTS } from 'kokoro-js';

const PORT = Number(process.env.PORT) || 8880;
const AUTH_TOKEN = process.env.AUTH_TOKEN || ''; // set = same bearer as your chat endpoints
const VOICES = ['af_heart', 'af_bella', 'am_michael'];
const DEFAULT_VOICE = 'af_heart';
const MAX_TEXT_LENGTH = 2000;

// Kokoro-82M, Apache-2.0, pure Node ONNX, real-time on CPU. The q8 weights
// (~90 MB) download and cache on first start; restarts are instant.
const tts = await KokoroTTS.from_pretrained('onnx-community/Kokoro-82M-v1.0-ONNX', { dtype: 'q8' });

const app = express();
app.use(express.json({ limit: '64kb' }));
app.use((req, res, next) => {
  if (!AUTH_TOKEN) return next(); // open; acceptable on private networks only
  const [scheme, token] = (req.get('authorization') || '').split(' ');
  if (scheme === 'Bearer' && token === AUTH_TOKEN) return next();
  res.status(401).json({ error: { code: 'unauthorized', message: 'Missing or invalid bearer token' } });
});
app.get('/healthz', (req, res) => res.json({ ok: true }));
app.post('/tts', async (req, res) => {
  const { text, voice } = req.body || {};
  if (typeof text !== 'string' || !text.trim()) {
    return res.status(400).json({ error: { code: 'bad_request', message: 'Body must be {"text": "..."}.' } });
  }
  if (text.length > MAX_TEXT_LENGTH) {
    return res.status(400).json({ error: { code: 'text_too_long', message: `Max ${MAX_TEXT_LENGTH} chars per request.` } });
  }
  try {
    const audio = await tts.generate(text, { voice: VOICES.includes(voice) ? voice : DEFAULT_VOICE });
    res.set('Content-Type', 'audio/wav');
    res.set('Cache-Control', 'no-store');
    res.send(Buffer.from(audio.toWav()));
  } catch {
    res.status(500).json({ error: { code: 'tts_failed', message: 'Synthesis failed.' } });
  }
});
app.listen(PORT, () => console.log(`voice-tts listening on :${PORT}`));

Run it and listen to it:

npm install express kokoro-js
AUTH_TOKEN=<your-token> node server.js
smoke test
curl -s -X POST localhost:8880/tts \
  -H 'Authorization: Bearer <your-token>' -H 'Content-Type: application/json' \
  -d '{"text": "Hello from my own server."}' -o out.wav && afplay out.wav

The in-repo version, examples/voice-tts/, adds a Dockerfile that bakes the weights in at build time, plus notes on mounting the handler in-process instead of running a sidecar. Either way, never log the text field. It’s the user’s conversation.

The honest hardware note

CPU real-time is true, but only on real cores. A 2 vCPU / 1 GB box synthesizes a sentence faster than it plays, and the one-ahead prefetch hides the rest. A small burstable VPS does not get there: on a t4g.small-class instance we measured about 5 seconds per sentence, which the prefetch can’t hide, so every reply starts with dead air and stutters between sentences. If your server is that small, use the device voice or the hosted voice instead of shipping a bad one.

Consuming a server voice in the app is a Pro feature, like the hosted voice. The device path stays free.

Which voice speaks

When a reply arrives, the app resolves the speaker in a fixed order:

  1. A declared voice.tts server endpoint, when the user’s Server voice setting for that app is on (the default).
  2. Parlane voice, for Pro users who have it on and only for apps that declare no voice.tts.
  3. The device voice, which is the free default and the fallback for both paths above.

One consequence is deliberate: a declared server TTS always overrides the hosted voice, even when the endpoint is down. A developer who self-hosts has opted their users’ reply text out of Parlane-hosted processing, and the app respects that opt-out instead of quietly routing around an outage. A failing endpoint falls back to the device voice, never to the hosted service.

What you built

Possibly nothing, which was the promise:

  • voice: {} in the manifest gives every user of your backend a spoken conversation loop with barge-in, free, with audio that never leaves the phone.
  • Parlane voice upgrades how replies sound with no server work, in exchange for a disclosed, policy-based privacy posture.
  • A self-hosted /tts endpoint (about 50 lines with Kokoro-82M) keeps reply text on your own infrastructure and takes precedence over the hosted voice by design.

To wire chat, and with it voice, into the backend you already have, use the prompt below with your coding agent.

The prompt

Working on your own backend instead of the example? Paste this into your coding agent. It carries the whole contract, so the agent can wire your endpoints and check its work against the hosted schemas.

paste into your coding agent
Make my app Parlane-ready.

Parlane is a universal native mobile client. It renders my backend natively — chat,
voice, dashboards, push — from plain JSON I serve. There is no SDK. I implement a tiny
contract; any of the following it can already do (MCP) it reuses.

Implement chat unless I explicitly tell you not to. Chat is the core surface: voice rides
on it with no extra server work (speech runs on-device), and a connected app without chat
feels broken on first open. Wire /chat to whatever conversational or agent capability my
backend has; if it truly has none, ask me before omitting it, and then leave the chat
surface undeclared in the manifest so the client hides those tabs.

Do this:
1. Serve an app manifest at /.well-known/parlane.json (or an MCP resource
   "parlane://manifest"). Declare only the surfaces I actually support. All fields are
   optional. Validate it against spec/manifest.schema.json.
   Ask me about the look: accent, colors, dark/light — declare a theme in the manifest to
   skin my app's screens.

2. If I don't already speak MCP, add the REST fallback:
   - POST /chat {conversation_id, message, attachments?} -> Server-Sent Events with these
     event types: `delta` {text}, `tool` {tool,status,label?}, `done` {conversation_id,
     message_id?}, `error` {code,message}. End with exactly one `done` or `error`.
   - GET /ui/{dashboard_id} -> a UI document (spec/ui-document.schema.json): a tree from the
     17-type catalog {stack, card, text, metric, chart, table, list, image, button, input,
     toggle, slider, select, progress, badge, divider, map}. version:1, single root, max
     depth 12, max 500 nodes. Full-replace only — no patch format.
   - POST /action {action_id, tool, params, dashboard_id?} -> {ui} (a full replacement UI
     document) or {ack:true}. Interactive components carry an action envelope
     {tool, params?, confirm?, refresh:"self"|"none"|<dashboardId>}; inputs bind values by a
     `bind` name that I merge into params.

3. Require Authorization: Bearer <token> on every request. Optionally print a connect QR
   encoding parlane://connect?url=...&token=...
   (Advanced, MCP transport only — skip unless I ask: to require OAuth 2.1 instead of a
   static token, gate the MCP endpoint so an unauthenticated request returns 401 with a
   WWW-Authenticate protected-resource pointer; the client then runs the full OAuth login
   itself — discovery, dynamic client registration, PKCE, and refresh. See §6a.)

4. For push (optional, but this is exactly how to turn it on): to enable push notifications you MUST
   declare surfaces.push { relay: true } in the manifest. Without that flag there is no push. That
   one flag wires the app to the Parlane-operated hosted relay automatically; do NOT set
   relayUrl, the hosted relay is the default (only set it to override the host, which is rare).
   Preferred pairing: pre-mint a pairing token (POST /pair/prepare) and put it plus the relay in the
   connect QR (parlane://connect?...&relay=...&pair=...) so a single scan connects AND pairs
   the relay with zero extra user steps; if my server does not pair at connect, the app offers a
   manual pairing fallback. CRITICAL for one-scan: after the app redeems the QR token my server never
   saw the phone's public key, so poll GET /pair/prepare/status?pairToken=... until it returns
   { state:"REDEEMED", appPubKey } and derive the session key from that appPubKey before sending any
   encrypted envelope. Trigger notifications with POST /notify carrying an encrypted payload
   plus a visible apnsHint matching spec/push.schema.json; no persistent WebSocket is needed
   for push. The relay and Apple can read apnsHint, and the current iOS app does not decrypt
   the sealed payload into a private preview. Use generic hints for sensitive events.
   I never supply Apple credentials; Parlane owns the only APNs key
   for its app and a self-hosted relay cannot deliver push. Test push against a TestFlight build
   (production APNs), not a dev build.

5. For the share sheet (optional): declare surfaces.share { accepts: [kinds I handle], tool }.
   The app only offers my app for the kinds I list — include "image"/"file" if I ingest
   screenshots or documents, or I am never offered for them. My share.tool receives
   params.attachments[] (images/files as base64 data, per spec/attachment.schema.json) plus flat
   note/url/text. Raise the tool endpoint's body limit to >=20 MB (nginx client_max_body_size,
   framework JSON limits) — base64 adds ~33% over the 10 MB cap, so a default 1 MB limit 413s on a
   screenshot — and enforce the 10 MB decoded cap server-side.
   If I only handle some file types, add surfaces.share.acceptsMimes (e.g. ["image/*","application/pdf"])
   so the app pre-filters unsupported types before upload; and reject bad shares with the standard codes
   {error:{code,message,details?}} — unsupported_kind / unsupported_mime {mime,accepted?} / attachment_too_large {maxBytes} — so failures tell the user what to fix.

Constraints: dashboards are declarative data, never code. Keep all user-visible copy in the
JSON I serve. Follow the schemas in spec/ exactly and test against spec/examples/.

Get Parlane on your phone

Request TestFlight accessOn TestFlight now, App Store at launch