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

v0.29.34Prompt RegistryTenant-Aware5-Layer Cascade

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

bash
npm install @happyvertical/smrt-prompts

Quick Start

typescript
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 automatically

The 5-Layer Cascade

Layers compose from lowest (code) to highest (runtime). Each layer can override any subset of fields:

text
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

typescript
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

typescript
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

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

typescript
// 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

typescript
// 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 tenantId on every resolvePrompt call 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 PromptOverride rows that violate the definition's editable list (write-time validation will reject them)
  • Don't include PII in prompt templates — overrides are stored in the tenant DB
  • Don't bypass resolvePrompt by reading PromptOverride rows directly

Related Modules