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.
export interface ComponentGenerator { generate(input: TrackingInputDraft): Promise<ComponentSpec>; /** The deterministic component, without consulting a model or a cache. */ generateDeterministic(input: TrackingInputDraft): ComponentSpec; }
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.
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; }
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.
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.
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.
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.
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.
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.
import { FIELD_LIMITS } from '@rudra-js/core';const candidates = catalog.slice(0, FIELD_LIMITS.candidates);// => at most 200 products, which is what parseTrackingInput accepts
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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
Name
Type
Default
Constraints
Description
surface
string
—
1 to 128 chars
slotoptional
string
"recommendations"
1 to 128 chars
currentSkuoptional
string
—
1 to 128 chars
currentCategoryoptional
string
—
1 to 128 chars
searchQueryoptional
string
—
max 200 chars
localeoptional
string
"en-US"
max 35 chars, pattern ^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$
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.
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.
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.
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.
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.
export interface CategoryAffinity { category: string; /** Unnormalised. Only the ordering is meaningful — do not show this to anyone. */ score: number; }
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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; }
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.
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.
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';
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.
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.
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.
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.
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.
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.
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']
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.
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.
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.
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.
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.
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.
import { z } from 'zod';// what an adapter sends as the tool's input_schemaconst toolSchema = z.toJSONSchema(generatedSpecSchema, { io: 'input' });toolSchema.type;// => 'object'
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.
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.
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.
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.
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.
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; }
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.
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[]; }
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.
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;
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
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.
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; }
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.
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>; }
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.
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.
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; }
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.
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.
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'
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'
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.
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.
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.
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.
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.
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.
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; }
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.
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.
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.
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.
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.
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'
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'
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
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.
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.
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.
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; }
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
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.
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.
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; }
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.
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[]; }
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.
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.
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; }
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.
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[]; }
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; // => falseresult.quantity.findings[0]?.token; // => '2'
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); // => trueresult.acrossFields.supported; // => falseresult.supported; // => false
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.
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.