# splithub.config.ts

The config is a plain TypeScript (or JavaScript) module that exports `defineConfig(...)` from `@splithub/core`. `defineConfig` validates and normalizes it at import time, so mistakes fail fast with a readable message. The file ships to the browser: never put secrets in it.

```ts
import { defineConfig } from '@splithub/core';

export default defineConfig({
  project: 'my-app',
  storage: { mode: 'cloud', key: 'pk_live_…' },
  runDraftsInDev: true,
  goals: {
    signup: { type: 'event', name: 'Sign up' },
    purchase: { type: 'event', revenue: true },
    'video-played': { type: 'event', event: 'Video Played', value: 2 },
    pricing_view: { type: 'pageview', path: '/pricing' },
    cta_click: { type: 'click', selector: '#cta' },
  },
  properties: {
    'sh-x1h78w': { name: 'Marketing site' },
  },
  layers: { checkout: {} },
  holdout: { percent: 5, goal: 'purchase' },
  experiments: {
    'hero-title': {
      kind: 'value',
      status: 'running',
      goal: 'signup',
      name: 'Hero headline',
      description: 'Feature-led vs outcome-led headline',
      metrics: { secondary: ['pricing_view'], guardrails: ['purchase'] },
      variants: {
        control: { weight: 50, value: 'Ship experiments from your terminal' },
        outcome: { weight: 50, value: 'Know which version wins, by Friday' },
      },
    },
    'pricing-cta': {
      kind: 'component',
      status: 'draft',
      goal: 'purchase',
      traffic: 0.5,
      variants: { control: 1, 'see-plans': 1, 'talk-to-us': 1 },
      maxExposures: 20000,
      layer: { key: 'checkout', range: [0, 50] },
      schedule: { startAt: '2026-11-01T00:00:00Z', endAt: '2026-11-30T00:00:00Z' },
    },
    'hero-combo': {
      kind: 'mvt',
      status: 'running',
      goal: 'signup',
      factors: {
        headline: { old: 'Ship experiments', new: 'Know what wins' },
        button: { blue: '#2563eb', green: '#16a34a' },
      },
    },
    'landing': {
      kind: 'redirect',
      status: 'running',
      goal: 'signup',
      match: '/',
      variants: { control: { weight: 50, to: '/' }, 'new-landing': { weight: 50, to: '/start' } },
    },
  },
});
```

## Top level

| Field | Required | Meaning |
|---|---|---|
| `project` | yes | Name shown in the CLI and the dashboard. |
| `storage` | yes | `{ mode: 'self', endpoint }` or `{ mode: 'cloud', key, endpoint? }`. |
| `goals` | no | Map of goal key → goal. |
| `properties` | no | Map of property id → analytics property. |
| `experiments` | yes | Map of experiment key → experiment. |
| `layers` | no | Map of layer key → `{ seed? }`, for experiments that must never share a visitor. |
| `holdout` | no | `{ percent, seed?, goal? }`: a share of visitors kept out of every experiment. |
| `visitorKey` | no | Cookie name for the visitor id, default `sh_vid`. |
| `runDraftsInDev` | no | `true` serves `draft` experiments like running ones while the app runs in development. Default `false`. |

`storage.endpoint` in self mode is the path (or URL) where the `@splithub/node` handler is mounted. In cloud mode `key` is the project's public key; `endpoint` defaults to `https://go.splithub.io/v1/events` and only changes for a self-hosted cloud or staging.

## Drafts in development

A `draft` experiment is invisible to visitors: everyone sees the control and nothing is recorded. To see it in your own app before you start it, set `runDraftsInDev: true`. While `NODE_ENV` is `development` (`next dev`, `vite`), drafts behave exactly like running experiments: variants by visitor, targeting, redirects, exposures and goals. A production build ignores them as before, so the same config is safe to deploy.

In development the schedule of a draft is ignored, so an experiment that starts next week can be checked today. In cloud mode development never sends events to the cloud, for drafts and running experiments alike, so your own visits never reach the results and nothing is billed. Deployed builds, and `vite preview`, send as usual. While nothing in the project is running (no experiment, no property) the SDK holds events back too, because the cloud would drop them.

In development the SDK also explains itself, one line per experiment: a draft that everyone sees as the control, a draft shown because of this option and which variant you got, a paused experiment, events held back. Next.js server code prints it in the terminal that runs `next dev`. In a Vite app the SDK runs in the browser, so add the plugin to see it in the terminal that runs `vite`:

```ts
// vite.config.ts
import { splithub } from '@splithub/core/vite';

export default defineConfig({ plugins: [react(), splithub()] });
```

On start and on every save of the config it prints each experiment and what it does in development, then forwards the SDK's notes from the browser. Without the plugin the notes stay in the browser console. Production builds print nothing. With `?sh_debug=1` the debug panel shows such assignments with the reason `dev`; its "new visitor" button lets you walk through every variant. `npx splithub init` turns the option on for new projects.

## Goals

A goal is an event marked as one. You do not have to declare event goals: an experiment's `goal` (or a metric) may name any event, and a name that is not in `goals` becomes an event goal keyed by its slug (`Video Played` → `video-played`, with `event: 'Video Played'`). Declare a goal when you want a label, a value, revenue, a page visit or a click.

| Type | Fields | Fires when |
|---|---|---|
| `event` | `revenue?: boolean`, `event?: string` | your code calls `track('<key>', props?)`, or `track('<event>')` when `event` is set; with `revenue: true` the `props.revenue` number is summed per variant |
| `pageview` | `path` | a page with that path is viewed (exact match, or prefix with a trailing `*`) |
| `click` | `selector` | an element matching the CSS selector is clicked (the SDK and the drop-in script bind it) |

Every goal also takes `name` (a label for people; the key stays what the code sends), `description`, and `value` (what one conversion is worth in analytics reports when the event carries no `revenue` prop). `event` names the event when it is not a valid key, like `'Video Played'` for `video-played`. Goals count as conversions in every analytics property unless a property's `goals` narrows them. Goals created in the dashboard reach cloud clients; add their definitions to the local file yourself, `pull` adopts experiments, not goals.

## Properties

```ts
properties: {
  'sh-x1h78w': { name: 'Marketing site', goals: ['signup', 'purchase'] },
}
```

| Field | Default | Meaning |
|---|---|---|
| `name` | the id | shown in the dashboard and the CLI (up to 80 characters) |
| `status` | `running` | `running` or `paused`; a paused property's events are dropped at ingest |
| `goals` | every goal | goal keys this property reports as conversions |
| `description` | none | free-form note |
| `kind` | `site` | `site` or `app` (screens, versions, retention; see [Apps](/docs/apps)) |

`npx splithub analytics create --name "Marketing site"` or `analytics connect` writes the entry with a generated id and, in cloud mode, creates it in the cloud at once. Properties made in the dashboard need no entry in the file. See [Analytics](/docs/analytics).

## Experiments

Keys are lowercase `a-z 0-9 - _`, 1–64 characters, and start with a letter or digit. The **first variant is the control**.

| Field | Default | Meaning |
|---|---|---|
| `kind` | required | `value`, `component`, `redirect` or `mvt` |
| `status` | required | `draft`, `running`, `paused`, `finished` |
| `variants` | required (not for `mvt`) | see below |
| `factors` | required for `mvt` | factor → level → value, see below |
| `goal` | none | the goal the verdict is computed on: a key from `goals` or any event name |
| `metrics` | none | `{ secondary?: [goal], guardrails?: [goal \| { goal, worse: 'higher' }] }`, read beside the primary goal |
| `traffic` | `1` | share of visitors (0–1) entering the experiment; others see the control without an exposure |
| `seed` | the key | hash seed; never change it on a running experiment |
| `name` | the key | human name shown in the dashboard and the CLI (up to 80 characters); rename it freely, the key and the data stay |
| `description` | none | shown in the dashboard and in emails |
| `match` | required for `redirect` | path the redirect applies to (exact, or prefix with trailing `*`) |
| `maxExposures` | none | the cloud finishes the experiment when stored exposure events reach this number |
| `schedule` | none | `{ startAt?, endAt? }` ISO dates; outside the window the control is served and nothing is recorded |
| `layer` | none | `{ key, range: [start, end] }`: the buckets 0–99 of a declared layer this experiment enrolls, end excluded |
| `minDays` | `0` | opt-in minimum runtime in days before a verdict, whole number 0–30 |
| `goodSample` | `5000` | total visitors at which the sample counts as good, 100–10,000,000; informational |
| `flow` | none | targeting graph, see [Targeting flows](/docs/targeting) |

### Variants by kind

```ts
// component: weights only, JSX lives in the app
variants: { control: 1, bold: 1 }

// value: weight + payload (string, number, boolean, object, array)
variants: { control: { weight: 50, value: 'Old' }, bold: { weight: 50, value: 'New' } }

// redirect: weight + destination
variants: { control: { weight: 50, to: '/' }, v2: { weight: 50, to: '/home-v2' } }
```

Weights are relative: `{ a: 1, b: 1 }` and `{ a: 50, b: 50 }` are the same split; `{ a: 90, b: 10 }` is a 90/10 split.

### Multivariate (`mvt`)

```ts
factors: {
  headline: { old: 'Ship experiments', new: 'Know what wins' },
  button: { blue: '#2563eb', green: '#16a34a' },
}
```

Every combination becomes a variant with an equal weight: `old-blue`, `old-green`, `new-blue`, `new-green`. The combination of every factor's first level is the control. `useValue('hero-combo')` returns the combination's values, `{ headline, button }`. At most 4 factors, 4 levels each and 16 combinations. Results show the combinations and, below them, each factor read on its own. Every combination needs the minimum sample by itself, so an `mvt` experiment needs more traffic than a two-variant one. The CLI writes `factors`, never the derived variants.

### Metrics and guardrails

`secondary` goals are compared with the control the same way as the primary goal, for information. A `guardrail` must not get significantly worse: a variant that hurts one is never called the winner, and the verdict becomes `guardrail_breached`. A guardrail is worse when its conversion rate drops; use `{ goal: 'refunds', worse: 'higher' }` for goals where more is bad. In the cloud, metric goals are counted from the moment they are pushed, with no backfill. See [Statistics](/docs/statistics).

### Layers and holdout

```ts
layers: { checkout: {} },
experiments: {
  'checkout-steps': { /* … */ layer: { key: 'checkout', range: [0, 50] } },
  'checkout-copy':  { /* … */ layer: { key: 'checkout', range: [50, 100] } },
}
```

Experiments in one layer never share a visitor. Each visitor falls in one bucket 0–99 per layer, the same for every experiment of that layer, and an experiment enrolls only the buckets in its range. Ranges are explicit, so adding an experiment never moves the visitors of another. Overlapping ranges of unfinished experiments in one layer fail `defineConfig`. A visitor outside the range sees the control without an exposure. `seed` on the layer reshuffles its buckets.

`holdout: { percent: 5 }` keeps that share of visitors (at most 50) on the control in every experiment, with no exposures. Everyone who reaches a running experiment is counted in the reserved comparison `__holdout` (`holdout` against `enrolled`), which measures the long-term effect of all your experiments together. `goal` is its primary goal (any event name; default: the first declared goal); the other goals are read as secondary metrics. `npx splithub holdout set 5 [--goal purchase]` writes it, `holdout off` removes it.

Holdout and layers are checked before traffic and the targeting flow, identically in the SDK, the Next.js middleware and the drop-in script.

## What the CLI changes

`create` adds an experiment (with `--factor`, `--secondary`, `--guardrail`, `--layer`, `--min-days`, `--good-sample`), `start`/`pause`/`finish` change `status`, `ship` and `remove` take an experiment out, `holdout` sets the holdout, `analytics create` and `analytics connect` add a property, `pull` adds missing dashboard-created experiments (including flows) but not goal definitions, `init` writes the file the first time. Edits keep your formatting where possible; the file stays a normal TypeScript module you can edit by hand.

## Rules

- Never change `seed`, `variants` or weights of a running experiment; create a new one.
- Never reuse the key of an earlier experiment: the key is its id for good.
- Keep one `id` for things that must change together, separate ids for independent questions.
- On low traffic run at most 2–3 experiments per page.
- Keys that exist in the file are owned by the file: the dashboard cannot edit them (it can pause or finish them).
- In cloud mode a status set in the dashboard wins over the file's `status`, from a visitor's first page, until you clear it. The dashboard shows both when they differ.
