You know personalized paywalls convert better, but what if you could use everything your app already knows about the user to get really (but not inappropriately so) personal?

Timing makes this worth the effort: according to our State of Subscription Apps 2026 report, 55% of 3-day trial cancellations happen on Day 0. The battle for a subscriber is won or lost in the first session, so if your paywall only gets one shot, it should speak to the person looking at it.

Make sure you're using the correct versions

Before getting started, make sure your app runs a compatible version of the RevenueCat SDK. Custom variables on the iOS SDK require version 5.57.0. You'll also need to target iOS 26 or above.

The example app

Chorus, the bird song identifier and walk tracker I'm building for Shipaton 2026, spends its onboarding learning why someone is using the app and what would get them to walk more. It asks about their dream bird, who they go on walks with, when they go on walks, and what would actually get them out the door more.

This post is about what happens next: using Apple's on-device Foundation Models framework to hand their own ’why’ back to them at the moment of the ask, on a RevenueCat remote paywall. The best part? It's free.

Here's the basic paywall with no personalization:

Chorus' basic paywall with no personalisation
Chorus' basic paywall with no personalisation

It does the job. It's okay, but it's not really speaking to the user. Let's change that.

Custom variables on the paywall

Using RevenueCat remote paywalls, you can start personalizing them using custom variables, but what if you could push that further? What if you could use everything your app already knows about your customer to really get personal?

Open your RevenueCat paywall editor. Click ‘Paywall Logic’ in the left-hand menu and select Variables. Then click ‘Create Variable’ and you'll see the following entry form:

New custom variable form screenshot on the paywall editor

I'll start by creating the userName variable (possibly the easiest bit of personalization), with the default value ‘Birder’. Chorus asks for the birder's name in onboarding, saves it, and passes it into the remote paywall in Swift like so:

struct ChorusPlusPaywall: View {
    let userName: String?   // asked for in onboarding; nil if they skipped it

    var body: some View {
        PaywallView(displayCloseButton: true)
            // If there's no name, send nothing; the dashboard
            // default ("Birder") fills the gap.
            .customPaywallVariables(userName.map { ["userName": .string($0)] } ?? [:])
    }
}

That'll produce a paywall like this:

Chorus' paywall using custom variables
Chorus' paywall using custom variables

Better. But we can make it even better with Foundation Models.

Apple's Foundation Models

Foundation Models is Apple's on-device large language model (LLM) framework, introduced in iOS 26. It generates text locally, with no server round trip and no per-token cost. Apple says "On-device models excel at a diverse range of text generation tasks, like summarization, entity extraction, text and image understanding, refinement, dialog for games, generating creative content, and more". Perfect for our use case. On-device models are much smaller than cloud-based models, so there are limitations, which I'll go through later.

As I mentioned, Chorus gets to know the customer during onboarding — this is where the magic happens.

Create a class that handles all interaction with Foundation Models. I've called mine ‘PaywallCopyGenerator’. In Chorus, each onboarding screen is aware of this class, and its functions get called at different points in the flow.

Right at the start, the welcome screen calls prewarm(), which checks SystemLanguageModel.default.availability and warms a session. Devices without Apple Intelligence bail out here: they ship the hand-written fallback line and never touch the model.

func prewarm() {
  guard warmSession == nil else { return }
  guard case .available = SystemLanguageModel.default.availability else { return }

  warmSession = LanguageModelSession(instructions: instructions)

  warmSession?.prewarm()
}

Prewarming the session reduces the time it takes for the user to see generated output. Do it as early as possible.

One gotcha: the first time a user's device meets the requirements, SystemLanguageModel can register as unavailable while the OS downloads the model in the background. Don't panic, it resolves itself. In Chorus I don't need to guard with an iOS 26 availability check because the app targets iOS 26; if you support older versions, you will.

Instructions and prompts

You'll notice you pass system instructions into the session. Apple has great guidance on getting the best out of the model by giving the model a role, persona, and tone. Here are Chorus's system instructions:

You are a warm British field naturalist writing paywall copy for Chorus, a
bird-walking app. You are given notes about a walker from the app's
onboarding: their dream bird, what would get them out walking more, and how
they walk. Write two short sentences spoken to them as "you".
1. Conjure their next walk as one small imagined moment, built only from
the notes: who is with them, when or where they walk, their dream bird
singing somewhere in it.
2. Say what Chorus can help them do about it.
Never invent people, places, times or history that are not in the notes.
Mention Chorus exactly once, in the help sentence; never name features or
prices. Never the walker's name. No questions, no exclamation marks, no em
dashes. Thirty words at most.

Write like a person talking, not an advert: plain, everyday words, said
simply, nothing you would not say to a friend on a walk. The examples
below are style only; never reuse their scenes, company or times of day
for a different walker. When the notes say who walks with them, hand it
back as theirs: "your partner", "your dog", "your kids", not "a partner"
or "the kids".

Example 1.
Notes: Their dream bird: the goldfinch; they have never heard one; that is
the dream. What would get them out more: knowing new birds were waiting.
They walk at dawn, with their kids. They get out less than they'd like.
Line: Imagine a dawn walk with your kids when a goldfinch strikes up, your
first ever. Chorus can help you catch it the moment it sings.

Example 2.
Notes: Their dream bird: the wren; they hear them all the time and love
them. What would get them out more: keeping the birds they've found close.
They notice birds by ear first.
Line: Picture the next wren that sings before you spot it, one more song
worth keeping. Chorus can help you hold on to every one.

Example 3.
Notes: Their dream bird: the blackbird; they hear them all the time and
love them. What would get them out more: calmer, quieter time outside.
They walk at dusk, as the day's song winds down.
Line: Imagine a quiet walk at dusk, just you and the blackbird seeing the
day out. Chorus can help you make more of those.

The instructions lean on five concepts from Apple's documentation:

  1. The persona: Chorus follows Apple's role-playing blueprint. Casting the model as a "warm British field naturalist" with the words "you are" merges character and voice in a single line. The instructions are also written in the exact register they expect back, right down to banning em dashes and obeying that ban themselves.
  2. Step-by-step logic: dense task descriptions are broken into a two-step plan. For smaller models, this reduces the cognitive load and keeps sequencing on track.
  3. Size constraints: the prompt stays lean. Two paragraphs of imperatives and one clear objective fit Apple's recommended length budget.
  4. Few-shot safety: three curated examples establish the style. An "examples are style only" guard prevents exemplar bleed. Without it, the model happily hallucinates dawn walks for night owls.
  5. Strategic repetition: high-stakes rules like "never invent" and "exactly once" are reinforced, and the runtime prompt repeats the most important rule last.

The runtime prompt works differently. Onboarding captures information about the user, then a few screens before the paywall we ask the model to generate the personalized line. That head start matters: on-device models are still fairly slow, so generating early means the line is ready by the time the paywall appears. (You can add a loading state at the end of onboarding if you prefer, but it's best not to block the paywall.)

Apple suggests sending the model hard facts, so a fairly long Swift function builds the runtime prompt with no conditional language (no ‘if’s for the model to reason about). Deterministic code decides which facts apply; the model only translates them into prose:

// Chorus — the walker's brief. Apple's "turn conditional prompting into programming
// logic", shipped: deterministic Swift turns onboarding answers into prose notes, so
// the on-device model only ever sees the conditions that apply. The model's whole job
// is translating these notes into one warm line. From `PaywallCopyGenerator.swift`.

/// The walker's why, in prose: dream bird + history, what would get them out more,
/// how they notice birds, and every context line that exists. Empty when everything
/// was skipped — there is nothing to reflect, and the fallback line ships instead.
func brief(signals: OnboardingSignals) -> String {
    var lines: [String] = []
    if let bird = signals.dreamBird {
        let heard: String = switch signals.dreamBirdHistory {
        case .often: "they hear them all the time and love them"
        case .yearsAgo: "they heard one once, years ago"
        case .unsure: "they aren't sure they've ever heard one"
        default: "they have never heard one; that is the dream"
        }
        let seen: String? = switch signals.dreamBirdSeen {
        case .often: "they see them all the time"
        case .yearsAgo: "they saw one once, years ago"
        case .unsure: "they couldn't say if they've seen one"
        case .never: "they have never seen one"
        case nil: nil
        }
        // Short name — briefing "the Common Nightingale" teaches stilted formal names.
        lines.append("Their dream bird: the \(bird.shortName); \(heard)"
                     + (seen.map { ", and \($0)" } ?? "") + ".")
    }
    switch signals.desiredPerk {
    case .whereNext: lines.append("What would get them out more: knowing new birds were waiting.")
    case .cameraScan: lines.append("What would get them out more: being able to name what they see.")
    case .wearBird: lines.append("What would get them out more: keeping the birds they've found close.")
    case .knowNow: lines.append("What would get them out more: knowing what's singing around them, right there and then.")
    case .everyDay: lines.append("What would get them out more: a reason to get out every day.")
    case .calm: lines.append("What would get them out more: calmer, quieter time outside.")
    case .learnSongs: lines.append("What would get them out more: learning to know the songs themselves.")
    case nil: break
    }
    switch signals.spotting {
    case .ear: lines.append("They notice birds by ear first.")
    case .eye: lines.append("They see birds but often can't name them.")
    default: break
    }
    switch signals.knowTheBirds {
    case .everyOne: lines.append("They want to know every bird they've heard.")
    case .special: lines.append("They'd like to know the special ones at least.")
    case .justListening: lines.append("They're happy just listening.")
    case nil: break
    }
    if let habitat = signals.habitat {
        switch habitat {
        case .gardenAndStreet: lines.append("They walk gardens and streets close to home.")
        case .park: lines.append("Their walks loop the local park.")
        case .woodland: lines.append("Their walks run under trees more often than not.")
        case .water: lines.append("Their walks keep close to water.")
        case .openCountry: lines.append("They walk open country where song carries far.")
        case .allOver: break
        }
    }
    switch signals.walkTime {
    case .dawn: lines.append("They walk at dawn, when the chorus is loudest.")
    case .dusk: lines.append("They walk at dusk, as the day's song winds down.")
    default: break
    }
    switch signals.companion {
    case .alone: lines.append("They walk alone.")
    case .partner: lines.append("They walk with their partner.")
    case .dog: lines.append("They walk with their dog.")
    case .kids: lines.append("They walk with their kids.")
    default: break
    }
    switch signals.walkFrequency {
    case .mostDays: lines.append("They walk most days.")
    case .fewTimesAWeek: lines.append("They walk a few times a week.")
    case .nowAndThen: lines.append("They get out now and then.")
    case .lessThanLike: lines.append("They get out less than they'd like.")
    case nil: break
    }
    return lines.joined(separator: " ")
}

OnboardingSignals is a struct that gets populated as the customer moves through onboarding.

The final piece of the puzzle is asking the model to generate output:

func generate(signals: OnboardingSignals) {
    guard task == nil else { return }

    let brief = self.brief(signals: signals)
    guard !brief.isEmpty else { return }   // everything skipped → fallback line

    working = true
    task = Task { [weak self] in
        defer { self?.working = false }
        guard let self else { return }
        self.generatedLine = await self.requestLine(
            prompt: "Notes: \(brief)\nUse only these notes. Write the line.")
    }
}

private func requestLine(prompt: String) async -> String? {
    guard let session = warmSession else { return nil }   // no model → fallback
    let response = try? await session.respond(
        to: prompt,
        generating: GeneratedLine.self,
        options: GenerationOptions(temperature: 0.85, maximumResponseTokens: 110))
    return response?.content.line
}

The runtime prompt is simple compared to the system instructions. We ask the model to fill a @Generable type:

@Generable
private struct GeneratedLine {
    @Guide(description: "Both sentences: the reflection, then the Chorus help line. Nothing else")
    var line: String
}

You can do interesting things here too, like checking the output for banned words and regenerating when one appears. You may also want to trim whitespace. The model occasionally leaks JSON artifacts into the string, so I trim it like this:

let line = raw.trimmingCharacters(in: .whitespacesAndNewlines)
    .trimmingCharacters(in: CharacterSet(charactersIn: "{}\"\u{201C}\u{201D}"))
    .trimmingCharacters(in: .whitespacesAndNewlines)

Pass the generated line into the paywall like before and you should see the foundation models generate something like the text surrounded by the red rectangle:

Chorus' paywall with the peronalized paywall body

Pretty cool, right?

Limitations of Apple’s Foundation Model for personalized paywall copy

Foundation models are small. I like to compare them to the early days of GPT-3: about three billion parameters against the trillions in server-side models. Output isn't always great, and they can hallucinate. That's why the safety checks matter. Out of the box, Apple protects against harmful output (racism, violence, profanity), but Apple also states that you're responsible for the content the model generates.

A note on prompt safety: Chorus never sends freeform user text to the model. The brief is built entirely from fixed onboarding answers, so there's nothing a user can type that ends up in the prompt. If you do include free text, treat it as untrusted: interpolate it into a rigid prompt structure, and never let it stand alone as instructions.

I also found the model isn't yet smart enough for more ambitious moves, like picking the most relevant feature for a customer and spotlighting it. But it's early days: the models improved massively from iOS 26 to iOS 27, and I expect that trend to continue.

One more reason to ground the model in the user's own answers: our SOSA 2026 data shows AI-powered apps earn 41% more per payer but churn 30% faster. AI features only pay off when they create real, lasting value. Personalization built from what users actually told you is exactly that.

Going even further with AI-personalized paywalls

From iOS 27 you can swap the on-device model for a cloud-based one through the Foundation Models framework. Anthropic's Claude already conforms, and the other big providers may follow suit.

There's also Apple's Private Cloud Compute, which runs much larger server-side models. You can use it for free if you have fewer than two million lifetime first-time downloads and are enrolled in the App Store Small Business Program.

With those larger models, the more ambitious personalization opens up, like picking the most relevant feature for each customer and spotlighting it. Teams like Tinder invest heavily in exactly this kind of paywall relevance work; with these APIs, you don't need a dedicated team to try it.

Whether you want ultra-personalized paywall copy that calls out a birder’s favorite feathered friend, or are looking to demo the exact feature that solves your user’s job-to-be-done, on-device AI is unlocking a new way to tailor paywalls to every individual — just in time to ask them to subscribe.


To keep going on paywalls, start with our paywalls study guide or check out the paywalls documentation.