Skip to main content

Companions in the Mobile App

Status: Audited against code 2026-07-12 · repo TrickList, branch v2-rebuild

Everything the mobile app ships for companions: where they appear, how chat and voice work, and the exact plumbing between screens, the Kith voice sidecar, and the backend brain.

Related pages

Hub: AI Companions · 3D internals: Animation System · Backend brain: Kaori AI Architecture

Surface map

SurfaceFileWhat it does
Homies rosterapp/(tabs)/homies/index.tsx"COMPANIONS" section above human homies
Home widgetsrc/components/home/CompanionWidget.tsxFeatured companion card on Home
Bot chatapp/(tabs)/homies/bot-chat/[botId].tsxText chat with rich-content cards
3D stageapp/(tabs)/homies/companion-stage/[botId].tsxVoice + choreography over the VRM stage
Voice sessionsrc/lib/companion/kithVoice.tsRaw WS client for the Kith sidecar
TTS playersrc/lib/companion/ttsPlayer.tsGapless streamed-audio playback
Voice hooksrc/hooks/useKithVoice.tsWS events → animation state + sentence cues
STT hooksrc/hooks/useVoiceInput.tsexpo-speech-recognition wrapper
Rich cardssrc/components/chat/RichContentCards.tsx5 card types with deep links

There is deliberately no src/lib/api/botChat.ts module — all three bot endpoints (GET /bot-chat/bots, GET /bot-chat/history/:botId, POST /bot-chat/message) are inline apiClient calls from the screens.

Roster & entry points

  • The Homies screen fetches GET /bot-chat/bots in parallel with human homies and renders companions in a "COMPANIONS" header section — avatar, yellow chip badge, bio, tap → bot chat. A user with zero homies still sees Kaori (the empty state is suppressed when companions exist).
  • The Home screen takes bots[0] as the featured companion and renders CompanionWidget: yellow-edged card, tap → chat, and a cube button → 3D stage. The cube (and the matching cube icon in the bot-chat header) only renders when botCharacter/name contains "kaori" — the 3D stage exists only for her so far.
  • Bot chat history is not part of DM conversations — bots live in the separate bot_chats collection and never appear on the conversations screen.

⚠️ Two scaling landmines here, both hardcoded in two places: the bots[0]-is-featured assumption (no roster ordering from the API) and the "kaori" substring gate for the stage. Both need parameterizing before Tony ships.

Bot chat screen

  • Optimistic send: a temp- message renders immediately; POST /bot-chat/message returns {userMessage, botMessage} which replace it. On failure the text is restored to the input.
  • Legacy rows: user messages from before the backend persisted message text (fixed May 2026) have no message field and render as an italic "message not saved" placeholder — permanent for pre-fix history.
  • Text chat never sends x-kith-session, so it is voiceless by design; voice lives on the stage.
  • Input capped at 1000 chars (500 on the stage); typing indicator is three static dots while awaiting the reply.

Rich content cards

RichContentCards.tsx renders five card types when a message carries richContent:

TypeRendersTap →
spot_cardimage, name, address, rating, category badgespot detail
spots_listfirst 3 spots + "+N more"spot detail per row
tricklist_cardname, trick count, difficulty, progress bartricklist detail
trick_cardimage, name, difficulty, description⚠️ trickbook index (ignores the trick id)
spot_draft_confirmationgreen "Spot Draft Created" confirmationnot tappable

Reality check: these cards are dormant. The backend has no richContent mechanism at all — tool results are fed back to the model as plain JSON and replies come back as plain text. The renderer shipped ahead of the backend (per the original plan); wiring tool results into richContent on the reply payload is a pending backend task, and the payoff is large — tappable spot/tricklist cards are the "companion operates the app" moment. Cards are also hardcoded dark-theme and richContent.data is untyped (any) — worth tightening when the backend side lands.

Voice stack (three layers)

useKithVoice (hook) ── owns ──► KithVoiceSession (WS) ──► wss://api.thetrickbook.com/kith/ws
│ (dev: ws://DEV_API_HOST:3040/ws)
└────────────── owns ──► TtsPlayer (react-native-audio-api)

KithVoiceSession (src/lib/companion/kithVoice.ts)

Raw WebSocket client. Server sends {type:'_ready', sessionId} on connect; the client can send {type:'speak', text} (direct TTS, bypasses the brain) and {type:'barge-in'}. Reconnects forever at a fixed 3s interval — no backoff, no cap. No auth token is attached to the connection (the known P0 — see launch audit).

TtsPlayer (src/lib/companion/ttsPlayer.ts)

Kith streams independently-decodable mp3_44100_128 chunks — one full turn cycle per sentence. The player keeps a single long-lived AudioBufferQueueSourceNode for the whole conversation (drained queue nodes never reach FINISHED, so per-turn nodes would leak), serializes decodes on a promise chain (decodeAudioData resolves out of order), and detects end-of-reply with a 500ms queue-drain grace timer — tts_end/turn_end are per-sentence boundaries and must not be treated as end-of-reply.

Pinned react-native-audio-api@0.13.1 workarounds (do not remove):

  • start() with no args throws → always start(0, 0)
  • onEnded never fires for queue nodes → use onBufferEnded + isLastBufferInQueue
  • worklets stays at 0.5.1 (Expo SDK 54) → audio-api worklet nodes disabled; we only need decodeAudioData + the queue node

useKithVoice (src/hooks/useKithVoice.ts)

Maps WS events onto a mutable CompanionVoiceState ref (mode, emotion, emotionIntensity) that the stage's per-frame loop reads — deliberately outside React state. Because Kith speaks one sentence per turn, the hook counts assistant turn_start events and fires onAssistantSentence(index) — this is what drives speech-synced choreography. beginReply() must be called on send to reset the counter. onReplyDone fires from the player's drain grace.

Known wart: voiceReady is set on _ready but never reset on WS close, so the "Talk to Kaori…" placeholder can be stale during a reconnect window.

STT (src/hooks/useVoiceInput.ts)

expo-speech-recognition with live interim transcript, auto-submit after 2s of silence, 5s never-spoke timeout — mirrors the web stage behavior. Hardcoded en-US, cloud recognition allowed. Permission denial is currently silent (no Settings guidance).

Two ordering rules the stage enforces around the mic:

  1. Barge-in before the mic opens — otherwise Kaori's speaker output bleeds into the head of the transcript.
  2. Reassert playback after the mic closes — speech recognition leaves the iOS audio session in record mode and never restores it; reassertPlayback() restores the playback/spokenAudio session so the reply's TTS isn't attenuated.

The stage screen's chat plumbing

  • Sends POST /bot-chat/message with the x-kith-session header when a session is live — this is what makes the backend (a) append the stage-demo prompt to the brain and (b) fire the reply text at Kith for voice.
  • Splits the reply into sentences client-side (/(?<=[.!?…])\s+/) to index sentence cues — this assumes the client split matches Kith's server-side chunking 1:1; divergence silently mis-cues choreography (a known fragility, see Animation System).
  • Voiceless fallback: no session → run the full trick once, end the demo after a hardcoded 7s.
  • Dead-session recovery: still thinking 8s after send → force back to idle.
  • The stage is never wrapped in KeyboardAvoidingView (resizing the expo-gl surface every keyboard toggle churns the GL context); rendering pauses when the screen loses focus (active={isFocused}).
  • Stage messages are ephemeral locally (last 2 rendered as translucent bubbles) but the backend logs everything in bot_chats — the history shows up in the text chat later.

Tuning constants (all hardcoded)

7000ms voiceless-demo teardown · 8000ms dead-session recovery · 400ms mode-pill polling · 500ms TTS drain grace · 2000ms STT silence auto-submit · 5000ms STT no-speech timeout · 3000ms WS reconnect. The 7s voiceless teardown must stay ≥ the longest trick duration (currently 4.2s) — nothing enforces that.

Known gaps (mobile)

  • Voice WS unauthenticated (P0, launch audit); no analytics/telemetry anywhere in the companion code; roster/history fetch errors are swallowed into empty states; trick_card deep link drops the trick id; STT permission denial is silent; no handling for audio interruptions (phone calls) beyond the STT reassert.