# Recipe: add an experiment

<!-- funnelsgrove:generated:start contract-v3/recipe/add-experiment -->
Step contract version: `3`

Contract hash: `d761e91d5ac6ff9e72c6d49c5bcd014270912473a66f99c49d726c65998085cf`

| Capability | Allowed types | Allowed identities | Required owner |
| --- | --- | --- | --- |
| `browser-registration-completed` | _None_ | _None_ | `trusted-subscription-claim-server` |
| `checkout` | `paywall_offer`, `upsell_offer`, `checkout` | `any` | `named-payment-helpers` |
| `choice-write` | `single_step_choice`, `single_step_choice_emoji`, `multi_select_choice` | `any` | `useStepChoices` |
| `email-capture` | `form_input` | `email-capture` | `submitEmailCapture` |
| `hardcoded-navigation` | _None_ | _None_ | `manifest-flow-controller` |
| `provider-direct-analytics` | _None_ | _None_ | `analytics-adapter-outbox` |
| `subscription-handoff` | `subscription_handoff`, `purchase_completed` | `any` | `runtime-handoff-helper` |
| `subscription-management` | `subscription_management`, `cancellation_offer` | `any` | `runtime-subscription-helpers` |
<!-- funnelsgrove:generated:end contract-v3/recipe/add-experiment -->

## Procedure

1. Start in a synced funnel checkout and run `fgrove status` and `git status --short`.
2. Build the control and variant steps or offer sets in the same local change.
3. Save one of the strict JSON specs below as `experiment.json`.
4. Create the database draft, hosted snapshot, and matching local generated files:

   ```bash
   fgrove experiments create --spec experiment.json --dir .
   ```

   For machine-readable output, run `fgrove experiments create --spec experiment.json --dir . --json`. It emits only `experimentId`, `experimentKey`, `draftVersionId`, and `writtenPaths`, with generated paths in canonical order.
5. Resolve assignment and any redirect before creating the destination step visit. Keep the default flow valid when assignment is stopped, missing, or invalid.
6. Run experiment-focused tests and `fgrove validate`, then follow the delivery path below.

## JSON contract

Use only the documented fields. Unknown top-level and variant fields are rejected.

All experiment types require non-empty `id`, `name`, and `stepId`; one `primaryMetric`; at least one unique `trackedMetrics` entry containing the primary metric; and two to five variants. Each variant requires a unique non-empty `variantKey`, `label`, and `routeToStepId`, an integer `trafficPercent` from 0 through 100, and `isControl`. Exactly one variant is the control and traffic must total 100.

Length limits are enforced after surrounding whitespace is trimmed: `variantKey` is at most 120 characters. `id`, `name`, `stepId`, `label`, and `routeToStepId` are each at most 200 characters after trimming. Pricing variants additionally require `offerSetId` as a UUID.

Metrics are exactly `step_completion`, `next_step_reached`, `checkout_opened`, `funnel_completed`, or `paying_customer`.

### Step experiment

Every variant routes to the step that implements that experience.

```json
{
  "id": "claim-headline-v1",
  "name": "Claim headline experiment",
  "type": "step",
  "stepId": "claim",
  "primaryMetric": "next_step_reached",
  "trackedMetrics": [
    "next_step_reached",
    "funnel_completed"
  ],
  "variants": [
    {
      "variantKey": "control",
      "label": "Control",
      "routeToStepId": "claim",
      "trafficPercent": 50,
      "isControl": true
    },
    {
      "variantKey": "variant_b",
      "label": "Variant B",
      "routeToStepId": "claim-b",
      "trafficPercent": 50,
      "isControl": false
    }
  ]
}
```

### Paywall experiment

Like `step`, every variant has a route target. Use `type: "paywall"` when the routes select complete paywall step variants.

```json
{
  "id": "paywall-layout-v1",
  "name": "Paywall layout experiment",
  "type": "paywall",
  "stepId": "paywall-entry",
  "primaryMetric": "checkout_opened",
  "trackedMetrics": [
    "checkout_opened",
    "paying_customer"
  ],
  "variants": [
    {
      "variantKey": "control",
      "label": "Control",
      "routeToStepId": "paywall-control",
      "trafficPercent": 50,
      "isControl": true
    },
    {
      "variantKey": "compact",
      "label": "Compact",
      "routeToStepId": "paywall-compact",
      "trafficPercent": 50,
      "isControl": false
    }
  ]
}
```

### Pricing experiment

Each pricing variant also requires the immutable UUID `offerSetId` of an active offer set in the same project. Every `routeToStepId` must exactly equal the top-level `stepId`.

```json
{
  "id": "paywall-price-v1",
  "name": "Paywall pricing experiment",
  "type": "pricing",
  "stepId": "paywall",
  "primaryMetric": "paying_customer",
  "trackedMetrics": [
    "checkout_opened",
    "paying_customer"
  ],
  "variants": [
    {
      "variantKey": "control",
      "label": "Control",
      "routeToStepId": "paywall",
      "trafficPercent": 50,
      "isControl": true,
      "offerSetId": "11111111-1111-4111-8111-111111111111"
    },
    {
      "variantKey": "annual_focus",
      "label": "Annual focus",
      "routeToStepId": "paywall",
      "trafficPercent": 50,
      "isControl": false,
      "offerSetId": "22222222-2222-4222-8222-222222222222"
    }
  ]
}
```

## Creation guarantees

The stable `id` becomes the experiment key. Keep it unchanged for an exact retry: an identical same-key draft is reused, while a different definition or non-draft experiment is rejected rather than overwritten.

Creation always produces a draft. It does not create or activate a PostHog flag, start traffic, or make unfinished variant steps or offer sets runnable. After QA, manage traffic through the lifecycle API exposed by the CLI:

```bash
fgrove experiments start <experiment-uuid>
fgrove experiments stop <experiment-uuid>
fgrove experiments restart <experiment-uuid>
fgrove experiments end <experiment-uuid>
fgrove experiments delete <experiment-uuid>
```

`stop` is reversible: it pauses traffic and keeps the experiment, variants, and PostHog flag available for a later `start`. `restart` stops a running experiment when necessary and starts it again while preserving the existing definition. `end` permanently archives the experiment; `delete` removes a draft. Each command updates PostHog when required and persists the final status in the control plane. Use `--json` for agent-readable output.

The corresponding authenticated API mutations are agent-only. Normal browser sessions cannot call experiment lifecycle mutations.

The API creates the experiment and variants atomically through ordinary data services. Experiments are stored in the control plane and included in the version-bound runtime artifact when the funnel is published. `src/config/experiments.generated.ts` and `src/config/experiments.ts` are deprecated compatibility files: new funnels and experiment creation do not generate, install, or update them.

## Recovery and errors

- Missing `.funnelsgrove-sync.json`: run `fgrove sync down` into a clean directory first.
- Invalid local JSON or `[FG-EXPERIMENT-SPEC]`: fix the reported field and retry with the same stable `id`.
- `[FG-EXPERIMENT-STALE-DRAFT]`: do not overwrite remote work. Download the latest draft into a clean temporary directory, merge deliberately, rerun validation, and retry the same exact spec.
- `[FG-EXPERIMENT-DRAFT-CONFLICT]`: the stable key already belongs to a different definition, owner, or lifecycle state. Reuse the original exact definition for a retry, or choose a new stable `id` only for a genuinely new experiment.
- If the API succeeded but the process stopped, retry safely with the same `id`; no source transaction is required.

## Validate and deliver

After implementing all referenced variant steps or offer sets, run:

```bash
fgrove validate
git status --short
```

For a funnel without GitHub source sync, deliver all local source changes with `fgrove sync up`.

For a GitHub-connected funnel, commit and push only the variant step or offer-set source changes, then run `fgrove github pull`. The experiment itself is stored in the control plane and does not create a source file. Never use `fgrove sync up` for the same GitHub-connected diff; `.funnelsgrove-sync.json` stays local and ignored.

## Pre-activation QA

Force every assignment, verify analytics metadata and deduplication, confirm refresh keeps assignment, test missing/invalid flags, and leave inactive variants unreachable from the default flow.

Pre-activation, missing-assignment, invalid-assignment, and fallback paths must not create phantom `step_started`, `step_completed`, or `step_exited` events.
