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.
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 cohortList-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 onSmart 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;
}
}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 apk_*key. Move the call to your backend.generation_failed(502) - the upstream model call failed; safe to retry.
7. Legal & privacy - read before you ship
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
varsor 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.
See also
- compose() - full method reference, signature, and response shape.
- identify() - enrich the person profile compose reads as a source.
- capture() - record the behavioral events that feed
user_events. - Behavioral analytics & funnels - build the profiles compose draws on.
- Errors & status codes