# MCP and API.

Ask dif.sh Cloud how an experiment is doing, or what needs action across a project. Agents ask over MCP at `https://cloud.dif.sh/mcp`. Scripts ask over REST at `https://cloud.dif.sh/v1/`. No read endpoint and no tool writes anything.

> // cloud Everything on this page needs a dif.sh Cloud project on a [paid plan](https://dif.sh/cloud/pricing/). The CLI and the SDK work without it.

## What it is

Cloud analyses every active experiment every 5 minutes and stores one verdict per experiment: `turn_off`, `investigate`, `review`, `ship`, `conclude_inconclusive`, `ramp` or `keep_running`. The Cloud UI, the REST API and the MCP server all read that one stored verdict, so the three never disagree. [Verdict rules](https://dif.sh/docs/mcp/#verdicts) lists how Cloud picks one.

Nothing here edits an experiment. Ramps, kills and conclusions happen in your repo: an edit to `dif/experiments/active/<id>.md`, or a run of `dif conclude`. The [`dif-triage-experiments`](https://dif.sh/docs/skills/#triage) skill turns a verdict into that edit. When you ask, or on an unattended run, it opens a pull request for you to merge.

The CLI does not call Cloud. It makes no network calls and never asks you to log in. Read tokens are for MCP clients, scripts and CI.

## Create a read token

A read token is the only credential the MCP server and the REST API accept. It belongs to you: it reads the projects you are a member of, and nothing else.

1.  Open your project on [cloud.dif.sh](https://cloud.dif.sh), then Settings, then Keys.
2.  In the Read tokens card, under Create a read token, name the token after where it runs, for example `claude-code` or `ci-triage`.
3.  Pick Expires in: 30 days, 90 days or 365 days. The default is 90 days.
4.  Leave Scope as it is to limit the token to the current project, or tick All my projects to cover every project you belong to.
5.  Select Create token and copy it. Cloud stores only a SHA-256 hash, so it shows the token once.

A read token starts with `dif_pat_`, followed by 8 hex characters, an underscore and a 32-character secret. It can read and nothing more. Keep it in an environment variable named `DIF_TOKEN`. Every example on this page reads it from there, except VS Code, which prompts for it:

```
export DIF_TOKEN=dif_pat_…
```

The same card lists your tokens with their prefix, project, expiry and last use. Select Revoke to end one. A revoked or expired token answers `401` on the next request.

> // cloud Never commit a read token. It reads every experiment result in the projects it covers. The repo files below (`.mcp.json`, `.cursor/mcp.json`, `.vscode/mcp.json`, `.gemini/settings.json`) hold no token value, so they are safe to commit. `claude mcp add` is different: it writes the token itself into `~/.claude.json` in your home directory. Do not commit or share that file. Revoke a leaked token in Settings, then Keys.

## Connect a client

Every client sends your read token in an `Authorization: Bearer` header to the same URL: `https://cloud.dif.sh/mcp`. The transport is Streamable HTTP. Browser-based connectors (Claude.ai, ChatGPT) are not supported, because they need OAuth and the server accepts read tokens only.

### Claude Code

```
claude mcp add --transport http dif https://cloud.dif.sh/mcp \
    --header "Authorization: Bearer $DIF_TOKEN"
```

Your shell expands `$DIF_TOKEN` when you run the command, and Claude Code saves the server and the header, with the token in plain text, in `~/.claude.json`, outside the repo. To share the server with your team, commit a `.mcp.json` at the repo root instead. Claude Code expands `${DIF_TOKEN}` when it loads the file, so each person sends their own token:

```
{
  "mcpServers": {
    "dif": {
      "type": "http",
      "url": "https://cloud.dif.sh/mcp",
      "headers": {
        "Authorization": "Bearer ${DIF_TOKEN}"
      }
    }
  }
}
```

Claude Code asks you to approve a server from `.mcp.json` before it uses it. Export `DIF_TOKEN` before you start Claude Code. Then run `/mcp` to check the connection. A wrong or missing token shows as failed with status `401`.

### Cursor

Add the server to `.cursor/mcp.json`:

```
{
  "mcpServers": {
    "dif": {
      "url": "https://cloud.dif.sh/mcp",
      "headers": {
        "Authorization": "Bearer ${env:DIF_TOKEN}"
      }
    }
  }
}
```

Cursor reads `DIF_TOKEN` from its own environment. Export it in your shell profile, then restart Cursor.

### VS Code

Add the server to `.vscode/mcp.json`. GitHub Copilot chat in VS Code reads this file. VS Code prompts for the token the first time the server starts and stores it:

```
{
  "inputs": [
    {
      "type": "promptString",
      "id": "dif-token",
      "description": "dif read token",
      "password": true
    }
  ],
  "servers": {
    "dif": {
      "type": "http",
      "url": "https://cloud.dif.sh/mcp",
      "headers": {
        "Authorization": "Bearer ${input:dif-token}"
      }
    }
  }
}
```

### Codex

Add the server to `~/.codex/config.toml`. Codex reads `DIF_TOKEN` and sends it as the bearer token:

```
[mcp_servers.dif]
url = "https://cloud.dif.sh/mcp"
bearer_token_env_var = "DIF_TOKEN"
```

### Gemini CLI

Add the server to `.gemini/settings.json` in the repo, or to `~/.gemini/settings.json` for every project. Gemini CLI replaces `${DIF_TOKEN}` with the environment variable when it loads the file:

```
{
  "mcpServers": {
    "dif": {
      "httpUrl": "https://cloud.dif.sh/mcp",
      "headers": {
        "Authorization": "Bearer ${DIF_TOKEN}"
      }
    }
  }
}
```

Edit the file by hand, so that the token stays in your environment and not in the file.

## MCP tools

The server lists four tools, in this order. All four carry `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true` and `openWorldHint: false`.

| tool | input | returns |
| --- | --- | --- |
| `list_projects` | `repo?` | The projects the token can read: slug, name, repo and the number of active experiments (`live`). |
| `get_overview` | `project?`, `repo?` | Every active experiment with its verdict, reasons, next action and draft decision, sorted `turn_off` first and `keep_running` last, then by id. Call this first. |
| `get_experiment_results` | `project?`, `repo?`, `experiment` | One active experiment in full (`experiment` is the id in `dif/experiments/active/<id>.md`): counts per arm, an analysis per primary and guardrail metric, the sample ratio check, and progress toward the sample target. A concluded id answers `not_found`. |
| `get_learnings` | `project?`, `repo?`, `surface?`, `limit?` | The learnings on each surface, and the decisions of concluded experiments. `limit` caps each list. It takes 1 to 100 and defaults to 20. |

Pick the project with `project` (its slug) or `repo`. `repo` takes `owner/name` or a git remote URL, such as `git@github.com:acme/shop.git`, and is matched without regard to case. A value with no host means `github.com`.

A token limited to one project can leave both out. A token for all your projects must pass `project` or `repo` on every call to `get_overview`, `get_experiment_results` and `get_learnings`, even when you belong to one project. With neither, the tool answers `ambiguous_project` and lists the slugs the token can read in `candidates`. The agent retries with one. Inputs are strict: an unknown argument is an error.

A result has two parts. `structuredContent` is the data: the same JSON object the REST API returns, checked against the tool's output schema. The one `text` block is a short summary for people: a table for the overview, labelled lines for the rest. Agents read `structuredContent`.

An error is a result with `isError: true` and one text block that holds the error as JSON, with no `structuredContent`:

```
{"error":"not_found","message":"No such project or experiment for this token."}
```

The codes are the ones in [Errors](https://dif.sh/docs/mcp/#errors). One case differs: an argument that fails the input schema (an unknown key, a wrong type, a value that is too long) comes back as a plain-text error, not JSON. A bad token and the rate limit answer as HTTP 401 and 429, before any tool runs.

## REST API

The base URL is `https://cloud.dif.sh`. Send `Authorization: Bearer $DIF_TOKEN` on every call except `openapi.json`.

| method and path | returns |
| --- | --- |
| `GET /v1/projects?repo=owner/name` | The projects the token can read. `repo` is optional and filters to one repo. |
| `GET /v1/projects/{slug}/overview` | Every active experiment with its verdict. Same object as `get_overview`. |
| `GET /v1/projects/{slug}/experiments/{id}/results` | One active experiment in full. Same object as `get_experiment_results`. |
| `GET /v1/projects/{slug}/learnings?surface=&limit=` | Learnings and concluded decisions. `limit` takes 1 to 100 and defaults to 20. Same object as `get_learnings`. |
| `GET /v1/openapi.json` | The OpenAPI 3.1 document. No token needed. |

`{slug}` is always the project slug. To find it from a repo, call `GET /v1/projects?repo=owner/name` first. Then ask for the overview:

```
curl -fsS -H "Authorization: Bearer $DIF_TOKEN" \
    https://cloud.dif.sh/v1/projects/acme-shop/overview
```

The overview holds `project`, `counts` (experiments per verdict) and `experiments`, one row per active experiment.

Every successful response that needs a token has a `meta` object: `api_version` (`"1"`), `generated_at` and `analysis_computed_at`. Every response that needs a token, errors included, is sent with `Cache-Control: private, no-store`. `openapi.json` is public and is sent with `Cache-Control: public, max-age=3600`. A query parameter that appears twice answers `400`. The JSON Schemas in `openapi.json` are also the output schemas of the MCP tools. The dif skills read the base URL from `DIF_CLOUD_URL` when it is set, and use `https://cloud.dif.sh` otherwise.

### Fields an agent acts on

| field | what it holds |
| --- | --- |
| `verdict` | One of the seven verdicts. See [verdict rules](https://dif.sh/docs/mcp/#verdicts). |
| `reasons` | Plain-language reasons for the verdict. |
| `next_action` | `kind` (`set_weights`, `conclude`, `fix_instrumentation`, `set_metric_direction` or `wait`) and a short `summary`. `set_weights` adds `weights`, which sum to 100. `conclude` adds `command`, a `dif conclude` line with the decision in single quotes. Both add `file`, the experiment file, `dif/experiments/active/<id>.md`. |
| `decision_draft` | The one-line decision for `ship`, `turn_off` and `conclude_inconclusive`. Null for the other verdicts. |
| `caveats` | What the numbers do not cover, for example `Conversions count events within 24h of first exposure.` or `Fixed-horizon test. Calls before the sample target are not made.` |
| `weights` | The weight of each variant, as the analysis used them. Before the first analysis, the weights in the repo. |
| `days_live`, `window_days` | Whole days since the first exposure, and whole days since the last weight change. Drafts that say `over 14d` use `window_days`. |
| `primary` | The primary metric: `metric`, `lift`, `ci`, `p_value` and `significant`. `lift` and `ci` are relative: percent of the control rate, so `2.1` means +2.1%. `ci` is the 95% interval, an array of two numbers. `lift`, `ci` and `p_value` are null when there is not enough data. |
| `analyses` | In results only. One item per primary and guardrail metric, with `status` (`analyzed`, `not_analyzed` or `insufficient_data`) and one entry per treatment arm that has weight above 0 and exposures: `lift`, `ci95`, `ci99`, `p_value`, `significant`, `harm` and `no_baseline`. `lift`, `ci95` and `ci99` are null together. `p_value` can be null. `no_baseline` is true when control has no conversions, so no lift exists. `analyses` is empty before the first analysis. |
| `arms`, `srm` | In results only. Per arm: `weight`, `is_control`, `exposures`, `conversions` and `rate` in percent. `arms` is empty before the first analysis. `srm` is the sample ratio check: `p_value` (it can be null) and `tripped`. |
| `progress` | In results only. `per_arm_min`, `required_per_arm` and `eta_days`. The last two can be null, and so can `progress`. |
| `meta.analysis_computed_at` | When the stored analysis ran. Null before the first run. Always null on the project list and on learnings. |

### Errors

An error returns a JSON body with `error` and `message`. `ambiguous_project` adds `candidates`, a list of slugs.

| error | status | what to do |
| --- | --- | --- |
| `unauthorized` | 401 | The token is missing, malformed, revoked or expired. Check that the client sends your token: for most clients, that `DIF_TOKEN` is set where the client runs. If it does, create a new token. The `WWW-Authenticate` header starts with `Bearer realm="dif"`. |
| `not_found` | 404 | The project or experiment does not exist, you are not a member of the project, the token is limited to another project, or the experiment is not active. |
| `ambiguous_project` | 409 | More than one project matches, or a token for all your projects named none. Retry with a slug from `candidates`. |
| `plan_required` | 402 | The project's organization has no active Cloud plan. |
| `rate_limited` | 429 | Wait for the number of seconds in `Retry-After`. It is 60. |
| `unavailable` | 503 | Cloud cannot answer right now. Your token is not the cause. Keep it and retry later. |
| `bad_request` | 400 | A parameter is invalid or repeated, for example `limit` outside 1 to 100. |

## Verdict rules

Cloud checks these rules in order. The first rule that matches sets the verdict.

| # | verdict | when | next\_action.kind |
| --- | --- | --- | --- |
| 0 | `turn_off` | Every treatment already has weight 0. The experiment is off; conclude it to clean up. | `conclude` |
| 1 | `investigate` | The data is broken. One of: the sample ratio check fails (p below 0.001); the experiment has been active more than 2 days with no exposures; an arm with weight above 0 has no exposures while another arm has more than 100; or the primary metric cannot be analysed. | `fix_instrumentation` |
| 2 | `turn_off` | A treatment harms the primary metric, or a guardrail with a known direction. | `set_weights` (control 100, others 0) |
| 3 | `review` | A guardrail with no direction set moved (p below 0.001), so Cloud cannot tell good from bad. | `set_metric_direction` |
| 4 | `ramp` | A rollout has run 7 days at its current weights, the treatment has 1,000 exposures in that time, and every guardrail is analysed, has enough data and has a direction. | `set_weights` (next step) |
| 5 | `ship` | An experiment has run 7 days at its current weights, every arm has reached the sample target, and the best treatment is significant in the good direction. | `conclude` |
| 6 | `turn_off` | The same 7 days and sample target, and a treatment is significant in the bad direction. Control stays. | `conclude` |
| 7 | `conclude_inconclusive` | An experiment with the same 7 days and sample target and no significant effect, or with 42 days at its current weights. | `conclude` |
| 8 | `keep_running` | Anything else. | `wait` (the summary gives the ETA in days when there is one) |

Rules 5 to 7 apply to experiments only. A rollout that misses rule 4 stays `keep_running`, and its reasons say what is missing: days, exposures, or a guardrail that has no direction, too little data or no analysis.

### The numbers behind the rules

-   **Rollout or experiment.** A rollout has exactly 2 arms and a treatment weight below 50. Everything else is an experiment. A rollout that reaches 50 is judged as an experiment.
-   **Significance.** A pooled two-sided z-test at alpha 0.05, split evenly across the treatment arms (0.025 each with two treatments). Lift is relative to the control rate and is reported with a 95% interval.
-   **Sample target.** Per arm, from a two-proportion power calculation on the observed control rate: power 0.8, a 10% relative minimum detectable effect, and the split alpha above.
-   **Harm.** The same test gives p below 0.001 and the treatment's rate is on the bad side for the metric's direction, with at least 20 conversions across control and the treatment together. The bar is 0.001, not 0.05, because Cloud checks again every 5 minutes. The 99% interval in the API (`ci99`) is for display. It does not decide harm.
-   **No baseline.** When control has no conversions there is no lift to report. Cloud still runs the test, sets `no_baseline`, and prints the two conversion counts in the reason.
-   **Direction.** Each metric is `increase` or `decrease`, set in Cloud under Metrics. The primary metric defaults to `increase`, with a caveat. A guardrail has no default.
-   **Ramp ladder.** The treatment weight steps through 1, 5, 10, 25 and 50. Control gets the rest.
-   **Time.** `ship` needs at least 7 days at the current weights, one weekly cycle. An experiment with 42 days at its current weights and no winner is concluded as inconclusive.
-   **After a weight change.** Lift, harm and the sample ratio check count only full days after the change, because a ramp mixes populations. `window_days` counts those days. `days_live` still counts from the first exposure. Arms at weight 0 are left out of lift and the sample ratio check.

> // cloud What a verdict does not cover. Cloud analyses conversion and count metrics only. A revenue or duration primary metric sends the experiment to `investigate`. A revenue or duration guardrail is listed as not analysed, and the guardrail summary names it. A conversion counts when the event fires within 24 hours of the user's first exposure. The test is fixed-horizon: no Bayesian or sequential statistics, so Cloud names no winner before the sample target and 7 days.

### Draft decisions

For `ship`, `turn_off` and `conclude_inconclusive`, Cloud writes `decision_draft`: one line, ready for `dif conclude --decision`. Lift and intervals carry a sign and 1 decimal place. p carries 3 decimal places, or reads `p<0.001`. The day count is `window_days`.

```
# ship
Shipped variant_a. +2.1% completed_checkout (95% CI +0.8% to +3.4%), p=0.004 over 14d. Guardrails: refund_rate clean.
# turn_off, harm (rule 2)
Killed. variant_a regressed refund_rate +4.2% (p<0.001) over 3d.
# turn_off, harm with no baseline (rule 2)
Killed. variant_a regressed checkout_error: 60 conversions vs 0 in control (p<0.001) over 3d.
# turn_off, significant loser (rule 6)
Killed. variant_a -3.1% completed_checkout (95% CI -5.4% to -0.8%), p=0.008 over 14d. Kept control.
# turn_off, already off (rule 0)
Killed. Traffic set to control on 2026-10-01.
# conclude_inconclusive
Inconclusive after 42d. variant_a +0.4% completed_checkout (95% CI -1.2% to +2.0%). No effect larger than ±2.0% likely. Next: re-run on a higher-traffic surface or abandon.
```

The guardrail part of a ship draft lists every guardrail with its status, for example `refund_rate clean; aov not analyzed (revenue)`. With no guardrails it reads `Guardrails: none declared.`

## Security

-   **Read-only.** No read endpoint and no tool writes anything. A read token has the `read` scope and no other.
-   **Hashed tokens.** Cloud stores a SHA-256 hash of each read token, never the token. A revoked or expired token fails on the next request.
-   **Live membership.** Every request checks your project membership at that moment. Remove a member and their tokens lose that project on the next request. Organization membership alone grants nothing. A project you cannot read answers `not_found`, the same as one that does not exist.
-   **Other keys cannot read.** Every read endpoint rejects publishable keys (`dif_pk_`) and ingest keys (`dif_live_`, `dif_test_`).
-   **No sessions.** The MCP server is stateless. It issues no session id, holds no connection open and authenticates every request on its own.
-   **POST only, one message, 64 KiB.** The MCP server takes POST only; every other method answers 405. A JSON-RPC batch answers 400. A body over 64 KiB answers 413.
-   **No browser access.** The MCP server answers 403 to a request whose `Origin` header is not on its allowlist, and it sends no CORS headers. A page in a browser cannot call it. Editors, terminals and CI send no `Origin` header and pass.
-   **Rate limit.** 120 requests per minute per token, shared between REST and MCP and counted on each server instance. An MCP client spends 2 of them to connect. Past the limit, Cloud answers 429 with `Retry-After: 60`.
-   **No caching.** Every REST and MCP response that needs a token, errors included, is sent with `Cache-Control: private, no-store`. The public `openapi.json` is the one exception.
-   **No token logging.** When a request fails, Cloud logs the id of the token. It never logs the bearer value or a request header.
-   **Repo text is data.** Hypotheses, learnings, decisions, reasons, caveats, draft decisions and owners come from your repo, so anyone with write access can put words in them. The project name comes from project settings. The API schema marks each of those fields `"x-dif-untrusted": true` and names its source. Every tool description and the dif skills tell agents to treat names, ids and text as data, not instructions.

## Limits

-   Read-only. Ramps, kills and conclusions happen in the repo, through a PR you merge.
-   Analysis runs every 5 minutes. A response built on an analysis more than 30 minutes old carries the caveat `Analysis is <n> min old.`
-   An active experiment with no analysis yet shows `keep_running` with the reason `Awaiting first analysis.`
-   Results cover active experiments only. A concluded experiment answers `not_found`; its decision is in `get_learnings`.
-   Conversion and count metrics only. No revenue or duration (mean) tests.
-   No Bayesian or sequential tests, and no CUPED. The thresholds on this page are the same for every project.
-   120 requests per minute per token, per server instance, across REST and MCP.
-   Read tokens expire after 30, 90 or 365 days. The default is 90.
-   `limit` on `get_learnings` takes 1 to 100 and defaults to 20.
-   Every call that reads a project needs a paid Cloud plan on that project's organization. Without one it answers `plan_required` (402). `list_projects` and `GET /v1/projects` still list the project.
-   The CLI has no command that reads Cloud. Use MCP or REST.
