AI compose - generations grounded in your analytics

Your dashboard already knows each end-user's attributes, what they've done, and how much they've consumed. compose() turns that into AI output: you define a compose typein the dashboard - a prompt plus the data sources it should read plus a structured output schema - and your code just names it. Vevee assembles the prompt from that user's real data, calls the model, meters the cost, and hands you typed JSON back.

i
Compose is backend-only - it spends your AI budget, so it needs a secret sk_* key and rejects pk_* keys. The prompt, sources, model, and output schema live server-side in the dashboard, so you can iterate on them without shipping code. Method reference: compose().

1. The mental model

Unlike track or capture, compose has almost no surface in your code. Everything that makes a generation good lives in the type:

  • Intent prompt- what you want generated, in plain language (“Write a warm two-line welcome for this user”).
  • Data sources - the blocks of context Vevee should pull about the user and your wider population (see the table below).
  • Output schema - the JSON shape the model must return, so you get a typed object, not free text.
  • Model & thinking level - which model runs and how much it deliberates, trading cost against quality.

Your code names the type and the end-user it's for. That's the whole call:

import { createClient } from '@vevee/sdk';

const vevee = createClient({ apiKey: process.env.VEVEE_SECRET_KEY! });

const r = await vevee.compose<{ headline: string; body: string }>(
  'welcome-message',   // the type you defined in the dashboard
  userId,              // whose data to ground the generation in
);
if (r.status === 'generated') console.log(r.output.headline);

2. Data sources - what a type can read

When you build a type in the dashboard you toggle on the blocks it should use. Each block is resolved per call against the userId you pass, so the same type produces a different prompt for every user.

// Source kinds you enable on a compose type (configured in the dashboard):

'static'              // fixed background text you write once
'vars'                // request-time variables passed in the compose() call
'user_attributes'     // this user's analytics profile attributes
'user_events'         // this user's recent behavioral events
'user_usage'          // this user's metering / quota consumption
'user_prompts'        // this user's recent logged prompts (if prompt logging is on)
'popular_features'    // most-used features across your whole population
'popular_attributes'  // common attribute values across the population
'conversion_signals'  // what tends to precede upgrades / conversions
'cohort_compare'      // this user vs a comparable cohort

List-shaped blocks (events, popular_*, conversion) accept a topN cap, and the attribute blocks accept an explicit keys list. Because the context is built from analytics data, compose lives next to capture() and identify()in the SDK - the richer those profiles are, the better compose's output.

3. What you can build with it

Personalized onboarding

A type with user_attributes + user_events can greet a new user by what they came to do, not a generic template.

const r = await vevee.compose<{ headline: string; body: string }>(
  'onboarding-welcome',
  userId,
);
if (r.status === 'opted_out') return;
// r.output.headline -> "Let's get your first invoice out"
// r.output.body     -> two lines referencing the plan they signed up on

Smart upgrade nudges

Combine user_usage with conversion_signals so the copy speaks to a user who is actually near a limit - and frames the upgrade the way it landed for similar users.

const r = await vevee.compose<{
  shouldNudge: boolean;
  message: string;
}>('upgrade-nudge', userId);

if (r.status === 'generated' && r.output.shouldNudge) showBanner(r.output.message);

“What to try next” recommendations

user_events + popular_featuressurfaces the features a user hasn't touched yet but that people like them rely on.

const r = await vevee.compose<{
  suggestions: { feature: string; reason: string }[];
}>('next-best-feature', userId);
if (r.status === 'generated') render(r.output.suggestions);

Re-engagement & win-back

With user_eventsthe model sees how long it's been and what the user last did, so a dormant-user email references their actual history instead of “we miss you”.

Weekly usage digest

A type reading user_usage + cohort_compare writes a human summary of what someone got done this week and how that compares to their cohort - far friendlier than a raw numbers table.

4. Passing per-call variables

Anything that changes per request - a topic, a tone, a product name, the page the user is on - goes through the optional third argument and surfaces to the prompt via the vars source.

const r = await vevee.compose<{ summary: string }>(
  'release-notes',
  userId,
  { version: '2.4.0', tone: 'concise' },
);
if (r.status === 'generated') console.log(r.output.summary);

5. Handling the result

Every call returns a result you branch on: an opted_out marker (no model call ran), or a generated result carrying the typed output, a generationId, and usage. The SDK casts output to your generic T - it doesn't validate at runtime, so keep T in sync with the type's output schema.

// app/api/onboarding/route.ts
import { createClient, VeveeError } from @vevee/sdk;

const vevee = createClient({ apiKey: process.env.VEVEE_SECRET_KEY! });

export async function POST(req: Request) {
  const { userId } = await req.json();

  try {
    const r = await vevee.compose<{
      headline: string;
      body: string;
    }>(onboarding-welcome, userId);

    if (r.status === opted_out) return Response.json(FALLBACK);
    console.log(r.generationId, r.usage.costMicroUsd); // inspect in the dashboard
    return Response.json(r.output);
  } catch (e) {
    if (e instanceof VeveeError && e.code === ai_budget_exceeded) {
      // budget exhausted - fall back to a static template, don’t fail the request
      return Response.json({ headline: Welcome, body: Glad you\re here. });
    }
    throw e;
  }
}
!
Compose runs a real model call, so it's slower and costlier than a metering request. Run it server-side, off the hot path - on signup, in a cron that prepares a digest, or lazily the first time a banner is shown - and cache the result rather than calling on every render. Cap spend with the type's maxOutputTokens and your workspace AI budget.

6. Errors worth handling

  • ai_budget_exceeded (429) - the workspace AI budget is spent; no generation runs. Fall back to a static template, as above.
  • not_found (404) - no type with that name. Usually a typo in the type key or an archived type.
  • requires_secret_key (403) - you called it with a pk_* key. Move the call to your backend.
  • generation_failed (502) - the upstream model call failed; safe to retry.

Compose is different from the rest of the SDK in one way that matters legally: it sends your end-user's personal data to an AI model provider (a new sub-processor, often a new international transfer) and personalizes content from it - which is profiling under the GDPR. Before you enable it in production:

  • Disclose it in your privacy policyas a distinct purpose (“AI-assisted personalization”) - what data feeds it, the legal basis (usually legitimate interest), the AI sub-processor, and retention. Drop-in EN/IT template text is in privacy-policy-templates.md.
  • Offer an opt-out. Profiling for personalization must be objectable (Art. 21). Compose now honors a dedicated AI-personalization opt-out: call optOut(userId, 'ai_personalization') and compose returns status: 'opted_out' for that user.
  • Don't feed special-category data (Art. 9) through vars or the prompt source without a valid basis.
  • Mind the bright line: generating copy is fine, but if you wire compose output into a consequential automated decision (pricing, access, discounts), Art. 22 applies and you need a human in the loop or explicit consent.
i
This is integration guidance, not legal advice. Have your DPO or counsel review your final privacy text and lawful basis. See also the Privacy & GDPR guide.

See also