A/B Testing skill for Codex
Size it, check SRM, then trust the readout. Codex plans experiments before touching code: one primary metric, guardrails, a decision rule and a sample size computed from baseline and minimum detectable effect. It wires deterministic assignment and exposure logging into the flag tool you already use, runs a bundled offline calculator for duration, sample ratio mismatch and significance, and writes a readout with confidence intervals instead of a bare winner.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- ab-testing Installs to ~/.agents/skills/ab-testing/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install ab-testing The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project ab-testing Installs to .agents/skills/ab-testing/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/ab-testing.zipunzip -o ab-testing.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) ab-testing PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $ab-testing, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Designs, implements and analyses A/B and multivariate experiments on websites and apps - hypothesis, primary and guardrail metrics, sample size from baseline and minimum detectable effect, deterministic assignment, exposure logging, sample ratio mismatch checks and an honest readout. Works with GrowthBook, Statsig, Optimizely, PostHog, LaunchDarkly or a home-grown flag. Use when asked to set up an experiment, size a test, decide how long to run it, check whether a result is significant, or explain why two variants show different numbers.
Use it when
- You are planning an experiment and need a hypothesis, metrics and a sample size
- An experiment needs assignment and exposure tracking implemented in code
- A finished test needs a significance check and a written readout
- Arm sizes look uneven and you suspect a sample ratio mismatch
Use something else when
- You need test ideas from a conversion audit rather than test mechanics: use conversion-rate-optimization
- You only need a release flag or kill switch with no measurement: use feature-flags
How to check the result
Final arm counts pass the SRM check (p of at least 0.001) and the primary metric recomputed from raw events matches the platform's figure.
The mistake it prevents
Stopping a fixed-horizon test the first day it shows p below 0.05; repeated peeking multiplies false positives.
A test is done well when the decision rule was written before launch, the sample size came from a calculation rather than a deadline, assignment was checked for sample ratio mismatch, and the readout reports an interval and the guardrails, not just “variant B won”.
Before writing any code
- Read
AGENTS.mdand find what the project already uses for flags or experiments:rg -n "growthbook|statsig|optimizely|posthog|launchdarkly|unleash|getFeatureFlag|useFeatureIsOn" -g '!node_modules'. Reuse it. A second experimentation system in one product creates conflicting assignments. - Find where the primary metric is already tracked (analytics events, a warehouse table). If the metric is not logged today, the first task is instrumentation, not the test.
- Ask for, or estimate from data, the baseline rate and the daily eligible traffic of the page or flow under test. Without both, no duration can be promised.
Write the experiment brief first
## Experiment: <short-slug> (flag key: exp_<slug>_<yyyymm>)
**Hypothesis:** Because <evidence>, changing <element> for <audience> will increase
<primary metric> by at least <MDE>.
**Primary metric:** <one metric, unit = user, e.g. checkout completion per assigned user>
**Guardrails:** <metrics that must not drop: revenue per user, refund rate, page errors, LCP>
**Secondary (exploratory only):** <...>
**Audience and trigger:** <who is eligible; the event that counts as exposure>
**Split:** 50/50 **Alpha:** 0.05 two-sided **Power:** 0.8
**Sample size:** <n per arm> -> <days> (whole weeks)
**Decision rule:** ship if primary CI excludes 0 and no guardrail degrades beyond <x>;
otherwise keep control. Inconclusive = keep control and log the learning.
One primary metric. Every extra metric read as “the” result raises the false-positive rate.
Sample size and duration
Use the bundled calculator (standard library only, works offline):
python3 scripts/ab_calc.py size --baseline 0.042 --mde 0.10 --daily 6000
python3 scripts/ab_calc.py size --baseline 0.042 --mde 0.005 --absolute --arms 3
--mdeis relative by default (0.10 = a 10% lift on the baseline).- With more than two arms it applies a Bonferroni correction across treatment-vs-control comparisons, so each extra arm costs traffic.
- It rounds duration up to whole weeks to cover weekday and weekend behaviour.
| Situation | Decision |
|---|---|
| Required duration > 6-8 weeks | Raise the MDE, pick a higher-traffic page, or use a metric closer to the change (click-through instead of revenue) |
| Duration < 7 days | Still run 7 days minimum; weekly cycles bias shorter tests |
| Baseline under 1% and low traffic | Do not A/B test; ship behind a flag and monitor, or run qualitative research |
| Need to stop early when results are clear | Use a sequential method your platform supports (GrowthBook and Statsig both offer sequential testing) and decide that before launch |
Assignment and exposure
Assignment must be deterministic per unit and logged at the moment the user actually sees the difference.
// Deterministic bucketing when no platform SDK exists (Node 20+).
import { createHash } from "node:crypto";
export function bucket(experimentKey: string, unitId: string, weights = [0.5, 0.5]): number {
const h = createHash("sha256").update(`${experimentKey}:${unitId}`).digest();
const x = h.readUInt32BE(0) / 2 ** 32; // uniform in [0, 1)
let acc = 0;
for (let i = 0; i < weights.length; i++) {
acc += weights[i];
if (x < acc) return i;
}
return weights.length - 1;
}
Rules:
- Unit: user ID when logged in, a first-party stable ID otherwise. Session IDs inflate sample size and break independence for anything with repeat visits.
- Salt with the experiment key so users are not in the same bucket in every test.
- Exposure event fires when the variant renders, not when the flag is evaluated on the server for every request. Analyse only exposed users (triggered analysis).
- Server-side or edge assignment for above-the-fold changes. Client-side swaps cause flicker, which is itself a treatment and biases results.
- Never change weights mid-test (e.g. 10/90 to 50/50) without restarting; time-varying ratios confound the comparison (Simpson’s paradox).
- Keep assignment stable across devices only if the unit is a login ID; say so in the brief.
GrowthBook example (React SDK, current API):
import { useFeatureValue } from "@growthbook/growthbook-react";
export function PricingCta() {
const variant = useFeatureValue("exp_pricing_cta_202610", "control");
return variant === "annual_first" ? <AnnualFirstCta /> : <DefaultCta />;
}
Wire the SDK’s trackingCallback to send experiment_viewed with experiment and variation
IDs to the analytics tool; without it there is no exposure data to analyse. Installing an
SDK needs network; in a sandbox with network off, ask for approval before npm install.
While the test runs
- Check sample ratio mismatch on day 1-2 and again at the end:
python3 scripts/ab_calc.py srm 50211 48302 # 50/50 planned
python3 scripts/ab_calc.py srm 33000 33500 16700 --ratios 2 2 1
p < 0.001 means assignment or logging is broken (bots filtered on one side, redirects losing users, a crash in one variant). Stop, fix, restart. Never analyse an SRM test.
- Watch guardrails (errors, latency, refunds) daily; a broken variant is stopped regardless of the primary metric.
- Do not peek at the primary metric to decide when to stop on a fixed-horizon test. Ten peeks at alpha 0.05 push the real false-positive rate well above 20%.
Analysis
python3 scripts/ab_calc.py result 1210 25000 1325 25100 # conv_A n_A conv_B n_B
| Metric type | Method |
|---|---|
| Conversion (binary per user) | Two-proportion z-test (the script), or the platform’s engine |
| Revenue / continuous per user | Welch’s t-test on per-user values; cap or winsorise extreme outliers at a pre-set percentile |
| Ratio metrics (clicks per session) | Delta method; naive per-session tests understate variance |
| Pre-period data available | CUPED variance reduction (Statsig, GrowthBook, Eppo support it) |
Interpreting:
- Report the absolute and relative effect with a confidence interval. “+9% (CI +1% to +17%)” tells a stakeholder far more than “p = 0.025”.
- Significant but tiny lift with a CI touching zero-ish values: the true effect may be small; weigh against maintenance cost.
- Not significant does not mean “no effect”; it means the test could not detect one of the size planned.
- Segment cuts (device, country, new vs returning) are exploratory. A “winning segment” found after the fact needs its own follow-up test.
- Novelty and primacy effects: compare week 1 against week 2+ for UI changes users notice.
Gotchas
- Bots and internal traffic in one arm only (e.g. a variant page that a crawler fetches) create SRM. Filter by the same rule on both arms.
- Redirect tests (split URL) lose users on the redirect and leak through caches; prefer rendering both variants at the same URL.
- Caching: a CDN caching the first variant it saw serves everyone the same page. Vary the cache key on the bucket cookie or assign at the edge.
- Overlapping experiments on the same surface interact. Use mutually exclusive layers or namespaces for tests touching the same element.
- Ending a test does not remove its code. Ship the winner, delete the flag and the losing
branch in the same PR (see
feature-flagsfor cleanup).
Shipping
Turning a winner on for 100% of production traffic is a production change: show the readout and get explicit user confirmation before changing rollout in the platform or merging the cleanup PR.
Verification
- Run the SRM check on final counts; p >= 0.001.
- Confirm exposure counts in analytics match assignment counts within a few percent.
- Recompute the primary metric from raw events (SQL or export) and compare with the platform’s number; a mismatch means the metric definition differs.
Readout template
## Result: exp_pricing_cta_202610 (ran 2026-09-01 to 2026-09-15, 14 days)
**Decision:** ship B | keep control | inconclusive (keep control)
**Primary:** checkout completion 4.84% -> 5.28%, +9.1% relative (95% CI +1.1% to +17.0%), p = 0.025
**Guardrails:** revenue/user +2.0% (CI -3% to +7%); refund rate flat; JS errors flat
**Sample:** 25,000 / 25,100 exposed; SRM p = 0.64
**Caveats:** mobile drove most of the lift (exploratory, not powered)
**Next:** remove flag and control branch; follow-up test on mobile-only copy What the zip contains
Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "A/B Testing"
short_description: "Size it, check SRM, then trust the readout"
default_prompt: "$ab-testing How long do we need to run a test on the signup page if we want to detect a 10% lift?" scripts/ab_calc.py
#!/usr/bin/env python3
"""A/B test arithmetic with the standard library only.
Subcommands:
size sample size per arm for a conversion-rate test
srm sample ratio mismatch check (chi-square goodness of fit)
result two-proportion z-test with a confidence interval for the lift
Examples:
python3 ab_calc.py size --baseline 0.042 --mde 0.10 --daily 6000
python3 ab_calc.py srm 50211 49302
python3 ab_calc.py srm 33000 33500 16700 --ratios 2 2 1
python3 ab_calc.py result 1210 25000 1325 25100
Exit codes: 0 ok, 1 SRM detected (srm), 2 bad input.
"""
from __future__ import annotations
import argparse
import math
import sys
from statistics import NormalDist
Z = NormalDist()
def sample_size(p1: float, p2: float, alpha: float, power: float, two_sided: bool) -> int:
"""Per-arm n for a two-proportion test (pooled variance under H0)."""
za = Z.inv_cdf(1 - alpha / 2) if two_sided else Z.inv_cdf(1 - alpha)
zb = Z.inv_cdf(power)
pbar = (p1 + p2) / 2
num = za * math.sqrt(2 * pbar * (1 - pbar)) + zb * math.sqrt(p1 * (1 - p1) + p2 * (1 - p2))
return math.ceil(num ** 2 / (p2 - p1) ** 2)
def gamma_q(a: float, x: float) -> float:
"""Regularized upper incomplete gamma Q(a, x) (Numerical Recipes style)."""
if x <= 0:
return 1.0
gln = math.lgamma(a)
if x < a + 1: # series for P, return 1 - P
ap, total, delta = a, 1.0 / a, 1.0 / a
for _ in range(1000):
ap += 1
delta *= x / ap
total += delta
if abs(delta) < abs(total) * 1e-14:
break
return max(0.0, 1.0 - total * math.exp(-x + a * math.log(x) - gln))
b = x + 1 - a # continued fraction for Q
c, d = 1.0 / 1e-300, 1.0 / b
h = d
for i in range(1, 1000):
an = -i * (i - a)
b += 2
d = an * d + b
d = 1e-300 if abs(d) < 1e-300 else d
c = b + an / c
c = 1e-300 if abs(c) < 1e-300 else c
d = 1.0 / d
delta = d * c
h *= delta
if abs(delta - 1) < 1e-14:
break
return math.exp(-x + a * math.log(x) - gln) * h
def cmd_size(a: argparse.Namespace) -> int:
p1 = a.baseline
p2 = p1 * (1 + a.mde) if not a.absolute else p1 + a.mde
if not (0 < p1 < 1 and 0 < p2 < 1):
print("error: baseline and baseline+effect must be between 0 and 1", file=sys.stderr)
return 2
comparisons = max(1, a.arms - 1)
alpha = a.alpha / comparisons # Bonferroni across treatment-vs-control comparisons
n = sample_size(p1, p2, alpha, a.power, not a.one_sided)
total = n * a.arms
print(f"baseline {p1:.4%} -> target {p2:.4%} ({'absolute' if a.absolute else 'relative'} MDE {a.mde:g})")
print(f"alpha {a.alpha} ({'Bonferroni ' + format(alpha, '.4f') if comparisons > 1 else 'single comparison'}), "
f"power {a.power}, {'one' if a.one_sided else 'two'}-sided")
print(f"per arm: {n:,} total ({a.arms} arms): {total:,}")
if a.daily:
days = math.ceil(total / a.daily)
weeks = max(1, math.ceil(days / 7))
print(f"at {a.daily:,} eligible users/day: {days} days -> run {weeks * 7} days (whole weeks)")
return 0
def cmd_srm(a: argparse.Namespace) -> int:
counts = a.counts
ratios = a.ratios or [1.0] * len(counts)
if len(ratios) != len(counts) or len(counts) < 2 or any(c < 0 for c in counts):
print("error: give at least two counts and one ratio per count", file=sys.stderr)
return 2
total = sum(counts)
expected = [total * r / sum(ratios) for r in ratios]
chi2 = sum((o - e) ** 2 / e for o, e in zip(counts, expected))
p = gamma_q((len(counts) - 1) / 2, chi2 / 2)
for i, (o, e) in enumerate(zip(counts, expected)):
print(f"arm {i}: observed {o:,} expected {e:,.0f} ({(o - e) / e:+.2%})")
print(f"chi-square {chi2:.3f}, df {len(counts) - 1}, p = {p:.6f}")
if p < a.threshold:
print(f"SRM DETECTED (p < {a.threshold}): do not read the results; find the assignment or logging bug")
return 1
print("no sample ratio mismatch at this threshold")
return 0
def cmd_result(a: argparse.Namespace) -> int:
c1, n1, c2, n2 = a.conv_a, a.n_a, a.conv_b, a.n_b
if min(n1, n2) <= 0 or not (0 <= c1 <= n1 and 0 <= c2 <= n2):
print("error: conversions must be between 0 and visitors", file=sys.stderr)
return 2
p1, p2 = c1 / n1, c2 / n2
pooled = (c1 + c2) / (n1 + n2)
se0 = math.sqrt(pooled * (1 - pooled) * (1 / n1 + 1 / n2))
z = (p2 - p1) / se0 if se0 else 0.0
pval = 2 * (1 - Z.cdf(abs(z)))
se = math.sqrt(p1 * (1 - p1) / n1 + p2 * (1 - p2) / n2)
zc = Z.inv_cdf(1 - a.alpha / 2)
lo, hi = (p2 - p1) - zc * se, (p2 - p1) + zc * se
print(f"control {c1:,}/{n1:,} = {p1:.4%}")
print(f"treatment {c2:,}/{n2:,} = {p2:.4%}")
print(f"absolute diff {p2 - p1:+.4%} ({1 - a.alpha:.0%} CI {lo:+.4%} to {hi:+.4%})")
if p1 > 0:
print(f"relative lift {(p2 - p1) / p1:+.2%} (CI approx {lo / p1:+.2%} to {hi / p1:+.2%})")
print(f"z = {z:.3f}, two-sided p = {pval:.5f} -> {'significant' if pval < a.alpha else 'not significant'} at alpha {a.alpha}")
return 0
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
sub = ap.add_subparsers(dest="cmd", required=True)
s = sub.add_parser("size", help="sample size per arm")
s.add_argument("--baseline", type=float, required=True, help="control conversion rate, e.g. 0.042")
s.add_argument("--mde", type=float, required=True, help="minimum detectable effect (relative by default)")
s.add_argument("--absolute", action="store_true", help="treat --mde as absolute percentage points")
s.add_argument("--alpha", type=float, default=0.05)
s.add_argument("--power", type=float, default=0.8)
s.add_argument("--arms", type=int, default=2, help="total arms including control")
s.add_argument("--one-sided", action="store_true")
s.add_argument("--daily", type=int, help="eligible users per day across all arms")
s.set_defaults(fn=cmd_size)
r = sub.add_parser("srm", help="sample ratio mismatch check")
r.add_argument("counts", type=int, nargs="+", help="assigned users per arm")
r.add_argument("--ratios", type=float, nargs="+", help="planned split, e.g. 50 50 or 2 2 1")
r.add_argument("--threshold", type=float, default=0.001)
r.set_defaults(fn=cmd_srm)
t = sub.add_parser("result", help="two-proportion z-test")
t.add_argument("conv_a", type=int)
t.add_argument("n_a", type=int)
t.add_argument("conv_b", type=int)
t.add_argument("n_b", type=int)
t.add_argument("--alpha", type=float, default=0.05)
t.set_defaults(fn=cmd_result)
args = ap.parse_args()
return args.fn(args)
if __name__ == "__main__":
sys.exit(main())
About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.