skills/dispatch-app/SKILL.md
Dispatch mobile app — Expo/React Native frontend, FastAPI backend (dispatch-api), message bus, TTS, push notifications, reply CLIs. Trigger words - dispatch app, sven app, expo, ios app, mobile app, react native, reply-sven, push notification, dispatch-api, api server, dispatch api.
npx skillsauth add svenflow/dispatch dispatch-appInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Full-stack mobile app for the dispatch personal assistant. Frontend (Expo/React Native) + backend API (FastAPI/dispatch-api) + message bus, TTS, and push notifications.
~/dispatch/apps/dispatch-app/~/dispatch/services/dispatch-api/~/.claude/skills/sven-app → ~/.claude/skills/dispatch-app~/.claude/skills/dispatch-api → ~/.claude/skills/dispatch-appRead this BEFORE touching any deploy command. Sessions repeatedly fail by defaulting to TestFlight or spawning unnecessary builds.
| 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) |
# 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 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
When you don't have physical access to the phone, use these two scripts from ~/dispatch/apps/dispatch-app/scripts/:
publish-updateExports 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.
serve-ipaServes 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.
To deploy the dispatch-app + dispatch-api on a new machine (e.g. Ryan's Pang), follow these steps:
Create ~/dispatch/config.local.yaml with your assistant name:
assistant:
name: "Pang" # Drives session prefix (pang-app:), display name, etc.
email: "[email protected]"
owner:
name: "Ryan"
phone: "+1XXXXXXXXXX"
email: "[email protected]"
The dispatch-api server reads assistant.name from this config. It derives:
{name.lower()}-app (e.g. pang-app)dispatch-messages.db, dispatch-audio/, etc. (generic, shared across all instances)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 what config.local.yaml's assistant.name produces ({name.lower()}-app)apiHost: Your Tailscale IP + port (e.g. 100.x.x.x:9091)bundleIdentifier: Unique per device/appThe 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.
# 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"
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
FastAPI server providing the API backend for the app and system dashboard.
| 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 |
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 use uv runkill -HUP <pid> — silently kills the process instead of 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
The server loads ~/dispatch/config.local.yaml at startup to derive:
ASSISTANT_NAME — from assistant.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.
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 sessionPOST /chats — create a new chatGET /chats — list all chats (includes is_thinking, has_notes)PATCH /chats/{chat_id} — rename a chatDELETE /chats/{chat_id} — delete chat + messages + notes + sessionGET /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)chat_notes table (1:1 with chats, CASCADE delete)has_notes boolean in chat list response indicates if notes existGET / — main dashboard HTMLGET /health — health checkcd ~/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"
| 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 |
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 — exports branding and sessionPrefix from Expo configDo NOT add instance-specific names to app code, component names, or file names.
Location: ~/dispatch/state/dispatch-messages.db
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).
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:
include_other: false to hide)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.
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.
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.
~/.claude/skills/dispatch-app/scripts/widget_models.py -- pydantic models, per-type descriptor pattern, FormResponse with batch answerswidget_data TEXT, widget_response TEXT, responded_at DATETIME on messages tablePOST /conversations/{chat_id}/messages/{message_id}/widget-response -- validates, injects, persistsAskQuestionWidget.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)content columnEach 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) |
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.
Daemon writes: sdk_session.py calls producer.set_session_busy(session_name, True/False) at transition points:
True when _pending_queries goes 0→1 (query starts)False on ResultMessage, query errors, and session shutdown (finally block)Table: session_states in 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 on session_states. Returns True if is_busy=1 and updated_at is within 5 minutes (staleness guard for daemon crashes).
Frontend polls: /chats and /messages endpoints include is_thinking field. App polls every 3s (chat list) and 1.5s (messages).
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.
When you add a new native Expo module:
bun add expo-network (or npx expo install expo-network)cd ios && pod install — native modules need CocoaPods linkingscripts/deploy-ios — JS-only metro reload is NOT enoughscripts/metro restart --clearThe 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.
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:
require() in a try/catch at module scope when the feature is used at component mount or in event handlers that need a null check.import() in a try/catch inside 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
--devicexcrun xctrace list devices # Lists all connected devices + simulators
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
The web build is served from ~/dispatch/apps/dispatch-app/dist/.
Fast Refresh preserves React state across file edits. useState initializers are IGNORED during hot reload.
For hot reload to work on a physical device over Tailscale:
metroHost: "YOUR_TAILSCALE_IP" to app.yaml (IP only, no port)scripts/metro start — reads metroHost from app.yaml and sets REACT_NATIVE_PACKAGER_HOSTNAME automaticallynpm run start:local bypasses this and runs plain expo startMullvad VPN + Tailscale coexistence:
IPNExtension (network extension)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/configIf 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).
If dispatch-api crashes in a loop with "Address already in use" on port 9091:
_stop_dispatch_api() does SIGTERM + 5s wait + SIGKILL._stop_dispatch_api() first. Direct .kill() + immediate respawn causes race conditions.kill $(lsof -ti tcp:9091 -sTCP:LISTEN) then wait 2-3 seconds before restarting.These items still reference "sven" and need future refactoring:
backends.py — The sven-app backend entry is hardcoded. Should auto-derive from config.local.yaml.cli.py — The --sven-app flag on inject-prompt. Consider renaming to --app.~~ ✅ Done — --app is primary, --sven-app kept as hidden backward-compat alias.~/.claude/skills/sven-app/ — Skill directory name. Currently symlinked to dispatch-app.reply-sven script name — Should be renamed to reply-app or similar. (reply-app exists, reply-sven is backward-compat wrapper.)tests/test_sven_app.py — Test file references sven-app backend by name.readers.py — Has sven-app backend-specific reader class.~~ ✅ Done — generalized to dispatch-app.common.py — Has sven-app source check for reply command routing.~~ ✅ Done — handles both names.~/code/house-app/ — also known as "Church app" or "The Church" app
com.house.api manages the backend processComprehensive test plan for all Critical User Journeys (CUJs). Run in the iOS Simulator.
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 |
| 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 |
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
└───────────────────────────────────┘
# 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.
[+ 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)hello — verify the mic icon is replaced by a blue ↑ send buttonwhat's in this image? in the text field, then tap ↑ sendNote: Simulator uses your Mac's microphone. Speech recognition requires network connectivity.
Requires: Kokoro TTS installed at
~/.claude/skills/tts/.
Requires: Daemon running (
claude-assistant statusshows active).
search the web for the latest news about AISkip in simulator — APNs push notifications require a physical device or TestFlight build. Simulator cannot receive remote push notifications.
On a real device:
/dashboard endpointkill $(lsof -ti tcp:9091 -sTCP:LISTEN)cd ~/dispatch/services/dispatch-api && nohup uv run server.py > ~/dispatch/logs/dispatch-api.log 2>&1 &Run through these 10 steps to quickly validate all major flows:
1. Launch app → Chats tab loads (list or empty state) ✓ CUJ 1
2. Tap + FAB → New chat opens with empty state ✓ CUJ 1
3. Type "hello" → Tap ↑ → Message appears as pending ✓ CUJ 2
4. Wait ~3s → Assistant response appears (left-aligned) ✓ CUJ 2
5. Tap ▶ on response → Audio generates, then plays ✓ CUJ 7
6. Verify thinking dots appeared during step 4 ✓ CUJ 8
7. Tap Rename → Enter "Smoke Test" → Title updates ✓ CUJ 5
8. Go back → Swipe chat right → Delete → Confirm → Gone ✓ CUJ 6
9. Agents tab → Tap + → Create "Test" → Send message ✓ CUJ 9
10. Settings tab → Verify green "Connected" status ✓ CUJ 10
Extended smoke test (add 5 more minutes):
11. Send image + text → Both appear in bubble ✓ CUJ 3
12. Mic button → Speak → Stop → Text in field ✓ CUJ 4
13. Kill API → Send message → "Not Delivered" appears ✓ CUJ 13
14. Restart API → Tap retry → Message sends ✓ CUJ 13
15. Dashboard tab → WebView loads ✓ CUJ 12
Using Expo Go (from App Store). Supports fast refresh for all JS changes. Dev menu via shake gesture or 3-finger long press. There is a Dev Tools button in Settings → Debug that attempts NativeModules.DevMenu.show() with a fallback alert to shake.
Do NOT use expo-dev-client — it adds a floating overlay icon that clutters the UI. Expo Go with fast refresh is sufficient for development.
Metro is managed by the daemon automatically. The daemon starts Metro on boot, health-checks via GET http://localhost:8081/status, and auto-restarts on crash with exponential backoff. You do NOT need to start or manage Metro manually.
Check Metro status:
curl -sf http://localhost:8081/status && echo " Metro healthy" || echo " Metro DOWN"
# Or use the metro CLI for more detail
~/dispatch/apps/dispatch-app/scripts/metro status
The phone connects to Metro over Tailscale WiFi (metroHost in app.yaml). When Metro is running and the phone is on the network, JS changes are live in ~2 seconds on save via Fast Refresh. No deploy step needed.
If Metro gets wedged (stale cache, syntax error recovery):
~/dispatch/apps/dispatch-app/scripts/metro restart --clear
Follow this decision tree for EVERY change to the app:
What did you change?
|
+-- JS/TS only (components, screens, styles, hooks, utils)?
| |
| +-- Metro healthy? (curl localhost:8081/status)
| | +-- YES -> Just save the file. Done. Fast Refresh handles it (~2s).
| | +-- NO -> Wait for daemon auto-restart (~30s), or deploy-ios --quick
| |
| +-- Phone showing "No connection to Metro"?
| -> Reopen the app. If still disconnected: shake -> Dev Menu -> Change server
| -> Check phone is on same Tailscale network as this Mac
|
+-- New JS-only npm package?
| +-- ~/dispatch/apps/dispatch-app/scripts/metro restart --clear
|
+-- Native change (new pod/package, entitlements, Info.plist, native module)?
| +-- deploy-ios (full rebuild with pod install)
|
+-- Web UI change?
| +-- npx expo export --platform web && claude-assistant restart
|
+-- Not sure?
+-- Save the file first. If Metro picks it up -> done.
If red error about native module -> deploy-ios
THE DEFAULT FOR JS CHANGES IS: JUST SAVE THE FILE. Do not run deploy-ios --quick for JS changes when Metro is running. That takes 2+ minutes for what Metro does in 2 seconds.
Multiple sessions may edit the app simultaneously. Be aware:
~/dispatch/state/metro-editor.lock — if another session is actively editing app code, coordinate or waitWhen editing app code, write to the lock file:
echo '{"session": "'$SESSION_ID'", "file": "path/to/file.tsx", "ts": '$(date +%s)'}' > ~/dispatch/state/metro-editor.lock
Remove it when done. Lock entries older than 5 minutes are stale.
| Target | Command | When to use |
|--------|---------|-------------|
| Web | npx expo export --platform web then claude-assistant restart | Web UI at localhost:9091 |
| iOS (JS only) | Just save the file (Metro hot reload) | JS/TS changes with Metro running |
| iOS (native) | deploy-ios (full rebuild) | New pods, entitlements, native modules |
| iOS (no Metro) | deploy-ios --quick (bundle JS into binary) | Metro is down and won't restart |
Metro hot reload is the primary path for JS changes. deploy-ios --quick is the fallback, not the default.
# Full clean deploy (native changes — new pods, entitlements, etc.)
~/dispatch/apps/dispatch-app/scripts/deploy-ios
# Quick deploy (JS fallback when Metro is unavailable)
~/dispatch/apps/dispatch-app/scripts/deploy-ios --quick
# Quick deploy + restart Metro after
~/dispatch/apps/dispatch-app/scripts/deploy-ios --quick --metro
The script handles:
--clearCommon issues:
cd ~/dispatch/apps/dispatch-app
npx expo export --platform web # Build static web bundle
claude-assistant restart # Restart daemon to serve new bundle
Troubleshooting: Silent 500 on / (web app not loading)
The root / endpoint serves ~/dispatch/apps/dispatch-app/dist/index.html. If that file is missing, the API returns a silent 500 with no helpful error message.
# Check if the web build exists
ls ~/dispatch/apps/dispatch-app/dist/index.html
# If missing, rebuild:
cd ~/dispatch/apps/dispatch-app
npx expo export --platform web --clear
claude-assistant restart
This is separate from iOS OTA updates — rebuilding the web bundle doesn't affect the iOS app.
NEVER use TestFlight or EAS for the dispatch app.
# Native changes (new pods, plugins, entitlements)
~/dispatch/apps/dispatch-app/scripts/deploy-ios
# JS fallback (Metro unavailable)
~/dispatch/apps/dispatch-app/scripts/deploy-ios --quick
When someone says "push app" or "deploy app":
deploy-iosThe same guarded-import pattern applies for web builds (npx expo export). Modules like expo-notifications, expo-haptics, and expo-speech-recognition are iOS/Android only. On web, a top-level import crashes the entire module, making all its exports undefined — which produces confusing errors like (0, _.functionName) is not a function.
Always guard with Platform.OS !== "web":
let Notifications: typeof import("expo-notifications") | null = null;
if (Platform.OS !== "web") {
try {
Notifications = require("expo-notifications") as typeof import("expo-notifications");
} catch { /* native module not available */ }
}
// Use optional chaining: Notifications?.setNotificationHandler(...)
Known iOS/Android-only modules that need guards:
expo-notifications (push notifications)expo-haptics (vibration feedback)@jamsch/expo-speech-recognition (voice input)expo-document-picker (file picker)expo-media-library (save to camera roll)development
Use when building React/Next.js components, dashboards, admin panels, apps, or any web interface. Trigger words - react, frontend, ui, dashboard, component, interface, web app, polish, audit, design review.
tools
Track flight status and get FlightAware links. Use when asked about flights, flight status, arrival times, or flight tracking. Trigger words - flight, flying, UA, AA, DL, landing, arriving, departure.
development
Query real-time locations of people sharing via Find My. Look up where someone is, reverse geocode GPS coordinates, set up geofence alerts. Trigger words - findmy, find my, location, where is, geofence, track location.
tools
Access Figma designs via MCP or Chrome. Use when asked about Figma files, design mockups, wireframes, or UI designs. Trigger words - figma, design, mockup, wireframe, UI design, FigJam.