YAML schema
Author experiments as YAML — full field reference with examples for every Mojito-parity feature.
Experiments are authored as YAML and validated against ExperimentSpec before
apply. The loader lives in packages/cli/src/yaml-loader.ts; the canonical
schema is in @fabricorg/experiments-domain.
Run validation any time:
fx validate experiments/Minimal example
id: homepage-cta
name: Homepage CTA
sampleRate: 1
variants:
- key: control
name: Current CTA
- key: treatment
name: New CTA
metrics:
- key: signup
name: Sign-ups
kind: conversion
eventName: signup
isPrimary: trueThat's the entire required surface. Everything below is optional.
Top-level fields
| Field | Type | Default | Notes |
|---|---|---|---|
id | string | required | lowercase kebab-case, must start with a letter |
name | string | required | Display name |
description | string | — | ≤ 4000 chars |
salt | string | id | Per-experiment hash salt; change to re-randomize |
sampleRate | 0..1 | 1 | Fraction of eligible traffic in the test |
holdback | 0..1 | — | Sugar: compiles to audience.sampleRate = 1 - holdback. Mutually exclusive with sampleRate |
divertTo | variant key | — | Route 100% of eligible traffic to one variant |
manualExposure | bool | false | Defer exposure until host calls client.expose(id) |
trigger | object | {kind:'auto'} | Activation DSL — see below |
sharedJs | string | — | JS that runs once before any variant code |
sharedCss | string | — | CSS injected once before any variant CSS |
metadata | object | — | Per-experiment config exposed via client.metadata(id) |
variants | array | required | At least 2 — see below |
metrics | array | [] | Conversion / count / sum metrics |
Variant fields
| Field | Type | Default | Notes |
|---|---|---|---|
key | string | required | [a-z0-9_-]+, ≤ 32 chars |
name | string | required | Display name |
weight | 0..10000 | even split | Bucket split. If any variant sets it, all must, summing to 10000 |
recipeSampleRate | 0..1 | — | Independent gate: below threshold → in-test, no recipe, no exposure |
js | string | — | Inline JavaScript, eval'd via new Function('experiment', 'variant', js) |
css | string | — | Inline CSS, injected as <style> |
domOps | array | — | Declarative DOM operations — see below |
payload | object | — | Arbitrary feature payload, returned by client.payload(id) |
DOM operations
domOps is a Mojito-friendlier alternative to js: declarative ops that the
SDK applies on assignment. CSP-friendly and survives strict header policies.
variants:
- key: control
name: Control
- key: treatment
name: Green CTA
domOps:
- op: replaceText
selector: 'a.cta[data-cta="primary"]'
value: "Get started — it's free"
- op: setStyle
selector: 'a.cta[data-cta="primary"]'
name: background
value: '#16a34a'
- op: addClass
selector: 'body'
value: 'fx-experiment-active'
- op: injectCSS
value: |
.hero h1 { letter-spacing: -0.02em; }Available ops:
op | Required fields | Notes |
|---|---|---|
replaceText | selector, value | Sets textContent |
replaceHTML | selector, value | Sets innerHTML |
setAttr | selector, name, value | el.setAttribute(name, value) |
addClass | selector, value | Space-separated; adds each class |
removeClass | selector, value | Space-separated |
setStyle | selector, name, value | Single CSS property |
remove | selector | Removes the element |
injectCSS | value | Appends a <style> to <head> |
injectHTML | selector, position, value | position ∈ beforebegin / afterbegin / beforeend / afterend |
All element-targeting ops accept waitForMs?: number (default 2000) — the SDK
polls every 50ms until the selector resolves or the timeout elapses. Mirrors
Mojito's waitForElement.
Trigger DSL
trigger controls when the SDK activates the experiment. Default: { kind: 'auto' } (immediate).
# Activate only on /cart pages.
trigger:
kind: urlMatch
pattern: /cart # substring; pass `regex: true` to interpret as regex source
# Wait for a specific element to render before applying.
trigger:
kind: waitForSelector
selector: 'button.add-to-cart'
timeoutMs: 3000
# Listen for a custom DOM event the host app dispatches.
trigger:
kind: event
name: 'app:ready'For triggers the DSL can't express, supply a host-side override at SDK init:
createClient(manifest, {
manifestUrl,
subjectId,
triggers: {
'cart-flow': () => location.pathname.startsWith('/cart') && Date.now() % 2 === 0,
},
});Shared JS / CSS
Runs once per page for the experiment, before any variant code. Use for helpers shared across recipes:
id: hero-tests
name: Hero variants
sharedJs: |
// Available as `experiment` to variant.js below.
experiment.helpers = { hide: (sel) => document.querySelector(sel)?.remove() };
sharedCss: |
.hero-shared-bg { background: linear-gradient(135deg, #fff, #f0f0f0); }
variants:
- key: control
name: Control
- key: treatment
name: Treatment
js: |
experiment.helpers.hide('.hero-secondary-cta');Metadata
Per-experiment config that variant code (or analytics tags) can read without
duplicating per-variant. Returned by client.metadata(id).
id: search-debounce
name: Search debounce tuning
metadata:
flushIntervalMs: 250
maxResults: 20
variants:
- key: control
name: Control
- key: aggressive
name: 100ms
payload: { debounceMs: 100 }holdback vs sampleRate vs recipeSampleRate
Three different sampling knobs — pick the right one:
| Knob | Scope | Question it answers |
|---|---|---|
sampleRate (or holdback) | Test-level | Of all eligible users, what fraction is in the test at all? |
weight | Variant-level | Among in-test users, how do we split across variants? |
recipeSampleRate | Variant-level | Of users assigned to this variant, what fraction actually sees it? |
Example combining all three:
id: pricing-page
name: Pricing page redesign
holdback: 0.1 # 10% of users excluded from the test entirely
variants:
- key: control
name: Current
weight: 5000 # 50% of in-test users
- key: redesign
name: Redesign
weight: 5000 # 50% of in-test users
recipeSampleRate: 0.2 # ...but only 20% of those actually see itTargeting (audience.rule)
Targeting rules narrow the eligible cohort to subjects matching a per-request
context — typically { user: { country, tier, plan, ... } }. Use it for
"only Pro users in EU on mobile" without filtering the cohort outside the SDK.
The rule is a small JSONLogic-style AST evaluated by both SDKs. Unknown
operators evaluate to false (fail-closed — never accidentally include
the wrong cohort).
id: pro-onboarding
name: Pro onboarding flow redesign
audience:
sampleRate: 1
rule:
and:
- in: [{ var: 'user.tier' }, ['pro', 'enterprise']]
- '>=': [{ var: 'user.accountAgeDays' }, 30]
- matches: [{ var: 'user.email' }, '@(?!example\.com).*$']
variants:
- key: control
name: Old flow
- key: treatment
name: RedesignOperators
| Operator | Example | Notes |
|---|---|---|
var | { var: 'user.country' } | Dotted path lookup against the targeting context |
== != === !== | { '==': [{ var: 'user.tier' }, 'pro'] } | Equality |
< <= > >= | { '>=': [{ var: 'user.accountAge' }, 30] } | Numeric comparison |
and or | { and: [ruleA, ruleB] } | Boolean composition |
not / ! | { not: ruleA } | Negation |
!! | { '!!': [{ var: 'user.id' }] } | Coerce to boolean |
in | { in: [{ var: 'user.tier' }, ['pro', 'ent']] } | Array membership; or substring for strings |
if | { if: [cond, then, cond, then, ..., else] } | Conditional ladder |
startsWith endsWith | { startsWith: [{ var: 'user.email' }, 'admin'] } | String prefix / suffix |
matches | { matches: [{ var: 'user.path' }, '^/cart'] } | JS regex test |
How the SDK reads the context
Browser SDK — pass targetingContext to init() / createClient():
import { init } from '@fabricorg/experiments-web';
const client = await init({
manifestUrl,
subjectId: currentUser.id,
targetingContext: {
user: {
tier: currentUser.tier,
country: currentUser.country,
accountAgeDays: daysSince(currentUser.createdAt),
email: currentUser.email,
},
},
});Server SDK — set ctx.user on the RequestContext:
import { NodeClient } from '@fabricorg/experiments-node';
import { forNextRequest } from '@fabricorg/experiments-node/next';
const ctx = forNextRequest(request, responseHeaders);
ctx.user = { tier, country, accountAgeDays, email };
const r = await client.exposure('pro-onboarding', subjectId, { ctx });The server SDK wraps ctx.user so rules continue to reference var: 'user.<field>'
— byte-identical rules work on both SDKs.
Sticky bypasses targeting
Once a subject is sticky-assigned to a variant, subsequent calls return the same variant even if their targeting context no longer matches. This is intentional — moving a user out of the cohort mid-experiment would invalidate the analysis. To remove a user from a long-running experiment, pause/kill via the lifecycle, not by changing targeting.
Mojito-parity sugar
For migrating from Mojito JS Delivery, three legacy fields are first-class:
id: w12-checkout
name: Checkout flow
sampleRate: 0.5
divertTo: treatment # send all 50% to treatment, no bucketing
manualExposure: true # client.expose() controls timing
variants:
- key: control
name: Control
- key: treatment
name: TreatmentThe Studio surface and fx import-mojito both understand these fields. See
Migrating from Mojito for the round-trip workflow.
Validating
fx validate experiments/
fx plan experiments/ # shows diff vs current state
fx apply experiments/ # applies if no breaking changesThe validator surfaces every constraint above with a path:
✗ experiments/pricing.yaml: variants[].redesign.recipeSampleRate must be >= 0