# spt-mobile — v1 design (grilled 2026-07-06)

Product: an Android app that is the user's Gateway onto SPT subnets. Three headline features, one architecture. Terms per `CONTEXT.md`; identity/transport rulings per `docs/adr/0001` and `0002`. spt-core dependencies collected in `docs/SPT-CORE-NEEDS.md`.

## Features (v1)

1. **Endpoint browser** — all endpoints across the subnets the paired hosts belong to, grouped by subnet, with live details (status, node, last-active, type, description, adapter — the registry `Instance` row, spt-core `crates/spt-net/src/net/registry.rs`).
2. **Endpoint view** — live digest (snapshot + follow) interlaced with conversation history by timestamp; compose + send (`user-msg`).
3. **Voice pipe** — localhost webhook receiver for Pebble Index transcripts → phone spool → `user-msg` to the current star.
4. **Two-way messaging** — inbound agent messages arrive as Android notifications; per-endpoint interlaced timeline.

## Architecture

```
PEBBLE app ──POST localhost──▶ webhook receiver ─▶ phone spool ─┐
                                                                 ▼
ANDROID APP (Kotlin/Compose UI)                            forwarder
  └─ foreground service                                          │
      ├─ webhook receiver (HTTP, localhost)                      │
      └─ spt-mobile-core (Rust JNI slice)                        │
           iroh dial by node key ── relay/holepunch ─────────────┼──▶
                                                                 ▼
PAIRED HOST k (walk host priority, first reachable serves)
  spt-mobile host binary (adapter endpoint binary)
    ├─ own iroh endpoint (pairing-token auth)
    ├─ Mobile Gateway perch instance (api bind --type gateway)
    ├─ host spool (per-phone inbound queue)
    ├─ conversation history log (per endpoint, msg-id keyed)
    └─ local api surface ─▶ spt-daemon ── iroh P2P ─▶ subnet
```

<!-- [doc->REQ-GATEWAY-PERIPHERAL] -->
<!-- [doc->REQ-HISTORY-HOST-LOG] -->
<!-- [doc->REQ-PUSH-FOREGROUND-SERVICE] -->
<!-- [doc->REQ-BROWSER-LIVE] -->
<!-- [doc->REQ-DIGEST-DIRECT-ROUTE] -->
<!-- [doc->REQ-ENDPOINT-INSTANCES] -->
<!-- [doc->REQ-MULTI-HOST-PAIRING] -->
## Rulings (the grill outcomes)

| # | Question | Ruling |
|---|----------|--------|
| 1 | Node on phone vs Gateway | **Hybrid**: paired-peripheral Gateway now (ADR-0001); Android becomes an spt-core build target later, on-phone node swaps transport not app logic. |
| 2 | Device link transport | **iroh via Rust JNI slice** (ADR-0002); node-key addressing solves IP roaming; no tailscale, no bespoke protocol. |
| 3 | Phone's subnet identity | **Paired peripheral** — pairs with each host binary (QR: node key + token); never a subnet member. |
| 4 | Cross-node digest gap | v1 = most-direct-route: digest served when SOME paired host is co-located with the target; else registry card + "pending cross-node". spt-core pull-forward recorded. Multi-host deploy+routing is a supported long-term feature (resilience: one node down ≠ phone dark). |
| 5 | Star semantics | **Per-paired-host star**, one endpoint each; hosts user-ordered; voice routes to first REACHABLE host's star. Failover keys on host reachability only, never target liveness (`QUEUED` = success). Stars phone-local. |
| 6 | Transcript loss story | **Phone spool + voice tag**: webhook always 200s into persistent FIFO; drains in order on reconnect; voice tag + msg-id ride `--json-payload` (existing send axis, ADR-0028 W4). Never drop a dictation. |
| 7 | Inbound push path | **Foreground service + host spool** (v1): service holds the iroh conn (and the webhook receiver — one lifetime, two jobs). Deep doze delays into maintenance windows; late, never lost. ntfy/UnifiedPush = future option; FCM ruled out. |
| 8 | History + dedup | **Host-side conversation history** (append-only per endpoint, msg-id keyed); phone cache = union across hosts. Dedup exact: msg-id for own sends, digest per-row `seq` cursor (REQ-DIGEST-CURSOR) for digest rows. No fuzzy matching. |
| 9 | Endpoint identity across hosts | **One Mobile Gateway endpoint, instance per paired host** (spt-core Instances model); device-link liveness drives instance state, lecturn pattern. |
| 10 | v1 multi-host line | **Pairing + failover in v1** (N hosts, ordered dial list; send/list/history/voice all failover). Cross-host digest routing = most-direct-route now, spt-core cross-node digest later. |

## Pebble webhook contract (verified from coredevices/mobileapp, `experimental/.../indexwebhook/INDEX_WEBHOOK_API.md`)

- `POST <url>` `multipart/form-data`; fields: `transcription` (plain text, conditional), `audio` (AAC m4a 16kHz mono, conditional), `recordedAt` (unix ms, always), `client` = `"ring"` (always). User-configurable headers for auth (`X-Widget-Token` convention); `X-Audio-Size` auto-added with audio.
- **Failed uploads retry only on the next recording** — no persistent retry on Pebble's side. The webhook receiver (foreground service) is the loss boundary; it must always answer.
- v1 payload mode: **Transcription only**. Audio-attach later (file-transfer substrate is progress-queryable per spt-core CONTEXT.md §file-transfer).
- Receiver auths via a locally generated token entered into Pebble's webhook headers.

<!-- [doc->REQ-VOICE-JSON-PAYLOAD] -->
<!-- [doc->REQ-VOICE-PIPE] -->
<!-- [doc->REQ-STAR-PER-HOST] -->
## Voice pipe semantics

1. Webhook receiver 200s unconditionally, enqueues `{transcription, recordedAt}` to phone spool (Room/SQLite).
2. Forwarder walks host priority; first reachable host receives the send over the device link.
3. Host binary sends `user-msg` to ITS star target with `--json-payload '{"origin":"voice","captured":"<ISO-8601 from recordedAt>","msg-id":"<uuid>"}'`.
4. All-hosts-unreachable: spool grows, UI badge shows pending count; FIFO flush on reconnect.
5. Host reachable but star's node down: spt spools subnet-side (`QUEUED` = success) — no failover, by rule.

<!-- [doc->REQ-DIGEST-CURSOR] -->
<!-- [doc->REQ-ENDPOINT-VIEW-INTERLACE] -->
<!-- [doc->REQ-INBOUND-NOTIFS] -->
## Interlaced view mechanics

- Merge conversation history rows + digest rows by timestamp.
- Collapse: digest echo of own send matches by msg-id; digest rows dedup by `seq` cursor across snapshot/follow reconnects.
- Cursor mechanics (REQ-DIGEST-CURSOR): the view's cursor is the max `seq` it holds (turn `input_seq`s + closed-entry `seq`s). Follow deltas (`{version, from, turns}`) apply by truncate-to-`from` + append; a delta whose `from` overshoots the view is a detected gap → re-sync via `digest-snapshot` with `after=<cursor>`. A cursor that predates the retained window returns the full window + `after_predates_window: true`, which replaces the view wholesale.
- Notifications: inbound history rows → Android notifs (per-endpoint channels), fired by the foreground service on delivery or spool drain.
<!-- [doc->REQ-RICH-RENDER] -->
- Rich text: message bodies, agent output, and expanded `<EVENT>` frames render a CommonMark subset (headings, bold/italic, inline + fenced code, lists, blockquotes, links) with XML/HTML fenced blocks syntax-highlighted; token colors are theme-derived (light/dark). The renderer is a lenient span mapper — unmatched markers render literally, so arbitrary agent text is always safe. The whole timeline is wrapped in a selection container for arbitrary text selection + copy.

<!-- [doc->REQ-GATEWAY-THREAD] -->
- Messages tab (per-gateway in/out threads; batch decision 2026-07-07): a top-level tab, two levels. Level 1 lists paired gateways (one paired host = one gateway). Level 2 is that gateway's UNIFIED message thread — every endpoint's host history unioned and sorted by timestamp, each row labeled by its endpoint, NO digest content (the raw in/out log, ruling 8). The host serves this with `history-fetch-all` (endpoint-tagged union across every endpoint it logs; the file stem IS the endpoint id for the safe-charset ids in use). A row tap deep-links into that endpoint's interlaced digest view, anchored (scrolled) to the tapped message by msg-id.

## Long-term (explicitly out of v1)

- spt-core cross-node digest → any paired host serves any endpoint's digest (app change: none; host binary proxies).
- On-phone spt node (Android as spt-core build target) → device link swaps for local IPC.
- Audio payload forwarding; ntfy/UnifiedPush wake path; second-device sync (history model already supports it).

<!-- [doc->REQ-HOST-ADAPTER] -->
## Build notes

- Repo seeded from `../experimplate` (INSTANTIATE.md flow; upstream seed defect fixed @ca13a7c). `[scan].roots` repointed to Gradle + Rust layout when code lands.
- App: Kotlin/Compose. Rust slice: cargo-ndk, aarch64 (+ x86_64 for emulator). **No spt-core crate/path deps anywhere** (source-independence rule, AGENTS.md): wire/format parity is clean-room from the public docs (<https://sabermage.github.io/spt-releases>) pinned by round-trip tests; device link is spt-mobile-owned iroh (`=0.98.2`, own ALPN `spt-mobile/link/0`, own pairing-token layer). Amended 2026-07-06; see ADR-0002 consequences.
- Host binary ships as a normal spt adapter (manifest + binary; adapter glue-model boundary — no spt-mobile-aware code in spt-core). Bind pattern proven by spt-core's `adapters/mock-gateway` fixture; `spt api bind --type gateway` earns `user-msg` authority at the identity gate.
