Phone Bridge Protocol Contract
Version: 1.2 Date: 2026-08-13
This document defines the WebSocket command protocol between the hardware board (aiden-firmware) and phone app (aiden-app).
Connection
- URL:
ws://192.168.42.1:8080/api/phone-bridge?platform=ios(orplatform=android) - Direction: App acts as WebSocket client actively connecting to board (WebSocket server)
- Network: Via USB ECM established
192.168.42.0/24subnet, board fixed IP192.168.42.1
WebSocket is the foreground fast path. The board also exposes
/api/phone-bridge/commands and /api/phone-bridge/results HTTP queue
endpoints. React Native timers and WebSocket in the iOS background must not be
treated as a permanent execution path, so the Agent selects among foreground
WebSocket, Dynamic Island restoration, PiP Bridge, Android FGS Bridge, and iOS
BLE Wake according to live capabilities. Lock-screen Live Activity entries
require visual confirmation instead of blind tapping.
iOS BLE Wake is an on-demand route for calendar_create, calendar_query,
calendar_delete, contacts_query, contacts_create, and
notification_send. The board first durably enqueues the command, then sends a
non-sensitive 12-byte GATT Wake hint. The app opens a short background window,
polls the HTTP queue with its stable phone_id, executes the native command,
and posts the result over HTTP. BLE never carries command or result payloads,
so the phone must remain connected to the board's USB ECM network while using
BLE Wake. Clipboard read/write and contacts update are not supported by this
route.
PiP Bridge is a narrow background queue mode. When the app reports pip_bridge_enabled=true while backgrounded, iOS gives PiP priority over the Dynamic Island, so the Dynamic Island return entry is not visible. In that state, the public open_app tool selects visible system search instead of its internal Phone Bridge route; only background-safe data tools (bridge_clipboard, bridge_calendar, bridge_contacts, bridge_notification) backed by command types (clipboard_*, calendar_*, contacts_*, notification_send) may use the HTTP queue.
Android FGS Bridge is also an HTTP queue mode. The foreground service polls /api/phone-bridge/commands?platform=android&phone_id=<stable>&app_state=background&fgs_bridge_enabled=true&limit=10; the board treats that as a background queue consumer for background-safe data commands only. It does not treat WebSocket as a reliable Android background transport, and it does not expose open_app through the FGS queue.
Heartbeat
The current app sends a heartbeat every 5 seconds (JSON with id "heartbeat"
or "ping"), and the board echoes it back. The app uses this to detect
connection liveness.
Example:
{"id": "heartbeat"}
The board records last_heartbeat_at timestamp; no heartbeat for more than 60 seconds is considered unhealthy connection.
Message Format
BridgeCommand (board → app)
{
id: string; // Unique command ID
type: string; // Command type (see below)
timeout_ms?: number; // Timeout milliseconds (optional, default 5000)
// Following fields used based on type
app?: string; // open_app semantic app name or alias
url?: string; // open_app URL using http, https, sms, mailto, or tel, sent by public open_url
payload?: object; // JSON payload for other command types
}
BridgeCommandResponse (app → board)
{
id: string; // Matches BridgeCommand.id
method?: string; // Execution method (optional)
error?: { // Omitted on success
category: string;
code: string;
message: string;
details?: object;
};
data?: object; // Return data (optional, used by read commands)
}
Success is represented by an absent error field; the current protocol does
not send an ok boolean.
AppEvent (app → board)
The app can also actively send event messages. Events reuse the BridgeCommandResponse outer fields, but id/method don't correspond to any board-issued command; the board won't treat it as a pending command acknowledgment.
Current events:
phone_environment: App reports phone environment snapshot upon WebSocket connection success and returning from background to foreground.phone_app_state: App reports the last visible app lifecycle state when it changes amongactive,background, andinactive, plus whether a Live Activity / Dynamic Island entry is available to return to Aiden and whether PiP Bridge mode is enabled. This state is for diagnostics and strategy decisions; it does not mean the app can execute permanently in iOS background.
Example:
{
"id": "phone_environment",
"method": "phone_environment",
"data": {
"captured_at": "2026-06-10T03:20:00Z",
"source": "aiden-app",
"platform": "ios",
"system_name": "iOS",
"system_version": "18.5",
"is_tablet": false,
"locale": "zh-Hans-CN",
"language": "zh",
"region": "CN",
"time_zone": "Asia/Shanghai",
"utc_offset_minutes": 480,
"utc_offset": "+08:00",
"uses_24_hour_clock": true,
"manufacturer": "Apple",
"brand": "Apple",
"model": "iPhone16,2",
"screen": {
"width": 393,
"height": 852,
"width_pixels": 1179,
"height_pixels": 2556,
"scale": 3
},
"battery": {
"level": 0.87,
"charging": true,
"state": "charging"
},
"system_apps": [
{"name": "Camera", "available": true, "category": "system", "availability_source": "builtin"},
{"name": "Contacts", "available": true, "category": "system", "availability_source": "builtin"}
],
"third_party_apps": [
{"name": "WeChat", "available": true, "category": "third_party", "availability_source": "can_open_url", "ios_url": "weixin://"},
{"name": "Douyin", "available": false, "category": "third_party", "availability_source": "can_open_url", "ios_url": "snssdk1128://"}
],
"available_apps": [
{"name": "WeChat", "available": true, "category": "third_party", "ios_url": "weixin://"},
{"name": "Douyin", "available": false, "category": "third_party", "ios_url": "snssdk1128://"}
]
}
}
The board writes the latest complete environment to the environment field of
GET /api/phone-bridge/status, and keeps app_state, return_entry,
return_entry_available, pip_bridge_enabled, and fgs_bridge_enabled for
Agent runtime context and internal routing. Runtime context carries compact
state facts such as connection status, app foreground/background state,
return-entry visibility, PiP/Dynamic Island visibility state, Android FGS
Bridge state, system type/version, language/region/timezone, screen dimensions,
and confirmed openable third-party candidate apps. Before each conversational
run, the Agent separately filters App-only tools and actions from live Phone
Bridge and BLE capabilities. open_app remains available because it can fall
back to visible system search; iOS BLE Wake omits clipboard read/write and
contacts update. Environment is cleared on disconnection to avoid using stale
information, but the latest app foreground/background state can be retained
for Dynamic Island recovery.
phone_app_state example:
{
"id": "phone_app_state",
"method": "phone_app_state",
"data": {
"app_state": "background",
"return_entry": "dynamic_island",
"return_entry_available": true,
"pip_bridge_enabled": true,
"reported_at": "2026-06-10T03:20:05Z"
}
}
system_apps represents system built-in apps/capabilities; on iOS doesn't depend on canOpenURL to determine existence; third_party_apps represents third-party candidate apps, probed via canOpenURL on iOS and package launchability on Android. available_apps is a legacy third-party candidate summary for old board compatibility; new implementations should prioritize reading the split fields.
Command Types
1. open_app
Open specified app or URL.
Request:
{
"id": "open_001",
"type": "open_app",
"app": "微信",
"timeout_ms": 10000
}
iOS implementation: Resolve the semantic app or URL request inside the companion app, then open the matching iOS URL scheme or system URL. Android implementation: Resolve the semantic app or URL request inside the companion app, then launch the matching package, intent URI, or system URL.
The board sends semantic launch targets and the companion app resolves platform details. The internal BridgeOpenApp route sends app (for example "微信" or "weixin"), while open_url sends an http, https, sms, mailto, or tel URL. Each command sets one target field.
Response:
{
"id": "open_001",
"method": "ios_url_scheme"
}
method indicates the underlying mechanism used by the app side, with common values including ios_url_scheme, ios_shortcut, android_intent, android_deeplink, launch_package, and open_url. Here open_url indicates an explicit supported URL.
The Agent's exposed open_app and open_url tools normalize underlying companion-app mechanisms into task-oriented method values and place the underlying value in the mechanism field.
On failure:
{
"id": "open_001",
"error": {
"category": "precondition_failed",
"code": "app_not_installed",
"message": "App not installed"
}
}
2. clipboard_read
Read system clipboard content.
Request:
{
"id": "clip_read_001",
"type": "clipboard_read",
"timeout_ms": 5000
}
iOS implementation: UIPasteboard.general.string
Android implementation: ClipboardManager.getPrimaryClip()
Response:
{
"id": "clip_read_001",
"data": {
"text": "clipboard content"
}
}
Empty clipboard returns "text": "".
Permissions:
- iOS 16+ will display paste banner, frequent reads will disturb users
- Android 10+ only foreground app can read clipboard, background needs foreground service
3. clipboard_write
Write to system clipboard.
Request:
{
"id": "clip_write_001",
"type": "clipboard_write",
"payload": {
"text": "content to copy"
},
"timeout_ms": 5000
}
iOS implementation: UIPasteboard.general.string = payload.text
Android implementation: ClipboardManager.setPrimaryClip(ClipData.newPlainText("label", text))
Response:
{
"id": "clip_write_001",
"method": "clipboard_write"
}
4. calendar_create
Create calendar event.
Request:
{
"id": "cal_create_001",
"type": "calendar_create",
"payload": {
"title": "Dentist appointment",
"start_at": "2026-06-02T15:00:00+08:00",
"end_at": "2026-06-02T16:00:00+08:00",
"all_day": false,
"location": "Clinic",
"notes": "Bring insurance card"
},
"timeout_ms": 8000
}
Field descriptions:
title(required): Event titlestart_at(required): Start time (RFC3339 format with timezone)end_at(required): End time (RFC3339 format with timezone)all_day(optional): Whether all-day event, default falselocation(optional): Locationnotes(optional): Notes
iOS implementation: Uses EventKit framework, requires NSCalendarsUsageDescription or NSCalendarsWriteOnlyAccessUsageDescription permission.
Android implementation: Uses CalendarContract API, requires WRITE_CALENDAR permission.
Response:
{
"id": "cal_create_001",
"data": {
"event_id": "ios_calendar_id_123"
}
}
event_id is the platform-returned event unique identifier for subsequent deletion.
5. calendar_query
Query calendar events within specified time range.
Request:
{
"id": "cal_query_001",
"type": "calendar_query",
"payload": {
"from": "2026-06-02T00:00:00+08:00",
"to": "2026-06-03T00:00:00+08:00"
},
"timeout_ms": 8000
}
iOS implementation: EKEventStore.events(matching:) queries the from to
to range.
Android implementation: Queries CalendarContract.Instances table, requires READ_CALENDAR permission.
Response:
{
"id": "cal_query_001",
"data": {
"events": [
{
"event_id": "ios_calendar_id_123",
"title": "Dentist appointment",
"start_at": "2026-06-02T15:00:00+08:00",
"end_at": "2026-06-02T16:00:00+08:00",
"location": "Clinic"
}
]
}
}
Returns empty array "events": [] when no events.
6. calendar_delete
Delete specified calendar event.
Request:
{
"id": "cal_delete_001",
"type": "calendar_delete",
"payload": {
"event_id": "ios_calendar_id_123"
},
"timeout_ms": 8000
}
iOS implementation: EKEventStore.remove(event:, span:, commit:)
Android implementation: ContentResolver.delete(CalendarContract.Events.CONTENT_URI, ...)
Response:
{
"id": "cal_delete_001",
"method": "calendar_delete"
}
If the event does not exist, the app returns a structured invalid_arguments
error with the native not-found code in details.native_code.
7. contacts_query
Query contacts.
Request:
{
"id": "contacts_query_001",
"type": "contacts_query",
"payload": {
"query": "Zhang San",
"limit": 20
},
"timeout_ms": 8000
}
Field descriptions:
query(optional): Search keyword, matches name or phone numberlimit(optional): Maximum return count, default 20
iOS implementation: Uses CNContactStore query, requires NSContactsUsageDescription permission.
Android implementation: Queries ContactsContract API, requires READ_CONTACTS permission.
Response:
{
"id": "contacts_query_001",
"data": {
"contacts": [
{
"contact_id": "contact_123",
"name": "Zhang San",
"phone_numbers": ["+86 138 1234 5678"],
}
]
}
}
Returns empty array "contacts": [] when no matching contacts.
8. contacts_create
Add new contact.
Request:
{
"id": "contacts_create_001",
"type": "contacts_create",
"payload": {
"name": "Li Si",
"phone_numbers": ["+86 139 8765 4321"],
"organization": "Company name",
"notes": "Notes"
},
"timeout_ms": 8000
}
Field descriptions:
name(required): Contact namephone_numbers(optional): Phone number arrayemails(optional): Email address arrayorganization(optional): Company/organization namenotes(optional): Notes on Android. iOS ignores this field because reading or updatingCNContactNoteKeyrequires an entitlement not requested by Aiden.
iOS implementation: Uses CNContactStore.add(CNSaveRequest) to create, requires NSContactsUsageDescription permission.
Android implementation: Uses ContentResolver.insert(ContactsContract.RawContacts.CONTENT_URI), requires WRITE_CONTACTS permission.
Response:
{
"id": "contacts_create_001",
"data": {
"contact_id": "new_contact_id_123"
}
}
contact_id is the platform-returned contact unique identifier for subsequent updates.
9. contacts_update
Update existing contact.
Request:
{
"id": "contacts_update_001",
"type": "contacts_update",
"payload": {
"contact_id": "contact_123",
"name": "Li Si (updated)",
"phone_numbers": ["+86 139 8765 4321", "+86 010 1234 5678"],
},
"timeout_ms": 8000
}
Field descriptions:
contact_id(required): Contact ID to update- Other fields same as
contacts_create, provided fields will overwrite original values
iOS implementation: Uses CNContactStore.execute(CNSaveRequest) to update contact.
Android implementation: Uses ContentResolver.update() to update ContactsContract.Data table.
Response:
{
"id": "contacts_update_001",
"method": "contacts_update"
}
If the contact does not exist, the app returns a structured invalid_arguments
error with the native not-found code in details.native_code.
10. notification_send
Send local notification.
Request:
{
"id": "notification_001",
"type": "notification_send",
"payload": {
"title": "Reminder",
"body": "Time to take medicine",
"schedule_at": "2026-06-04T18:00:00+08:00",
"sound": true,
"badge": 1
},
"timeout_ms": 5000
}
Field descriptions:
title(required): Notification titlebody(optional): Notification bodyschedule_at(optional): Scheduled send time (RFC3339), send immediately if not filledsound(optional): Whether to play sound, default truebadge(optional): App badge number (iOS)
iOS implementation: Uses UNUserNotificationCenter to send local notification, requires user authorization.
Android implementation: Uses NotificationManager and AlarmManager (scheduled), Android 13+ requires POST_NOTIFICATIONS permission.
Response:
{
"id": "notification_001",
"data": {
"notification_id": "notification_123"
}
}
notification_id identifies the local notification in the app result. The
current Phone Bridge command set does not expose notification cancellation.
Error Handling
When the app cannot execute a command, it returns a structured error field:
{
"id": "...",
"error": {
"category": "user_action_required",
"code": "permission_denied",
"message": "Calendar access not granted"
}
}
Common error scenarios:
- Permission not granted:
permission_denied - App not installed:
app_not_installed - Invalid parameters:
invalid_arguments - Native system API failure:
native_module_failed - Unsupported background route:
app_backgrounded
Timeout and Reconnection
- Timeout: Each command on the board side has
timeout_ms; stops waiting for response after timeout. App should respond before timeout as much as possible. - Reconnection: After WebSocket disconnects, app should auto-reconnect, retrying at 3-5 second intervals. Board side has no active reconnection mechanism.
- Idempotence: WebSocket/HTTP deduplication caches command IDs, and the HTTP queue retries timed-out in-flight commands. Reuse the same unique ID only for the same logical command.
Time Format
All time fields must be RFC3339 format with timezone offset, e.g.:
2026-06-02T15:00:00+08:00(3pm GMT+8)2026-06-02T07:00:00Z(7am UTC)
Use standard libraries for parsing (iOS ISO8601DateFormatter, Android Instant.parse).
Permission Management
iOS
- Clipboard read: iOS 14+ displays banner, no need to declare permission
- Calendar: Must add to
Info.plist:iOS 17+ subdivided into<key>NSCalendarsUsageDescription</key><string>Used to quickly create and manage calendar events</string>NSCalendarsFullAccessUsageDescription(read/write) andNSCalendarsWriteOnlyAccessUsageDescription(write-only). - Contacts: Must add to
Info.plist:<key>NSContactsUsageDescription</key><string>Used to query and manage contacts</string> - Notification: Need to request user authorization via
UNUserNotificationCenter.requestAuthorization.
Android
- Clipboard: Android 10+ background cannot read clipboard, needs foreground service or ensure app is in foreground.
- Calendar: Requires runtime permissions:
Authorization appears on first use; denial returns a structured<uses-permission android:name="android.permission.READ_CALENDAR" /><uses-permission android:name="android.permission.WRITE_CALENDAR" />
permission_deniederror. - Contacts: Requires runtime permissions:
<uses-permission android:name="android.permission.READ_CONTACTS" /><uses-permission android:name="android.permission.WRITE_CONTACTS" />
- Notification: Android 13+ requires runtime permission:
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
Testing Recommendations
- Focused unit tests: Cover command routing, HTTP queue filtering, deduplication, and structured-error handling on both repositories.
- Timeout scenarios: Test timeout during permission prompts and confirm the app correctly handles later commands after authorization.
- Real-device routes: Verify foreground WebSocket, PiP/FGS polling, Dynamic Island recovery, and iOS BLE Wake with the phone on the board USB ECM link.
- Edge cases: Empty clipboard, no calendar events, invalid
event_id, all-day events, cross-timezone queries, empty contacts query, duplicate contacts, invalidcontact_id, and scheduled notifications.
Version Compatibility
Current protocol version 1.2. When extending with new commands in the future:
- New fields are backward compatible (old app ignores unknown fields)
- New command types require a coordinated board/App release and compatibility tests
- Modifying existing field semantics requires version number upgrade
Appendix: Complete Examples
Heartbeat
App → board:
{"id": "heartbeat"}
Board → app (echo):
{"id": "heartbeat"}
Open WeChat
Board → app:
{
"id": "open_1717667890123_1",
"type": "open_app",
"app": "微信",
"timeout_ms": 10000
}
App → board:
{
"id": "open_1717667890123_1",
"method": "ios_url_scheme"
}
Read Clipboard
Board → app:
{
"id": "clip_read_1717667890234_2",
"type": "clipboard_read",
"timeout_ms": 5000
}
App → board:
{
"id": "clip_read_1717667890234_2",
"data": {
"text": "https://example.com"
}
}
Create Calendar Event
Board → app:
{
"id": "cal_create_1717667890345_3",
"type": "calendar_create",
"payload": {
"title": "Team meeting",
"start_at": "2026-06-05T10:00:00+08:00",
"end_at": "2026-06-05T11:00:00+08:00",
"location": "Meeting room A",
"notes": "Discuss Q2 planning"
},
"timeout_ms": 8000
}
App → board:
{
"id": "cal_create_1717667890345_3",
"data": {
"event_id": "12345678-ABCD-1234-5678-1234567890AB"
}
}