# Deploy: yothere hosted media worker (`yothere-voice-worker`)

The Layer-2c media plane: a second Fly app, next to the control plane (`yothere-cloud`),
that joins Daily SFU rooms and runs the voice pipeline (one subprocess per call). See the
header comments in `fly.toml` / `Dockerfile` for the architecture.

**This app is PRIVATE-ONLY (pre-rollout Fix 1).** `POST /dispatch` must not be reachable
from the public internet; the control plane talks to it over Fly private networking
(Flycast: `http://yothere-voice-worker.flycast`). The fleet-token gate on `/dispatch`
stays as defense in depth, not as the only wall.

## Stand-up (all commands from the repo root)

1. **Create the app, no deploy yet** (skip if it exists):

   ```sh
   fly apps create yothere-voice-worker
   # or: flyctl launch --no-deploy --copy-config --config deploy/worker/fly.toml
   ```

2. **Allocate the PRIVATE (Flycast) IP — before the first deploy** (changed vs the
   public spike: no public IPs, no certs):

   ```sh
   fly ips allocate-v6 --private -a yothere-voice-worker
   ```

   Allocating it first means the first `fly deploy` does not auto-allocate public IPs
   for the `[http_service]`. Do **not** run `fly certs add` for this app — nothing
   public terminates TLS here.

3. **Secrets** (unchanged):

   ```sh
   fly secrets set -a yothere-voice-worker \
     DAILY_API_KEY="<daily-key>" \
     GEMINI_API_KEY="<gemini-key>" \
     YOTHERE_WORKER_FLEET_TOKEN="<long-random-shared-secret>" \
     YOTHERE_CONTROL_PLANE_URL="https://app.yothere.ai"
   ```

4. **Deploy** (unchanged; build context is the repo root):

   ```sh
   fly deploy --config deploy/worker/fly.toml --dockerfile deploy/worker/Dockerfile .
   ```

5. **Verify it is private-only** (the load-bearing check):

   ```sh
   fly ips list -a yothere-voice-worker
   ```

   The list must show ONLY the private v6 (`fly-flycast` / private=true). If any public
   v4/v6 appears (e.g. the app was first deployed before this fix), release it:

   ```sh
   fly ips release <public-ip> -a yothere-voice-worker
   ```

   Then confirm from outside: `curl https://yothere-voice-worker.fly.dev/healthz` must
   fail to resolve/connect.

6. **Point the control plane at the Flycast address** (changed vs the public spike,
   which used `https://yothere-voice-worker.fly.dev`):

   ```sh
   fly secrets set -a yothere-cloud \
     YOTHERE_WORKER_URL="http://yothere-voice-worker.flycast" \
     YOTHERE_WORKER_FLEET_TOKEN="<same-shared-secret>"
   ```

   `http://`, not `https://` — Flycast is HTTP-only (the 6PN network is
   WireGuard-encrypted by the platform), and the worker's `fly.toml` deliberately does
   not set `force_https` for the same reason. Fly Proxy listens on port 80 for the
   `[http_service]` and forwards to `internal_port` 8080, so no port suffix is needed.

## Checking `/healthz` once private

The public `curl https://…fly.dev/healthz` from the spike no longer works (by design).
Use one of:

- **Platform checks** (the `[[http_service.checks]]` block still runs over the private
  network — health checks need no public IP):

  ```sh
  fly checks list -a yothere-voice-worker
  ```

- **From inside the worker VM** (the python:3.12-slim image has no curl, so use python):

  ```sh
  fly ssh console -a yothere-voice-worker -C \
    "python -c \"import urllib.request;print(urllib.request.urlopen('http://127.0.0.1:8080/healthz').read().decode())\""
  ```

- **From the control plane box over Flycast** (also proves the exact path a dispatch
  takes):

  ```sh
  fly ssh console -a yothere-cloud -C \
    "python -c \"import urllib.request;print(urllib.request.urlopen('http://yothere-voice-worker.flycast/healthz').read().decode())\""
  ```

## Emergency voice tuning: `YOTHERE_VOICE_VAD_*`

The worker's Gemini server-VAD tuning comes from the per-transport profile in
`src/yothere/voicecall/config.py` (`VAD_PROFILES`): hosted calls are browser mics over
the SFU, so they run the `webrtc` profile (HIGH start sensitivity for responsive
barge-in, 900ms end-of-turn silence, END sensitivity LOW). If a live deploy needs
retuning without a code change, set any of these as Fly secrets on
`yothere-voice-worker`: an explicit value beats the profile default, unset values keep
resolving from the profile.

```sh
fly secrets set -a yothere-voice-worker \
  YOTHERE_VOICE_VAD_START_SENSITIVITY=low \
  YOTHERE_VOICE_VAD_END_SENSITIVITY=low \
  YOTHERE_VOICE_VAD_SILENCE_MS=1200 \
  YOTHERE_VOICE_VAD_PREFIX_MS=300
```

- `..._START_SENSITIVITY` / `..._END_SENSITIVITY`: `low` / `high` shorthand or the full
  google.genai enum (`START_SENSITIVITY_LOW`, ...). A typo degrades to the profile
  default instead of breaking the connect.
- `..._SILENCE_MS`: how long the user must be silent before the turn closes.
- `..._PREFIX_MS`: audio kept from before the detected speech onset (guards against
  clipped utterance starts).

`fly secrets unset` any of them to fall back to the profile. (The example values above
are the `telephony` profile, the fastest way to trial the noise-tolerant tuning on a
hosted call.)

## Why `YOTHERE_CONTROL_PLANE_URL` stays public

The worker's per-call callbacks (enqueue, transcript, failure status) go to
`YOTHERE_CONTROL_PLANE_URL`. In principle this could also ride the private network
(`http://yothere-cloud.flycast` after `fly ips allocate-v6 --private -a yothere-cloud`),
keeping worker->control-plane traffic off the public edge too. It stays
`https://app.yothere.ai` for now because the control plane's `deploy/fly.toml` sets
`force_https = true`, which 301s any plain-HTTP Flycast request to an `https://…flycast`
URL that cannot terminate — i.e. private callbacks would break. Flip it only as a
deliberate change (drop `force_https` on the control plane, then re-verify browser
redirects); until then, per-call-token auth over public TLS is fine for this direction.
