FabricFabricExperiments
Reference

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: true

That's the entire required surface. Everything below is optional.

Top-level fields

FieldTypeDefaultNotes
idstringrequiredlowercase kebab-case, must start with a letter
namestringrequiredDisplay name
descriptionstring≤ 4000 chars
saltstringidPer-experiment hash salt; change to re-randomize
sampleRate0..11Fraction of eligible traffic in the test
holdback0..1Sugar: compiles to audience.sampleRate = 1 - holdback. Mutually exclusive with sampleRate
divertTovariant keyRoute 100% of eligible traffic to one variant
manualExposureboolfalseDefer exposure until host calls client.expose(id)
triggerobject{kind:'auto'}Activation DSL — see below
sharedJsstringJS that runs once before any variant code
sharedCssstringCSS injected once before any variant CSS
metadataobjectPer-experiment config exposed via client.metadata(id)
variantsarrayrequiredAt least 2 — see below
metricsarray[]Conversion / count / sum metrics

Variant fields

FieldTypeDefaultNotes
keystringrequired[a-z0-9_-]+, ≤ 32 chars
namestringrequiredDisplay name
weight0..10000even splitBucket split. If any variant sets it, all must, summing to 10000
recipeSampleRate0..1Independent gate: below threshold → in-test, no recipe, no exposure
jsstringInline JavaScript, eval'd via new Function('experiment', 'variant', js)
cssstringInline CSS, injected as <style>
domOpsarrayDeclarative DOM operations — see below
payloadobjectArbitrary 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:

opRequired fieldsNotes
replaceTextselector, valueSets textContent
replaceHTMLselector, valueSets innerHTML
setAttrselector, name, valueel.setAttribute(name, value)
addClassselector, valueSpace-separated; adds each class
removeClassselector, valueSpace-separated
setStyleselector, name, valueSingle CSS property
removeselectorRemoves the element
injectCSSvalueAppends a <style> to <head>
injectHTMLselector, position, valuepositionbeforebegin / 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:

KnobScopeQuestion it answers
sampleRate (or holdback)Test-levelOf all eligible users, what fraction is in the test at all?
weightVariant-levelAmong in-test users, how do we split across variants?
recipeSampleRateVariant-levelOf 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 it

Targeting (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: Redesign

Operators

OperatorExampleNotes
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: Treatment

The 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 changes

The validator surfaces every constraint above with a path:

✗ experiments/pricing.yaml: variants[].redesign.recipeSampleRate must be >= 0

On this page