Dispatch App
Full-stack mobile app for the dispatch personal assistant. Frontend (Expo/React Native) + backend API (FastAPI/dispatch-api) + message bus, TTS, and push notifications.
- Frontend code:
~/dispatch/apps/dispatch-app/ - Backend API code:
~/dispatch/services/dispatch-api/ - Legacy symlink:
~/.claude/skills/sven-app→~/.claude/skills/dispatch-app - Legacy symlink:
~/.claude/skills/dispatch-api→~/.claude/skills/dispatch-app
⚠️ MANDATORY PRE-FLIGHT: How to Deploy App Changes
Read this BEFORE touching any deploy command. Sessions repeatedly fail by defaulting to TestFlight or spawning unnecessary builds.
Deploy Decision Table
| Change type | Metro running? | Action |
|---|---|---|
| JS/TS only (components, screens, hooks, utils) | ✅ YES | Just save the file. Metro Fast Refresh handles it in ~2s. Done. |
| JS/TS only | ❌ NO | deploy-ios --quick |
| New JS-only npm package | either | scripts/metro restart --clear |
| Native (new pod/module, entitlements, Info.plist) | either | deploy-ios (full rebuild) |
| Web UI | N/A | npx expo export --platform web --clear && claude-assistant restart |
| Remote (traveling, no cable) | JS only | publish-update (self-hosted OTA) |
| Remote (traveling, no cable) | Native changes | serve-ipa (HTTPS/Tailscale) |
Check Metro Before Spawning Any Build
# ALWAYS check this first before running deploy-ios or any build command
pgrep -f "metro" && echo "Metro is running" || echo "Metro is NOT running"
curl -s http://localhost:8081/status && echo "Metro healthy" || echo "Metro down"
If Metro is healthy and the change is JS-only — stop here, just save the file.
NEVER USE TESTFLIGHT OR EAS FOR THIS APP
NEVER use TestFlight or EAS for the dispatch app. The dispatch app deploys directly to device via USB — no TestFlight, no EAS, no expo publish, no archive workflows. If you find yourself running eas build, expo publish, or opening Xcode for archiving — stop immediately, you are in the wrong workflow.
# Native changes only
~/dispatch/apps/dispatch-app/scripts/deploy-ios
# JS fallback (Metro unavailable)
~/dispatch/apps/dispatch-app/scripts/deploy-ios --quick
Remote Deploy (Traveling / No Cable)
When you don't have physical access to the phone, use these two scripts from ~/dispatch/apps/dispatch-app/scripts/:
JS-Only Changes: publish-update
Exports a JS bundle and publishes it as a self-hosted OTA update. The dispatch-api manifest endpoint automatically serves the latest update to the app.
cd ~/dispatch/apps/dispatch-app
scripts/publish-update # uses runtimeVersion from app.config.ts
scripts/publish-update --message "Fix widgets" # with optional message
Limitation: Only works for JS/TS changes. Native changes (new pods, entitlements, Info.plist) require a full rebuild — use serve-ipa instead.
Incompatible with dev builds. Only works on production/ad-hoc builds that have expo-updates configured.
Native Changes: serve-ipa
Serves a pre-built IPA over HTTPS via Tailscale for ad-hoc installation (itms-services protocol).
CRITICAL: Always build with -configuration Debug so the binary connects to Metro for hot reload over Tailscale. The Debug build reads RCTMetroHost from Info.plist (baked from metroHost in app.yaml) and sets jsLocation in AppDelegate.swift, so the device finds Metro at the Tailscale IP instead of scanning local LAN interfaces.
cd ~/dispatch/apps/dispatch-app
# Step 1: Archive with Debug config
xcodebuild archive \
-workspace ios/Sven.xcworkspace \
-scheme Sven \
-configuration Debug \
-archivePath /tmp/SvenDebug/Sven.xcarchive \
-destination "generic/platform=iOS" \
CODE_SIGN_IDENTITY="Apple Development"
# Step 2: Export with development method
xcodebuild -exportArchive \
-archivePath /tmp/SvenDebug/Sven.xcarchive \
-exportPath /tmp/SvenDebugIPA \
-exportOptionsPlist ExportOptions.plist \
-allowProvisioningUpdates
# Step 3: Serve
scripts/serve-ipa /tmp/SvenDebugIPA/Sven.ipa
This starts an HTTPS server on port 8444 using Tailscale certs, generates an itms-services manifest, and prints an install link. The user opens the link on their iPhone to install.
Requirements: Phone must be on Tailscale network. Metro must be running (npm start). IPA must be signed for development with the phone's UDID.
Never use -configuration Release for serve-ipa builds — Release bundles JS statically, so there's no hot reload and you'd have to rebuild for every JS change.
Setup for New Instances (e.g. Pang)
To deploy the dispatch-app + dispatch-api on a new machine (e.g. Ryan's Pang), follow these steps:
1. Config (Backend)
Create ~/dispatch/config.local.yaml with your assistant name:
assistant:
name: "Pang" # Drives session prefix (pang-app:), display name, etc.
email: "your-assistant@example.com"
owner:
name: "Ryan"
phone: "+1XXXXXXXXXX"
email: "owner@example.com"
The dispatch-api server reads assistant.name from this config. It derives:
- Session prefix:
{name.lower()}-app(e.g.pang-app) - Display name: Used in dashboard and API responses
- Infrastructure files:
dispatch-messages.db,dispatch-audio/, etc. (generic, shared across all instances)
2. Config (Mobile App)
Create ~/dispatch/apps/dispatch-app/app.yaml (gitignored):
appName: Pang
displayName: Pang
bundleIdentifier: com.yourname.pang
apiHost: YOUR_TAILSCALE_IP:9091
sessionPrefix: pang-app
accentColor: "#3478f7"
scheme: pang
iconPath: ./assets/images/icon.png
adaptiveIconPath: ./assets/images/adaptive-icon.png
splashColor: "#09090b"
speechCorrections:
pang: Pang
peng: Pang
Key fields:
sessionPrefix: Must match whatconfig.local.yaml'sassistant.nameproduces ({name.lower()}-app)apiHost: Your Tailscale IP + port (e.g.100.x.x.x:9091)bundleIdentifier: Unique per device/app
3. Backend Setup
The app backend must be registered in ~/dispatch/assistant/backends.py. For a new assistant name, add a backend entry:
"pang-app": BackendConfig(
name="pang-app",
label="PANG_APP",
session_suffix="-pang-app",
registry_prefix="pang-app:",
send_cmd='~/.claude/skills/dispatch-app/scripts/reply-app "{chat_id}"',
send_group_cmd='~/.claude/skills/dispatch-app/scripts/reply-app "{chat_id}"',
history_cmd="",
supports_image_context=True,
),
TODO: Make backends.py auto-derive the app backend from
config.local.yamlinstead of hardcoding.
4. Build & Run
# Start the API server
cd ~/dispatch/services/dispatch-api && nohup uv run server.py > ~/dispatch/logs/dispatch-api.log 2>&1 &
# Build the mobile app (iOS)
cd ~/dispatch/apps/dispatch-app
npm install
npx expo prebuild --clean --platform ios
npx expo run:ios --device "DEVICE_UDID"
5. Allowed Tokens
On first app launch, register the device token:
# The app auto-generates a UUID token on first launch
# Add it to ~/dispatch/services/dispatch-api/allowed_tokens.json
Backend API (dispatch-api)
FastAPI server providing the API backend for the app and system dashboard.
Quick Reference
| Item | Value |
|---|---|
| Location | ~/dispatch/services/dispatch-api/ |
| Health check | curl -s http://localhost:9091/health |
| Check if running | lsof -ti tcp:9091 -sTCP:LISTEN |
| Start | cd ~/dispatch/services/dispatch-api && uv run server.py |
| Logs | ~/dispatch/logs/dispatch-api.log |
Starting the Server
CRITICAL: The ONLY correct way to start dispatch-api:
cd ~/dispatch/services/dispatch-api && uv run server.py
Never do any of these:
uvicorn server:app— fails because fastapi dependency comes from PEP 723 inline metadatasource .venv && uvicorn ...— no .venv existspython3 server.py— always useuv runkill -HUP <pid>— silently kills the process instead of restarting
Restarting
# 1. Check if running
PID=$(lsof -ti tcp:9091 -sTCP:LISTEN)
# 2. Kill existing process (if any)
[ -n "$PID" ] && kill "$PID"
# 3. Start fresh
cd ~/dispatch/services/dispatch-api && nohup uv run server.py > ~/dispatch/logs/dispatch-api.log 2>&1 &
# 4. Verify
sleep 2 && curl -s http://localhost:9091/health
Server Architecture
The server loads ~/dispatch/config.local.yaml at startup to derive:
ASSISTANT_NAME— fromassistant.name(default: "Dispatch")APP_SESSION_PREFIX—{name.lower()}-app(e.g. "sven-app", "pang-app")
These drive session routing, display names, and SDK event lookups.
Key Endpoints
App Endpoints
POST /prompt— receive voice transcript, inject into sessionPOST /prompt-with-image— receive transcript + image (multipart/form-data)GET /messages?since=<timestamp>&chat_id=<id>— poll for new messagesGET /audio/<message_id>— download TTS audio filePOST /restart-session— restart a Claude session
Multi-Chat
POST /chats— create a new chatGET /chats— list all chats (includesis_thinking,has_notes)PATCH /chats/{chat_id}— rename a chatDELETE /chats/{chat_id}— delete chat + messages + notes + session
Chat Notes
GET /chats/{chat_id}/notes— read notes for a chat (returns{chat_id, content, updated_at})PUT /chats/{chat_id}/notes— create/update notes (upsert, 50K char limit)- Notes stored in
chat_notestable (1:1 with chats, CASCADE delete) has_notesboolean in chat list response indicates if notes exist
System Dashboard
GET /— main dashboard HTMLGET /health— health check
Frontend (Expo/React Native)
Quick Reference
cd ~/dispatch/apps/dispatch-app
# Lint (ALWAYS before committing)
npm run lint
# Web build (served by dispatch-api at /app/)
npx expo export --platform web
# iOS dev build
npx expo run:ios --device "DEVICE_UDID"
# iOS clean rebuild (after native config changes)
npx expo prebuild --clean --platform ios
npx expo run:ios --device "DEVICE_UDID"
Architecture
- Polling-based — no WebSocket. Chat list polls every 3s, messages every 1.5s
- Device token auth — UUID generated on first launch, registered via POST /register
- API URL configurable at runtime via Settings tab
Key Files
| Path | Purpose |
|---|---|
app/chat/[id].tsx |
Chat detail screen |
app/agents/[id].tsx |
Agent session detail screen |
app/(tabs)/index.tsx |
Chat list screen |
src/config/branding.ts |
App name, accent color, session prefix |
app.config.ts |
Expo config (reads app.yaml) |
app.yaml |
Per-instance config (gitignored) |
config.default.json |
Default runtime config |
Naming Convention
The codebase uses generic names everywhere. Instance-specific branding comes from app.yaml:
app.yaml— display name, bundle ID, voice corrections, session prefixsrc/config/branding.ts— exportsbrandingandsessionPrefixfrom Expo config
Do NOT add instance-specific names to app code, component names, or file names.
Backend (Message Bus + TTS + Push)
Message Bus (SQLite)
Location: ~/dispatch/state/dispatch-messages.db
reply-app CLI
Location: ~/.claude/skills/dispatch-app/scripts/reply-app
Called by Claude to send responses. Stores message in SQLite, generates TTS audio on demand, and sends push notification. (reply-sven exists as a backward-compat wrapper.)
# Basic text message
reply-app <chat_id> "message"
# With image attachment
reply-app <chat_id> "message" --image /path/to/image.jpg
# With audio attachment (mp3, wav, etc.)
reply-app <chat_id> "message" --audio /path/to/audio.mp3
Audio attachments are stored in ~/dispatch/state/dispatch-audio/{chat_id}/ via copy_audio_to_canonical() in dispatch_db.py. The app renders them with an inline waveform player (works for both user and assistant messages).
reply-widget CLI
Location: ~/.claude/skills/dispatch-app/scripts/reply-widget
Send interactive widgets to the app. Reads JSON from stdin, validates via pydantic, stores with plain-text fallback, sends push notification.
# Send an ask_question widget (2 questions, each gets "Other" by default)
cat <<'EOF' | reply-widget <chat_id> ask_question
{"questions": [{"question": "Which approach?", "options": [{"label": "Incremental", "description": "Small PRs"}, {"label": "Big bang"}]}, {"question": "Timeline?", "options": [{"label": "1 week"}, {"label": "2 weeks"}]}]}
EOF
# Multi-select + hide Other option
cat <<'EOF' | reply-widget <chat_id> ask_question
{"questions": [{"question": "Which features?", "options": [{"label": "Auth"}, {"label": "Search"}, {"label": "Cache"}], "multi_select": true, "include_other": false}]}
EOF
When to use ask_question: Need specific choice from options -> widget. Open-ended question -> plain text.
UX behavior:
- All questions rendered at once (not paginated)
- Each question has its options + "Other" with text input (default on, set
include_other: falseto hide) - Single "Save" button at bottom -- no auto-submit
- 3 states: unanswered -> pending -> answered
Response format: Widget responses arrive as multi-line:
[Widget Response {message_id}]
Q: "Which approach?" -> Incremental
Q: "Timeline?" -> Other: "3 weeks, need to sync with deploy"
Dedup rule: If you see [Widget Response {id}] for a message_id you already processed, ignore it. This can happen after session resume.
Limits: 1-4 questions per widget, 2-4 options per question, other_text max 500 chars.
Error handling: On validation failure: prints error to stderr, exit code 1, no DB write.
progress_tracker widget
Display-only widget showing multi-step task progress. No user response needed — agent sends updates by sending a new message with updated statuses.
# Show task progress
cat <<'EOF' | reply-widget <chat_id> progress_tracker
{"title": "Deploying update", "steps": [{"label": "Building app", "status": "complete"}, {"label": "Running tests", "status": "in_progress", "detail": "42/50 passed"}, {"label": "Deploying to production", "status": "pending"}]}
EOF
# Minimal (no title, just steps)
cat <<'EOF' | reply-widget <chat_id> progress_tracker
{"steps": [{"label": "Searching flights", "status": "complete"}, {"label": "Comparing prices", "status": "in_progress"}, {"label": "Booking best option"}]}
EOF
Step statuses: pending (default), in_progress, complete, error
Limits: 1-10 steps per widget, optional title, optional detail per step.
Updating progress: Send a new progress_tracker message with updated statuses. Each widget message is immutable — updates are new messages.
When to use: Multi-step tasks where the user would otherwise wait in silence. Great for: deployments, searches, data processing, any async workflow.
map_pin widget
Display-only widget showing location pins. Tapping a pin or the "Open in Maps" button opens Apple Maps.
# Single location
cat <<'EOF' | reply-widget <chat_id> map_pin
{"title": "Boston Common", "pins": [{"latitude": 42.3551, "longitude": -71.0657, "label": "Boston Common"}]}
EOF
# Multiple pins
cat <<'EOF' | reply-widget <chat_id> map_pin
{"title": "Restaurant options", "pins": [{"latitude": 42.3601, "longitude": -71.0589, "label": "Legal Sea Foods"}, {"latitude": 42.3625, "longitude": -71.0567, "label": "No. 9 Park"}], "zoom": 15}
EOF
Limits: 1-10 pins, optional title, optional label per pin, zoom 1-20 (default 14).
When to use: Discussing places, restaurants, directions, real estate, travel destinations. Any time a location is relevant.
Widget System Architecture
- Shared models:
~/.claude/skills/dispatch-app/scripts/widget_models.py-- pydantic models, per-type descriptor pattern, FormResponse with batch answers - DB columns:
widget_data TEXT,widget_response TEXT,responded_at DATETIMEon messages table - API endpoint:
POST /conversations/{chat_id}/messages/{message_id}/widget-response-- validates, injects, persists - App components:
AskQuestionWidget.tsx-- interactive form UI, radio/checkbox + Other text input, Save button, 3 statesProgressTrackerWidget.tsx-- display-only, vertical step timeline with status iconsMapPinWidget.tsx-- display-only, location cards with "Open in Maps" button (no native maps dependency)
- Graceful degradation: Old clients see plain-text fallback in
contentcolumn
Sessions (Multi-Chat)
Each chat in the app gets its own dedicated SDK session:
| Item | Format |
|---|---|
| Session name | {session_prefix}/{session_prefix}:<uuid> |
| Transcript dir | ~/transcripts/{session_prefix}/{session_prefix}:<uuid>/ |
| Tier | admin (full access) |
is_thinking Detection
The app shows "thinking dots" when a session is actively processing. This uses the daemon's BUSY flag via a shared session_states table in bus.db.
How It Works
Daemon writes:
sdk_session.pycallsproducer.set_session_busy(session_name, True/False)at transition points:Truewhen_pending_queriesgoes 0→1 (query starts)Falseon ResultMessage, query errors, and session shutdown (finally block)
Table:
session_statesin bus.db — one row per session, upserted on state changes:CREATE TABLE session_states ( session_name TEXT PRIMARY KEY, is_busy INTEGER NOT NULL DEFAULT 0, updated_at INTEGER NOT NULL ) WITHOUT ROWID;dispatch-api reads:
_check_is_thinking(session_name)does a single PK lookup onsession_states. ReturnsTrueifis_busy=1andupdated_atis within 5 minutes (staleness guard for daemon crashes).Frontend polls:
/chatsand/messagesendpoints includeis_thinkingfield. App polls every 3s (chat list) and 1.5s (messages).
Why Not a Time Window?
Previously used a 30-second window on sdk_events — checked if the latest event was within 30s and not a "result". This had false negatives during long subagent calls or gaps between turn injection and first tool_use. The BUSY flag is authoritative and has no window limitations.
Common Gotchas
Adding native Expo modules (e.g. expo-network, expo-camera)
When you add a new native Expo module:
- Install:
bun add expo-network(ornpx expo install expo-network) - Pod install:
cd ios && pod install— native modules need CocoaPods linking - Native rebuild required:
scripts/deploy-ios— JS-only metro reload is NOT enough - Clear metro cache:
scripts/metro restart --clear
The full sequence when you hit "Unable to resolve module" for a native package:
cd ~/dispatch/apps/dispatch-app
# 1. Verify the package is in node_modules
ls node_modules/expo-network
# 2. Full rebuild (stops metro, reinstalls pods, deploys)
scripts/deploy-ios
# 3. Start metro for hot reload after deploy
scripts/metro start --clear
Key insight: If pods are missing but node_modules has the package, the native build will succeed but metro will fail to resolve the module at JS bundle time. deploy-ios automatically stops metro before building, preventing stale cache issues.
Native modules not available in the running binary (Expo Go crash)
Distinct from "Unable to resolve module": A native module can be in node_modules yet absent from the running app binary (e.g., when using Expo Go or after a JS-only update that didn't rebuild native code). A top-level import of such a module crashes the entire module tree, not just the file that imports it. This produces misleading errors like "missing default export" on screens that don't use the module at all.
The pattern: never top-level import optional native modules.
For modules used on user action (file pickers, media library, camera), use a guarded import:
// Synchronous require guard — for modules used at component mount or on interaction
let DocumentPicker: typeof import("expo-document-picker") | null = null;
try {
DocumentPicker = require("expo-document-picker");
} catch {
// Native module not available (e.g. Expo Go)
}
// Then guard usage:
if (!DocumentPicker) {
Alert.alert("Not available", "This feature requires a native build.");
return;
}
const result = await DocumentPicker.getDocumentAsync({ ... });
// Async dynamic import guard — for modules used only in async callbacks (e.g. save image)
try {
const MediaLibrary = await import("expo-media-library");
await MediaLibrary.saveToLibraryAsync(localUri);
} catch {
// Fall back to share sheet or show alert
await Share.share({ url: localUri });
}
When to use which:
- Use synchronous
require()in atry/catchat module scope when the feature is used at component mount or in event handlers that need a null check. - Use async dynamic
import()in atry/catchinside an async callback when the module is only ever needed once the user performs a specific action (e.g., tapping "Save Image").
Do NOT do this — a bare top-level import crashes the whole module tree:
import * as DocumentPicker from "expo-document-picker"; // ✗ crashes Expo Go
Finding device names for --device
xcrun xctrace list devices # Lists all connected devices + simulators
Message pagination — NEWEST N, not oldest
The SQL MUST use a subquery to get the NEWEST 200:
SELECT * FROM (SELECT ... ORDER BY created_at DESC LIMIT 200) ORDER BY created_at ASC
Web build path
The web build is served from ~/dispatch/apps/dispatch-app/dist/.
Expo Fast Refresh
Fast Refresh preserves React state across file edits. useState initializers are IGNORED during hot reload.
Hot Reload Over Tailscale (Remote Development)
For hot reload to work on a physical device over Tailscale:
- Add
metroHost: "YOUR_TAILSCALE_IP"toapp.yaml(IP only, no port) - Use
scripts/metro start— readsmetroHostfromapp.yamland setsREACT_NATIVE_PACKAGER_HOSTNAMEautomatically npm run start:localbypasses this and runs plainexpo start
Mullvad VPN + Tailscale coexistence:
- Mullvad's split tunnel must exclude BOTH the Tailscale app binary AND the
IPNExtension(network extension) - Just excluding the Tailscale app is not enough — the IPNExtension handles actual network traffic
- On Mac: Mullvad → Settings → Split tunneling → add both Tailscale AND IPNExtension
- Start order: Mullvad first, then Tailscale
- Same applies on iOS — Tailscale needs split tunnel exclusion in the VPN app
Instance-Specific Files (Must Be Gitignored)
These files are per-instance and MUST NOT be tracked in git:
services/dispatch-api/allowed_tokens.json— device auth tokensstate/dispatch-push-config.json— APNs credentialsstate/dispatch-apns-tokens.json— device push tokensapps/dispatch-app/app.yaml— instance branding/config
If a git pull wipes allowed_tokens.json, the app will show 403: Invalid token. Re-add the device token (visible in app Settings → Device Token).
dispatch-api Port Conflicts
If dispatch-api crashes in a loop with "Address already in use" on port 9091:
- An old process is holding the port. The daemon's
_stop_dispatch_api()does SIGTERM + 5s wait + SIGKILL. - Critical: ALL code paths that respawn dispatch-api (health check, restart, etc.) must go through
_stop_dispatch_api()first. Direct.kill()+ immediate respawn causes race conditions. - Manual fix:
kill $(lsof -ti tcp:9091 -sTCP:LISTEN)then wait 2-3 seconds before restarting.
Remaining Hardcoded Items (Follow-up)
These items still reference "sven" and need future refactoring:
backends.py— Thesven-appbackend entry is hardcoded. Should auto-derive fromconfig.local.yaml.✅ Done —cli.py— The--sven-appflag oninject-prompt. Consider renaming to--app.--appis primary,--sven-appkept as hidden backward-compat alias.~/.claude/skills/sven-app/— Skill directory name. Currently symlinked todispatch-app.reply-svenscript name — Should be renamed toreply-appor similar. (reply-appexists,reply-svenis backward-compat wrapper.)tests/test_sven_app.py— Test file referencessven-appbackend by name.✅ Done — generalized toreaders.py— Hassven-appbackend-specific reader class.dispatch-app.✅ Done — handles both names.common.py— Hassven-appsource check for reply command routing.
Related Projects
- house-app at
~/code/house-app/— also known as "Church app" or "The Church" app- Expo/React Native frontend with FastAPI backend
- Backend runs on port 9092 (separate from dispatch-api on 9091)
- LaunchAgent
com.house.apimanages the backend process - Not part of the dispatch system — standalone project with its own API
Manual Testing (Simulator)
Comprehensive test plan for all Critical User Journeys (CUJs). Run in the iOS Simulator.
Test Constants
These values are referenced throughout. Update here if the app changes.
| Constant | Value | Source |
|---|---|---|
| API port | 9091 |
~/dispatch/services/dispatch-api/server.py |
| Chat list poll interval | 3s | MESSAGE_POLL_INTERVAL |
| Message poll interval | 1.5s | MESSAGE_POLL_INTERVAL |
| SDK events poll interval | 2s | useSdkEvents.ts |
| Text truncation threshold | 840 chars | MessageBubble.tsx MAX_COLLAPSED_LENGTH |
| Input max length | 10,000 chars | InputBar.tsx |
| Message bubble max width | 85% of screen | MessageBubble.tsx |
| Image thumbnail preview | 72×72px | InputBar.tsx |
| Connection auto-retry | 5s | settings.tsx |
Glossary
| Term | Meaning |
|---|---|
| FAB | Floating Action Button — the circular + button (56×56px, accent-colored) pinned to the bottom-right corner |
| Lazy TTS | Text-to-speech audio generated on-demand the first time you tap play, then cached for instant replay |
| SDK Events | Real-time log of Claude's tool executions (Bash → "Running command", Read → "Reading file", etc.) |
| Agent session | A persistent Claude conversation — either a contact session (iMessage/Signal) or a dispatch-api session (app-created) |
| Thinking indicator | Animated 3-dot bubble (bottom-left, assistant style) that appears while Claude is processing |
| Device token | UUID generated on first app launch, used to authenticate API requests |
UI Layout Reference
Chat List screen:
┌───────────────────────────────────┐
│ Chats │ ← Header
├───────────────────────────────────┤
│ ┌───────────────────────────────┐ │
│ │ Chat Title 2m ago │ │ ← Chat row (swipe right → red Delete)
│ │ Last message preview... │ │
│ └───────────────────────────────┘ │
│ ┌───────────────────────────────┐ │
│ │ Another Chat 1h ago │ │
│ │ Preview text... │ │
│ └───────────────────────────────┘ │
│ [+] │ ← FAB (bottom-right)
├───────────────────────────────────┤
│ [Chats] [Agents] [Dashboard] [⚙] │ ← Tab bar
└───────────────────────────────────┘
Empty state: "No conversations yet" / "Tap + to start a new chat"
Chat Detail screen:
┌───────────────────────────────────┐
│ [← Chats] Chat Title [Rename] │ ← Header
├───────────────────────────────────┤
│ │
│ ┌──────────────────┐ │ ← Assistant bubble (left, #27272a)
│ │ Response text │ [▶][↓] │ [▶] = play audio, [↓] = save image
│ └──────────────────┘ │
│ ┌──────────────────┐ │ ← User bubble (right, accent color)
│ │ Your message │ │
│ └──────────────────┘ │
│ │
│ ⬤ ⬤ ⬤ │ ← Thinking indicator (when active)
├───────────────────────────────────┤
│ [+img] [ Message... ] [🎤/↑] │ ← Input bar
└───────────────────────────────────┘
Prerequisites
# 1. Start the API server
cd ~/dispatch/services/dispatch-api && nohup uv run server.py > ~/dispatch/logs/dispatch-api.log 2>&1 &
# 2. Verify it's running (expect: {"status": "ok"})
curl -s http://localhost:9091/health
# 3. Build & launch in simulator
cd ~/dispatch/apps/dispatch-app
npm install
npx expo prebuild --clean --platform ios
npx expo run:ios # launches default simulator
# 4. Ensure the daemon is running (needed for session creation & is_thinking)
claude-assistant status
# 5. First launch: configure API URL
# Settings tab → API Server → enter "http://localhost:9091"
Tip: Add test photos to the simulator by dragging image files onto the simulator window.
CUJ 1: Chat List & Create Chat
- App opens to Chats tab — verify either:
- Existing chats appear as a scrollable list with title, last message preview, and relative timestamp, OR
- Empty state shows "No conversations yet" with subtitle "Tap + to start a new chat"
- Pull-to-refresh: Pull down on the chat list — verify a loading spinner appears briefly, then list reloads
- Tap the + FAB (blue circle, bottom-right) — a new chat screen opens with empty state: "No messages yet / Send a message to start the conversation"
- Tap ← Chats to go back — verify the new chat now appears in the list with title "New Chat"
CUJ 2: Send Text Message (First Message Flow)
- Open a chat (or create a new one)
- Verify the input bar shows:
[+ image picker]on the left, text field with placeholder "Message {AppName}...", and a microphone icon on the right (send button is hidden when input is empty) - Type
hello— verify the mic icon is replaced by a blue ↑ send button - Tap send — verify:
- Your message appears as a right-aligned bubble (accent color) with reduced opacity (pending state)
- Within the chat list poll interval (see Test Constants), an assistant response appears as a left-aligned bubble (#27272a dark gray)
- Your message returns to full opacity
- If this was the first message in a new chat, go back to chat list — verify the chat title auto-updated from "New Chat" to a title derived from your message
CUJ 3: Send Message with Image
- Open a chat
- Tap the + button (dark gray circle, left of text input) — the system photo picker opens
- Select a photo — verify a 72×72px thumbnail preview appears above the input bar with an × remove button (small circle, top-right of thumbnail)
- Type
what's in this image?in the text field, then tap ↑ send - Verify your message bubble contains both the image (full-width, 4:3 aspect ratio) and the text
- Verify the assistant responds with a description of the image
- Remove before sending: Tap × on the thumbnail — verify it disappears and the image picker button returns
- Clipboard paste: Copy any image to clipboard, then tap the text input field — verify a bar appears above the input: "📋 Paste image from clipboard" (tap it to attach)
CUJ 4: Voice Dictation
- Open a chat — verify the mic button (gray microphone icon, right side) is visible when the text field is empty
- Tap the mic — grant microphone permission if prompted
- Verify the input border turns red, placeholder changes to "Listening...", and the mic button is replaced by a red square stop button
- Speak "hello world" — verify text appears in the input field as you speak (interim results update in real-time)
- Tap the red stop button — verify dictation stops, final text remains in the input field
- Tap ↑ send to submit the dictated text
Note: Simulator uses your Mac's microphone. Speech recognition requires network connectivity.
CUJ 5: Rename Chat
- Open a chat — verify the header shows the chat title with a "Rename" button (accent-colored text, right side of header)
- Tap Rename — a prompt dialog appears with title "Rename Chat"
- Enter "Test Conversation" and confirm
- Verify the header title updates to "Test Conversation"
- Go back to chat list — verify the updated title appears there too
CUJ 6: Delete Chat
- On the chat list, swipe a chat row to the right — a red panel (80px wide) slides in from the right with "Delete" text
- Tap Delete — a confirmation dialog appears
- Confirm — verify the chat disappears from the list immediately (optimistic delete)
- Multi-chat safety: If you have other chats, verify they are completely unaffected (still present, messages intact)
CUJ 7: Audio Playback (Lazy TTS)
Requires: Kokoro TTS installed at
~/.claude/skills/tts/.
- Open a chat with at least one assistant message
- Find the ▶ play button (small dark circle with triangle, to the right of the assistant's message bubble)
- Tap ▶ — verify:
- Button changes to a spinner (generating TTS for the first time — may take 2-5 seconds)
- Once generated, audio plays and the button becomes ‖ pause (two vertical bars)
- Audio plays even if the device is in silent mode
- Tap ‖ pause — verify playback pauses (button returns to ▶)
- Tap ▶ again — verify playback resumes from where it paused (not from the beginning)
- Let audio finish — verify button returns to ▶ automatically
- Tap ▶ a second time — verify audio starts instantly (cached, no spinner)
CUJ 8: Thinking Indicator & SDK Events
Requires: Daemon running (
claude-assistant statusshows active).
- Send a message that triggers multi-step processing, e.g.:
search the web for the latest news about AI - Within 1-2 seconds, verify the thinking indicator appears: three gray pulsing dots (8×8px each) in an assistant-style bubble above the input bar, with a small ▾ chevron
- Tap the thinking bubble — verify it expands to show a scrolling list of SDK events with labels like:
- "Searching the web" (for WebSearch tool)
- "Fetching webpage" (for WebFetch)
- "Running command" (for Bash)
- Completed steps show a blue ✓ checkmark with duration (e.g., "2.5s")
- Active step shows a purple ● dot
- Tap the expanded indicator again (▴ chevron) — verify it collapses back to 3 dots
- When the assistant's response arrives, verify the thinking indicator disappears completely
CUJ 9: Agent Sessions
- Switch to the Agents tab (second tab in tab bar)
- Verify either:
- Existing agent/contact sessions appear in a list, OR
- Empty state: "No agent sessions / Tap + to create a new agent session"
- Filter: Tap filter pills at the top — "All", "Contact", "Dispatch API" — verify the list filters correctly
- Search: Type a session name in the search bar — verify results filter in real-time
- Create: Tap the + FAB — enter a name (e.g., "Test Agent") — verify a new session opens
- The session detail screen shows:
- Header with session name and tier/source badges (e.g., "Admin", "Dispatch API")
- Two tabs: Messages and SDK Events
- Send message: Type a message and send (input bar only shows for dispatch-api type sessions)
- Rename: Tap Rename in header → enter new name → verify it updates (dispatch-api sessions only)
- Delete: Tap Delete (red text) → confirm dialog → verify session removed from list (dispatch-api sessions only)
CUJ 10: Settings
- Switch to Settings tab (rightmost tab) — verify the header shows the app name, "v1.0.0", and "Powered by Claude"
- CONNECTION section:
- API Server: Shows current URL. Tap → enter a URL → verify connection re-checks
- Status: Green dot (●) + "Connected" when server is reachable. Tap to manually re-check
- ABOUT section:
- Device Token: Shows truncated token (first 8 + "..." + last 4 chars, monospace). Tap → verify "Copied!" feedback appears for 2 seconds
- DEBUG section:
- Logs: Tap → verify logs screen opens showing live system logs
- Restart Session: Tap → confirmation dialog "Restart the Claude session? This will clear the conversation context." → tap "Restart" → verify session restarts (new messages won't have prior context)
- Clear Notifications: Tap → verify alert "Done - Notifications cleared." appears
CUJ 11: Push Notifications (Device Only)
Skip in simulator — APNs push notifications require a physical device or TestFlight build. Simulator cannot receive remote push notifications.
On a real device:
- Send a message and wait for the assistant to respond
- Background the app (swipe up / press home)
- Verify a push notification banner appears with the assistant's response
- Tap the notification — verify the app opens directly to the correct chat
- Verify the notification is dismissed from the notification center for that chat
CUJ 12: Dashboard
- Switch to Dashboard tab (third tab)
- Verify a WebView loads the system dashboard from the
/dashboardendpoint - Verify the page loads without errors (not a blank white screen). The dashboard content is server-rendered HTML — as long as it displays text and data, it is working
- If WebView fails to load, verify a fallback "Open in Browser" button appears that launches Safari
CUJ 13: Error Handling & Retry
- Kill the API server:
kill $(lsof -ti tcp:9091 -sTCP:LISTEN) - Try sending a message — verify:
- Your message appears grayed-out (pending), then the gray clears and a red ● ! badge appears to the right of the bubble with "Not Delivered" text (#ef4444 red) below it
- Switch to Settings — verify the Status row shows a red dot (●) + "Disconnected"
- Restart the API server:
cd ~/dispatch/services/dispatch-api && nohup uv run server.py > ~/dispatch/logs/dispatch-api.log 2>&1 & - Wait up to 10 seconds — verify Settings status auto-recovers to green "Connected" (auto-retry interval per Test Constants)
- Go back to the chat — tap the red ! badge / "Not Delivered" on the failed message — verify the message retries and the assistant eventually responds
CUJ 14: Message Bubble Features
- Long text truncation: Send a message with 900+ characters (e.g., paste a long paragraph). The assistant's response may also be long. Verify messages over the truncation threshold (see Test Constants) show truncated text ending with "..." and a "Show more" link (#a1a1aa gray, 13px)
- Expand: Tap "Show more" — verify full text appears with a "Show less" link at the bottom
- Collapse: Tap "Show less" — verify it truncates again
- Timestamp: Tap any message bubble — verify a relative timestamp appears below it (e.g., "2 minutes ago", gray #71717a, 11px)
- Save image: On an assistant message with an image, find the ↓ save button (dark circle with download arrow, right of bubble). Tap it — verify a spinner appears briefly, then the image is saved to the photo library
CUJ 15: App Suspend & Resume
- Open a chat with several messages
- Background the app: Press Cmd+Shift+H (simulator home)
- Wait 5 seconds
- Reopen the app — verify:
- Chat state is fully preserved (same messages, same scroll position)
- Polling resumes (new messages from assistant appear if any were generated while backgrounded)
- Switch to chat list — verify pull-to-refresh works after resume
CUJ 16: Scroll Behavior
- Open a chat and send 15+ messages (back and forth with the assistant)
- Verify the list auto-scrolls to the bottom when a new message arrives
- Scroll up to read older messages — verify the list stays at your scroll position (does NOT snap back to bottom)
- Send a new message — verify the list scrolls back to the bottom to show your new message
Quick Smoke Test (5 minutes)
Run through these 10 steps to quickly validate all major flows:
1.
…(truncated)