Start the app beside the backend
# A new project, with the app
pithy init --target mobile --react-native
# An existing Worker, api, gains an app
pithy ui add react-native --worker apipithy init asks whether to add the app on a mobile or both target, and --react-native answers yes. A new Worker composes nothing yet, so that app has no screens: run pithy ui add react-native again once auth, payments or organization is composed, and it adds them. The backend is the same either way, because auth already speaks both dialects.
The app gets an apps/<name>/ of its own, and it is not a Worker. It has a pithy.worker.jsonc, so pithy dev runs expo start beside your Workers on a pinned port. It has no wrangler.jsonc, so pithy deploy leaves it alone. --app names the directory, app by default, and --worker names the Worker it calls.
What lands in the app
Expo SDK 57 and React Native 0.86, with Expo Router. The screens are the web set’s, rewritten in React Native primitives, under the same file names and the same string keys, so one Spanish catalog covers both. They come from @pithy-sh/ui-react-native and share @pithy-sh/ui-core with the web screens: the route table, useRead, useAction and <Redirect>. As on the web, Pithy writes each file once and never again. From then on it is yours.
Config is a file. Metro cannot resolve the virtual:pithy/* modules a Vite app imports, so src/pithy-config.generated.ts holds the composed config as a file: auth’s, i18n’s, payments’, Turnstile’s and consent’s client projections, each resolved for dev and for every environment the project deploys to, and the Worker’s origin in each environment. Four fields are yours, and survive every rewrite:
appScheme, the scheme a sign-in returns to. The app’s name by default.universalLinkOrigin, the https origin a sign-in or a checkout in the system browser returns through.- The Google client ids.
rails, what each platform sells through.
app.config.ts reads the scheme and the link origin from the same file, so you set them once. When the Worker’s config changes, run pithy ui sync --app app, and add --check in CI to fail while the file is behind, in any environment’s projection. Restart Metro after a sync: it reads its config once, at start.
One origin per environment
origins.dev, origins.staging and origins.prod say where the Worker answers, and a build calls the one EXPO_PUBLIC_PITHY_ENV names. Set it to prod in the env of an eas.json build profile, and the store build calls production and nothing else. EAS builds in the cloud, so a variable set only in your shell never reaches it. Unset, it is dev.
The same variable picks the projections. Each capability’s projection is keyed like the origins, so a prod build reads prod’s Turnstile sitekey, catalog and consent base path, and a staging build staging’s. A build naming an environment the file holds no projection for stops with an error naming the field, such as turnstile.prod: the file was written before the project deployed there, and pithy ui sync --app app writes one for each environment it deploys to.
Pithy fills staging and prod from the Worker’s declared domain, its route or its BASE_URL, and writes null where none names one. A Worker that answers only on workers.dev is that case: set the origin by hand, and pithy ui sync keeps it until the Worker declares one. A phone build for staging or prod stops at launch when its origin is unset or not https, and names the field to set, because the bearer credential goes there.
In dev, three devices reach your machine three ways. The iOS simulator reaches http://localhost:<port> as written. An Android emulator reaches this machine at 10.0.2.2, and the app makes that swap itself. A real device on your network reaches neither: set origins.dev to this machine’s network address, with the Worker listening there, and pithy ui sync keeps it.
The web build is served from the Worker’s own origin, so its calls are relative and ride the cookie, as the React front end’s do. expo export --platform web builds it. Under pithy dev Metro serves it on its own port, so metro.config.js adds one middleware: every request under the Worker’s paths goes on to origins.dev as it came, cookies and all, and Metro answers the rest. The paths are the generated config’s workerPaths, written from the Worker’s composed route table. The middleware runs only while Metro serves, and expo export builds without it.
Build against the contract
The kit’s own calls arrive typed, from its packages. For your own routes, write the Worker’s OpenAPI document:
pithy contractIt lands at apps/<worker>/openapi.json, and it describes every route the Worker composes: its parameters, its body, its responses, and how the caller is verified. Generate your app’s types or calls from it with any OpenAPI generator for TypeScript, rather than writing each one by hand, and run it again whenever a route changes. pithy contract has the details, including how to mark which of your own routes are not described yet.
Validate what comes back. A response is data crossing a trust boundary, and the schemas in the document are the ones the Worker itself was built against.
Sign in with a bearer token, always
A phone never uses a cookie session. The scaffolded session is createBearerSession from @pithy-sh/auth/src/client/bearer, and signing in on mobile is the flow it runs:
- The credential is the raw session token a sign-in answers with. It goes on every request to the Worker as
Authorization: Bearer <token>, and nowhere else. - It lives in the secure store:
expo-secure-store, which is the Keychain on iOS and the Keystore on Android. Never inAsyncStorage, which is plain storage. - Not the
accessToken.POST /auth/token/rotateanswers with a JWT beside the session token. The kit’s own routes do not accept it: it is for a service outside your Worker that verifies against/auth/jwks.
The session rotates for you, before the token expires. /auth/token/rotate spends the token it is shown and answers with its successor. Presenting a spent one again is denied, and after a 30-second grace it is read as theft: every session in that sign-in’s family is revoked. So the session runs one rotation at a time, every request that arrives during it waits for it, and none proceeds until the successor is stored. Never call the route yourself. The token model is why.
Register the device at sign-in
x-pithy-device-id: <a stable id you generate once and keep>
x-pithy-platform: ios
x-pithy-device-name: …
x-pithy-device-model: …The scaffolded session sends these at sign-in. Only the id is required, and the session is bound to the device, so the user can see it in their device list and revoke a lost phone in one call. Sessions and devices has every header.
Sign-in returns to the app
Apple on iOS, and Google on a phone, sign in natively. The app asks the platform for an identity token and posts it to POST /auth/sign-in/social, so no browser opens. Google signs in natively only once google.webClientId is set in the generated config, because its id token is asked for with the web client id as its audience; Add Google sign-in has the ids. Apple on Android, Google with no web client id, and both on the web build take the redirect below. Apple Sign-In has the request.
Every other provider redirects, and comes back to the app’s own link. Auth refuses a sign-in that returns to a link its trustedOrigins does not name, so add the app’s scheme, and universalLinkOrigin when you set one. pithy ui add react-native names the exact entries:
auth({
trustedOrigins: ["myapp://", "https://app.example.com"],
}),Prefer the universal link. The system opens an https universal link (iOS) or app link (Android) only in the app that proves it owns the domain. Any installed app can claim a custom scheme. An in-app auth session closes on an https callback only on iOS 17.4 or later, so to support an older iOS, leave universalLinkOrigin unset and return on the scheme.
The redirect starts in the system browser, and ends with a one-time code. The scaffolded sign-in opens the Worker’s GET /auth/sign-in/social/start in the sign-in sheet rather than posting from the app, because the provider’s callback checks a signed state cookie and only the sheet’s own jar can carry it there. A cookie set at the end would land in that browser too, which the app never reads. So the app makes a PKCE verifier first, the Worker returns to the app’s link with a code, and the app trades code and verifier at POST /auth/handoff for the session token. The code is good once, for sixty seconds. Another app that catches the link has the code and not the verifier, so it has nothing to trade.
A magic link needs a universal link. Whoever asks for a magic link chooses its challenge, and somebody else opens it, so an app claiming your scheme could catch the code meant for your user. The Worker hands a magic link off only to an https link in trustedOrigins. With universalLinkOrigin unset, the scaffolded sign-in offers no magic link on a phone: it emails a one-time code instead.
You never register the app’s link with a provider. It only ever sees your Worker’s callback. Apple Sign-In walks the handoff step by step, for a client you write yourself.
Buying
One rail per platform. rails.ios, rails.android and rails.web say what each platform sells through. apple and google buy inside the app through StoreKit and Play Billing. stripe, lemonSqueezy and paddle open a hosted checkout. none shows the plans and sells nothing. Set them with --ios-rail, --android-rail and --web-rail, at the prompt, or in the file. The defaults are each platform’s store and the Worker’s hosted rail, wherever the Worker enables them, and a rail the Worker does not enable is refused by name.
A store purchase is tagged with a token the device keeps for that user, Apple’s appAccountToken and Google’s obfuscatedAccountId. It is a random UUID minted once per user and device and held in the secure store, never derived from the user id: the Worker links a store account to whoever presents it first, so a guessable token would let somebody claim another’s purchases. The purchase lands on the same entitlement as the web, and selling on the App Store and selling on Google Play cover the store side.
A hosted rail on a phone opens checkout in the system browser, and the app reads the subscription again when you come back. Whether Apple’s App Store Review Guidelines (3.1.1, 3.1.3) or Google Play’s payments policy allow that link out in your market is your call, not the kit’s. Paddle on a phone needs paddle.checkout: "hosted" in the Worker’s config. An overlay or inline checkout opens inside a web page, and a phone has none, so the paywall refuses one and names the line to change. Selling with Paddle has the setting.
Everything else a phone does differently
Turnstile runs in a WebView. The app draws no widget of its own. It loads the Worker’s GET /turnstile/widget in react-native-webview, a page on the Worker’s own origin that renders the login widget and posts the solved token back. The WebView loads nothing but that page and Cloudflare’s challenge. Your prod widget’s allowed hostnames must include the Worker’s origin, or production refuses the challenge. The web build draws Cloudflare’s widget in the page instead, with the sitekey of the environment it is built for, read from the generated config, so sync and rebuild it after the sitekeys change. Turnstile has the widget settings.
The language comes from the device. The scaffolded pithy-locale.tsx reads the reader’s preferred languages in the order they set them, and keeps a choice in AsyncStorage. React Native sets left-to-right or right-to-left once, at launch, so a choice that lays out the other way shows a line asking for a restart rather than flipping half a layout. Where a locale comes from has the chain.
Consent knows a signed-out phone by its own visitor id. A phone has no cookie, so the scaffolded transport mints a random id for consent alone, keeps it in the secure store, and sends it as x-pithy-consent-visitor on consent’s requests: every request under the base path consent’s projection names, so a Worker composing consent({ basePath: "/privacy" }) is called at /privacy with the id on it, and the consent screen calls there too. It is never the device id, which operators can list. A choice made signed out merges into the account at sign-in. Consent has the rest.
An invitation opens in the app. With --organization, the app gets the invitation screen the web gets, and the organization in force follows the session across every rotation. It reads the session the sign-in screens hold, so it needs them.
Upgrades do not compare these templates. pithy upgrade --packages diffs the React screens you copied against the version it moves to. For a React Native app it names the app and leaves the diff against @pithy-sh/ui-react-native’s templates to you.
Reading errors
Branch on the error code, never the message. Every failure is one { error: { code, status, message } } body, and the code is the stable part. The OpenAPI document describes it once for every operation.
What this page does not cover
Store signing, packaging and submission. Pithy builds nothing for a store: an EAS build (eas build) does, and your Apple and Google accounts decide the rest.
With the Claude Code plugin
The pithy plugin has a client-mobile agent that builds on this scaffold: it changes the screens and leaves the transport to the package, reads openapi.json before it writes a call of your own, and keeps the session token in the secure store.