BlogCompose

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."

If compose() returns status "opted_out", the end-user has opted out of AI processing: no recap, no fallback, no nag. Respecting it silently is part of the feature - which is why the code only renders on status === "generated".
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 silently

What 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
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