iOS App
A native SwiftUI iPhone client for the VITA server. After the 2026-07-21 overhaul the app is voice-first and deliberately thin: its default surface is a hands-free voice mode that turns spoken utterances into messages injected into telegram topic sessions, and its remaining tabs β location, photos, podcast, and (rebuilt) meetings β are all thin views over server-side APIs. Almost no logic lives on the device: transcription, routing, storage, agent work, and every reply happen on the server, reached over a private Tailscale network. The one deliberate exception is the meetings tab's live agenda-item voice session, which connects the phone directly to OpenAI's realtime API β but even there the phone never holds a key or a prompt (both are baked server-side into an ephemeral, short-lived credential).
Framing
silicon-zack lives on the server. The iOS app is one of his embodiments β a way for bio-zack to feed him voice, photos, location, and to hold live agenda working-sessions hands-free. The design rule that follows: the phone holds no intelligence and no persistent secrets. No API keys ship to the device, no model runs on it. It records audio, captures GPS, uploads photos, and β for voice β fires-and-forgets. The live agenda-item session is the sole live-model surface, and it runs on a server-minted ephemeral credential (120s TTL) with the persona/grounding/tools baked in server-side, so the phone still never sees a raw key or a prompt.
The key framing shift in the overhaul: the voice inbox is fire-and-forget messaging, not conversation. The phone does not host a chat. A spoken utterance becomes a message dropped into a telegram topic, and silicon-zack replies there, in telegram, in the resident session that owns that topic. This replaced the old turn-based voice-session UI (record β server Claude turn β TTS β play), which is gone.
What the overhaul removed
~73 Swift files were deleted. Removed from the app entirely:
- Turn-based voice sessions β
VoiceSessionManagerfamily, the Voice chat/session-list UI, and the hands-free PCM WebSocket path. - Clux sessions, Cadence remote-session control, and Cloud session streaming views β remote control of server sessions from the phone was unwanted.
- Feed UI, Reports tab, SystemKernel overview.
- The pre-existing meetings realtime-surrogate stack β the old Gemini Live / OpenAI Realtime surrogate that let silicon-zack "join" a live meeting, plus the recordβupload meetings flow that briefly replaced it on the morning of 2026-07-21. Both are gone. The morning's recordβupload flow was killed the same day (bio-zack: the phone will never capture meetings β his HiDock/recorder system owns capture); the meetings tab is now agenda meetings (below), a fundamentally different feature. Deleted iOS classes:
MeetingRecorder,MeetingUploadManager/MeetingMultipartBody, and the oldViews/Sync/UI.
The corresponding server routers (voice.py, cloud.py, clux.py, reports.py, system_kernel.py, and the non-push parts of feed.py) are still registered in main.py but are now dead code from the app's perspective; feed.py's push-registration endpoints stay live. The old meetings surrogate stack is fully deleted server-side too: meetings_upload.py, the pre-existing sync_meetings.py router, and sync_voice.py are gone (not just dead). The remaining dead endpoints are flagged for later pruning.
Retained: photos (upload + tab), location, podcast player, meetings (rebuilt as agenda meetings), settings/onboarding, push notifications, debug logging, and the audio primitives (AudioRecorder / AudioPlayer / AudioSessionManager / ToneGenerator / SessionNameSpeaker).
The tab shell
ContentView deliberately does not use SwiftUI's TabView (with overflow it auto-wraps into an unwanted "More" page). Instead it uses a manual switcher β a ZStack that keeps every tab's view alive across switches β plus a custom icon-only VitaTabBar. The tab enum is the source of truth (Design/Components/VitaTabBar.swift):
| Index | Tab | Controller / Manager | View |
|---|---|---|---|
| 0 | Voice (default) | VoiceModeController |
VoiceModeView |
| 1 | Location | LocationManager |
LocationTab |
| 2 | (removed) | β | old projects/clux tab retired |
| 3 | Photos | PhotosManager |
PhotosTabView |
| 4 | Podcast | PodcastManager |
PodcastTabView |
| 5 | Meetings | AgendaMeetingManager |
MeetingsTabView |
The app launches on tab 0. vitaApp.swift is the @main entry: it registers the photo-upload background task (com.vita.photo-upload), wires APNs registration through AppDelegate, and shows OnboardingView if credentials are missing (which, since network auth is Tailscale, effectively never β KeychainHelper.hasRequiredCredentials() returns true; onboarding still sets the server URL).
Voice mode (the flagship surface)
Voice mode is a hands-free, on-device wake-word listener that captures a spoken utterance and dispatches it to the server voice inbox. It is designed to run while bio-zack is rowing, biking, or driving β with a podcast playing.
On-device state machine (VoiceModeController)
off β starting β armed
armed β triggered (wake phrase OR PTT button)
triggered β recording (earcon, then capture) OR β armed (abort)
recording β finalizing (silence timeout / "send it" / 120s max)
β armed ("never mind" / too short)
finalizing β dispatching β confirming(2s) β armed
any β suspended (AVAudioSession interruption began, e.g. a call)
suspended β armed (interruption ended)
Mechanics that matter:
- Wake phrase.
WakeWordListenerruns an on-deviceSFSpeechRecognizerin 50-second rolling windows (proactively rolled during silence so a wake phrase is never cut mid-word), with an RMS pre-gate so silence doesn't waste the recognition budget. Default phrase"hey vita"plus homophones (hey veda,hey rita, β¦). A 3-second debounce prevents double-triggers. If speech recognition is unavailable, the wake word is disabled and the app falls back to PTT-only. - Single shared audio engine.
VoiceModeEngineowns oneAVAudioEngineand one input tap, fanning each buffer to the wake listener and (during capture) the recorder. There is no second engine. - Podcast keeps playing while armed. The listen-mode session is
.playAndRecord+.mixWithOthers+.allowBluetoothA2DP+.defaultToSpeaker(VoiceModeAudioSession.listenConfig). This keeps high-quality A2DP output on the headphones while capturing on the phone's built-in mic β it never enables plain.allowBluetooth(HFP), which would force both directions onto the low-quality mono Bluetooth link and tank the podcast. - On trigger. An earcon plays; then:
- if the in-app podcast is playing, it's paused via
PodcastManager.pausePlaybackForVoiceMode()and the mixable listen session is kept; - if nothing of ours is playing, the session switches to the non-mixable capture config (
.mixWithOthersdropped), which interrupts external audio (Spotify etc.) for a clean recording. - Recording ends on 1.8s of silence (configurable 1.0β3.0s), the phrase "send it", the phrase "never mind" (cancel/discard), or a 120s hard cap. The utterance is an M4A.
- Resume. After the file closes, the session is released with
.notifyOthersOnDeactivationso external audio resumes fast, and our own podcast resumes on the episode we paused. - PTT fallback. A push-to-talk button in
VoiceModeViewworks in every state β the always-available manual path, independent of wake-word availability.
Offline outbox (VoiceModeOutbox)
Every utterance is persisted to disk (m4a + JSON manifest) with an idempotency key BEFORE the first POST, so a crash or kill between capture and upload never loses audio. Failed/offline utterances stay queued and drain FIFO when connectivity returns (NetworkMonitor) or on foreground, with bounded retry (3 attempts at 1s/4s/16s). The idempotency key is echoed to the server so a replay returns the stored result (status: duplicate) rather than re-injecting.
Server pipeline (voice_inbox.py + voice_inbox_routing.py)
The app POSTs multipart/form-data to POST /api/v1/voice-inbox/dispatch. Fields: audio (the m4a, audio/mp4), optional transcript_hint, optional topic_hint, dry_run, idempotency_key. Server flow:
- Idempotency replay β a seen
idempotency_keyreturns the stored response (status: duplicate) before any effect. - Transcribe β Groq Whisper (
scripts/integrations/groq_whisper.py); audio wins, else thetranscript_hint. (The Groq mime bug is fixed: the content-type is now derived from the filename extension β iOS m4a βaudio/mp4β instead of being hardcoded toaudio/ogg.) STT failure β HTTP 422. - Resolve target topic (
resolve_topic): topic_hint(client already knows the id or name), then- inline directive β the speaker named the topic ("send this to the education topic: β¦"); the directive is stripped from the injected body but the full transcript is still echoed. Aliases (
edβeducation,docβdoctor, β¦) and fuzzy matching (difflib floor 0.75, margin 0.08) apply, then - LLM fallback β a small openrouter model picks the best topic from the live list, then
- default β a durable topic literally named
voice, created once and persisted; arouting_noterecords that resolution fell through. - Echo β post
π€ <transcript>into the resolved topic viavita.telegram.send(synchronous, ledger-recorded so the resident session sees it as context). - Inject β
enqueue_prompt(topic_id=β¦)drops the bare transcript (with only a short(voice message from bio-zack)marker β never duplicating the echoed text) into the residenttelegram-<topic_id>session, which runs it on its next scheduler tick and replies in the topic.
The response carries transcript, resolved_topic (topic_id, name, method β hint/alias/directive/llm/default, confidence), candidates, and status β dispatched/dry_run/duplicate/error. GET /api/v1/voice-inbox/topics?limit=N returns the live topic list for the client's hint picker.
E2E verified live 2026-07-21 (topic 13732 test): the echo appeared in the topic and the resident session replied in-topic.
Meetings (rebuilt: agenda meetings β prep β live per-item duplex voice β async processing β wrap)
The recordβupload flow is gone. Meetings are now structured solo working sessions: bio-zack hands silicon-zack a goal, silicon-zack researches and lays out a grounded agenda, and they talk it through one item at a time in a live duplex voice call β with the real work happening asynchronously between items and a written summary at the end. Nothing on the phone is a meeting recorder; the phone is a live-conversation client. (Capture of actual meetings lives entirely with the HiDock recorder / recorder-service ingest path β not the phone.)
The flow
- Compose a goal.
MeetingComposerViewβ "what do you want to work through?" β a goal field (e.g. plan the growth WBR for tuesday's L10) + optional notes.POST /api/v1/agenda-meetingsopens a per-meeting telegram topic, persists the meeting (statuspreparing), and launches the prep agent. - Prep (a full Claude agent). A full-toolset agent (
agenda_preptask type, via the claude daemon /stream_inference) researches the goal β searches vita's long-term memory, reads files, pulls live data β and breaks it into 2-6 agenda items. Each item gets atitle,goal, integersuggested_minutes, and a compiledgrounding_prompt: a context pack (facts with sources, prior decisions, open questions, what to push bio-zack on) that arms the live voice agent. Status flipspreparing β ready; the agenda posts to the telegram topic. - Run each item as one bounded duplex voice session. In
MeetingDetailView, "start" on an item opensLiveItemView. The phone hitsPOST .../items/{id}/start; the server mints an OpenAI ephemeral client secret (gpt-realtime-2.1-mini, voicemarin, 24 kHz PCM, WebSocket) with the persona + item grounding + the one tool baked in server-side, and returns only the ephemeral secret + connection params (never the API key, never the instructions). The phone then connects directly to OpenAI over WebSocket (auth rides in theSec-WebSocket-Protocolsubprotocols:["realtime", "openai-insecure-api-key.<ephemeral>"]) for full duplex β silicon-zack speaks first, live transcripts stream both directions, and barge-in works (hardware AEC via.playAndRecord+.voiceChatmode + voice-processing on the IO node, so the model's speaker output doesn't feed the mic). The server never touches audio. - End the item. The model has exactly one tool:
end_agenda_item(required arg: a one-linesummary). When bio-zack says "close that out" / "move on" / "next", the model calls it and the phone ends the session (ended_by: tool); there's also a manual end item button (ended_by: button). The phone POSTs the serialized transcript toPOST .../items/{id}/completeβ the item goeslive β processing. - Process the item (async, while he keeps talking). A per-item agent (
agenda_item_process) extracts DECISIONS + ACTIONS from the transcript and does the executable work with its tools (files Precedent tasks, writes artifacts, runs lookups), then writesitems/<item_id>/outputs.mdβ itemprocessing β done. Meanwhile the phone auto-advances: a 3-second interstitial ("up next" + countdown, "stay here" to cancel) then starts the next item's live session. - Wrap. Once every item reaches a terminal state (
done/error), a wrap agent (agenda_wrap) writes the meeting summary β meetingwrapping β done. There's also an idempotent manualPOST .../wrap.
Every stage's output β the agenda, each item's decisions/actions/outputs, the wrap summary, and any failure note β is posted to the per-meeting telegram topic (vita.telegram.open_topic on create, vita.telegram.send throughout).
Honest-failure design
Statuses only ever reflect live reality. Any agent death, timeout, or empty result flips the meeting (or item) to error with an error_detail, and a fallback note posts to the telegram topic β never a faked "done" or an infinite spinner. On app/server startup an orphan sweep (sweep_orphans(), run in main.py's lifespan) marks any preparing/processing/wrapping run that's older than its budget (prep 300s, item 240s, wrap 180s + a 60s grace) as error, so a run lost to a restart doesn't hang.
Provider abstraction (swappable realtime layer)
The realtime layer is deliberately provider-swappable on both ends. Server-side, mint_realtime_session(provider=...) dispatches through a table (_PROVIDERS, currently {"openai": ...}; model/voice/rate are module constants). iOS-side, RealtimeVoiceProviding is a protocol and OpenAIRealtimeWSProvider is the only file that speaks the OpenAI wire dialect β it normalizes frames down to a vendor-agnostic RealtimeEvent enum. Gemini Live / xAI can slot in behind both seams later. (xAI is currently blocked: its ephemeral tokens can't carry baked session config.)
Storage & statuses
Everything lives under data/agenda-meetings/<id>/: meeting.json (single-writer, all reads/writes via agenda_store.py) + items/<item_id>/{transcript.md, outputs.md}. Statuses:
- meeting:
preparing | ready | in_progress | wrapping | done | error - item:
pending | live | processing | done | error
Location, photos, podcast (unchanged surfaces)
- Location β
LocationManager(CoreLocation) collects significant-change GPS always-on plus optional 60s continuous, buffers pings inUserDefaultsfor offline, batch-uploads toPOST /api/v1/location/batch, and monitors up to 20 geofence regions (iOS cap) supplied by the server fromprofile/places.json. Enter/exit firesPOST /api/v1/location/geofence-triggerimmediately. See Voice & Location. - Photos β a
BGProcessingTask(com.vita.photo-upload, registered at launch) scans the photo library and uploads new photos on a β₯15-minute cadence when on network (PhotoUploadManager). The Photos tab browses what the server has processed (PhotosTabView+ lightbox). See Photos. - Podcast β
PodcastManager(split across+Downloads/+Lifecycle/+Persistence/+Playback/+State) is a full on-device player for the curated feed; it auto-refreshes on an interval and, per voice mode above, keeps playing while the wake-word listener is armed.
Push notifications
APNs registration runs through AppDelegate β PushNotificationManager.requestPermissionIfNeeded(); the hex-encoded token is POSTed to POST /api/v1/feed/push/register (the endpoint lives under the feed router β one of the few feed endpoints still in use). Local notifications also fire completion chimes. Deep links: vita://meeting/<id> / vita://meetings/<id> focus the Meetings tab and open that meeting's detail.
Debug logging back-channel
So an agent can read iOS runtime behavior without the device in hand, RemoteLogger / FileLogger ship structured log lines to the server (ios_debug.py), appended to a tailable file:
tail -f data/voice/ios-debug.log
API surface
Voice inbox β /api/v1/voice-inbox
| Endpoint | Method | Purpose |
|---|---|---|
/dispatch |
POST | multipart m4a β STT β topic resolution β echo + inject. Fields: audio, transcript_hint?, topic_hint?, dry_run, idempotency_key?. Returns transcript, resolved_topic, candidates, status |
/topics |
GET | Live telegram topics (?limit=N), most-recently-active first |
Agenda meetings β /api/v1/agenda-meetings
| Endpoint | Method | Purpose |
|---|---|---|
` //` |
POST | Create a meeting (fields: goal, notes?) β opens a telegram topic, launches the prep agent. Returns {id, status: "preparing", telegram_topic_id} (202) |
` //` |
GET | List meetings newest-first (?limit=N) β {meetings: [{id, goal, status, created_at, item_count, telegram_topic_id}]} |
/{id} |
GET | Full meeting.json (agenda items, per-item status/summaries, wrap summary, error_detail) β poll while status is transient |
/{id}/items/{item_id}/start |
POST | Mark item live, mint the realtime credential. Returns {item_id, status, session} β session is the OpenAI ephemeral mint (url, credential, model, sample rates, voice, expires_at). 409 if not startable |
/{id}/items/{item_id}/complete |
POST | Persist the transcript, move item β processing, launch the item agent. Fields: transcript, duration_seconds?, ended_by? (tool/button/error) |
/{id}/wrap |
POST | Idempotent manual wrap trigger (202) |
/{id} |
DELETE | Cancel live tasks best-effort, remove the meeting directory |
Location β /api/v1/location
| Endpoint | Method | Purpose |
|---|---|---|
/update |
POST | Single GPS update |
/batch |
POST | Batch upload (offline sync) |
/geofence-trigger |
POST | Enter/exit event |
/places / /places/geofence |
GET | All places / top-20 priority to monitor |
/places /places/{id} |
POST/PUT/DELETE | Manage places |
/history /history/{date} /summary/{date} /insights /dates |
GET | History + analytics |
Retained by other tabs
| Router | Prefix | Used by |
|---|---|---|
photos.py / photo_upload.py |
/api/v1/photos, /api/v1/photo-upload |
Photos tab + background upload |
podcast.py / podcast_curation.py |
/api/v1/podcast, /api/v1/podcast-curation |
Podcast player |
feed.py (push only) |
/api/v1/feed/push/register /unregister |
APNs registration |
ios_debug.py |
/api/v1/ios-debug |
Debug-log back-channel |
State: where everything lives
On-device (UserDefaults + Keychain)
All keys are catalogued in Services/SettingsKeys.swift (add a case, never a magic string).
| Key | Purpose |
|---|---|
vita_server_url |
Base URL override (default http://desk:33800) |
vita.voiceMode.wakePhrase |
Wake phrase (default "hey vita") |
vita.voiceMode.silenceTimeout |
Utterance-end silence window, 1.0β3.0s (default 1.8) |
vita.voiceMode.maxUtteranceSeconds |
Hard cap before auto-send (default 120) |
vita.voiceMode.rmsFloorDb |
Speech floor in dB (default -35) |
vita.continuousTracking |
Continuous GPS on/off |
vita.pendingLocations / vita.cachedPlaces |
Buffered pings / offline geofence cache |
vita.photoUploadEnabled / vita.photoUploadWifiOnly |
Photo-upload toggles |
vita.photos.filters / .mode / .density |
Photos tab UI state |
vita.podcast.refreshIntervalMinutes |
Podcast auto-refresh (default 15) |
The Keychain vita_token is vestigial β network auth is Tailscale.
The voice outbox persists utterance m4a + manifest under Application Support/voice-mode/outbox/.
Server-side
| Location | Content |
|---|---|
data/telegram/voice_inbox_state.json |
Voice-inbox idempotency replays + persisted default-topic id |
data/agenda-meetings/<id>/meeting.json |
Per-meeting record (single-writer via agenda_store.py) |
data/agenda-meetings/<id>/items/<item_id>/ |
Per-item transcript.md + outputs.md |
data/voice/ios-debug.log |
iOS debug-log file |
data/location/ |
GPS history by date |
profile/places.json |
Geofence/place definitions (gitignored, PII) |
Build workflow
The codebase is edited on Linux (vita-ios/) but compiles only on a Mac over Tailscale: edit on Linux β rsync (with --delete) β xcodebuild on the Mac.
Every xcodebuild must set DEVELOPER_DIR. The Mac's default Xcode 16.0 has a wedged CoreSimulator (macOS 26.5 mismatch β AssetCatalogSimulatorAgent spawn failures). Point all builds at Xcode 26.6:
export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer
# Simulator ('iPhone 16 Pro' works under Xcode 26.6)
ssh <mac> "export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer && cd ~/work/vita-ios/vita && \
xcodebuild -scheme vita -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 16 Pro' build"
# Device: build (unlock keychain, allow provisioning), then install with devicectl
ssh <mac> "security unlock-keychain -p '<pw>' ~/Library/Keychains/login.keychain-db && \
export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer && cd ~/work/vita-ios/vita && \
xcodebuild -scheme vita -sdk iphoneos -allowProvisioningUpdates build"
ssh <mac> "xcrun devicectl device install app --device 94D84567-491E-509C-9DC8-9D7849916AE4 <DerivedData .app path>"
The physical iPhone (15 Pro, iOS 26.5.2) must be awake + on Tailscale for devicectl install. xtool (Linux-native building) was evaluated and rejected (USB-only install, no .xcodeproj support, unproven entitlements). iOS-specific issues are filed against the main repo with the ios label. The full dev/build reference lives in vita-ios/CLAUDE.md.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
Build: AssetCatalogSimulatorAgent spawn failure |
DEVELOPER_DIR not set β default Xcode 16.0 sim runtime wedged |
Export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer |
| Build: "Multiple commands produce" | Stale file on the Mac (overhaul deleted ~73 files) | Re-run rsync with --delete, rebuild |
| "Cannot reach VITA server" | Tailscale down | Enable Tailscale on the iPhone; curl http://desk:33800/api/v1/health |
| Voice message never appears in telegram | echo/inject failed | Check /voice-inbox/dispatch status; error + detail names the failure (echo vs injection-rejected) |
| Voice routed to wrong topic | resolution fell through | Inspect resolved_topic.method; default + routing_note means the named topic didn't resolve |
| No transcript | audio empty / Groq Whisper failing | Verify the M4A is non-empty; STT errors surface as HTTP 422 |
Meeting stuck at preparing / item stuck processing |
Prep/item agent still running, or lost to a restart | Poll GET /{id}; the orphan sweep flips runs older than their budget to error with error_detail (posted to the topic too) |
| Live item won't connect | Ephemeral mint expired (120s TTL) or OpenAI rejected the session | The app retries once with a fresh /start; persistent failure surfaces as couldn't connect. Check the server log for the agenda realtime minted echo / a 502 realtime mint failed |
| Geofences not firing | Missing "Always" permission | Grant Location "Always"; recall the 20-region cap |
devicectl install fails |
Phone asleep / off-network / keychain locked | Wake+unlock the phone, confirm Tailscale, unlock the Mac keychain |
Where to go next
- Agenda Meetings β the meetings tab's server-side lifecycle, grounding packs, and honest-failure design
- Voice & Location β location context and the server-side voice surfaces
- Photos β what happens to uploaded photos (faces, indexing)
- Podcast β how the curated feed is built
- Architecture β the FastAPI server the app is a client of
- Peripherals β the hardware voice puck (separate PCM path, server-side)