Spotify Wrapped is a growth loop, not a year-end gimmick. Ship one for your AI app in an afternoon.
Spotify Wrapped works because people love seeing their own behavior reflected back as a story. Every AI app with usage data can run that loop monthly - and almost none do, because turning usage rows into narrative used to be a content problem. It is now a schema problem.
The implementation
Spotify Wrapped works because of one psychological fact: people love seeing their own behavior reflected back as a story. It drives shares, re-engagement, and (quietly) Premium upgrades. Every AI app with usage data can run this loop monthly - "you generated 214 images this month, here's your story" - and almost none do, because turning usage rows into narrative used to be a content problem. It's now a schema problem: a monthly usage recap, generated per user, in one call. My app meters usage through Vevee (@vevee/sdk), so every user's month already exists as structured events. compose() reads that and returns the recap as typed JSON. The compose type's data sources are the interesting part: alongside the user's own events and usage, it can pull population-level blocks - popular features and aggregate comparisons - so the recap can say "you used background removal more than most users this month" without me building any cohort analytics. The intent prompt: "Write a celebratory monthly recap from this user's real numbers. Concrete over generic - counts, streaks, firsts. End with exactly one suggestion based on what power users do that this user hasn't tried."
import { createClient } from "@vevee/sdk";
const vevee = createClient({ apiKey: process.env.VEVEE_SECRET_KEY! });
interface MonthlyRecap {
headline: string; // "Your February in 214 images"
highlights: string[]; // 3 concrete, number-driven facts
topFeature: string;
percentileBlurb: string; // "more than most users this month"
nudge: string; // ONE forward-looking suggestion
}
const recap = await vevee.compose<MonthlyRecap>("monthly-recap", userId);
if (recap.status === "generated") {
renderRecapCard(recap.output); // in-app card + email
await vevee.capture({ distinctId: userId, event: "feature_used",
properties: { feature: "monthly_recap" } });
}
// opted_out → no recap; respect it silentlyWhat a user gets
Here is the rendered recap for one user's month. Every number is real, pulled from the user's metering events. The model's job is narration, not invention - the schema and data blocks keep it honest.
- Your February in 214 images
- 214 generations - your biggest month yet (January was 90)
- Background removal 61 times: your signature move
- First HD export on Feb 12 - then 23 more
- You used the editor more than most users this month. One thing you haven't touched: batch processing - power users pair it with background removal to do in minutes what you're doing in sessions.
Where the conversion hides
The recap is an engagement feature on the surface and a paywall setup underneath. Measure it with the reserved-event funnel you already have: feature_used (recap viewed) → paywall_shown → checkout_completed, filtered by properties.feature = "monthly_recap". Three mechanisms do the work:
- It quantifies the habit. A user who reads "214 generations, your biggest month yet" has just been told, by their own data, that they are a power user. That identity does more pre-selling than any feature list.
- The nudge is plan-aware. For free users near their cap, the forward-looking suggestion naturally becomes "at this pace you'll hit your limit by the 9th" - and your limit-reached upsell flow takes it from there.
- It re-engages on a schedule. A monthly email people actually open (it's about them) is a recurring re-activation surface that costs fractions of a cent per user - usage.costMicroUsd on each result keeps the receipts, and the dashboard's monthly compose budget hard-caps the spend.
Afternoon checklist
Four pieces, none of them novel. The data's been sitting in your metering layer the whole time. Wrapped was never a data problem - it was a "who's going to write 10,000 personalized summaries" problem. That problem is gone.
- One compose type in the dashboard (prompt + data sources + JSON schema) - 20 minutes
- One server route calling compose<MonthlyRecap>() - it's sk_*-key, server-side
- One recap card component + one email template
- One monthly cron iterating active users
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.