●BILLING — Your balance is really two: Build credits and Cloud credits. Cloud credits cover what a finished app spends at runtime, and while the project toggle stays off, AI features keep their old behaviour●NOV 1 — Forty-seven days until the extension deadline for Google Play's target API 36 requirement. Cross it non-compliant and your app stops reaching new devices●SDK 56 — Expo Go 56.0.1 on Android is reported to reject every SDK 56 project as incompatible, including a bare template. Neither the QR code nor EAS Update will open●NEW — Rork no longer creates Expo projects. How we thought through keeping the existing one versus rebuilding●DEVTOOLS — On SDK 56, Expo Go leaves the DevTools console unable to evaluate anything and the Sources tab empty. Switching to a development build has restored it for some●COMPANION — Companion is now positioned as a desktop app for loading Swift apps onto an iPhone over USB. If your notes describe something else, this is a good moment to revisit them●BILLING — Your balance is really two: Build credits and Cloud credits. Cloud credits cover what a finished app spends at runtime, and while the project toggle stays off, AI features keep their old behaviour●NOV 1 — Forty-seven days until the extension deadline for Google Play's target API 36 requirement. Cross it non-compliant and your app stops reaching new devices●SDK 56 — Expo Go 56.0.1 on Android is reported to reject every SDK 56 project as incompatible, including a bare template. Neither the QR code nor EAS Update will open●NEW — Rork no longer creates Expo projects. How we thought through keeping the existing one versus rebuilding●DEVTOOLS — On SDK 56, Expo Go leaves the DevTools console unable to evaluate anything and the Sources tab empty. Switching to a development build has restored it for some●COMPANION — Companion is now positioned as a desktop app for loading Swift apps onto an iPhone over USB. If your notes describe something else, this is a good moment to revisit them
Where the AI decision lives once Rork splits your app into two native codebases
New Rork apps are Swift on iPhone and Kotlin on Android — two separate codebases. Here is how I decide which AI-related judgments stay in each client and which move to the shared backend, plus the minimal contract both clients read the same way.
I spent an evening adding an Android app to a project that had only ever been an iPhone app. I pressed the + above the preview, picked Android (Kotlin), and watched the agent rebuild the screens and the navigation one by one.
When it finished, the first thing I looked for was not the layout. I went looking for the line that decides how many items a free user gets to see.
I found it right next to the Kotlin screen. The same rule existed on the iPhone side too, written in Swift. Both were correct. Both worked. What stayed with me was only this: the same meaning now lived in two places.
What follows is not about building an AI feature. It is about drawing a line — which AI-related judgments stay in each client, and where the shared backend takes over. If you only have one platform today, there is still a line worth drawing now, so I will cover that too.
What split was not the screens. It was the conditionals
Let me set the ground first. Rork's documentation states plainly that new apps are Swift on iPhone, Kotlin on Android, and React on the web, and that existing Expo projects keep working as before (What happened to Expo?).
The same page carries a sentence I took more seriously than the rest: the two apps are separate codebases, a Swift change is not the same file as a Kotlin change, and the two can drift. What a single project shares is the backend, the chat, and the place you publish from — not the code itself (iPhone, Android, or web: what's the difference).
If you came from a single React Native codebase, this is where the mental model has to shift. That the screens doubled is obvious the moment you look at the preview. What is harder to see is that the conditionals doubled along with them.
In an app with AI features, those conditionals cluster. Which inputs go through and which are refused. How many calls remain today. Which model to use. How much of the response to keep. None of those are screens. They are judgments, and once a judgment lives twice, the answers start to diverge on the first day you only fix one side.
I wrote about the step before this one — whether to keep an Expo project or rebuild it — in Rork no longer creates new Expo projects. This piece picks up after the split.
The drift I hit first, in my own apps
As an indie developer I have run wallpaper apps for a long time. The iOS and Android sides were always separate native implementations, with only the delivered images and their metadata coming down from a shared server. In other words, I had already been living for years in the shape Rork now uses for new projects.
What actually broke was nothing dramatic. It was one line deciding whether a given image could be shown in a given region.
Early on I kept that check in the clients. The reasoning was simple: fewer round trips, faster screens. It did not go well. The day I added one exclusion condition to one implementation, I forgot the other. No crash. No error. The same image simply kept appearing on one platform only.
I found out because someone wrote in. The logs showed nothing unusual, because both sides were answering correctly according to the conditions each one held.
The line I drew that week has not moved since.
A judgment that stays quiet when it breaks does not belong in the client. If I make an exception, it has to be somewhere drift causes no real harm.
AI features carry this property even more strongly. Responses are supposed to differ every time, so "different" stops being a signal that anything is wrong. Two clients can run two different policies and return two different answers, and both will look perfectly reasonable.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦You will be able to sort every AI-related judgment in your app into the ones that belong in each client and the ones that belong in the shared backend
✦You will be able to close the places that drift silently between Swift and Kotlin before the two sides start giving different answers
✦You will be able to lift a working decision-envelope implementation, read identically by Swift and Kotlin, straight into your own project
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
So I sort AI-related judgments into four kinds and fix the placement in advance. The two axes are "can I take it back later?" and "would I notice if it drifted?"
Kind of judgment
Examples
Where it lives
Why
Presentation
Loading treatment, hint text length, placement of the retry button
Each client
Drift causes no harm, and platforms are allowed to differ here
Device capability
Whether on-device inference is available, microphone permission, low power mode
Each client
Only the device knows; the server has nothing to decide with
Business rules
Free tier limits, regional availability, which model to use, the kill switch
Shared backend
Drift goes unnoticed, and taking it back requires a new release
Counting and history
Usage tallies, refusal reasons, policy version
Shared backend
Counted in two places, neither number is correct
The top two stay in the clients. The bottom two move to the shared backend, in the sense that neither side gets to finish the thought alone.
When a case sits on the border, I use one question: "can I change this tomorrow without touching the version already shipped?" If the answer is no, it is the backend's job. That framing is not specific to AI — I reached the same conclusion counting what I could still move remotely in Remote Config keys and the version already shipped.
Returning the decision as one envelope
Everything I move to the backend goes through one small endpoint, separate from the model call itself. What comes back is not just yes or no, but why, and which version of the policy said so.
Here is the minimal shape on Cloudflare Workers, taken from what I run and stripped of app-specific conditions.
// worker/src/ai-decision.ts// The single place two native apps go to get the SAME answer.// Clients do not decide. They receive and obey.export interface Env { AI_POLICY: KVNamespace; // the policy itself, versioned USAGE: DurableObjectNamespace; // the usage tally}type Reason = | 'ok' | 'quota_exceeded' | 'region_blocked' | 'feature_disabled' | 'policy_unavailable';interface Decision { policy_version: string; // must appear in both clients' logs allow: boolean; reason: Reason; remaining: number; // calls left today model: string | null; // null when we are stopping ttl_seconds: number; // how long a client may wait before asking again}// The default used when the policy cannot be read.// Failing to allow: false is the whole point — flip it to true and// every device gets unlimited calls for exactly as long as you are down.const CLOSED: Decision = { policy_version: 'unknown', allow: false, reason: 'policy_unavailable', remaining: 0, model: null, ttl_seconds: 30,};export default { async fetch(req: Request, env: Env): Promise<Response> { if (req.method !== 'POST') { return json({ ...CLOSED, reason: 'feature_disabled' }, 405); } let body: { user_id?: string; feature?: string; region?: string }; try { body = await req.json(); } catch { return json({ ...CLOSED, reason: 'feature_disabled' }, 400); } const userId = body.user_id; const feature = body.feature; if (!userId || !feature) { return json({ ...CLOSED, reason: 'feature_disabled' }, 400); } let policy: { version: string; enabled: boolean; daily_limit: number; model: string; blocked_regions: string[]; } | null = null; try { policy = await env.AI_POLICY.get(`policy:${feature}`, 'json'); } catch { policy = null; // a KV read failure also falls to the closed side } if (!policy) return json(CLOSED, 200); if (!policy.enabled) { return json( { ...CLOSED, policy_version: policy.version, reason: 'feature_disabled', ttl_seconds: 300 }, 200, ); } const region = (body.region ?? '').toUpperCase(); if (region && policy.blocked_regions.includes(region)) { return json( { ...CLOSED, policy_version: policy.version, reason: 'region_blocked', ttl_seconds: 3600 }, 200, ); } // Count usage in one place, in a Durable Object. // Per-device counters roll back on reinstall and double on device change. const id = env.USAGE.idFromName(`${feature}:${userId}`); const used = await env.USAGE.get(id) .fetch('https://usage/increment', { method: 'POST' }) .then((r) => r.json<{ count: number }>()) .catch(() => null); if (!used) return json({ ...CLOSED, policy_version: policy.version }, 200); const remaining = Math.max(0, policy.daily_limit - used.count); const allow = remaining > 0; return json( { policy_version: policy.version, allow, reason: allow ? 'ok' : 'quota_exceeded', remaining, model: allow ? policy.model : null, ttl_seconds: allow ? 60 : 900, }, 200, ); },};function json(d: Decision, status: number): Response { return new Response(JSON.stringify(d), { status, headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }, });}
The branch count is not the interesting part. Putting CLOSED in the default position is. An outage that opens the gate gets used hardest at the worst possible moment. Returning reason as a string serves the same purpose: whoever writes the client never has to guess why it stopped.
Returning ttl_seconds earns its place specifically when you have two clients. If Swift and Kotlin each pick their own refresh interval, that interval is the first thing to diverge. Let the server name it and neither side gets to be cleverer than the other.
Swift and Kotlin stop deciding and start receiving
The client side gets short, because it no longer holds any judgment.
// AIDecisionClient.swift// Holds no policy. Receives an envelope and obeys it.import Foundationstruct AIDecision: Decodable { let policyVersion: String let allow: Bool let reason: String let remaining: Int let model: String? let ttlSeconds: Int enum CodingKeys: String, CodingKey { case policyVersion = "policy_version" case allow, reason, remaining, model case ttlSeconds = "ttl_seconds" } // Used when the call fails. Must mean exactly what CLOSED means on the server. static let closed = AIDecision( policyVersion: "unknown", allow: false, reason: "policy_unavailable", remaining: 0, model: nil, ttlSeconds: 30 )}actor AIDecisionClient { private let endpoint: URL private var cached: (decision: AIDecision, expiresAt: Date)? init(endpoint: URL) { self.endpoint = endpoint } func decision(userID: String, feature: String, region: String) async -> AIDecision { if let c = cached, c.expiresAt > Date() { return c.decision } var req = URLRequest(url: endpoint) req.httpMethod = "POST" req.timeoutInterval = 5 req.setValue("application/json", forHTTPHeaderField: "Content-Type") req.httpBody = try? JSONSerialization.data(withJSONObject: [ "user_id": userID, "feature": feature, "region": region, ]) do { let (data, response) = try await URLSession.shared.data(for: req) guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return .closed } let decision = try JSONDecoder().decode(AIDecision.self, from: data) cached = (decision, Date().addingTimeInterval(TimeInterval(decision.ttlSeconds))) return decision } catch { // Close, do not crash. Opening here makes every offline device unlimited. return .closed } }}
The Kotlin side reads the same envelope with the same meaning.
// AiDecisionClient.kt// Same contract as the Swift version. Field names and defaults must match.import kotlinx.coroutines.Dispatchersimport kotlinx.coroutines.withContextimport kotlinx.serialization.SerialNameimport kotlinx.serialization.Serializableimport kotlinx.serialization.json.Jsonimport okhttp3.*import okhttp3.MediaType.Companion.toMediaTypeimport okhttp3.RequestBody.Companion.toRequestBodyimport java.util.concurrent.TimeUnit@Serializabledata class AiDecision( @SerialName("policy_version") val policyVersion: String, val allow: Boolean, val reason: String, val remaining: Int, val model: String? = null, @SerialName("ttl_seconds") val ttlSeconds: Int,) { companion object { val CLOSED = AiDecision("unknown", false, "policy_unavailable", 0, null, 30) }}class AiDecisionClient(private val endpoint: String) { private val json = Json { ignoreUnknownKeys = true } private val http = OkHttpClient.Builder() .callTimeout(5, TimeUnit.SECONDS) .build() @Volatile private var cached: Pair<AiDecision, Long>? = null suspend fun decision(userId: String, feature: String, region: String): AiDecision = withContext(Dispatchers.IO) { cached?.let { (d, expiresAt) -> if (System.currentTimeMillis() < expiresAt) return@withContext d } val payload = """{"user_id":"$userId","feature":"$feature","region":"$region"}""" val request = Request.Builder() .url(endpoint) .post(payload.toRequestBody("application/json".toMediaType())) .build() try { http.newCall(request).execute().use { res -> if (!res.isSuccessful) return@withContext AiDecision.CLOSED val body = res.body?.string() ?: return@withContext AiDecision.CLOSED val decision = json.decodeFromString<AiDecision>(body) cached = decision to (System.currentTimeMillis() + decision.ttlSeconds * 1000L) decision } } catch (e: Exception) { AiDecision.CLOSED // exceptions fall to the closed side too } }}
There is a reason to keep both files side by side. When you change one, you want to be able to see with your eyes what to change in the other. Even if you ask the agent to update both, having the contract written down first is what makes the result stable.
ignoreUnknownKeys = true and the optional fields on the Swift side are deliberate. The day the server adds a field, older builds should keep running. Make this strict and every extension to the envelope costs you a release.
What needed moving was not the model call
This is where I was most wrong.
Hearing "two codebases", my first instinct was that moving the model call to the server would remove the duplication. In practice the benefit was small. A call that lives in two places does not break. The request assembly can differ a little and the response still comes back the same shape — and if it does not, nothing works and you find out immediately.
What hurts when it splits is the policy sitting just upstream of the call. Who gets how many. Which regions are off. Which model to use. Those keep working when they live in two places. Because they keep working, nothing ever tells you they have drifted.
There was a second surprise. I assumed that one chat knowing both apps would make drift easier to catch. Sometimes it worked the other way. Both sides receive plausible-looking code. An empty side is easy to spot; two sides that are similar but not identical hide until you read the diff yourself.
So I settle the contract before letting the agent touch both apps. If the judgment exists in only one place, the only thing that can land on both sides is the receiving half — and that is a much smaller surface to compare.
I used the same reasoning for key placement in API keys in environment variables versus an Edge Function, where the deciding question was who receives the bill when something gets overused. Placement of judgment turns on "would I notice the drift", placement of keys turns on "can I stop it" — different axes, but both follow from the same fact: what you hand to the client is hard to take back.
Three conditions for keeping a judgment in the client
I have written mostly about moving things out, so here is the other half. A judgment stays in the client only when all three of these hold.
Drift causes no loss or disadvantage to the user. Presentation and ordering live here.
The server cannot decide it. It depends on something only the device knows — capability, permission, battery state.
Waiting for the next release is acceptable if I want to change it tomorrow. It is not the kind of thing I would need to stop in a hurry.
The third one does the most work in practice. Asking "would I ever want to stop this quickly?" up front is what leaves you with a move to make on a bad night. Put differently: anything you might want to stop belongs in the envelope from the start, because that is the cheaper position.
If you only have one platform today
You can draw the line before adding Android or the web. The order I would suggest is four steps.
Extract only the conditionals from your current code. Skip the screen implementations and list the if conditions alone. You will end up with something like twenty rows.
Assign each row to one of the four kinds above. Mark everything that landed in "business rules" or "counting and history".
Count the marked rows. That count is the amount that doubles the moment you add a second platform. Three or four is not urgent. Past ten, moving them before you add the platform is reliably cheaper.
When you move them, group by envelope, not by feature. One entry point per feature simply relocates the problem: now the client has to decide which endpoint to call.
None of this is wasted if a second platform never comes. Widening the set of things you can change without touching a shipped build is worth it on its own.
Where I tripped
A few places that cost me time when I built this.
Forgetting to log policy_version. Without it you cannot reconstruct which policy a given device was running when someone writes in. Both clients attach it to every AI success and failure log.
Letting the client choose the cache lifetime. Leave it to each implementation and one side holds a stale policy forever. Handing ttl_seconds down removes the whole class of problem.
Opening on failure. Inside a catch, allow: true is tempting. An outage is exactly when you want the gate shut, on both sides.
Counting in two clients. Per-device tallies roll back on reinstall and double on device change. Count in one place only.
Asking the agent for "the same thing on both sides" and stopping there. The generated code will look right on both. Field names and defaults are worth lining up and reading yourself.
One thing to run tomorrow
If you do a single thing, check whether your AI feature opens or closes when it fails. Turn off the network, launch the app, and hit the feature. That alone tells you which way your current implementation falls.
I try to finish that check before adding a second platform. A line you have corrected on one side moves across in the same shape — which is the whole reason for drawing it early.
Thank you for reading. I hope it gives you something to work with if you are standing at the same fork.
Share
Thank You for Reading
Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.