Case study · Wellness

Felt

An iOS wellness app with swipeable affirmations, themed reminders, and an AI chat companion, built with no user accounts at all. Identity is device-bound through Apple App Attest, and every AI response is metered by a server-authoritative credits system.

Role
iOS Engineer
Platform
iOS (iPhone), SwiftUI
Stack
Swift · SwiftUI · Firebase · RevenueCat · Azure Functions
Scale
416 commits · 3 languages · v1.9–1.14
Overview

No accounts, a metered AI backend, offline content from day one.

Felt is an iOS app for a small consumer-wellness startup: swipeable, categorized affirmations with themed backgrounds, daily local-notification reminders, and an AI chat and journaling companion called Mentora, monetized through subscriptions. The defining constraint running through all of it is that none of it requires an account. Every user is anonymous, and every dollar of AI cost still has to be metered and defended against abuse.

I worked on Felt's iOS client alongside a small team over the app's roughly three-year history: 416 commits, 135 Swift files, about 27,500 lines. My focus was the architecture that makes an account-less app safe to run a paid LLM behind: device attestation, the credits system, and the networking layer everything else sits on.

The problem

Deliver a low-friction, no-login wellness app where an anonymous user gets personalized affirmations and an AI chat companion. The app still has to meter and bill AI usage per person and resist API abuse, without ever asking anyone to create an account.

Four things made that hard in practice: identity had to exist without accounts, via a device-bound Device-ID established through App Attest and a Firebase anonymous token, with everything downstream (credits, conversations, goals, calendar) keyed off it; AI responses cost real money, so usage needed server-authoritative metering rather than a client-side promise; chat needed to feel token-by-token responsive while also carrying credit updates and other state over the same connection; and core content had to work with no network at all.

Constraints

  • iOS 16.4 minimum, iPhone only. App Attest itself needs iOS 14+; the project floors higher, and the FeltWidget extension floors at iOS 17.5.
  • App Store review compliance, recurring. Two shipped versions (1.9 and 1.10) exist solely to clear App Store validation errors, one of them on a build that had previously been approved.
  • Privacy and data-at-rest, stated plainly. Non-exempt encryption is declared false; the microphone permission string states audio is sent to transcribe and is not stored; a server-side endpoint deletes a device's conversations on request while keeping the device registration.
  • App-Attest-unsupported environments still have to work. The Simulator and unsupported devices fall back to a generated UUID-style device ID, functional but without the hardware-rooted guarantee attestation is meant to provide.
  • Deployment is TestFlight-gated. CI builds only on pushes to main or a testFlight branch, distributing to an internal group, never externally.
  • Backend coupling lives in Remote Config. Base URL, the Azure function key, and the AI model name are all read at runtime, with hardcoded fallbacks compiled into the binary as a safety net.
Architecture

One choke point for HTTP, one for identity.

The client is a single SwiftUI app plus a widget extension. Everything past the screen funnels through a small set of singleton services, and everything past the device funnels through Azure.

Felt architecture: SwiftUI screens and view models call through repositories and client services (NetworkService, SSEHandler, DeviceAttestationService, and CreditsService) to an Azure Functions backend fronting an OpenAI-compatible LLM, Whisper transcription, and Firebase. IOS APP · SWIFTUI Screens & Views 34 screens · 29 views ViewModels @ObservableObject · 6 VMs Repositories Calendar · Mentora · Theme Local SQLite Store en / es / fr → Documents CLIENT SERVICES · SWIFT NetworkService · APIEndpoint Single HTTP choke point SSEHandler text/event-stream chat parser DeviceAttestationService App Attest · Secure Enclave · Keychain CreditsService · Remote Config Credit metering · runtime config BACKEND · AZURE + FIREBASE Azure Functions API x-functions-key · Traffic Manager LLM (OpenAI-compatible) gpt-4o-mini · Responses API Whisper Transcription POST /api/v1/transcribe Firebase Anonymous auth · Remote Config Swift calls HTTPS / SSE
Every request funnels through one NetworkService and one APIEndpoint enum. Device attestation and the credits ledger are what let an account-less app safely front a paid LLM.

Screens and views never build URLs. They go through ViewModels to Repositories and singleton Services, and those Services funnel HTTP through exactly one APIEndpoint enum and one NetworkService. The two deliberate exceptions are SSE chat, which gets its own SSEHandler, and multipart audio upload for transcription. Affirmation content itself never touches the network: it ships as bundled SQLite databases copied into the Documents directory on first launch, one database per language.

Sending a message to Mentora shows how the pieces connect: the ViewModel calls SSEHandler with a Firebase anonymous ID token, the device's Device-ID from the Keychain, and a P-ID header derived from RevenueCat's entitlement. The backend streams the response back over text/event-stream: token deltas render with a deliberate 50ms delay per chunk for a readable typing cadence, while the same stream carries credit-balance updates, gifted-credit events, emotion state, and follow-up suggestions in-band. An HTTP 403 with an out-of-credits body routes straight to the paywall; anything else retries up to three times before giving up.

Engineering decisions

Decisions shaped by an app with no login screen.

01

Anonymous, device-bound identity — no accounts

Identity is established through Apple App Attest plus a Firebase anonymous token, with the resulting Device-ID and attestation credentials stored in the Keychain. Everything downstream, credits, conversations, goals, calendar, keys off that one device-bound identifier.

The alternative was email or social login with server-side accounts and cross-device sync. For a low-friction wellness app, that tax on every new user wasn't worth it. The trade it creates instead is covered below.

02

Server-authoritative credits, a client P-ID flag

AI usage is metered per device through a server-side credits balance; the client sends its RevenueCat-derived pro status as a P-ID header, and the running balance rides along on every streamed chat chunk. Hitting zero returns a 403 that routes straight to the paywall.

Unmetered AI and purely client-side gating were both ruled out. A paid LLM behind an account-less app needs the server, not the client, holding the ledger.

03

One APIEndpoint enum, one NetworkService

All 25+ REST endpoints are cases of a single enum owning path, method, headers, body, and query, and one NetworkService injects the Firebase bearer token and decodes every response with snake_case conversion. Auth and header logic lives in exactly one place instead of being rebuilt per feature.

SSE chat and multipart audio upload are the two deliberate exceptions. A stream and a file upload don't fit a single-response enum cleanly, so they get their own handler and method rather than being forced into the abstraction.

04

Runtime configuration through Firebase Remote Config

Base URL, the Azure function key, the AI model name, paywall grouping, review-prompt thresholds, and roughly thirty other flags all come from Remote Config at runtime, each with an in-binary fallback. Swapping the backend endpoint or the model, or killing Smartlook session recording, doesn't need an App Store release.

The alternative, compile-time configuration, would have meant a forced update for every backend or model change.

05

Bundled offline SQLite, not a network-first content model

Affirmation text ships as SQLite databases inside the bundle, one per language plus a database of the user's own affirmations, copied into the Documents directory on first launch. Browsing affirmations works offline and instantly; the network is reserved for themes, icons, AI, and everything else.

A heavier local store like Core Data, or fetching core content over the network, were both ruled out for content this static and this central to the app's first-open experience.

06

SSE streaming with in-band side-channels for chat

A dedicated SSEHandler parses OpenAI-Responses-shaped events off one stream and multiplexes text deltas, credit-balance updates, gifted-credit toasts, emotion state, and follow-up suggestions through it, rather than a plain block request/response, or a separate WebSocket channel for state.

That keeps chat token-by-token responsive while credit and emotion state stay live, without extra round-trips per update.

Trade-offs

What it cost, on purpose.

Every architecture is a purchase. These are the prices that came with metering a paid LLM behind an app that never asks anyone to sign in.

Anonymous device identity over accounts

No login, a hardware-rooted signal against abuse, and onboarding that starts the moment the app opens.

The cost

No cross-device sync and no account recovery: lose the device or its Keychain, and history and credits go with it. Unsupported devices and the Simulator also fall back to a self-generated, non-attested ID that weakens the guarantee for those clients.

A client-provided P-ID pro flag

Entitlement plumbing is one header, derived from RevenueCat, attached to every request.

The cost

Pro status is asserted by the client, not proven. The trust boundary leans entirely on the server reconciling that header against its own record of the subscription; the app itself can't prove it.

An artificial per-token delay in chat

A steady 50ms delay per streamed token gives an even, readable typing cadence regardless of how bursty the backend's output actually is.

The cost

It's deliberately slower than the raw stream. Total render time scales with response length, on top of whatever the model itself takes.

Hardcoded fallbacks for Remote Config

If Remote Config fails to load or comes back empty, the app keeps working. Backend URL, API key, and configuration can all change without an App Store release.

The cost

The fallback values are baked into the shipped binary as a safety net, which means they're extractable by anyone who goes looking. That's a real trade, made deliberately rather than by accident.

Technical challenges

The parts that fought back.

Stopping an attestation retry loop

A transient network or verification failure during App Attest could, naively, trigger endless re-registration. DeviceAttestationService branches on the specific error: network or verification failures return the already-stored device ID instead of retrying, and only a missing key or an invalid challenge triggers actual re-registration. Concurrent verification calls also coalesce into a single in-flight task, with two retries and a one-second backoff, instead of each caller kicking off its own.

A gifted-credits toast, firing twice

The backend's credit-gifting event shows up on two different chunks of the same SSE stream: an early gifted-credit event, and again on the stream's completion event. A naive handler fires the celebration toast twice for one gift. The fix is a per-stream guard: once the notification has posted for a given stream, it doesn't post again.

A SwiftUI layout feedback loop

Commit bd939fa, shipped 2026-04-23, fixed a real feedback loop in the calendar preferences and chat geometry. Self-referential geometry and preference updates were triggering re-layout based on their own output, thrashing the layout pass instead of settling. It's the kind of bug that's invisible in a code review and obvious the moment you watch the frame rate.

Performance & reliability

What the repo actually says.

No install or latency numbers are published here. What's verifiable is the shape of the codebase and its operational defaults.

135
Swift files
~27,500 lines across the iOS app and the FeltWidget extension.
416
Commits
Roughly three years of continuous development, April 2023 to April 2026.
3
Languages localized
English, Spanish, and French affirmation content, each its own bundled SQLite database.

Automated test coverage is effectively zero: the test targets are Xcode's default empty stubs, with no coverage of the attestation, credits, or SSE-parsing logic that actually matters most. The operational defaults are more considered: image caching is capped at 200MB on disk and 100MB in memory; chat renders each streamed token with a deliberate 50ms delay and retries a failed request up to three times; network timeouts are 30 seconds for REST calls and 90 seconds for audio transcription upload; and the credits balance is fetched with in-flight coalescing and a 2-second snapshot cache so app launch doesn't fire duplicate requests. CI wraps builds and deploys in three retry attempts with a 60-minute timeout.

Security

An account-less app's real threat model.

Every request carries a Firebase anonymous ID token and an Azure function key. Device integrity is the harder problem, and it's handled directly rather than assumed:

  • Device integrity — answered by Apple App Attest: a challenge, a Secure-Enclave-generated non-exportable key, an attestation object, and a server-issued Device-ID; ongoing requests use fresh assertions over a hash of the request data, not a static token.
  • Credential storage — the device ID and attestation credentials live in the Keychain, not UserDefaults; legacy device IDs that predate this design were migrated out of UserDefaults into the Keychain.
  • Abuse and cost control — credits are metered server-side per device, a 403 with an out-of-credits body blocks further requests, and an idempotency key on chat and memory-update requests prevents duplicate charges from a retried request.
  • Input validation — HTTP status handling is centralized into typed errors, including a dedicated goal-limit-reached case, and transcription rejects empty or unreadable audio before it reaches Whisper.
  • Data deletion — a server-side endpoint deletes a device's conversations on request while keeping the device registration itself; the microphone permission string states plainly that audio isn't stored.

The honest gaps: there's no certificate or public-key pinning, so a trusted MITM proxy can still inspect traffic past the Firebase token and function key. The P-ID pro flag is asserted by the client; the server has to independently reconcile it against the actual subscription, or it's spoofable. On the Simulator, or on devices that don't support App Attest, the app falls back to a self-generated, non-attested device ID: functional, but without the hardware-rooted guarantee App Attest exists to provide. And the Azure function key and backend base URL both have hardcoded fallback values compiled into the binary as a Remote-Config-failure safety net, which makes them extractable by anyone who pulls the binary apart. That's a deliberate trade, not an oversight, but a real one.

Engineering journal

From offline content app to metered AI client.

2023 · Apr 23

Initial commit, SQLite-first

Felt started as an offline-first affirmations app. The SQLite copy-to-Documents mechanism and localization scaffolding landed within days of the first commit, and content shipped offline from day one.

2023 · Sep 23

Subscriptions land, share crashes

RevenueCat went in the same day a share-during-screenshot crash got logged. The screenshot-share path stayed a recurring crash source for a while after.

2024 · Sep 12

Widgets

Home and lock-screen widgets shipped, the start of the line that became the FeltWidget extension and its Mentora variants.

2025 · Sep 10

Device attestation and credits

The app's biggest architectural pivot: Apple App Attest, a Keychain-backed Device-ID, and the credits system arrived together, turning Felt from a plain content app into a metered AI backend client. Hardened over roughly eight pull requests through November.

2025 · Nov–Dec

Two releases just to pass App Store review

Versions 1.9 and 1.10 shipped specifically to resolve App Store validation errors, one of them on a version that had already been approved once.

2026 · Apr 23

A dense day of fixes

Part of a 59-commit April: split the AI chat screen so the Swift type-checker could handle it, hardened Whisper's silence handling, and fixed a SwiftUI layout feedback loop in calendar preferences and chat geometry (commit bd939fa), where self-referential geometry updates were causing re-layout thrash.

Lessons learned

What I'd tell myself at the start.

  • Centralizing auth and headers in one enum and one service pays off as endpoints grow. Past 25 REST endpoints, having exactly one place that assembles the Bearer token, function key, Device-ID, and P-ID header was worth it. The two deliberate exceptions, SSE and multipart upload, show where a single abstraction stops fitting.
  • Shipping a metered AI feature on an account-less app means identity and cost design come before UX. Device attestation and a credits ledger had to exist before the chat feature could safely ship at all.
  • Near-zero test coverage on high-risk logic shows up later as a tail of same-day fix and revert commits. Attestation loops, credit accounting, and SSE parsing are exactly the paths I'd want targeted unit tests around first, not last.
Outcome

Still under active development.

Felt was under active development as of its most recent commit, having grown from a simple offline affirmations app into an account-less iOS client with a server-metered AI chat companion. Over 416 commits and roughly three years, the architecture around identity (App Attest), cost control (credits), and configuration (Remote Config) hardened considerably.

Automated test coverage on that same high-risk logic remains thin, and the commit history shows it: a steady cadence of same-day fixes and reverts alongside the feature work. That's the kind of trade a small team makes to keep shipping, and the kind worth naming honestly rather than glossing over.