@happyvertical/smrt-prompts
Tenant-aware prompt registry with a 5-layer override cascade. Code defines defaults; file config, app overrides, tenant overrides, and runtime overrides personalize at every level.
Overview
smrt-prompts is the Phase 2 centerpiece for prompt governance in SMRT. Prompt templates and AI model selection are defined in code, then layered with file configuration, app-wide overrides, tenant-scoped overrides, and runtime overrides — each layer overrides any subset of fields, so inheritance is field-by-field.
Installation
npm install @happyvertical/smrt-promptsQuick Start
import {
definePrompt,
resolvePrompt,
PromptOverride,
PromptOverrideCollection,
clearPromptCache,
} from '@happyvertical/smrt-prompts';
// 1. Register a code default (typically at module load time)
definePrompt({
key: 'content.summarize.headline',
template: 'Write a concise headline for: {body}',
ai: {
profile: 'summarization', // resolves via smrt-config
params: { temperature: 0.7 },
},
editable: { template: true, params: true }, // tenants may NOT change profile
});
// 2. Resolve a prompt for the current tenant
const resolved = await resolvePrompt('content.summarize.headline', {
db,
tenantId: 'tenant-123',
override: {
// Runtime override — highest priority
params: { temperature: 0.3 },
},
});
// 3. Store a tenant-level override
const overrides = await PromptOverrideCollection.create({ db });
const override = await overrides.create({
key: 'content.summarize.headline',
tenantId: 'tenant-123',
template: 'Generate a headline in our brand voice: {body}',
});
await override.save();
// Cache for (key, tenantId) is invalidated automaticallyThe 5-Layer Cascade
Layers compose from lowest (code) to highest (runtime). Each layer can override any subset of fields:
Priority (low to high)
1. Code default definePrompt({ key, template, ai })
2. File / config getPackageConfig<PromptPackageConfig>('prompts', defaults)
3. App-level stored PromptOverride row with tenantId = null
4. Tenant-level PromptOverride row with current tenant
5. Runtime override resolvePrompt(key, { override })
Each layer may override any subset of fields:
- template (the prompt body)
- profile (named AI profile to use)
- model (override the profile's model)
- params (temperature, max_tokens, etc.)
A null/undefined field at any layer means "fall through to the lower layer."Core API
definePrompt
definePrompt({
key: 'projects.issue.incorporateFeedback',
template: `Incorporate this feedback into the issue body:
{feedback}
Current body:
{body}`,
ai: {
profile: 'general-purpose',
params: { temperature: 0.5 },
},
// editable: Partial<{ template, profile, model, params }> of booleans.
// Every field defaults to false (locked) — opt fields IN explicitly.
editable: { template: true, params: true },
});resolvePrompt
const resolved = await resolvePrompt('projects.issue.incorporateFeedback', {
db,
tenantId, // optional — omit to read tenant from context
override: { /* runtime */ }, // highest-priority partial override (singular)
variables: { feedback, body }, // template variables for rendering
});
// resolved: ResolvedPrompt = {
// key: string,
// template: string, // merged template (vars NOT yet applied)
// text: string, // template with variables rendered
// ai: { // ResolvedPromptAI
// profile?: string,
// provider?: string, // resolved from the profile via smrt-config
// model?: string,
// params: Record<string, unknown>,
// // ...plus flattened params (temperature, maxTokens, ...)
// },
// }PromptOverride model
class PromptOverride extends SmrtObject {
key: string // matches definePrompt key
tenantId: string | null // null means app-level
template: string | null
profile: string | null
model: string | null
params: string | null // JSON string
// conflictColumns: ['key', 'context']
// context is set on save() to (tenantId ?? '__app__') so app-level
// rows stay unique despite the nullable tenantId.
// Write-time validation against the prompt's editable config (booleans).
}Profile to Provider Indirection
Prompts never reference a provider directly. They select a named profile, and the
profile resolves to a provider/model in smrt-config. This keeps tenant overrides
safe — a tenant cannot accidentally point a prompt at an unapproved provider.
// smrt-config (app config layer)
{
prompts: {
profiles: {
summarization: { provider: 'anthropic', model: 'claude-3-5-sonnet' },
'general-purpose': { provider: 'openai', model: 'gpt-4o-mini' },
}
}
}
// Prompts pick profiles, not providers:
definePrompt({ key: '...', ai: { profile: 'summarization' } });
// A tenant override may change params or template,
// but cannot pick an unapproved provider directly.Caching
// resolvePrompt() caches per (key, tenantId, db) with a TTL.
// Cache is invalidated automatically on:
// - PromptOverride.save()
// - PromptOverride.delete()
//
// Manual invalidation (e.g. in tests) — clearPromptCache takes no args
// and clears the entire process cache:
import { clearPromptCache, getPromptCacheTtlMs } from '@happyvertical/smrt-prompts';
clearPromptCache(); // clear everything
getPromptCacheTtlMs(); // inspect the configured TTL (ms)Gotchas
DOs
- Namespace keys by package or domain:
projects.issue.incorporateFeedback - Mark sensitive fields as non-editable via
editable - Let stored overrides use null to fall through to the lower layer
- Pass
tenantIdon everyresolvePromptcall inside a tenant context - Use the runtime override layer for per-call adjustments (e.g. temperature for A/B tests)
DON'Ts
- Don't reference provider/model directly in prompts — pick a profile
- Don't write
PromptOverriderows that violate the definition'seditablelist (write-time validation will reject them) - Don't include PII in prompt templates — overrides are stored in the tenant DB
- Don't bypass
resolvePromptby readingPromptOverriderows directly