The wire format for iOS Live Activities, widgets, and controls. Bridge-agnostic, push-included, with every silent-failure mode enforced as a CI invariant.
Mobile Surfaces is not a Live Activity bridge. It is the layer above the bridge that gives your app, your widget extension, your control widget, and your backend a single typed shape (LiveSurfaceSnapshot) to agree on. One kind discriminator picks one of six surfaces; one projection helper per surface turns the snapshot into the ActivityKit content state, the WidgetKit timeline entry, the Control Center value provider, or the APNs alert payload that surface needs.
Three pieces ship together:
@mobile-surfaces/surface-contracts: typedLiveSurfaceSnapshotplus six projection helpers, a published JSON Schema, and Standard Schema interop.@mobile-surfaces/push: Node APNs client. Single-session HTTP/2 multiplexing, JWT signing, push-to-start tokens, iOS 18 broadcast channels, typed errors for every documented APNs reason.- Trap catalog: 34 documented iOS silent-failure modes plus 7 catalog-maintenance gates (
data/traps.json), rendered asAGENTS.mdandCLAUDE.md. 24 rules are enforced at PR time bypnpm surface:check; the rest surface as typed runtime errors or advisory notes.
Mobile Surfaces is a single-maintainer reference architecture. The trap catalog and push client reflect failure modes encountered building the reference app, not a survey of every production deployment. Treat it as a worked example to read and adapt, not a turnkey dependency with a support contract.
import { assertSnapshot, toLiveActivityContentState } from "@mobile-surfaces/surface-contracts";
const snapshot = assertSnapshot({
schemaVersion: "5",
kind: "liveActivity",
id: `order-${order.id}`,
surfaceId: `order-${order.id}`,
updatedAt: new Date().toISOString(),
state: "active",
liveActivity: { title: "Order #1234", body: "Out for delivery", progress: 0.66, /* ... */ },
});
await pushClient.update(activityToken, snapshot);An Expo iOS team shipping a multi-surface product — a Live Activity, the Dynamic Island, home-screen and Lock Screen widgets, a Control Center control — backed by a Node service. Stay on expo-live-activity or any other ActivityKit bridge: Mobile Surfaces is the typed contract and the push-side layer above whichever bridge you use, not a replacement for it. If you ship a single surface with no backend, the contract is more structure than the job needs.
Three install paths depending on where you are starting from.
Drop the contract and push client in alongside expo-live-activity, a hand-rolled native module, or any other bridge:
pnpm add @mobile-surfaces/surface-contracts @mobile-surfaces/pushScaffold a working iPhone app with every surface set up end to end:
pnpm create mobile-surfacesThe trap catalog runs from inside a Mobile Surfaces checkout. Clone this repo, install once, and point the audit at your project:
pnpm surface:audit --root ./path/to/projectAdd --json to wire the report into CI.
Full documentation is at mobile-surfaces.com/docs.
iOS Live Activities silently fail. Your code compiles, your push returns HTTP 200, the app runs, and nothing shows up on the Lock Screen. There is no error message and no log to tell you what went wrong. The cause is one of a dozen iOS-specific traps that Apple's documentation barely mentions:
- Push tokens minted by your dev build cannot talk to Apple's production server, but the failure looks like a generic 400.
- Two Swift files in different folders have to be byte-identical or your activity silently never appears.
- The app and the widget share state through an App Group identifier. If the two sides do not match exactly, the widget reads placeholder data forever.
- Apple aggressively rate-limits high-priority Live Activity pushes; sustained sends get silently dropped.
- The generated
ios/directory rebuilds on every prebuild, so manual fixes in Xcode get wiped.
Add a home-screen widget, an iOS 18 Control Center button, and a backend driving all of it through APNs, and the surface area for silent failure roughly doubles.
Mobile Surfaces is not a replacement for expo-live-activity or any other ActivityKit bridge. It sits above whichever bridge you use. The contract keeps your snapshot shape consistent across surfaces. The push client drives the wire correctly. The trap catalog turns ActivityKit's silent-failure modes into CI errors so they break at PR time, not on a customer device. See Mobile Surfaces with expo-live-activity for the side-by-side.
- Docs hub
AGENTS.md: invariants for AI coding assistants (also published asCLAUDE.md)CONTRIBUTING.md- LICENSE: MIT