Wellness app that generates personalized self-hypnosis / meditation audio tracks from user-chosen components (AI script + ElevenLabs voice), built for long, uninterrupted, customizable sessions — including ketamine-assisted therapy (KAP) and psychedelic-assisted contexts that mainstream apps refuse to support.
Positioning: wellness / self-help. No medical claims. Not a substitute for clinical care.
Discovery complete. See docs/PRD.md for the full product requirements.
- MVP: Track Builder + Player (Sprints 0–2)
- Stack: Expo / React Native (TS) · FastAPI · PostgreSQL (SQLAlchemy + Alembic) · Python-native auth
- Audio: pre-generate + cache via a pluggable
AudioSourcelayer
apps/
api/ FastAPI backend — LLM script generation (hardened prompt + moderation) + ElevenLabs TTS via the ScriptSource/AudioSource seams (runnable, tested)
mobile/ Expo / React Native app — track builder (component selection) + Sprint-0 test harness
packages/
shared/ TypeScript contract types shared by client + API
docs/ PRD, roadmap, architecture, naming research
Database (Postgres via Docker — also creates the lull_test / lull_migtest databases tests use):
docker compose -f apps/api/docker-compose.yml up -d # local Postgres on :5432
cd apps/api && uv run alembic upgrade head # apply migrations to the dev DBStaging/prod: set LULL_DATABASE_URL to the managed/VPS Postgres DSN (overrides the local default).
API (no key needed — runs on the stub AudioSource):
cd apps/api && uv sync
uv run pytest -q # needs the Postgres above
uv run uvicorn lull_api.main:app --reload # http://localhost:8000 (/health, /script, /tts)Real round-trips: copy apps/api/.env.example → .env. For TTS set LULL_AUDIO_SOURCE=elevenlabs
LULL_ELEVENLABS_API_KEY. For LLM script generation setLULL_SCRIPT_SOURCE=claudeLULL_ANTHROPIC_API_KEY(defaults stay on the offline stubs — no keys needed).
Mobile (Expo SDK 54; deps reconciled via the workspace install):
pnpm install # from repo root — installs the workspace
cd apps/mobile
pnpm exec expo start # --android / --ios / --webThe app auto-derives the API base from the Metro host it connects to (same host, port 8000) — no per-target config for the common case. Overrides:
- Physical device over LAN/Tailscale: start Metro advertising a phone-reachable host so the
derived API base matches —
REACT_NATIVE_PACKAGER_HOSTNAME=<reachable-ip> pnpm exec expo start(Expo Go must match the project's SDK; the API must bind--host 0.0.0.0). - Anything non-default (staging, a separate API box): set
EXPO_PUBLIC_API_BASEexplicitly (e.g.http://10.0.2.2:8000for the Android emulator) — it always wins over derivation.
GitHub Actions runs on every PR (.github/workflows/):
ci.yml— api (ruff+pytestagainst a Postgres service) and mobile (tsc+eslint).codex-review.yml— an automated, advisory cross-family code review via OpenAI Codex, independent of CodeRabbit. Posts findings as a sticky PR comment; never blocks merge. Requires anOPENAI_API_KEYrepo secret (gh secret set OPENAI_API_KEY); skips cleanly when unset or on forks.
Internal dev-test loop: install once, then every merge to main pushes an over-the-air JS/asset
update that the app pulls automatically on next cold start (expo-updates checkAutomatically: ON_LOAD). This is not store distribution (Sprint 6).
One-time setup
EXPO_TOKENrepo secret (create at expo.dev → Access Tokens):gh secret set EXPO_TOKEN. The OTA workflow no-ops until this is set.- Build + install the internal APK on the Android device (Expo's infra builds it):
Install the resulting APK on the device (open the build link, or
cd apps/mobile eas build --profile preview --platform android # subscribes the build to the `preview` channel
eas build:run). - API base for the standalone build. A built APK has no Metro host to derive the API URL from,
so it falls back to
localhost:8000(the phone itself). Set the repo variableEXPO_PUBLIC_API_BASEto a device-reachable API URL (gh variable set EXPO_PUBLIC_API_BASE); the OTA workflow embeds it into each published bundle. Use HTTPS: a release-mode APK blocks cleartext (plainhttp://) traffic on modern Android, so a plain-HTTP dev API (e.g. anhttp://100.x.y.z:8000Tailscale box) won't be reachable from the build. Front the dev API with TLS, or — for dev only, never production — enable cleartext via theexpo-build-propertiesplugin (android.usesCleartextTraffic: true). API connectivity for on-device builds is tracked with the device-proving work (#24), separate from these delivery rails.
Reaching the dev API from a standalone APK (dev-TLS over Tailscale, #54)
A built APK blocks cleartext, so the dev API needs an HTTPS front. tailscale serve terminates TLS
at https://<host>.<tailnet>.ts.net and proxies to local uvicorn — no app change, nothing baked
into the APK (the durable, Tailscale-free public-API path is #55). Run this on the host that
EXPO_PUBLIC_API_BASE points at — the API must run on the same box whose *.ts.net name the APK
dials (e.g. the VPS), not necessarily your laptop. A mismatch here surfaces as "Network request
failed" on the device. Then:
cd apps/api
make dev-tls # runs uvicorn on 127.0.0.1:8000 behind tailscale serve
make dev-tls CHECK=--check # dry run: print what it would do, run nothingThe helper cooperates with an already-running tailscale serve (the build box's persistent
front): it leaves an existing serve untouched and just runs uvicorn on the loopback port the serve
proxies to. Only on a box with no serve does it set one up for the session and tear that one down on
exit. uvicorn listens on 127.0.0.1 — tailscale serve proxies to loopback, so exposing other
interfaces would only leak cleartext.
Point the build's EXPO_PUBLIC_API_BASE (EAS env var / repo variable) at the https://…ts.net host
(no trailing slash), and cold-restart the APK. Verify: tailscale serve status shows the tailnet
host proxying to http://127.0.0.1:8000, and curl -v https://<host>.ts.net/health completes the
TLS handshake and returns {"status":"ok",…}. The serve flags target Tailscale ≥1.60.
How updates flow
- Merge to
main→.github/workflows/eas-update.ymlrunseas update --channel preview→ the installed build downloads the new bundle on its next launch and applies it on the following cold start (checkAutomatically: ON_LOAD+fallbackToCacheTimeout: 0— the app starts instantly from cache and swaps in the update on the next relaunch, rather than blocking startup on the network). - Foreground-resume caveat: the check happens on app launch/cold start, not on resume from background. A long-backgrounded app applies the update the next time it's fully relaunched.
Native-change caveat (important)
OTA only ships JS/assets over the existing native runtime. runtimeVersion uses the
fingerprint policy: adding a native module/permission (or other native change) automatically
changes the computed runtimeVersion, so the OTA targets a new runtime that your already-installed
build doesn't subscribe to — it simply won't pull an incompatible bundle (no crash, no manual
version bump needed). To pick up a native change on the device you must do a fresh
eas build --profile preview + install; pure JS/asset changes flow over OTA automatically.
docs/PRD.md— product requirementsdocs/ROADMAP.md— sprints + risk gatesdocs/ARCHITECTURE.md— stack, pipeline, data modeldocs/NAMING.md— naming & domain research (name deferred)