BlogCompose

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.
The rule to hold yourself to: the static experience must be the one you would ship if compose() didn't exist. Anything less turns the opt-out into a punishment.

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.
See your own numbers in two minutes.Add the SDK and your users, costs and revenue fill in on the next request.
Join waitlist

Keep reading

ShareCopy a link to this post.

Set your first limit in about five minutes.

50k operations free every month. No card required.
Join waitlist