rudra.js

Generation exports

The entry point. One call turns a tracking payload into a component specification.

ComponentGeneratorinterfacecore

What createComponentGenerator hands back. generate always resolves to something renderable — a model that is slow, refusing, erroring or not configured gets you the deterministic component instead, and the only way it rejects is a malformed payload. generateDeterministic is the synchronous version, which skips the model and the cache even when you configured both.

Since

0.6.0

See also createComponentGenerator, ComponentSpec, TrackingInputDraft.

Declaration
export interface ComponentGenerator { generate(input: TrackingInputDraft): Promise<ComponentSpec>; /** The deterministic component, without consulting a model or a cache. */ generateDeterministic(input: TrackingInputDraft): ComponentSpec; }

ComponentGeneratorOptionsinterfacecore

Everything createComponentGenerator takes, all of it optional. The defaults are no provider, an in-process cache, cohort generation, signals ranking, 1500ms for the model and 50ms for a cache read. generation is the one with a bill attached: cohort keys the cache on a coarse digest so lookalike shoppers share one generated component, while per-shopper keys on the full digest, so nearly every shopper gets a call of their own.

Since

0.6.0

See also createComponentGenerator, ComponentProvider, SpecCache, GenerationEvent.

Declaration
export interface ComponentGeneratorOptions { /** * Omit to run without a model. That is a supported configuration rather than * a stub: it is the control arm of the benchmark, and the right setting for * anyone who has not yet decided on a provider. */ provider?: ComponentProvider | null; /** Defaults to an in-process cache. Pass `createNullSpecCache()` to disable. */ cache?: SpecCache; /** * How long the model gets. Past this the deterministic component renders and * the request is aborted. Defaults to 1500ms. */ modelTimeoutMs?: number; /** * How long the cache gets. The shipped caches cannot exceed it, but the store * is a port a host implements — a hung Redis read on the render path would * hold the page open, which is exactly what this module exists to prevent. */ cacheTimeoutMs?: number; /** * 'cohort' shares one generated component between shoppers who look alike and * fills in each shopper's own products. 'per-shopper' generates for the * individual, which is what the benchmark compares against. Defaults to * 'cohort'. */ generation?: 'cohort' | 'per-shopper'; /** * How the products are ordered. 'signals' scores each candidate from this * shopper's signals. 'given' keeps the order you sent, for a shop whose own * ranking is better than four weights. Either way the exclusions and the * stock check still apply, and each product still carries a basis * reconciliation can verify. Defaults to 'signals'. */ rank?: RankOrder; /** Observability. Never allowed to break a render. */ onEvent?: (event: GenerationEvent) => void; }

createComponentGenerator(options?: ComponentGeneratorOptions) => ComponentGeneratorfunctioncore

Builds a generator and hands it back — hold onto it, because the cache and the de-duplication of identical in-flight requests live inside it, and a fresh generator per render throws both away. With no provider nothing calls a model and nothing is billed — a supported setting rather than a stub, and the right one until you have settled on a provider.

Since

0.6.0

Arguments

  • options (ComponentGeneratorOptions)optional

Returns

(ComponentGenerator)

Example

const generator = createComponentGenerator({ provider: null });

const spec = await generator.generate({
  user: { id: 'shopper-1' },
  context: { surface: 'pdp', currentSku: 'A-1' },
  candidates: [{ sku: 'A-1', title: 'Cast iron skillet', category: 'Cookware', price: 39 }],
});
// => spec.source === 'fallback'

See also ComponentGeneratorOptions, ComponentGenerator, parseTrackingInput, ComponentSpec.

Declaration
export declare function createComponentGenerator(options?: ComponentGeneratorOptions): ComponentGenerator;

GenerationEventinterfacecore

Reported once for every call that got past input validation, whatever happened after that — generateDeterministic emits one too, with key: null and degradedReason: 'requested'. One flat shape rather than a variant per outcome, so fallback share, cache hit rate and spend all come out as ratios over the same set of events. Sum usage only where calledModel is true — requests that joined an in-flight generation carry the same figures, so summing every event counts one call many times.

Since

0.6.0

See also ComponentGeneratorOptions, DegradedReason, SpecSource, TokenUsage.

Declaration
export interface GenerationEvent { /** Null when no key was computed, which means no provider was configured. */ key: string | null; source: SpecSource; /** Wall-clock milliseconds for the whole call. */ elapsedMs: number; /** * True for the caller that sent the request, on every outcome — including a * call that timed out, errored or came back unparseable. Requests that joined * an in-flight generation share its answer and its usage figures, so cost * must be summed over this flag rather than over every event. * * It counts requests sent, which is an upper bound on requests billed: an * adapter that throws before it reaches the vendor looks the same from here * as one that throws after. An upper bound is the useful direction — the * calls that produce nothing are the ones worth seeing, and reporting them as * no call at all hides them completely. */ calledModel: boolean; /** What reconciliation removed. Absent when no spec was reconciled. */ violations?: string[]; usage?: TokenUsage; degradedReason?: DegradedReason; error?: unknown; cache?: 'hit' | 'miss' | 'error' | 'timeout'; }

Tracking input exports

The payload you send: who the shopper is, what they did, and what you could show them.

Bundletypecore

One set you sell together, as it comes out of the schema with currency filled in. The model never picks a set and is never told its price; reconciliation picks which set to show and the spec carries only its id, so this is the shape the renderer reads to draw it.

Inferred from bundleSchema, so its fields are documented there.

Since

0.6.0

See also bundleSchema, reconcileSpec, RudraComponent.

Declaration
export type Bundle = z.infer<typeof bundleSchema>;

bundleSchemaschemacore

Validates one set you sell together: the members, your price for the set, the currency it's in, and your own label if you want one. A set needs at least two members and must not name the same product twice. It doesn't check that the members are candidates — the full payload schema does that — so validate the renderer's bundles with it and pass the same list you sent to parseTrackingInput.

Since

0.6.0

Fields

NameTypeDefaultConstraintsDescription
idstring1 to 128 chars
skusstring[]2 to 5 items
pricenumbermin 0
currencyoptionalstring"USD"pattern ^[A-Z]{3}$
labeloptionalstring1 to 200 chars

Example

const bundle = bundleSchema.parse({
  id: 'kitchen-starter',
  skus: ['A-1', 'A-2'],
  price: 119,
});
// => { id: 'kitchen-starter', skus: ['A-1', 'A-2'], price: 119, currency: 'USD' }

See also Bundle, trackingInputSchema, RudraComponent, reconcileSpec.

Declaration
bundleSchema: z.ZodObject<{ id: z.ZodString; skus: z.ZodArray<z.ZodString>; price: z.ZodNumber; currency: z.ZodDefault<z.ZodString>; label: z.ZodOptional<z.ZodString>; }, z.core.$strict>

FIELD_LIMITSconstantcore

The caps a tracking payload is held to: how long each free-text field can run, and how many entries each array can carry. They're exported so you can validate against the same numbers in your own pipeline instead of finding them out from a rejection. Each one caps a field or an array on its own — none of them is an aggregate prompt budget, since fitting a payload into a context window is the digest's job and it trims rather than throws.

Since

0.6.0

Value

{
  "identifier": 128,
  "shortText": 200,
  "searchQuery": 200,
  "tag": 64,
  "tagsPerProduct": 20,
  "metaEntries": 50,
  "signalsPerCategory": 500,
  "candidates": 200,
  "productsPerBundle": 5,
  "bundles": 20,
  "localeTag": 35,
  "maxItems": 12,
  "reason": 120
}

Example

import { FIELD_LIMITS } from '@rudra-js/core';

const candidates = catalog.slice(0, FIELD_LIMITS.candidates);
// => at most 200 products, which is what parseTrackingInput accepts

See also trackingInputSchema, productSchema, parseTrackingInput, DIGEST_LIMITS.

Declaration
FIELD_LIMITS: { readonly identifier: 128; readonly shortText: 200; readonly searchQuery: 200; readonly tag: 64; readonly tagsPerProduct: 20; readonly metaEntries: 50; readonly signalsPerCategory: 500; readonly candidates: 200; readonly productsPerBundle: 5; readonly bundles: 20; readonly localeTag: 35; readonly maxItems: 12; readonly reason: 120; }

Interactiontypecore

One thing the shopper did that isn't a view, like, purchase or cart add. The model only ever hears the type and how often it happened — value and meta never reach a prompt.

Inferred from interactionSchema, so its fields are documented there.

Since

0.6.0

See also interactionSchema, TrackingSignals, buildDigest.

Declaration
export type Interaction = z.infer<typeof interactionSchema>;

parseTrackingInput(value: unknown) => TrackingInputfunctioncore

Validates a tracking payload and fills in what you left out. Throws a ZodError on anything that doesn't fit, including a misspelled field name — an invalid payload is a caller bug, and it should be loud. Pass the parsed candidates to the renderer, not the raw objects you built them from.

Since

0.6.0

Arguments

  • value (unknown)

Returns

(TrackingInput)

Example

const input = parseTrackingInput({
  user: { id: 'shopper-1' },
  context: { surface: 'pdp', currentSku: 'A-1' },
  candidates: [{ sku: 'A-1', title: 'Cast iron skillet', category: 'Cookware', price: 39 }],
});
// => { schemaVersion: '1', context: { slot: 'recommendations', locale: 'en-US', maxItems: 4, ... }, ... }

See also safeParseTrackingInput, trackingInputSchema, TrackingInput, createComponentGenerator.

Declaration
export declare function parseTrackingInput(value: unknown): TrackingInput;

Producttypecore

One catalog row after parsing, with currency, isInStock and tags filled in. Type your catalog with this and pass the parsed rows to the renderer, which reads every title, price and image from them at render time.

Inferred from productSchema, so its fields are documented there.

Since

0.6.0

See also productSchema, RudraComponent, TrackingInput.

Declaration
export type Product = z.infer<typeof productSchema>;

productSchemaschemacore

Validates one row of your catalog. Run the renderer's products through it as well as your candidates — that prop is a second door into the framework, and this schema is what rejects a data: URL or a protocol-relative //evil.example/pixel.png in imageUrl. It reads a path the way a browser does, so /\evil.example/pixel.png and the same trick written with a tab or a newline are out too.

Since

0.6.0

Fields

NameTypeDefaultConstraintsDescription
skustring1 to 128 chars
titlestring1 to 200 chars
categorystring1 to 128 chars
pricenumbermin 0
currencyoptionalstring"USD"pattern ^[A-Z]{3}$
imageUrloptionalstringmax 200 chars
ratingoptionalnumber0 to 5
reasonoptionalstring1 to 120 chars
isInStockoptionalbooleantrue
tagsoptionalstring[][]max 20 items

Example

const product = productSchema.parse({
  sku: 'A-1',
  title: 'Cast iron skillet',
  category: 'Cookware',
  price: 39,
});
// => { sku: 'A-1', ..., currency: 'USD', isInStock: true, tags: [] }

See also Product, trackingInputSchema, RudraComponent, FIELD_LIMITS.

Declaration
productSchema: z.ZodObject<{ sku: z.ZodString; title: z.ZodString; category: z.ZodString; price: z.ZodNumber; currency: z.ZodDefault<z.ZodString>; imageUrl: z.ZodOptional<z.ZodString>; rating: z.ZodOptional<z.ZodNumber>; reason: z.ZodOptional<z.ZodString>; isInStock: z.ZodDefault<z.ZodBoolean>; tags: z.ZodDefault<z.ZodArray<z.ZodString>>; }, z.core.$strict>

PurchaseSignaltypecore

A past order line, as parsed. Purchases pull category affinity up harder than any other signal, and a purchased SKU is dropped from what this shopper gets shown — a bundle is the one place it can still appear.

Inferred from purchaseSignalSchema, so its fields are documented there.

Since

0.6.0

See also purchaseSignalSchema, SkuSignal, reconcileSpec.

Declaration
export type PurchaseSignal = z.infer<typeof purchaseSignalSchema>;

RenderContexttypecore

Where on the site this block is going, and what the shopper is looking at while it's built. These fields are cohort key material too, so surface, slot, locale, maxItems and currentCategory decide which shoppers share one generated component.

Inferred from renderContextSchema, so its fields are documented there.

Since

0.6.0

See also renderContextSchema, TrackingInput, createComponentGenerator.

Declaration
export type RenderContext = z.infer<typeof renderContextSchema>;

safeParseTrackingInput(value: unknown) => TrackingInputResultfunctioncore

Same validation as parseTrackingInput, handed back as a result instead of thrown. Reach for it when you want to read result.error.issues without importing zod yourself.

Since

0.6.0

Arguments

  • value (unknown)

Returns

(TrackingInputResult)

Example

const result = safeParseTrackingInput({ user: { id: 'shopper-1' } });
if (!result.success) {
  console.error(result.error.issues.map((issue) => issue.path.join('.')));
  // => ['context', 'candidates']
}

See also parseTrackingInput, TrackingInputResult, trackingInputSchema.

Declaration
export declare function safeParseTrackingInput(value: unknown): TrackingInputResult;

SkuSignaltypecore

The base shape for a signal pointing at one SKU — likes, dislikes and cart entries use it as is. Leave category out and it falls back to the candidate's own, so a signal for a SKU you never sent as a candidate adds nothing to category affinity unless you name the category yourself.

Inferred from skuSignalSchema, so its fields are documented there.

Since

0.6.0

See also skuSignalSchema, ViewSignal, PurchaseSignal, TrackingSignals.

Declaration
export type SkuSignal = z.infer<typeof skuSignalSchema>;

TrackingInputtypecore

One parsed payload: who the shopper is, what they did, and what you could show them. parseTrackingInput returns it and everything downstream reads it; generate takes the looser TrackingInputDraft and parses for you. input.candidates is the list you hand straight to the renderer.

Inferred from trackingInputSchema, so its fields are documented there.

Since

0.6.0

See also trackingInputSchema, parseTrackingInput, TrackingInputDraft, createComponentGenerator.

Declaration
export type TrackingInput = z.infer<typeof trackingInputSchema>;

TrackingInputDrafttypecore

The shape a caller passes in, before defaults are applied. Type the object your tracking pipeline builds with this one, and the object that comes back out with TrackingInput. It is also what generate and generateDeterministic accept.

Since

0.6.0

See also TrackingInput, trackingInputSchema, parseTrackingInput.

Declaration
export type TrackingInputDraft = z.input<typeof trackingInputSchema>;

TrackingInputResulttypecore

The result of a non-throwing parse. Exported so you can type a validation failure without depending on zod directly.

Since

0.6.0

See also safeParseTrackingInput, TrackingInput.

Declaration
export type TrackingInputResult = z.ZodSafeParseResult<TrackingInput>;

trackingInputSchemaschemacore

The whole contract between your application and rudra-js, in one schema. Three checks live at this level rather than on the parts: candidate SKUs have to be unique, bundle ids have to be unique, and every product named in a bundle has to be a candidate as well. Use parseTrackingInput unless you need the schema object itself.

Since

0.6.0

Fields

NameTypeDefaultConstraintsDescription
schemaVersionoptional"1""1"
userobject
idstring1 to 128 chars
segmentoptionalstring1 to 128 chars
isReturningoptionalboolean
contextRenderContext
signalsoptionalobject{}
likesoptionalSkuSignal[][]max 500 items
dislikesoptionalSkuSignal[][]max 500 items
mostViewedoptionalViewSignal[][]max 500 items
lastPurchasedoptionalPurchaseSignal[][]max 500 items
cartoptionalSkuSignal[][]max 500 items
recentSearchesoptionalstring[][]max 500 items
interactionsoptionalInteraction[][]max 500 items
candidatesProduct[]1 to 200 items
bundlesoptionalBundle[][]max 20 items

Example

const input = trackingInputSchema.parse({
  user: { id: 'shopper-1' },
  context: { surface: 'pdp' },
  candidates: [{ sku: 'A-1', title: 'Cast iron skillet', category: 'Cookware', price: 39 }],
});
// => defaults filled: schemaVersion '1', slot 'recommendations', locale 'en-US', maxItems 4

See also parseTrackingInput, TrackingInput, TrackingInputDraft, productSchema.

Declaration
trackingInputSchema: z.ZodObject<{ schemaVersion: z.ZodDefault<z.ZodLiteral<"1">>; user: z.ZodObject<{ id: z.ZodString; segment: z.ZodOptional<z.ZodString>; isReturning: z.ZodOptional<z.ZodBoolean>; }, z.core.$strict>; context: z.ZodObject<{ surface: z.ZodString; slot: z.ZodDefault<z.ZodString>; currentSku: z.ZodOptional<z.ZodString>; currentCategory: z.ZodOptional<z.ZodString>; searchQuery: z.ZodOptional<z.ZodString>; locale: z.ZodDefault<z.ZodString>; maxItems: z.ZodDefault<z.ZodNumber>; }, z.core.$strict>; signals: z.ZodPrefault<z.ZodObject<{ likes: z.ZodDefault<z.ZodArray<z.ZodObject<{ sku: z.ZodString; category: z.ZodOptional<z.ZodString>; at: z.ZodOptional<z.ZodNumber>; weight: z.ZodOptional<z.ZodNumber>; }, z.core.$strict>>>; dislikes: z.ZodDefault<z.ZodArray<z.ZodObject<{ sku: z.ZodString; category: z.ZodOptional<z.ZodString>; at: z.ZodOptional<z.ZodNumber>; weight: z.ZodOptional<z.ZodNumber>; }, z.core.$strict>>>; mostViewed: z.ZodDefault<z.ZodArray<z.ZodObject<{ sku: z.ZodString; category: z.ZodOptional<z.ZodString>; at: z.ZodOptional<z.ZodNumber>; weight: z.ZodOptional<z.ZodNumber>; views: z.ZodDefault<z.ZodNumber>; dwellMs: z.ZodOptional<z.ZodNumber>; }, z.core.$strict>>>; lastPurchased: z.ZodDefault<z.ZodArray<z.ZodObject<{ sku: z.ZodString; category: z.ZodOptional<z.ZodString>; at: z.ZodOptional<z.ZodNumber>; weight: z.ZodOptional<z.ZodNumber>; quantity: z.ZodDefault<z.ZodNumber>; price: z.ZodOptional<z.ZodNumber>; }, z.core.$strict>>>; cart: z.ZodDefault<z.ZodArray<z.ZodObject<{ sku: z.ZodString; category: z.ZodOptional<z.ZodString>; at: z.ZodOptional<z.ZodNumber>; weight: z.ZodOptional<z.ZodNumber>; }, z.core.$strict>>>; recentSearches: z.ZodDefault<z.ZodArray<z.ZodString>>; interactions: z.ZodDefault<z.ZodArray<z.ZodObject<{ type: z.ZodString; sku: z.ZodOptional<z.ZodString>; category: z.ZodOptional<z.ZodString>; at: z.ZodOptional<z.ZodNumber>; value: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean]>>; meta: z.ZodOptional<z.ZodPipe<z.ZodCustom<Record<string, string | number | boolean>, Record<string, string | number | boolean>>, z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean]>>>>; }, z.core.$strict>>>; }, z.core.$strict>>; candidates: z.ZodArray<z.ZodObject<{ sku: z.ZodString; title: z.ZodString; category: z.ZodString; price: z.ZodNumber; currency: z.ZodDefault<z.ZodString>; imageUrl: z.ZodOptional<z.ZodString>; rating: z.ZodOptional<z.ZodNumber>; reason: z.ZodOptional<z.ZodString>; isInStock: z.ZodDefault<z.ZodBoolean>; tags: z.ZodDefault<z.ZodArray<z.ZodString>>; }, z.core.$strict>>; bundles: z.ZodDefault<z.ZodArray<z.ZodObject<{ id: z.ZodString; skus: z.ZodArray<z.ZodString>; price: z.ZodNumber; currency: z.ZodDefault<z.ZodString>; label: z.ZodOptional<z.ZodString>; }, z.core.$strict>>>; }, z.core.$strict>

TrackingSignalstypecore

Everything the shopper has done, split by kind, after parsing. Every category defaults to [], so a payload with no signals is a first-time visitor rather than a malformed request.

Inferred from trackingSignalsSchema, so its fields are documented there.

Since

0.6.0

See also trackingSignalsSchema, TrackingInput, buildDigest.

Declaration
export type TrackingSignals = z.infer<typeof trackingSignalsSchema>;

ViewSignaltypecore

A product view, as parsed. Views for the same SKU are added up into one entry, and dwellMs is summed into the digest but never sent to a model.

Inferred from viewSignalSchema, so its fields are documented there.

Since

0.6.0

See also viewSignalSchema, SkuSignal, buildDigest.

Declaration
export type ViewSignal = z.infer<typeof viewSignalSchema>;

interactionSchemaschemacore

Validates one catch-all event: a name for what happened, an optional SKU or category, and up to 50 meta entries. meta is the only open shape in the payload — everything else is a strict object — and it refuses the __proto__ key. In per-shopper mode the type string is sent to the model as written, so keep it a merchandising label rather than anything a shopper typed about themselves.

Since

0.6.0

Fields

NameTypeDefaultConstraintsDescription
typestring1 to 128 chars
skuoptionalstring1 to 128 chars
categoryoptionalstring1 to 128 chars
atoptionalinteger0 to 4102444800000
valueoptionalstring | number | boolean
metaoptionalany

See also Interaction, trackingSignalsSchema, FIELD_LIMITS.

Declaration
interactionSchema: z.ZodObject<{ type: z.ZodString; sku: z.ZodOptional<z.ZodString>; category: z.ZodOptional<z.ZodString>; at: z.ZodOptional<z.ZodNumber>; value: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean]>>; meta: z.ZodOptional<z.ZodPipe<z.ZodCustom<Record<string, string | number | boolean>, Record<string, string | number | boolean>>, z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean]>>>>; }, z.core.$strict>

purchaseSignalSchemaschemacore

Validates one past order line. quantity and price are accepted and then never read — no price in the payload reaches a model or changes a score, so send them only if it keeps your own pipeline tidy. A non-empty lastPurchased is also what marks a shopper as returning when you don't set user.isReturning yourself.

Since

0.6.0

Fields

NameTypeDefaultConstraintsDescription
skustring1 to 128 chars
categoryoptionalstring1 to 128 chars
atoptionalinteger0 to 4102444800000
weightoptionalnumber0 to 1
quantityoptionalinteger1max 9007199254740991, > 0
priceoptionalnumbermin 0

See also PurchaseSignal, skuSignalSchema, buildDigest.

Declaration
purchaseSignalSchema: z.ZodObject<{ sku: z.ZodString; category: z.ZodOptional<z.ZodString>; at: z.ZodOptional<z.ZodNumber>; weight: z.ZodOptional<z.ZodNumber>; quantity: z.ZodDefault<z.ZodNumber>; price: z.ZodOptional<z.ZodNumber>; }, z.core.$strict>

renderContextSchemaschemacore

Validates the context block. locale has to be a single language tag such as en-US — not a list, and not an Accept-Language header. Draw locale, currentCategory and surface from sets you control: each distinct value is a new cohort, so a value a visitor can choose is a model call a visitor can choose to buy.

Since

0.6.0

Fields

NameTypeDefaultConstraintsDescription
surfacestring1 to 128 chars
slotoptionalstring"recommendations"1 to 128 chars
currentSkuoptionalstring1 to 128 chars
currentCategoryoptionalstring1 to 128 chars
searchQueryoptionalstringmax 200 chars
localeoptionalstring"en-US"max 35 chars, pattern ^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$
maxItemsoptionalinteger41 to 12

See also RenderContext, trackingInputSchema, FIELD_LIMITS.

Declaration
renderContextSchema: z.ZodObject<{ surface: z.ZodString; slot: z.ZodDefault<z.ZodString>; currentSku: z.ZodOptional<z.ZodString>; currentCategory: z.ZodOptional<z.ZodString>; searchQuery: z.ZodOptional<z.ZodString>; locale: z.ZodDefault<z.ZodString>; maxItems: z.ZodDefault<z.ZodNumber>; }, z.core.$strict>

skuSignalSchemaschemacore

Validates one SKU-shaped signal. weight is optional and runs 0 to 1; a signal without one counts as 1, so a weight can only turn a signal down, never boost it. at sorts signals by recency and is then dropped — no timestamp ever reaches a prompt.

Since

0.6.0

Fields

NameTypeDefaultConstraintsDescription
skustring1 to 128 chars
categoryoptionalstring1 to 128 chars
atoptionalinteger0 to 4102444800000
weightoptionalnumber0 to 1

See also SkuSignal, viewSignalSchema, purchaseSignalSchema, trackingSignalsSchema.

Declaration
skuSignalSchema: z.ZodObject<{ sku: z.ZodString; category: z.ZodOptional<z.ZodString>; at: z.ZodOptional<z.ZodNumber>; weight: z.ZodOptional<z.ZodNumber>; }, z.core.$strict>

trackingSignalsSchemaschemacore

Validates the signals block. Each array caps at 500 entries and defaults to empty, and the digest cuts them down much further before any prompt sees them — 12 likes, 10 viewed products, 5 searches.

Since

0.6.0

Fields

NameTypeDefaultConstraintsDescription
likesoptionalSkuSignal[][]max 500 items
dislikesoptionalSkuSignal[][]max 500 items
mostViewedoptionalViewSignal[][]max 500 items
lastPurchasedoptionalPurchaseSignal[][]max 500 items
cartoptionalSkuSignal[][]max 500 items
recentSearchesoptionalstring[][]max 500 items
interactionsoptionalInteraction[][]max 500 items

See also TrackingSignals, buildDigest, DIGEST_LIMITS, FIELD_LIMITS.

Declaration
trackingSignalsSchema: z.ZodObject<{ likes: z.ZodDefault<z.ZodArray<z.ZodObject<{ sku: z.ZodString; category: z.ZodOptional<z.ZodString>; at: z.ZodOptional<z.ZodNumber>; weight: z.ZodOptional<z.ZodNumber>; }, z.core.$strict>>>; dislikes: z.ZodDefault<z.ZodArray<z.ZodObject<{ sku: z.ZodString; category: z.ZodOptional<z.ZodString>; at: z.ZodOptional<z.ZodNumber>; weight: z.ZodOptional<z.ZodNumber>; }, z.core.$strict>>>; mostViewed: z.ZodDefault<z.ZodArray<z.ZodObject<{ sku: z.ZodString; category: z.ZodOptional<z.ZodString>; at: z.ZodOptional<z.ZodNumber>; weight: z.ZodOptional<z.ZodNumber>; views: z.ZodDefault<z.ZodNumber>; dwellMs: z.ZodOptional<z.ZodNumber>; }, z.core.$strict>>>; lastPurchased: z.ZodDefault<z.ZodArray<z.ZodObject<{ sku: z.ZodString; category: z.ZodOptional<z.ZodString>; at: z.ZodOptional<z.ZodNumber>; weight: z.ZodOptional<z.ZodNumber>; quantity: z.ZodDefault<z.ZodNumber>; price: z.ZodOptional<z.ZodNumber>; }, z.core.$strict>>>; cart: z.ZodDefault<z.ZodArray<z.ZodObject<{ sku: z.ZodString; category: z.ZodOptional<z.ZodString>; at: z.ZodOptional<z.ZodNumber>; weight: z.ZodOptional<z.ZodNumber>; }, z.core.$strict>>>; recentSearches: z.ZodDefault<z.ZodArray<z.ZodString>>; interactions: z.ZodDefault<z.ZodArray<z.ZodObject<{ type: z.ZodString; sku: z.ZodOptional<z.ZodString>; category: z.ZodOptional<z.ZodString>; at: z.ZodOptional<z.ZodNumber>; value: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean]>>; meta: z.ZodOptional<z.ZodPipe<z.ZodCustom<Record<string, string | number | boolean>, Record<string, string | number | boolean>>, z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean]>>>>; }, z.core.$strict>>>; }, z.core.$strict>

viewSignalSchemaschemacore

Validates one view signal. views defaults to 1, so a row per view and a single row carrying a count score the same once they're merged. That count scales by log2, not linearly, so the tenth view of a product moves category affinity far less than the first.

Since

0.6.0

Fields

NameTypeDefaultConstraintsDescription
skustring1 to 128 chars
categoryoptionalstring1 to 128 chars
atoptionalinteger0 to 4102444800000
weightoptionalnumber0 to 1
viewsoptionalinteger1max 9007199254740991, > 0
dwellMsoptionalnumbermin 0

See also ViewSignal, skuSignalSchema, buildDigest.

Declaration
viewSignalSchema: z.ZodObject<{ sku: z.ZodString; category: z.ZodOptional<z.ZodString>; at: z.ZodOptional<z.ZodNumber>; weight: z.ZodOptional<z.ZodNumber>; views: z.ZodDefault<z.ZodNumber>; dwellMs: z.ZodOptional<z.ZodNumber>; }, z.core.$strict>

Signals exports

Condensing a shopper history into the small digest a model is allowed to see.

buildDigest(input: TrackingInput) => SignalDigestfunctioncore

Boils a validated tracking payload down to the small, ordered view that the prompt builder, the product ranker and the deterministic path all read. Every list is cut to its DIGEST_LIMITS length — SKU lists by recency, views and categories and interaction types by size, searches in the order you sent them — because the contract accepts 500 signals per array and a prompt cannot afford them. Searches and custom interactions never clear isColdStart: the shopper has to have liked, disliked, viewed, bought or basketed a product.

Since

0.6.0

Arguments

  • input (TrackingInput)

Returns

(SignalDigest)

Example

const digest = buildDigest(input);

digest.likedSkus;
// => ['A-1']
digest.categoryAffinity[0];
// => { category: 'Cookware', score: 4 }

See also SignalDigest, DIGEST_LIMITS, toCohortDigest, parseTrackingInput.

Declaration
export declare function buildDigest(input: TrackingInput): SignalDigest;

CategoryAffinityinterfacecore

One category the shopper leans towards, with the score that put it in that position. The score is unnormalised and only means something beside the other scores in the same digest — the ranker reads it as a ratio to the strongest one — so do not render it and do not compare it between shoppers. The prompt never carries scores at all, only category names, and in cohort mode only the top name survives.

Since

0.6.0

See also SignalDigest, buildDigest, toCohortDigest.

Declaration
export interface CategoryAffinity { category: string; /** Unnormalised. Only the ordering is meaningful — do not show this to anyone. */ score: number; }

DIGEST_LIMITSconstantcore

How much of each signal category survives into the digest: 12 likes, 12 dislikes, 8 purchases, 8 cart entries, 10 viewed SKUs, 5 searches, 6 categories, 8 interaction types. FIELD_LIMITS lets a payload carry 500 signals per array and rejects anything past that rather than trimming it; these numbers are where the trimming happens. They are constants, not options on createComponentGenerator.

Since

0.6.0

Value

{
  "liked": 12,
  "disliked": 12,
  "purchased": 8,
  "cart": 8,
  "viewed": 10,
  "searches": 5,
  "affinity": 6,
  "interactionTypes": 8
}

See also buildDigest, SignalDigest, FIELD_LIMITS.

Declaration
DIGEST_LIMITS: { readonly liked: 12; readonly disliked: 12; readonly purchased: 8; readonly cart: 8; readonly viewed: 10; readonly searches: 5; readonly affinity: 6; readonly interactionTypes: 8; }

InteractionCountinterfacecore

How often one of your own interaction types showed up in the payload. The digest keeps the eight most frequent, and that count is everything a model learns about an interaction — value and meta stay behind.

Since

0.6.0

See also SignalDigest, Interaction, buildDigest.

Declaration
export interface InteractionCount { type: string; count: number; }

SignalDigestinterfacecore

What buildDigest returns, and the only view of a shopper that reaches the model — the reconciler still checks the answer against the raw payload. It carries no prices, no timestamps and no image URLs; dwell time is totalled onto topViewed here and still never reaches a model. isColdStart is true when the payload carried no likes, dislikes, purchases, cart entries or views — searches and interactions do not count.

Since

0.6.0

See also buildDigest, toCohortDigest, ViewedProduct, CategoryAffinity, InteractionCount.

Declaration
export interface SignalDigest { userId: string; segment?: string; isReturning: boolean; surface: string; slot: string; locale: string; maxItems: number; currentSku?: string; currentCategory?: string; searchQuery?: string; likedSkus: string[]; dislikedSkus: string[]; purchasedSkus: string[]; cartSkus: string[]; topViewed: ViewedProduct[]; recentSearches: string[]; categoryAffinity: CategoryAffinity[]; interactionCounts: InteractionCount[]; /** True when there is no behavioural evidence to personalise on. */ isColdStart: boolean; }

toCohortDigest(digest: SignalDigest) => SignalDigestfunctioncore

Strips a digest back to the fields the cohort cache key covers, so a component cached for one shopper cannot carry their history to everyone else in the cohort. Everything that identifies one shopper goes — the id, likes, dislikes, purchases, the basket, views, searches, interaction counts, the SKU in front of them and their search query; what stays is the surface, slot, locale, item count, segment, browsed category, isColdStart and the top category's name, with its score zeroed. Cohort generation is the default, so this runs on every model call unless you ask for per-shopper.

Since

0.6.0

Arguments

  • digest (SignalDigest)

Returns

(SignalDigest)

Example

const cohort = toCohortDigest(buildDigest(input));

cohort.userId;
// => 'cohort'
cohort.likedSkus;
// => []

See also buildDigest, SignalDigest, createComponentGenerator.

Declaration
export declare function toCohortDigest(digest: SignalDigest): SignalDigest;

ViewedProductinterfacecore

A SKU the shopper looked at, with the view count and dwell time added up across every view signal for that SKU. topViewed holds the ten most-viewed; the model is told the SKU and the count, never the dwell.

Since

0.6.0

See also SignalDigest, ViewSignal, buildDigest.

Declaration
export interface ViewedProduct { sku: string; views: number; dwellMs?: number; }

Component spec exports

The closed vocabulary a model answers in. No field here can hold a price.

The four tones a banner may carry: info, promo, urgency and restock. The model picks one and it lands on the rendered banner as data-rudra-banner-tone, which is where you hang styling. It's a separate vocabulary from TONES, which is the tone of the whole component.

Since

0.6.0

Value

[
  "info",
  "promo",
  "urgency",
  "restock"
]

See also BannerBlock, TONES.

Declaration
BANNER_TONES: readonly ['info', 'promo', 'urgency', 'restock']

BannerBlocktypecore

One line of merchandising copy, with a tone and an optional call-to-action label. The text is required, so a banner whose words fail the claim screen drops out of the spec rather than rendering as an empty strip.

Inferred from bannerBlockSchema, so its fields are documented there.

Since

0.6.0

See also BANNER_TONES, Block.

Declaration
export type BannerBlock = z.infer<typeof bannerBlockSchema>;

Blocktypecore

One entry in a spec's ordered list of blocks, discriminated on kind. Blocks never nest, and only the first four survive: reconciliation caps the list and records a too-many-blocks violation when the model sends more.

Inferred from blockSchema, so its fields are documented there.

Since

0.6.0

See also BlockKind, blockSchema, GeneratedSpec.

Declaration
export type Block = z.infer<typeof blockSchema>;

BlockKindtypecore

The six names a block can have: hero, grid, carousel, banner, copy and bundle. A registry holds one renderer per kind, so this is the key you write against when you swap one for your own design-system component.

Since

0.6.0

See also Block, BlockRegistry, extendRegistry.

Declaration
export type BlockKind = Block['kind'];

BundleBlocktypecore

A set your shop sells together, shown as one offer. The model asks for the block and writes the words; bundleId arrives null and is filled in per request from the bundles you passed, which is why the prompt tells the model to write about the offer rather than about the products in it.

Inferred from bundleBlockSchema, so its fields are documented there.

Since

0.6.0

See also Block, Bundle, bundleSchema.

Declaration
export type BundleBlock = z.infer<typeof bundleBlockSchema>;

CarouselBlocktypecore

A row of products read left to right, for when the order carries meaning. In the default cohort mode the items are refilled per request, best pick first, so the title is the model's and the products are yours.

Inferred from carouselBlockSchema, so its fields are documented there.

Since

0.6.0

See also Block, ProductReference, GridBlock.

Declaration
export type CarouselBlock = z.infer<typeof carouselBlockSchema>;

ComponentSpecinterfacecore

What generate returns and what RudraComponent renders: a generated spec plus the provenance the server owns. That's the source, the timing, the provider and model, and degradedReason whenever the deterministic component is the one showing.

Since

0.6.0

See also GeneratedSpec, SpecSource, DegradedReason, RudraComponent.

Declaration
export interface ComponentSpec extends GeneratedSpec { specVersion: typeof SPEC_VERSION; slot: string; source: SpecSource; /** Epoch milliseconds at which the underlying generation completed. */ generatedAt: number; /** Wall-clock milliseconds spent producing it, including any cache lookup. */ latencyMs: number; /** Provider name, or null when no model was involved. */ provider: string | null; /** Model identifier, or null when no model was involved. */ model: string | null; /** * Why the deterministic component is showing instead of a generated one. * Present on every fallback, including the ones where no model was involved * at all — no provider configured, or the caller asked for it directly. */ degradedReason?: DegradedReason; }

CopyBlocktypecore

A short piece of editorial prose, for when explaining the theme of a selection helps more than another product tile would. The body is required, so a copy block the claim screen empties drops instead of rendering.

Inferred from copyBlockSchema, so its fields are documented there.

Since

0.6.0

See also Block.

Declaration
export type CopyBlock = z.infer<typeof copyBlockSchema>;

DegradedReasontypecore

Why the deterministic component is showing instead of a generated one. A closed set worth splitting your fallback rate by: no-provider and requested are choices you made, while provider-error, timeout, invalid-generation and unusable-on-serve are things going wrong. It reaches the page as data-rudra-degraded under hasDiagnostics.

Since

0.6.0

See also SpecSource, ComponentSpec, GenerationEvent.

Declaration
export type DegradedReason = /** No provider was configured, so nothing was ever asked. */ 'no-provider' /** The provider errored, or threw before it reached the vendor. */ | 'provider-error' /** The deadline fired before an answer arrived. */ | 'timeout' /** An answer came back that did not satisfy the schema. */ | 'invalid-generation' /** A usable answer reconciled down to nothing for this shopper. */ | 'unusable-on-serve' /** The caller asked for the deterministic component on purpose. */ | 'requested';

EMPHASISconstantcore

normal or featured, the weight a product carries inside a grid or carousel. Featured adds .rudra-card--featured alongside .rudra-card and changes nothing else, so how much it stands out is your stylesheet's call.

Since

0.6.0

Value

[
  "normal",
  "featured"
]

See also ProductReference, ProductCard.

Declaration
EMPHASIS: readonly ['normal', 'featured']

GeneratedSpectypecore

Everything the model decides: tone, headline, subheadline, the ordered blocks, and a rationale written for your logs rather than for shoppers. ComponentSpec is this plus provenance, and it's also what a cache entry holds.

Inferred from generatedSpecSchema, so its fields are documented there.

Since

0.6.0

See also ComponentSpec, generatedSpecSchema, CachedSpec.

Declaration
export type GeneratedSpec = z.infer<typeof generatedSpecSchema>;

GridBlocktypecore

A grid of 2, 3 or 4 columns with an optional title, the general choice when several products are comparably relevant, and the only block the deterministic component ever emits. The column count is trimmed to the items that survived reconciliation but never drops below two, so a four-column grid left holding two renders as two.

Inferred from gridBlockSchema, so its fields are documented there.

Since

0.6.0

See also Block, ProductReference, CarouselBlock.

Declaration
export type GridBlock = z.infer<typeof gridBlockSchema>;

HeroBlocktypecore

One large statement, optionally anchored to a single product. The hero keeps the product the model named even in cohort mode, because the headline and body were written about that one; when it can't be placed the link is dropped and the words stay.

Inferred from heroBlockSchema, so its fields are documented there.

Since

0.6.0

See also Block, reconcileSpec.

Declaration
export type HeroBlock = z.infer<typeof heroBlockSchema>;

ProductReferencetypecore

One product placed in a grid or a carousel: the SKU, the basis it was picked on, the reason and badge shown under it, and its emphasis. No title, price or image lives here — the renderer reads those from your catalog as the page is served.

Inferred from productReferenceSchema, so its fields are documented there.

Since

0.6.0

See also productReferenceSchema, RecommendationBasis, EMPHASIS.

Declaration
export type ProductReference = z.infer<typeof productReferenceSchema>;

RECOMMENDATION_BASESconstantcore

The six reasons a product may be shown, from similar_to_current through to popular. Two of them prove less than they read: complements_cart and complements_purchase check only that the basket or the order history isn't empty, because nothing here knows that one product goes with another. popular claims nothing about the shopper and always holds.

Since

0.6.0

Value

[
  "similar_to_current",
  "most_viewed",
  "complements_cart",
  "complements_purchase",
  "liked_category",
  "popular"
]

See also RecommendationBasis, productReferenceSchema.

Declaration
RECOMMENDATION_BASES: readonly [ /** Same category as the product being viewed. */ 'similar_to_current', /** The shopper has viewed this product. */ 'most_viewed', /** Goes with something already in the cart. */ 'complements_cart', /** Goes with something the shopper has bought. */ 'complements_purchase', /** In a category the shopper's signals favour. */ 'liked_category', /** No claim about this shopper at all — the safe default. */ 'popular']

RecommendationBasistypecore

One of the six bases, the union behind RECOMMENDATION_BASES. It renders as data-rudra-basis on every card, so it's what you group clicks by when you want to know which kind of reason sells.

Since

0.6.0

See also RECOMMENDATION_BASES, ProductReference.

Declaration
export type RecommendationBasis = (typeof RECOMMENDATION_BASES)[number];

SpecSourcetypecore

Where the served spec came from: llm for a fresh model call, cache for a reuse, fallback for the deterministic component. It's on every generation event and on the rendered wrapper as data-rudra-source, so hit rate and fallback share can be read off a page.

Since

0.6.0

See also DegradedReason, GenerationEvent, ComponentSpec.

Declaration
export type SpecSource = 'llm' | 'cache' | 'fallback';

TONESconstantcore

The four tones for the component as a whole: neutral, enthusiastic, urgent and editorial. The prompt tells the model to match the tone to the evidence and treat neutral as the default, and it comes out on the wrapper as data-rudra-tone. A banner has its own separate set in BANNER_TONES.

Since

0.6.0

Value

[
  "neutral",
  "enthusiastic",
  "urgent",
  "editorial"
]

See also BANNER_TONES, GeneratedSpec.

Declaration
TONES: readonly ['neutral', 'enthusiastic', 'urgent', 'editorial']

blockSchemaschemacore

Validates one block — the discriminated union of the six kinds, keyed on kind. generatedSpecSchema already runs it across a whole answer, so reach for this only when you store or move blocks on their own.

Since

0.6.0

Fields

NameTypeDefaultConstraintsDescription
kind"hero"
headlinestring
bodystringnullable
skustringnullable
ctaLabelstringnullable

Example

const block = blockSchema.parse({ kind: 'copy', title: null, body: 'Built for wet weather.' });
block.kind;
// => 'copy'

See also Block, generatedSpecSchema.

Declaration
blockSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{ kind: z.ZodLiteral<"hero">; headline: z.ZodString; body: z.ZodNullable<z.ZodString>; sku: z.ZodNullable<z.ZodString>; ctaLabel: z.ZodNullable<z.ZodString>; }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"grid">; title: z.ZodNullable<z.ZodString>; columns: z.ZodUnion<readonly [z.ZodLiteral<2>, z.ZodLiteral<3>, z.ZodLiteral<4>]>; items: z.ZodArray<z.ZodObject<{ sku: z.ZodString; basis: z.ZodEnum<{ complements_cart: "complements_cart"; complements_purchase: "complements_purchase"; liked_category: "liked_category"; most_viewed: "most_viewed"; popular: "popular"; similar_to_current: "similar_to_current"; }>; reason: z.ZodNullable<z.ZodString>; badge: z.ZodNullable<z.ZodString>; emphasis: z.ZodEnum<{ featured: "featured"; normal: "normal"; }>; }, z.core.$strip>>; }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"carousel">; title: z.ZodNullable<z.ZodString>; items: z.ZodArray<z.ZodObject<{ sku: z.ZodString; basis: z.ZodEnum<{ complements_cart: "complements_cart"; complements_purchase: "complements_purchase"; liked_category: "liked_category"; most_viewed: "most_viewed"; popular: "popular"; similar_to_current: "similar_to_current"; }>; reason: z.ZodNullable<z.ZodString>; badge: z.ZodNullable<z.ZodString>; emphasis: z.ZodEnum<{ featured: "featured"; normal: "normal"; }>; }, z.core.$strip>>; }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"banner">; tone: z.ZodEnum<{ info: "info"; promo: "promo"; restock: "restock"; urgency: "urgency"; }>; text: z.ZodString; ctaLabel: z.ZodNullable<z.ZodString>; }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"copy">; title: z.ZodNullable<z.ZodString>; body: z.ZodString; }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"bundle">; title: z.ZodNullable<z.ZodString>; body: z.ZodNullable<z.ZodString>; ctaLabel: z.ZodNullable<z.ZodString>; bundleId: z.ZodNullable<z.ZodString>; }, z.core.$strip>], "kind">

GeneratedSpecResulttypecore

What safeParseGeneratedSpec hands back: success true with the parsed spec, or false with a ZodError. Check success first, then read result.error.issues without pulling zod into your own app.

Since

0.6.0

See also safeParseGeneratedSpec, GeneratedSpec.

Declaration
export type GeneratedSpecResult = z.ZodSafeParseResult<GeneratedSpec>;

generatedSpecSchemaschemacore

The contract a model answers in, and the schema the generator hands your provider to send as its tool definition. It has no field for a price, a product name, an image or a URL: the renderer fills those from your catalog, and claim screening strips any the model works into the free text.

Since

0.6.0

Fields

NameTypeDefaultConstraintsDescription
tone"neutral" | "enthusiastic" | "urgent" | "editorial"
headlinestring
subheadlinestringnullable
blocksBlock[]
rationalestring

Example

import { z } from 'zod';

// what an adapter sends as the tool's input_schema
const toolSchema = z.toJSONSchema(generatedSpecSchema, { io: 'input' });
toolSchema.type;
// => 'object'

See also GeneratedSpec, blockSchema, ProviderRequest.

Declaration
generatedSpecSchema: z.ZodObject<{ tone: z.ZodEnum<{ editorial: "editorial"; enthusiastic: "enthusiastic"; neutral: "neutral"; urgent: "urgent"; }>; headline: z.ZodString; subheadline: z.ZodNullable<z.ZodString>; blocks: z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{ kind: z.ZodLiteral<"hero">; headline: z.ZodString; body: z.ZodNullable<z.ZodString>; sku: z.ZodNullable<z.ZodString>; ctaLabel: z.ZodNullable<z.ZodString>; }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"grid">; title: z.ZodNullable<z.ZodString>; columns: z.ZodUnion<readonly [z.ZodLiteral<2>, z.ZodLiteral<3>, z.ZodLiteral<4>]>; items: z.ZodArray<z.ZodObject<{ sku: z.ZodString; basis: z.ZodEnum<{ complements_cart: "complements_cart"; complements_purchase: "complements_purchase"; liked_category: "liked_category"; most_viewed: "most_viewed"; popular: "popular"; similar_to_current: "similar_to_current"; }>; reason: z.ZodNullable<z.ZodString>; badge: z.ZodNullable<z.ZodString>; emphasis: z.ZodEnum<{ featured: "featured"; normal: "normal"; }>; }, z.core.$strip>>; }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"carousel">; title: z.ZodNullable<z.ZodString>; items: z.ZodArray<z.ZodObject<{ sku: z.ZodString; basis: z.ZodEnum<{ complements_cart: "complements_cart"; complements_purchase: "complements_purchase"; liked_category: "liked_category"; most_viewed: "most_viewed"; popular: "popular"; similar_to_current: "similar_to_current"; }>; reason: z.ZodNullable<z.ZodString>; badge: z.ZodNullable<z.ZodString>; emphasis: z.ZodEnum<{ featured: "featured"; normal: "normal"; }>; }, z.core.$strip>>; }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"banner">; tone: z.ZodEnum<{ info: "info"; promo: "promo"; restock: "restock"; urgency: "urgency"; }>; text: z.ZodString; ctaLabel: z.ZodNullable<z.ZodString>; }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"copy">; title: z.ZodNullable<z.ZodString>; body: z.ZodString; }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"bundle">; title: z.ZodNullable<z.ZodString>; body: z.ZodNullable<z.ZodString>; ctaLabel: z.ZodNullable<z.ZodString>; bundleId: z.ZodNullable<z.ZodString>; }, z.core.$strip>], "kind">>; rationale: z.ZodString; }, z.core.$strip>

parseGeneratedSpec(value: unknown) => GeneratedSpecfunctioncore

Validates a spec that didn't come from the generator — a fixture, a recorded transcript, a row out of your own store — and throws a ZodError when it doesn't fit. Anything a provider returns is already checked for you, so this is for specs arriving by another route.

Since

0.6.0

Arguments

  • value (unknown)

Returns

(GeneratedSpec)

Example

import { readFile } from 'node:fs/promises';

const spec = parseGeneratedSpec(JSON.parse(await readFile('pdp.json', 'utf8')));
spec.tone;
// => 'editorial'

See also safeParseGeneratedSpec, generatedSpecSchema, createFixedSpecProvider.

Declaration
export declare function parseGeneratedSpec(value: unknown): GeneratedSpec;

productReferenceSchemaschemacore

The shape of one placed product inside a block. basis is the field to watch: it's checked against the shopper's real signals when the page is served, and one that doesn't hold up is rewritten to popular, taking the reason and the badge with it.

Since

0.6.0

Fields

NameTypeDefaultConstraintsDescription
skustring
basis"similar_to_current" | "most_viewed" | "complements_cart" | "complements_purchase" | "liked_category" | "popular"
reasonstringnullable
badgestringnullable
emphasis"normal" | "featured"

See also ProductReference, RECOMMENDATION_BASES, reconcileSpec.

Declaration
productReferenceSchema: z.ZodObject<{ sku: z.ZodString; basis: z.ZodEnum<{ complements_cart: "complements_cart"; complements_purchase: "complements_purchase"; liked_category: "liked_category"; most_viewed: "most_viewed"; popular: "popular"; similar_to_current: "similar_to_current"; }>; reason: z.ZodNullable<z.ZodString>; badge: z.ZodNullable<z.ZodString>; emphasis: z.ZodEnum<{ featured: "featured"; normal: "normal"; }>; }, z.core.$strip>

safeParseGeneratedSpec(value: unknown) => GeneratedSpecResultfunctioncore

Non-throwing check for a spec you don't trust — a raw model answer, an entry read back from a shared cache, something posted to your own endpoint. Read result.error.issues without pulling zod into your app.

Since

0.6.0

Arguments

  • value (unknown)

Returns

(GeneratedSpecResult)

Example

const result = safeParseGeneratedSpec({ tone: 'loud', headline: 'Wet weather kit' });
result.success;
// => false

See also parseGeneratedSpec, GeneratedSpecResult, generatedSpecSchema.

Declaration
export declare function safeParseGeneratedSpec(value: unknown): GeneratedSpecResult;

SPEC_VERSIONconstantcore

The version stamped on every ComponentSpec as specVersion, currently '1'. If you persist specs anywhere, it's the field that tells you the shape moved under you.

Since

0.6.0

Value

"1"

See also ComponentSpec.

Declaration
SPEC_VERSION: '1'

Selection & reconciliation exports

Choosing which products fill a block, and settling what a model asked for against what you sell.

ProductPickinterfacecore

One row of the selector's output: the product, the basis that says why it's here, and the sentence a shopper reads under it. basis is what reconciliation later checks against the real signals, reason is your own reason on the candidate or the selector's own wording when you left it unset, and score is unnormalised — only the ordering means anything.

Since

0.6.0

See also selectProducts, RecommendationBasis, Product.

Declaration
export interface ProductPick { product: Product; /** Why this product, stated so reconciliation can check it. */ basis: RecommendationBasis; /** How the basis reads to a shopper. The host's own sentence when it wrote one. */ reason: string; /** Unnormalised. Only the ordering is meaningful. */ score: number; }

ReconcileResultinterfacecore

What survived reconciliation. isUsable is false when there's nothing left worth rendering — no product placed, or a headline that got emptied — and violations lists what was dropped or rewritten and why, in machine-readable form. That list is the thing to graph if you want to know how often a model call is being paid for and thrown away.

Since

0.6.0

See also reconcileSpec, GeneratedSpec, ComponentSpec.

Declaration
export interface ReconcileResult { spec: GeneratedSpec; /** True when something survived that is worth rendering. */ isUsable: boolean; /** Machine-readable notes on what was removed or changed, for evaluation. */ violations: string[]; }

reconcileSpec(generated: GeneratedSpec, input: TrackingInput, digest: SignalDigest, ourReasons?: ReadonlyMap<string, string>) => ReconcileResultfunctioncore

Takes what the model wrote and makes it safe to serve. Unknown, blocked and duplicate SKUs go, text is clamped to length, every model-written sentence is screened for claims you can't back, and a bundle block gets a real set picked from this shopper's basket and browsing. It runs as the page is served rather than when the spec was generated, so a product that sold out since is dropped here; when it comes back isUsable: false, render the deterministic component instead.

Since

0.6.0

Arguments

  • generated (GeneratedSpec)
  • input (TrackingInput)
  • digest (SignalDigest)
  • ourReasons (ReadonlyMap<string, string>)optional

Returns

(ReconcileResult)

Example

const { spec, isUsable, violations } = reconcileSpec(generated, input, buildDigest(input));
// => isUsable: false
// => violations: ['unknown-sku:Z-9', 'unverifiable-claim:quantity:headline', 'unusable:no-headline']

See also ReconcileResult, GeneratedSpec, SignalDigest, TrackingInput.

Declaration
export declare function reconcileSpec(generated: GeneratedSpec, input: TrackingInput, digest: SignalDigest, /** * The reason this request wrote itself, by SKU: the host's own `reason` on a * candidate, or the sentence the selector wrote when there was none. Neither is the * model's words, and the deterministic component renders the same sentence unscreened. * * Only `fitToShopper` fills it, so in `per-shopper` mode it is empty and every * reason is the model's, including one that happens to read the same. */ ourReasons?: ReadonlyMap<string, string>): ReconcileResult;

selectProducts(input: TrackingInput, digest: SignalDigest, options?: SelectOptions) => ProductPick[]functioncore

Scores every candidate against one shopper's digest and returns the picks, best first. No model, nothing billed, and the same digest always gives the same answer — this is what renders when the model is slow or not configured, and in cohort generation it's what fills the grid and carousel rows the model laid out. Anything the shopper shouldn't be shown is dropped before scoring: out of stock, already bought, in the basket, thumbs-downed, or the product they're looking at right now.

Since

0.6.0

Arguments

  • input (TrackingInput)
  • digest (SignalDigest)
  • options (SelectOptions)optional

Returns

(ProductPick[])

Example

const digest = buildDigest(input);

selectProducts(input, digest);
// => [{ product: { sku: 'A-3', title: 'Chef knife', ... }, basis: 'complements_cart', reason: 'Goes with what is in your cart', score: 0.84 }]

selectProducts(input, digest, { rank: 'given' });
// => the same picks, in the order you sent your candidates

See also ProductPick, buildDigest, SignalDigest, TrackingInput.

Declaration
export declare function selectProducts(input: TrackingInput, digest: SignalDigest, options?: SelectOptions): ProductPick[];

Providers exports

The port a model sits behind, and the adapters that implement it.

AnthropicProviderOptionsinterfaceanthropic

Options for the Anthropic adapter. Only apiKey is required: model defaults to claude-sonnet-5, the model this package was written against, and thinking to { type: 'disabled' }, because core gives the whole call 1500ms and a model that reasons before answering will not finish inside it. Two gotchas: an identity-linked key also needs workspaceId or the API answers 400, and a model that rejects an explicit disabled needs thinking: null, which sends no thinking field at all.

Since

0.6.0

See also createAnthropicProvider, ComponentGeneratorOptions.

Declaration
export interface AnthropicProviderOptions { apiKey: string; /** Defaults to the current Claude model this package was written against. */ model?: string; maxTokens?: number; baseUrl?: string; /** * Required when the key is identity-linked rather than workspace-scoped — * such a key belongs to a person across several workspaces, so the API cannot * infer which one a request acts in and rejects it with a 400. */ workspaceId?: string; /** * Sent as the request's `thinking`. Defaults to `{ type: 'disabled' }`, * because core budgets 1500ms for the whole call and a model that reasons * before answering does not finish inside it. Pass `null` to send nothing, * which is what a model that rejects an explicit `disabled` needs, and raise * `modelTimeoutMs` to match when you do. */ thinking?: { type: 'adaptive' | 'disabled'; } | null; /** Injected so the adapter is testable without a network or an SDK. */ fetch?: typeof globalThis.fetch; }

ComponentProviderinterfacecore

The port every model adapter implements: a name, a model id, and one generate call. Core depends on no vendor SDK, so anything of this shape works — a hosted API, a model you run yourself, a deployment in your own tenancy, or a recorded fixture. Core re-parses whatever comes back, so the two obligations left to you are the ones it cannot see: throw on a refusal, a transport error or an unparseable response instead of returning a partial or invented spec — a throw is what makes the deterministic component render — and stop the moment signal aborts.

Since

0.6.0

See also ProviderRequest, ProviderResult, createAnthropicProvider, createComponentGenerator.

Declaration
export interface ComponentProvider { /** Short identifier recorded on every generated spec, e.g. 'anthropic'. */ readonly name: string; /** Concrete model identifier, e.g. 'claude-opus-5'. */ readonly model: string; generate(request: ProviderRequest): Promise<ProviderResult>; }

createAnthropicProvider(options: AnthropicProviderOptions) => ComponentProviderfunctionanthropic

Adapts the Anthropic Messages API to ComponentProvider. One POST to /v1/messages per generation, with the tool schema derived from the schema core exports instead of a second copy kept here. When the API errors, the thrown Error carries the status and the vendor's error category but not its message — that message quotes the request back, and the request can hold a shopper's search terms.

Since

0.6.0

Arguments

  • options (AnthropicProviderOptions)

Returns

(ComponentProvider)

Example

const provider = createAnthropicProvider({ apiKey: process.env.ANTHROPIC_API_KEY! });
const generator = createComponentGenerator({ provider });
// => provider.model === 'claude-sonnet-5'

See also AnthropicProviderOptions, ComponentProvider, createComponentGenerator.

Declaration
export declare function createAnthropicProvider(options: AnthropicProviderOptions): ComponentProvider;

ProviderRequestinterfacecore

One generation, as an adapter receives it. system is the same string across a deployment and is the part worth prompt-caching; folding user into that prefix would quietly destroy the hit rate. Convert schema with your own SDK's helper rather than hand-writing JSON Schema that drifts, and stop work when signal fires — the caller's budget has run out.

Since

0.6.0

See also ComponentProvider, ProviderResult, generatedSpecSchema.

Declaration
export interface ProviderRequest { /** * Stable across every request in a deployment. Adapters that support prompt * caching should mark this as the cached prefix; interpolating anything * per-shopper into it would silently destroy the cache hit rate. */ system: string; /** Per-request content. Must not be merged into the cached prefix. */ user: string; /** * The schema the response must satisfy. Adapters convert it with their own * SDK helper, so there is no hand-maintained JSON Schema to drift out of * sync with the one `component-spec` defines. */ schema: z.ZodType<GeneratedSpec>; /** Fires when the caller's budget elapses. An adapter must stop work. */ signal: AbortSignal; }

ProviderResultinterfacecore

What an adapter answers with. usage is optional: report it when the API tells you and core passes it through to GenerationEvent.usage. Core re-validates the spec whatever you send, so an adapter that returns something malformed is a bug, not a security hole.

Since

0.6.0

See also ComponentProvider, TokenUsage, GeneratedSpec.

Declaration
export interface ProviderResult { spec: GeneratedSpec; usage?: TokenUsage; }

TokenUsageinterfacecore

Token counts an adapter reports for one call, passed straight through to GenerationEvent.usage. Nothing in core reads it for control flow — it is there so you can total your spend. Sum it only over events where calledModel is true, since requests that joined an in-flight generation carry the same numbers.

Since

0.6.0

See also ProviderResult, GenerationEvent, ComponentProvider.

Declaration
export interface TokenUsage { inputTokens?: number; outputTokens?: number; cacheReadTokens?: number; cacheWriteTokens?: number; }

buildPrompt(input: TrackingInput, digest: SignalDigest) => PromptPairfunctioncore

Builds the system and user halves of the prompt for one generation. The shopper lines and candidate list sit between untrusted-data markers, out-of-stock products are dropped, and at most 60 candidates go. createComponentGenerator calls this for you — call it directly to see what a payload would actually send a model.

Since

0.6.0

Arguments

  • input (TrackingInput)
  • digest (SignalDigest)

Returns

(PromptPair)

Example

const input = parseTrackingInput({ user: { id: 'shopper-1' }, context: { surface: 'pdp' }, candidates: catalog });
const { user } = buildPrompt(input, buildDigest(input));
// => user starts with 'BEGIN_UNTRUSTED_DATA'

See also PromptPair, buildDigest, toCohortDigest, ProviderRequest.

Declaration
export declare function buildPrompt(input: TrackingInput, digest: SignalDigest): PromptPair;

createFixedSpecProvider(spec: GeneratedSpec) => ComponentProviderfunctioncore

A provider that answers every request with the spec you hand it and never touches the network. Reach for it to render the blocks the deterministic component never emits — hero, carousel, banner, copy, bundle — which only ever come from a model.

Since

0.6.0

Arguments

  • spec (GeneratedSpec)

Returns

(ComponentProvider)

Example

const provider = createFixedSpecProvider({
  tone: 'neutral', headline: 'Built for cast iron', subheadline: null,
  blocks: [{ kind: 'copy', title: null, body: 'Pans, lids and seasoning oil.' }],
  rationale: 'Fixed spec, no model call.',
});
createComponentGenerator({ provider });
// => a ComponentProvider named 'fixed', model 'none'

See also ComponentProvider, GeneratedSpec, createComponentGenerator.

Declaration
export declare function createFixedSpecProvider(spec: GeneratedSpec): ComponentProvider;

PromptPairinterfacecore

What buildPrompt returns. The system half is identical on every request, so an adapter can mark it as its cached prefix; the user half is this shopper and this page and belongs outside that prefix.

Since

0.6.0

See also buildPrompt, ProviderRequest.

Declaration
export interface PromptPair { /** Stable across requests. Safe for a provider to cache. */ system: string; /** Everything about this shopper and this page. */ user: string; }

Caching exports

Reusing one generated specification across shoppers who look alike.

CachedSpecinterfacecore

One cache entry: the generated spec and generatedAt, the epoch milliseconds when the model produced it. That's the whole of it — no tracking payload, no shopper, no prompt. The timestamp has to travel with the spec, since a component served from cache was not newly generated and nothing downstream can work out its age any other way.

Since

0.6.0

See also SpecCache, createMemorySpecCache.

Declaration
export interface CachedSpec { spec: GeneratedSpec; /** Epoch milliseconds at which the model produced this. */ generatedAt: number; }

createMemorySpecCache(options?: MemorySpecCacheOptions) => SpecCachefunctioncore

The default cache, and the one you get if you pass no cache at all. Holds entries in this process for ttlMs, up to maxEntries, and drops the one read longest ago once it fills. Run more than one instance and each keeps its own copy, so your hit rate divides by the instance count — that's the point at which you hand createComponentGenerator a shared store instead.

Since

0.6.0

Arguments

  • options (MemorySpecCacheOptions)optional

Returns

(SpecCache)

Example

const cache = createMemorySpecCache({ ttlMs: 30_000, maxEntries: 500 });
const generator = createComponentGenerator({ provider: null, cache });

await cache.get('nothing-stored-under-this-key');
// => undefined

See also MemorySpecCacheOptions, SpecCache, createNullSpecCache.

Declaration
export declare function createMemorySpecCache(options?: MemorySpecCacheOptions): SpecCache;

createNullSpecCache() => SpecCachefunctioncore

Stores nothing, so every request generates. This is a supported setting, not a stub: it's the control when you're measuring what a generation costs or how long it takes, because with a cache in front a benchmark reports your hit rate instead.

Since

0.6.0

Returns

(SpecCache)

Example

const generator = createComponentGenerator({
  provider: createAnthropicProvider({ apiKey: process.env.ANTHROPIC_API_KEY ?? '' }),
  cache: createNullSpecCache(),
});

const spec = await generator.generate(input);
spec.source;
// => 'llm'

See also createMemorySpecCache, SpecCache.

Declaration
export declare function createNullSpecCache(): SpecCache;

MemorySpecCacheOptionsinterfacecore

Settings for the in-memory cache: ttlMs for how long an entry stays valid (60 seconds), maxEntries for the ceiling (10,000), and now as an injectable clock so tests don't have to wait out a TTL. Bad numbers throw a RangeError when you build the cache rather than failing quietly later — Number(process.env.SPEC_TTL) on an unset variable is NaN, and the error message says so.

Since

0.6.0

See also createMemorySpecCache, SpecCache.

Declaration
export interface MemorySpecCacheOptions { /** How long an entry stays valid. Defaults to 60 seconds. */ ttlMs?: number; /** Hard ceiling on entries. Least recently read is evicted first. */ maxEntries?: number; /** Injectable clock, so tests do not have to wait. */ now?: () => number; }

SpecCacheinterfacecore

Where you swap the in-process cache for a store every instance shares, so one generation gets reused across processes. It's async so a Redis or Memcached client can implement it; a synchronous get would look tidier and rule all of those out. A read that hangs past cacheTimeoutMs — 50ms by default — counts as a miss, so a store that is down costs you a generation rather than the page.

Since

0.6.0

See also CachedSpec, createMemorySpecCache, createNullSpecCache.

Declaration
export interface SpecCache { get(key: string): Promise<CachedSpec | undefined>; set(key: string, cached: CachedSpec): Promise<void>; delete?(key: string): Promise<void>; }

Rendering exports

Turning a specification into React Server Components, with no client JavaScript.

BlockRegistrytypereact

Which component draws each block kind. All six kinds need an entry, so build one with extendRegistry rather than by hand — a kind added in a later version then arrives with its default renderer instead of breaking your build.

Since

0.6.0

See also extendRegistry, BlockRenderer, defaultRegistry, BlockKind.

Declaration
export type BlockRegistry = { [Kind in BlockKind]: BlockRenderer<Kind>; };

BlockRenderContextinterfacereact

Everything a renderer needs that the spec does not carry: your catalog, your bundles, and your own link and price formatting. A product's title, price, image and link are absent from the spec and reachable only through here. That split is why a generated component is safe to put on a page: the model decides how it reads, the shop decides what is true about a product.

Since

0.6.0

See also BlockRenderer, ProductCard, defaultFormatPrice, defaultHrefForSku.

Declaration
export interface BlockRenderContext { /** The host's catalog, keyed by SKU. Read-only: it is the caller's own map. */ readonly products: ReadonlyMap<string, Product>; /** Sets the shop sells together, keyed by id. */ readonly bundles: ReadonlyMap<string, Bundle>; /** Host-owned link construction. */ readonly hrefForSku: (sku: string) => string; readonly formatPrice: (product: Product) => string; readonly formatBundlePrice: (bundle: Bundle) => string; }

BlockRenderer(props: { block: Extract<Block, { kind: Kind; }>; context: BlockRenderContext; }) => ReactNodetypereact

What you write when you swap a block out for your own design-system component. It's keyed by kind, so a BlockRenderer<'grid'> only ever sees a grid block — no narrowing to do at the top of your own function.

Since

0.6.0

Arguments

  • props ({ block: Extract<Block, { kind: Kind; }>; context: BlockRenderContext; })

Returns

(ReactNode)

See also extendRegistry, BlockRegistry, BlockRenderContext, Block.

Declaration
export type BlockRenderer<Kind extends BlockKind> = (props: { block: Extract<Block, { kind: Kind; }>; context: BlockRenderContext; }) => ReactNode;

defaultFormatBundlePrice(bundle: Bundle, locale?: string) => stringfunctionreact

Formats a bundle's price in the currency the shop put on the set, never a sum of the parts. Price and currency come off the same object, so members priced in another currency change nothing — and as with defaultFormatPrice, a price that isn't a finite number throws.

Since

0.6.0

Arguments

  • bundle (Bundle)
  • locale (string)optional

Returns

(string)

Example

import { bundleSchema } from '@rudra-js/core';
import { defaultFormatBundlePrice } from '@rudra-js/react';

const kit = bundleSchema.parse({ id: 'starter-kit', skus: ['A-1', 'A-2'], price: 119, label: 'Starter kit' });

defaultFormatBundlePrice(kit, 'en-US');
// => '$119.00'

See also defaultFormatPrice, bundleSchema, Bundle.

Declaration
export declare function defaultFormatBundlePrice(bundle: Bundle, locale?: string): string;

defaultFormatPrice(product: Product, locale?: string) => stringfunctionreact

Formats a price the way its own currency is written. No digit count is forced on purpose — dinar has three decimal places and yen has none, so hard-coding two either hides a real digit or invents one. Leave locale out and you get the server's, which is rarely the shopper's; a price that isn't a finite number throws rather than rendering as free.

Since

0.6.0

Arguments

  • product (Product)
  • locale (string)optional

Returns

(string)

Example

import { productSchema } from '@rudra-js/core';
import { defaultFormatPrice } from '@rudra-js/react';

const skillet = productSchema.parse({ sku: 'A-1', title: 'Cast iron skillet', category: 'Cookware', price: 39, currency: 'USD' });

defaultFormatPrice(skillet, 'en-US');
// => '$39.00'

See also defaultFormatBundlePrice, productSchema, RudraComponentProps.

Declaration
export declare function defaultFormatPrice(product: Product, locale?: string): string;

defaultHrefForSku(sku: string) => stringfunctionreact

Builds the link behind every card, hero and bundle item when you pass no hrefForSku. It's /product/{sku} with the SKU URL-encoded, so override it the moment your product routes look like anything else.

Since

0.6.0

Arguments

  • sku (string)

Returns

(string)

Example

import { defaultHrefForSku } from '@rudra-js/react';

defaultHrefForSku('A-1');
// => '/product/A-1'

See also BlockRenderContext, RudraComponentProps.

Declaration
export declare function defaultHrefForSku(sku: string): string;

extendRegistry(overrides: Partial<BlockRegistry>) => BlockRegistryfunctionreact

Replaces the renderers you name and keeps the defaults for the rest. It's written out kind by kind rather than spread on purpose: a spread lets { hero: maybeRenderer } put an explicit undefined over the default, and the render then throws on the first hero block it reaches.

Since

0.6.0

Arguments

  • overrides (Partial<BlockRegistry>)

Returns

(BlockRegistry)

Example

import { RudraComponent, extendRegistry } from '@rudra-js/react';

const registry = extendRegistry({
  grid: ({ block }) => (
    <ul>
      {block.items.map((item) => (
        <li key={item.sku}>{item.sku}</li>
      ))}
    </ul>
  ),
});
// => your grid, plus our hero, carousel, banner, copy and bundle

See also BlockRegistry, BlockRenderer, defaultRegistry, ProductCard.

Declaration
export declare function extendRegistry(overrides: Partial<BlockRegistry>): BlockRegistry;

ProductCatalogtypereact

What the products prop accepts: a list, or anything keyed by SKU that answers get(sku) and has(sku). Both methods are how a keyed catalog is recognised, but only get is ever called, so a view over a store too big to copy into a Map per request needs nothing more than those two. The check looks for the methods rather than instanceof Map, which is per-realm and fails on a perfectly good Map that arrived from a worker.

Since

0.6.0

See also RudraComponentProps, Product, productSchema.

Declaration
export type ProductCatalog = readonly Product[] | ReadonlyMap<string, Product>;

RudraComponent(props: RudraComponentProps) => JSX.Element | nullcomponentreact

Renders a component spec as markup. A Server Component — no hooks, no state, no client bundle and no hydration — so the recommendation area is in the initial HTML response and a crawler that never runs JavaScript still reads it. Renders nothing at all when every block came up empty, rather than leaving a headline over an empty box.

Since

0.6.0

Arguments

  • props (RudraComponentProps)

Returns

(JSX.Element | null)

Example

import { RudraComponent } from '@rudra-js/react';

<RudraComponent spec={spec} products={input.candidates} locale="en-GB" />;
// => <section class="rudra" data-rudra-slot="recommendations" data-rudra-source="fallback">…

See also RudraComponentProps, ProductCatalog, ComponentSpec, extendRegistry.

Declaration
export declare function RudraComponent({ spec, products, bundles, registry, hrefForSku, formatPrice, formatBundlePrice, locale, hasDiagnostics, className, }: RudraComponentProps): JSX.Element | null;

RudraComponentPropsinterfacereact

Only spec and products are required, but a spec carrying a bundle block also needs bundles or that block is dropped before it renders. Validate products with productSchema from @rudra-js/core before it reaches here — this prop is a second door into the framework, and that schema is the only thing that rejects a data: or protocol-relative imageUrl. hasDiagnostics is off by default because it tells a visitor which model you run and when it's failing.

Since

0.6.0

See also RudraComponent, ProductCatalog, productSchema, bundleSchema.

Declaration
export interface RudraComponentProps { spec: ComponentSpec; /** * The host catalog. Every product fact on the page comes from here rather * than from the specification. * * A list of products, or anything keyed by SKU — a `Map`, or your own view * over a catalog too large to hold in one. The renderers only ever call * `get(sku)` and `has(sku)`, so a view needs nothing else to be fast. * * Validate them with `productSchema` from `@rudra-js/core` — the same schema * your candidates already passed — not with `parseTrackingInput`, which * parses a whole tracking payload and will reject a bare catalog. * * This is a second door into the framework. `imageUrl` lands in an * `<img src>`, and `productSchema` is the only thing that rejects a * protocol-relative `//evil.example/pixel.png` or a `data:` URL — React * neutralises `javascript:` on its own, but not those. A price that is not a * finite number throws rather than rendering as free. */ products: ProductCatalog; /** Sets the shop sells together. Only needed if a spec can carry a bundle block. */ bundles?: readonly Bundle[]; registry?: BlockRegistry; hrefForSku?: (sku: string) => string; formatPrice?: (product: Product) => string; /** Same as `formatPrice`, but for a bundle — the shop's price, not a sum of the parts. */ formatBundlePrice?: (bundle: Bundle) => string; /** * The shopper's locale, used to punctuate prices. Defaults to the server's, * which is almost never the shopper's — pass it if the shop serves more than * one. Ignored when `formatPrice` and `formatBundlePrice` are supplied. */ locale?: string; /** * Adds the model's own reasoning, the provider and the model name to the * markup. Useful while developing and while benchmarking; it publishes which * vendor a shop uses and whether the component is currently degraded, so it * is off unless asked for. */ hasDiagnostics?: boolean; className?: string; }

defaultRegistryconstantreact

The six renderers the component uses when you pass no registry. extendRegistry already falls back to it for every kind you leave out, so you rarely name it yourself.

Since

0.6.0

Example

import { RudraComponent, defaultRegistry } from '@rudra-js/react';

<RudraComponent spec={spec} products={catalog} registry={defaultRegistry} />;
// => the same markup as leaving `registry` out

See also extendRegistry, BlockRegistry, RudraComponentProps.

Declaration
defaultRegistry: BlockRegistry

ProductCard(options: { reference: ProductReference; context: BlockRenderContext; }) => JSX.Element | nullcomponentreact

Draws one product card. Grid and carousel call it for you — you only reach for it when you're writing a replacement renderer and want the default card inside it. Every product fact comes from the catalog; the spec contributes reason, badge and basis, which React escapes on the way into the markup, and emphasis, which only picks a class name.

Since

0.6.0

Arguments

  • options ({ reference: ProductReference; context: BlockRenderContext; })

Returns

(JSX.Element | null)

Example

import { ProductCard, extendRegistry } from '@rudra-js/react';

const registry = extendRegistry({
  grid: ({ block, context }) =>
    block.items.map((r) => <ProductCard key={r.sku} reference={r} context={context} />),
});
// => our default cards, with none of our grid markup around them

See also BlockRenderContext, ProductReference, extendRegistry.

Declaration
export declare function ProductCard({ reference, context, }: { reference: ProductReference; context: BlockRenderContext; }): JSX.Element | null;

Verification exports

Reading every word a model wrote and dropping anything your catalog cannot support.

BatchResultinterfaceattested

What comes back from verifyFields: each field's verdict, plus acrossFields — the wording layer over every field joined. supported is true only when each field passes and the joined read passes too.

Since

0.6.0

See also verifyFields, FieldResult, LayerReport.

Declaration
export interface BatchResult { /** True when every field is, and when the fields read as one carry no banned claim. */ supported: boolean; fields: FieldResult[]; /** The wording layer over every field joined, so a phrase split across two fields still reads. */ acrossFields: LayerReport; }

Factsinterfaceattested

The facts a check runs against. Write values however your own system writes them — 39, '$1,299.00', '4,8' — and every numeral inside each one counts as supported. The list is flat rather than typed, so a SKU of AT-2199 also makes "Was $2,199" pass: supply only numbers you would be happy to see anywhere in the copy.

Since

0.6.0

See also verify, BANNED_PHRASES.

Declaration
export interface Facts { /** In any shape they are written: `39`, `'$1,299.00'`, `'4,8'`. Every numeral in one counts. */ values: readonly (string | number | bigint)[]; /** Claims to ban on top of the built-in English list, for the host's own language. */ bannedPhrases?: readonly string[]; /** Wording the shop stands behind. A banned claim inside one of these is not reported. */ allowedPhrases?: readonly string[]; }

FieldResultinterfaceattested

One field's own verdict inside a verifyFields run, carrying the field name so you can point at what failed. They come back in the key order of the object you passed.

Since

0.6.0

See also BatchResult, VerifyResult.

Declaration
export interface FieldResult { field: string; result: VerifyResult; }

Findinginterfaceattested

One token that failed, written to sit in an audit log. token is the numeral as the text wrote it, or on the wording layer the denylist phrase that matched, and reason says why it failed. Neither records where in the text the token sat.

Since

0.6.0

See also LayerReport, Layer.

Declaration
export interface Finding { layer: Layer; /** The numeral as the text wrote it, or the phrase as the denylist holds it. */ token: string; /** Audit evidence. Names the layer and the token. */ reason: string; }

Layertypeattested

Which half of the check a finding came from. quantity is the provable half; wording is the denylist.

Since

0.6.0

See also Finding, LayerReport.

Declaration
export type Layer = 'quantity' | 'wording';

LayerReportinterfaceattested

What one layer found, and what its verdict is worth — proof for quantities, best-effort for wording. checked counts what that layer weighed: for quantity it is the numerals found in the text, so checked: 0 means there was no numeral and nothing was proved; for wording it is the number of denylist phrases screened, which never falls to zero.

Since

0.6.0

See also VerifyResult, Finding, Layer.

Declaration
export interface LayerReport { supported: boolean; /** * What this layer's verdict is worth. `proof` means every numeral in the text is * a value the host supplied — not that the sentence around it is true. */ strength: 'proof' | 'best-effort'; /** Numerals found, or phrases screened against. Zero means nothing was checked. */ checked: number; /** One per distinct token that failed. */ findings: Finding[]; }

verify(text: string, facts: Facts) => VerifyResultfunctionattested

Checks one piece of model-written copy against the facts you stand behind. The two layers are reported apart because only one is a proof — every numeral in the text has to be a value you supplied, while the wording check is a denylist and carries strength: 'best-effort' to say so.

Since

0.6.0

Arguments

  • text (string)
  • facts (Facts)

Returns

(VerifyResult)

Example

const skillet = { sku: 'A-1', title: 'Cast iron skillet', category: 'Cookware', price: 39, currency: 'USD' };

const result = verify('Only 2 left at $39', { values: [skillet.price] });
result.supported; // => false
result.quantity.findings[0]?.token; // => '2'

See also verifyFields, Facts, VerifyResult.

Declaration
export declare function verify(text: string, facts: Facts): VerifyResult;

verifyFields(fields: Record<string, string>, facts: Facts) => BatchResultfunctionattested

Runs the same check over every field of one card, reading the facts once. It also reads the fields joined as one, so a claim the model split across two of them — a badge of 'Free' next to 'delivery on every order' — still gets caught.

Since

0.6.0

Arguments

  • fields (Record<string, string>)
  • facts (Facts)

Returns

(BatchResult)

Example

const result = verifyFields(
  { badge: 'Free', blurb: 'delivery on every order' },
  { values: [] },
);
result.fields.every((f) => f.result.supported); // => true
result.acrossFields.supported; // => false
result.supported; // => false

See also verify, BatchResult, FieldResult.

Declaration
export declare function verifyFields(fields: Record<string, string>, facts: Facts): BatchResult;

VerifyResultinterfaceattested

The result of one verify call, with the two layers kept apart. supported is true only when both are, so read quantity and wording separately when you care which half failed.

Since

0.6.0

See also verify, LayerReport, BatchResult.

Declaration
export interface VerifyResult { /** True when both layers are. */ supported: boolean; quantity: LayerReport; wording: LayerReport; }

BANNED_PHRASESconstantattested

The built-in wording denylist: 81 English claims like 'selling fast', 'free delivery' and 'top pick' that have no number in them to check. It is English-first and will never be finished — bannedPhrases adds your own language on top, nothing takes an entry off it, and allowedPhrases is how you name the wording your shop really stands behind.

Since

0.6.0

See also Facts, verify.

Declaration
BANNED_PHRASES: readonly string[]