AI personalization without the creepy part: opt-out as a first-class return value
Users have learned that "personalized for you" means "we mined everything you ever did" - and the backlash is rational. When I added LLM-generated personalized copy to my app, the part I sweated was not the generation. It was making declining it a real, respected choice.
Opt-out is in the type signature
Personalized UX has a trust problem it earned. Users have learned that "personalized for you" means "we mined everything you ever did," and the backlash is rational. So when I added LLM-generated personalized copy to my app - paywalls, onboarding, emails written from each user's real usage - the part I sweated wasn't the generation. It was making declining it a real, respected choice instead of a dark-pattern toggle. Here's the architecture, because the interesting part is that consent isn't a setting I check - it's a state the API can return. I use Vevee's compose() (@vevee/sdk) for the generation. Its return type is a discriminated union:
type ComposeResult<T> =
| { status: "generated"; generationId: string; output: T; usage: ComposeUsage }
| { status: "opted_out"; generationId: null; output: null; usage: null };Consent enforced at the API layer, not the call site
If the user opted out of AI personalization, the call doesn't generate anything - no user data is assembled into a prompt, no model is called, nothing is spent. You get opted_out back, and TypeScript will not let you touch output until you've handled it. This is the design decision worth stealing regardless of stack: consent enforced at the API layer, not the call site. I cannot forget to check the user's preference in some new code path next quarter - there is no code path where personalization happens without the platform checking first. The compiler maintains my privacy policy.
const result = await vevee.compose<PaywallCopy>("personalized-paywall", userId);
if (result.status === "generated") {
renderPaywall(result.output);
} else {
renderPaywall(STATIC_COPY); // the non-personalized experience, by right
}Scoped consent, not one big switch
The second mistake personalization systems make is a single "tracking: on/off" toggle, which forces users to choose between all of it or none. The SDK scopes it: a user can keep contributing anonymous funnel analytics while refusing AI-generated copy, or vice versa. In my settings UI that's two plain toggles with one honest sentence each - "Use my activity to personalize messages and suggestions" - and the off state is not nagged, not re-prompted, not "are you sure?"-ed.
// user toggles "AI personalization" off in my settings page:
await vevee.optOut(userId, "ai_personalization");
// analytics, AI personalization, or both:
await vevee.optOut(userId, "analytics");
await vevee.optOut(userId, "all");
// and it's queryable:
const out = await vevee.isOptedOut(userId, "ai_personalization");The fallback experience is a product surface, not an error state
Opted-out users get my best static paywall, my best generic onboarding - not a degraded shame-mode. Two reasons beyond ethics:
- It's your control group for free. Opted-out and budget-capped users see static copy; if you're A/B testing composed vs. static (you should be), the static arm has to be genuinely good or you're testing against a strawman.
- Punitive fallbacks teach users to distrust the toggle, and users who distrust one toggle opt out of everything, everywhere, including the analytics you actually need.
What this buys you
The pattern generalizes: if you're adding LLM personalization to anything, put the consent check below your application code, make the declined state a typed return value, and make the fallback something you're proud of. Personalization that's easy to refuse is personalization people accept. Concretely, the typed opt-out buys you three things:
- GDPR alignment by construction - personalization has a purpose-scoped consent gate that's enforced server-side, with an audit trail (every compose call, including blocked ones, is persisted with a generationId).
- A trust story you can say out loud. "Personalization is generated from your usage, you can turn it off in one click, and when it's off we don't even assemble the prompt" fits in a tooltip and survives a Hacker News comment section.
- Higher effective opt-in. People decline personalization when it's vague and total. When it's scoped, explained, and reversible, most leave it on - which means the conversion lift from personalized copy comes from a consenting majority instead of a resentful everyone.
Keep reading
I rewrote my cancel flow with one LLM call. It argues better than I do.
The cancel flow is where SaaS revenue goes to die politely. "Are you sure? You'll lose access to Premium features" has never changed a single mind - but reminding a user of their own 312 generations this month is an argument.
ComposeThe trial-ending email everyone sends is the same email. Here's the one that converts
"Your trial ends in 3 days! Upgrade now to keep access." You've received a hundred of these. You've deleted a hundred of these. The email fails because it's about your product, not about the user's trial.