Watch Integration — Apple Watch & Wear OS
VibeCody extends its AI coding assistant to wrist-worn devices, giving developers a lightweight session monitor and voice dispatch capability while away from the desktop.
Architecture Overview
┌─────────────────────────────────────────────────────────────────┐
│ vibecli daemon (vibecli --serve --port 7878) │
│ │
│ ┌─────────────────┐ ┌──────────────────┐ ┌───────────────┐ │
│ │ watch_auth.rs │ │ watch_session_ │ │ watch_bridge │ │
│ │ HMAC-SHA256 │ │ relay.rs │ │ .rs │ │
│ │ JWT lifecycle │ │ Compact payloads │ │ Axum /watch/* │ │
│ │ P-256 ECDSA reg│ │ OLED-optimised │ │ SSE streaming │ │
│ └────────┬────────┘ └────────┬─────────┘ └──────┬────────┘ │
│ │ │ │ │
└───────────┼────────────────────┼───────────────────┼─-──────────┘
│ │ │
┌──────▼──────┐ ┌──────▼──────┐ ┌─────▼──────┐
│ LAN / TLS │ │ LAN / TLS │ │ SSE feed │
└──────┬──────┘ └──────┬──────┘ └─────┬──────┘
│ │ │
┌────────▼────────────────────▼───────────────────▼──---────┐
│ Transport fallback chain │
│ 1. Direct LAN (Wi-Fi, same subnet) │
│ 2. Tailscale mesh (cross-network) │
│ 3. Phone relay (WatchConnectivity on iOS / │
│ Wearable Data Layer on Android) │
└──────────┬──────────────────────────────┬─────────────-───┘
│ │
┌──────────▼──────────┐ ┌────────────▼─────────────-─┐
│ Apple Watch │ │ Android Wear OS │
│ WatchOS 9+ │ │ Wear OS 3+ │
│ VibeCody watchApp │ │ VibeCodyWear app │
│ WatchConnectivity │ │ Wearable Data Layer API │
│ Secure Enclave key │ │ Android Keystore P-256 │
└─────────────────────┘ └──────────────────────────-─┘
Rust Modules
watch_auth.rs — Authentication & Device Registration
Provides challenge-response registration and JWT-based session security.
Key types:
| Type | Description |
|---|---|
WatchAuthManager |
Main entry point; holds machine ID and JWT secret |
RegistrationChallenge |
Single-use 32-char hex nonce with 5-minute TTL |
WatchRegisterRequest |
Device public key (64-byte raw P-256) + P-256 ECDSA signature over SHA-256(nonce ‖ device_id ‖ issued_at_be) |
WatchDevice |
Registered device record (ID, platform, public key, revocation) |
WatchClaims |
JWT payload: sub (device ID), machine_id, kind, exp |
WristActivityEvent |
On-wrist / off-wrist events with P-256 ECDSA signature over SHA-256(device_id ‖ on_wrist_byte ‖ timestamp) |
NonceRegistry (relay) |
Replay-prevention map with 30-second timestamp window |
Token lifecycle:
[Watch] → GET /watch/challenge
[Daemon] ← { nonce, machine_id, issued_at, expires_at }
[Watch] → POST /watch/register { device_id, platform, public_key_b64, nonce, signature_b64, issued_at }
[Daemon] verifies P-256 ECDSA over SHA-256(nonce ‖ device_id ‖ issued_at_be)
using the provided 64-byte raw P-256 public key
[Daemon] ← { access_token (15 min), refresh_token (7 days), device_id }
[Watch] → GET /watch/sessions (Authorization: Bearer <access_token>)
...
[Watch] → POST /watch/refresh (Authorization: Bearer <refresh_token>)
[Daemon] ← { access_token, refresh_token }
Security properties:
- JWT signed with HMAC-SHA256 (32-byte secret stored in
ProfileStore) - P-256 ECDSA (secp256r1) device key pair — private key never leaves the device. Apple’s Secure Enclave only supports P-256, so both watch platforms use the same curve for code reuse and symmetric verification (commit
3308278amigrated away from Ed25519)- Apple Watch: Secure Enclave via CryptoKit
SecureEnclave.P256.Signing - Wear OS: Android Keystore with StrongBox/TEE backing (
KeyProperties.KEY_ALGORITHM_EC+NIST P-256)
- Apple Watch: Secure Enclave via CryptoKit
- Wrist-suspension lock: suspended sessions block tool execution
- Replay prevention: nonces are single-use within a 5-minute window
watch_session_relay.rs — Compact Payloads
Transforms full session models into OLED-optimised representations.
Key types:
| Type | Description |
|---|---|
WatchSessionSummary |
Session ID, status, model, step count, last message preview (≤80 chars), message count |
WatchMessage |
Role, content (≤512 chars with … truncation), timestamp |
WatchAgentEvent |
Streaming SSE event: kind, delta, tool, step, status, error |
WatchSandboxStatus |
CPU %, memory MB, disk MB, running flag |
NonceRegistry |
Thread-safe replay-prevention map (Arc |
Helper functions:
pub fn truncate(s: &str, max_chars: usize) -> String
pub fn to_watch_event_json(payload: &serde_json::Value) -> WatchAgentEvent
pub fn to_watch_message(row: &MessageRowView<'_>) -> WatchMessage
pub fn to_watch_summary(session: &SessionRowView<'_>, messages: &[MessageRowView<'_>]) -> WatchSessionSummary
SSE event mapping:
SSE type field |
WatchAgentEvent.kind |
Extra fields |
|---|---|---|
token_delta |
delta |
delta = text |
tool_start |
tool_start |
tool = name, step = step number |
tool_end |
tool_end |
tool = name, status = "ok" / "err" |
done |
done |
status = status string |
error |
error |
error = message (≤200 chars) |
| (anything else) | info |
— |
watch_bridge.rs — Axum Router
Standalone Axum state and 11 HTTP routes under /watch/*.
State:
pub struct WatchBridgeState {
pub streams: WatchEventStreams, // session_id → BroadcastSender
pub api_token: Option<String>, // bearer token for auth
pub auth_manager: Arc<Mutex<WatchAuthManager>>,
pub nonce_registry: NonceRegistry,
}
pub type WatchEventStreams = Arc<Mutex<HashMap<String, broadcast::Sender<serde_json::Value>>>>;
Routes:
| Method | Path | Description |
|---|---|---|
GET |
/watch/health |
Unauthenticated health check |
GET |
/watch/challenge |
Issue registration challenge nonce |
POST |
/watch/register |
Register device (P-256 ECDSA key exchange) |
POST |
/watch/refresh |
Refresh expired access token |
GET |
/watch/sessions |
List active sessions (auth required) |
GET |
/watch/sessions/:id |
Session detail |
GET |
/watch/sessions/:id/messages |
Message history |
POST |
/watch/dispatch |
Send message to active session |
GET |
/watch/stream/:session_id |
SSE stream of agent events |
POST |
/watch/wrist-event |
Wrist on/off event (session lock) |
POST |
/watch/sandbox/:id/control |
Pause / resume / stop sandbox |
Platform Clients
Apple Watch (vibewatch/VibeCodyWatch/)
- Language: Swift / SwiftUI
- Auth: CryptoKit P-256 (Secure Enclave), stored in Keychain
- Transport: WatchConnectivity framework for phone relay; direct HTTP over LAN
- Screens: Session list → Conversation → Voice input → Sandbox status → Settings
Wear OS (vibewatch/VibeCodyWear/)
- Language: Kotlin / Jetpack Compose for Wear
- Auth: Android Keystore P-256 with StrongBox check; EncryptedSharedPreferences for token storage
- Transport: OkHttp SSE client for direct connections; Wearable Data Layer API for offline relay via companion phone
- Voice:
SpeechRecognizerwithEXTRA_PREFER_OFFLINE=true(audio never leaves device) - Key files:
WearAuthManager.kt— Keystore key pair, signature building, token refreshWearNetworkManager.kt— SSE streaming, token validation, phone relay fallbackWearDataLayerClient.kt— Wearable Data Layer message sendingWearDataLayerService.kt(companion app) —WearableListenerServicebridging watch to daemon
VibeUI Panel
The Watch Devices panel (vibeui/src/components/WatchManagementPanel.tsx) provides:
- Platform badge: “watchOS” (accent) or “Wear OS” (green) based on device model heuristic
- Per-device: status dot, platform badge, device ID, last-seen timestamp
- QR code pairing modal (generates
/watch/challenge→ deep-link URL) - Security info: Secure Enclave (iOS) / StrongBox TEE (Android)
- Transport info: Phone relay via WatchConnectivity (iOS) / Data Layer (Android)
Navigation: Governance → Watch Devices
TDD Coverage
watch_auth.rs (inline #[cfg(test)])
| Test | What it verifies |
|---|---|
ttl_matches_constant |
Access/refresh TTL constants match JWT exp |
machine_id_bound_to_manager |
for_testing() sets correct machine ID |
wrong_secret_rejected |
Different secret fails JWT decode |
challenge_window_is_nonce_ttl |
expires_at - issued_at == NONCE_TTL_SECS |
register_request_serde_roundtrip |
JSON round-trip preserves all fields |
wrist_suspended_serialises |
wrist_suspended field present in JSON |
revoked_at_is_none_by_default |
Newly registered device has no revocation |
ed25519_wrong_length_rejected |
Invalid sig bytes return error |
stale_wrist_event_rejected |
Timestamp > 30s old fails |
max_watch_devices_is_positive |
MAX_WATCH_DEVICES > 0 |
watch_claims_serde_roundtrip |
WatchClaims JSON round-trip |
watch_session_relay.rs (inline)
| Test | What it verifies |
|---|---|
truncate_short_string_unchanged |
Short strings pass through unchanged |
truncate_adds_ellipsis |
Long strings get … suffix |
truncate_exact_length_unchanged |
Exact-length strings are not truncated |
tool_start_event |
tool_start maps name and step |
tool_end_success_event |
tool_end with success maps to "ok" |
tool_end_failure_event |
tool_end without success maps to "err" |
done_event |
done maps status field |
error_event_truncated |
Error messages capped at 200 chars with … |
unknown_event_type_defaults_to_info |
Unrecognised types map to "info" |
kind_field_in_event |
All events have a kind field |
session_summary_message_count |
Summary counts all messages |
session_summary_last_activity |
Summary uses latest message timestamp |
session_summary_empty_messages |
Summary handles empty message list |
nonce_registry_distinct_nonces_accepted |
Multiple distinct nonces all accepted |
watch_sandbox_status_serde |
WatchSandboxStatus JSON round-trip |
watch_agent_event_serde |
WatchAgentEvent JSON round-trip |
watch_bridge.rs (inline)
| Test | What it verifies |
|---|---|
watch_dispatch_response_streaming_url_contains_session_id |
URL has session ID |
watch_event_streams_new_is_empty |
Fresh map has no entries |
watch_event_streams_accepts_broadcaster |
Sender insertion works |
watch_sandbox_control_request_serde |
WatchSandboxControlRequest round-trip |
watch_bridge_state_size_of_does_not_panic |
State type is sized |
nonce_replay_rejected_in_bridge_context |
Replay prevention in bridge |
watch_dispatch_request_without_session_id |
None session_id preserved |
watch_dispatch_request_with_session_id |
Some session_id preserved |
BDD Coverage
tests/features/watch_auth.feature — 10 scenarios / 38 steps
- Challenge nonce is 32-char hex
- Challenge nonce is consumed on use (single-use)
- Access token embeds device ID and machine ID
- Expired token is rejected
- Tampered signature is rejected
- Refresh token has correct kind field
- Token signed with wrong secret is rejected
- Wrist event with stale timestamp is rejected
- P-256 ECDSA signature with wrong length is rejected
- WatchDevice serialises round-trip through JSON
tests/features/watch_session_relay.feature — 15 scenarios / 57 steps
- Short/long/exact-length truncation
- SSE delta, tool_start, tool_end (success/failure), done, error, unknown events
- Nonce replay rejection, multiple nonces, stale timestamp
- Session summary preview and message count
- Message content capping at 512 characters
tests/features/watch_bridge.feature — 10 scenarios / 35 steps
- Streaming URL format validation
- WatchEventStreams empty on init / accepts senders
- Nonce replay rejection, distinct nonces accepted
- SandboxControlRequest serialisation
- Dispatch request null / existing session_id
- WatchBridgeState sizing
- WatchDispatchResponse field serialisation
Total BDD: 35 scenarios, 130 steps — all green
Security Considerations
| Threat | Mitigation |
|---|---|
| Stolen bearer token | Short 15-min access TTL; refresh token stored in Keychain/EncryptedSharedPreferences |
| Token replay | Per-session nonce registry with 30-second window |
| Man-in-the-middle | TLS required for all non-LAN transports; Tailscale adds mutual auth |
| Rogue device pairing | Challenge nonce expires in 5 minutes; requires P-256 ECDSA signature over SHA-256(nonce ‖ device_id ‖ issued_at) from a hardware-backed key |
| Wrist lift = session access | wrist_suspended flag blocks tool execution when watch is off wrist |
| Voice audio exfiltration | EXTRA_PREFER_OFFLINE=true on Wear OS; voice processed on-device |
| Session token in Data Layer | Companion phone uses its own bearer token; never sends watch token to daemon |
Configuration
Add to ~/.vibecli/config.toml:
[watch]
enabled = true # default: true when --serve is active
port = 7878 # shared with HTTP daemon
require_tls = false # set true in production
max_devices = 10 # per machine_id
session_lock_on_suspend = true # block tool calls when off wrist