Skip to main content
The phone and the companion watch app exchange JSON-style maps: the phone sends commands, the watch sends session events. There is no protobuf schema. Events use the same snake_case shape as SessionEvent.toJson() (Dart), toMap() (Kotlin), and toDictionary() (Swift). Phone-side implementations:
  • Flutter: the plugin’s native relays (iOS and Android), used when SynheartSession has a WatchBiosignalProvider.
  • Kotlin: WatchSessionRelay (Wear OS).
  • Swift: none. A native iOS app sends the commands itself with WCSession.

Transport

Notes:
  • The Flutter plugin sends start_session only if the watch is reachable at that moment (iOS: WCSession.isReachable; Android: at least one connected node).
  • The Wearable Data Layer delivers messages only between apps with the same package name and signing certificate. A node can accept a message that no app answers; the Kotlin relay therefore gives up with a sensor_unavailable error if no event arrives within 10 seconds.
  • Neither side uses transferUserInfo or DataClient.

Phone → watch

start_session

include_raw_samples and raw_emit_interval_sec are not sent; there is no raw-stream opt-in on the wire. A Swift watch app can build a SessionConfig directly from this dictionary with SessionConfig(from:) (extra keys such as command are ignored).

stop_session

Best-effort: the phone does not wait for a reply. The watch answers with its final session_summary. There is no status command. Watch status is read locally on the phone (WCSession properties on iOS, connected nodes on Android) — see getWatchStatus() (Flutter) and WatchSessionRelay.status() (Kotlin).

Watch → phone

Session events

Each message is one event map: Example frame (the keys inside metrics depend on the watch app):
metrics is a flat map. The phone SDKs pass it through without validating keys, so a watch app can add its own. session_summary and session_error are terminal: the Kotlin relay’s flow completes after either, and the Flutter relays stop forwarding for that session. Decoding differences:
  • Kotlin decodes events with SessionEvent.fromMap; an event that fails to decode is logged and dropped. An unknown error_code becomes INVALID_STATE.
  • Flutter decodes with SessionEvent.fromJson. Its session_error decoder reads the code from code, not error_code, and its biosignal_frame decoder reads samples with timestamp_ms, bpm, rr_interval_ms, accelerometer, and spo2 (the Kotlin and Swift engines write rr_intervals_ms as a list, plus source and device_id).
  • In Flutter, decoded events feed WatchBiosignalProvider as heart-rate input; they are not passed to the host stream. See Lifecycle.

Heart-rate samples (Wear OS)

On /synheart/session/hr_sample, one reading per message:
The Kotlin relay exposes these as WatchSessionRelay.hrSamples() (WatchHrSample(timestampMs, bpm)), independent of any session flow. A missing ts_ms is replaced with the phone’s current time.

Flutter channel surface

The Dart layer talks to the native relays through MethodChannel("ai.synheart.session/methods") and EventChannel("ai.synheart.session/events"):
getWatchStatus() returns the connectivity snapshot:

Ordering

  • session_started precedes frames and the summary for a given session_id.
  • seq increases within a session; gaps can appear if the transport drops a message.
  • session_summary and session_error are mutually exclusive.
  • Use emitted_at_ms and sample timestamps for chronological alignment rather than arrival order.

Privacy posture

  • Raw samples are not requested over the wire.
  • No PII fields are defined — session_id is a host-supplied identifier (Dart defaults to a UUID v4), and window_label is free-form; keep it free of PII.
  • Lifecycle — how the phone and watch sessions relate.
  • Providers — WatchBiosignalProvider and how it consumes these events.
  • Errors — error codes and the error_code / code key difference.