Inspiration

Therapy costs money most people don't have, and it runs on a waitlist most people can't wait through. I wasn't trying to replace a clinician. I wanted the thing that fills the gap between appointments, or before someone ever gets one. A place to say the hard thing out loud at 11pm without booking anything.

The "out loud" part is the whole idea. Typing at a chatbot is a different experience from hearing a voice wait for you to finish a sentence. So TherapyAI is voice first, and almost every hard problem in it came from that one decision.

What it does

You pick a coach. Each one has its own voice, persona, and speaking pace. You pick a session length. Then you talk. Solo, with a partner, or as a family, in English or Spanish.

Real voice sessions. Sub-second turn taking over WebRTC. The coach handles interruptions, tolerates silence, and doesn't talk over you.

It remembers. At the end of a session an analysis pass writes memory, insights, and an opener for next time. Session two doesn't start from zero. It picks up a thread.

Openers are never the same twice. The first line you hear is chosen from seeded, tone-annotated variants based on how your last session actually ended and how long it's been since. Someone coming back after six weeks gets a different greeting than someone who came back the next day.

Sessions end well. There's a wind-down, a paced sequence of cues that gets the conversation to a real close instead of a timer cutting someone off mid-sentence.

Insights from what you said. The dashboard shows patterns pulled from your own transcripts, not generic advice cards.

Text mode. If you can't talk, because you're in a quiet room or your mic is bad, the session runs on a text transport instead. It's billed by exchange rather than by the clock.

A directory of real humans. Nationwide licensed provider search built on public NPPES data, for when what someone needs is an actual clinician. Every filter lives in the URL and gets stripped before it reaches any analytics service.

Everywhere a user can read it, this is described as wellness coaching and not licensed clinical care.

How I built it

Next.js 15 App Router and React 19 on Vercel, TypeScript throughout, Tailwind v4. Prisma against Supabase Postgres. Clerk for auth, Stripe for billing, Upstash Redis for caching and locks.

The voice pipeline is Vapi orchestrating Deepgram for transcription, Claude for the conversation, and ElevenLabs for speech. The end-of-session analysis runs on a smaller model.

Every coach comes off exactly one config entry: name, portrait, preview clip, voice ID, speaking pace, and the per-language voice. Nothing else in the codebase is allowed to write a voice block. That way the voice you auditioned on a card can't differ from the one that shows up in the session.

Billing is counted in seconds rather than rounded minutes. Rounding a 6 minute 10 second session up to 7 minutes is just quietly overcharging people.

Challenges I ran into

Audio dropouts I couldn't fix. Sessions had intermittent gaps in the coach's speech. I built a tool that pulls the server-side audio and reports a dropout rate, then chased the cause through three candidates: streaming latency settings, chunk sizing on the way to text-to-speech, and a client-side de-clicker. Measurement closed all three. It turned out to be the vendor's frame writer zero-filling frames its source hadn't filled, which isn't something I can patch from my side. The useful outcome wasn't a fix. It was a repeatable measurement that turned "it sounds a bit off sometimes" into a number.

Making sessions end. The model would say goodbye and then keep going, or hang up in the middle of its own farewell, or repeat its closing three times. The fix ended up being a wind-down clock with one source of truth for timing, cues that scope how long a turn can be instead of licensing a monologue, and a hang-up gated on an explicit unlock marker rather than on the model's own judgment about whether it had finished. Every one of those rules exists because a real session went wrong in a specific way.

Two languages, one memory store. Session notes get written in English no matter what was spoken, because two languages in one store means every reader has to handle both. That's fine when a model is in the loop and can translate on the fly. It breaks for the spoken opener, which goes straight to text-to-speech with no model in the middle. So that one value carries a separate rendering per language, and it refuses to speak at all rather than fall back to the wrong one.

Nothing goes into a recording. I'd added session replay to debug UI problems, and it was capturing transcript bubbles. The masking rule is now "mask everything," restated explicitly instead of inherited from library defaults, and the boundaries are pinned by tests. A narrower rule fails badly: the next surface you build gets captured by default.

What I learned

A comment is a claim, not a measurement. Over and over the thing that was wrong was code doing work under a comment explaining why the work was necessary. A database warmup running three queries it didn't need. A permission check polling every second on every screen, including during a live session. A crisis detection service that turned out to be a sketch, with a log line everywhere the escalation should have been. None of that was findable by reading. It took a stopwatch, or a grep for importers.

The other one: a passing test on code nothing renders is worse than no test at all. I deleted a component whose suite had been green for months while nothing had put it on screen. Someone could have been asked to change that button, edited it, watched the tests pass, and shipped nothing.

What's next

Getting the Android build through Play review.

Built With

Share this project:

Updates