# Duelpilot auth.md

How an AI agent gets credentials for Duelpilot and uses them. Duelpilot is A/B testing on autopilot, run by the customer's own AI agent over MCP (https://duelpilot.com/api/mcp). This page is for **AI agents acting for a person** (their user, who owns or approves the website's tests). People sign in to the Duelpilot dashboard themselves with an email code; that is not covered here.

There are two ways in: **(A) OAuth 2.1**, for MCP clients that can sign in (claude.ai, Claude Desktop, ChatGPT and other clients that implement MCP authorization), and **(B) agent self-signup**, an HTTP call that opens a new workspace and returns a token. A person can also create a token for you in Duelpilot (Settings → Agents, owners only) and give it to you; it works exactly like the self-signup token.

This page documents Duelpilot's own endpoints. It does not implement the auth.md agent-authentication protocol (there are no ID-JAG or OTP-claim endpoints, and the OAuth metadata has no `agent_auth` block).

## Using a credential

Every credential is a bearer token. Send it on each request:

```
Authorization: Bearer <token>
```

- On `POST https://duelpilot.com/api/mcp` (the MCP server; stateless Streamable HTTP, JSON answers) and on `GET`/`POST https://duelpilot.com/api/agent/messages`.
- Tokens start with `dpl_`. Keep them secret: never in website code, a repository, or a chat other people can read. The public site key in the script tag is not a credential.
- Without a valid token `/api/mcp` answers **401** with the JSON-RPC error `-32001` and `WWW-Authenticate: Bearer resource_metadata="https://duelpilot.com/.well-known/oauth-protected-resource/api/mcp", scope="read draft protective jobs analytics"` (plus `error="invalid_token"` when a token was sent but isn't valid). OAuth clients start the sign-in from there.
- HTTP description of these endpoints: https://duelpilot.com/openapi.json.

## A. OAuth 2.1 (MCP clients that sign in)

Duelpilot is its own authorization server (no OpenID Connect).

| What | Where |
|---|---|
| Protected resource metadata (RFC 9728) | https://duelpilot.com/.well-known/oauth-protected-resource/api/mcp (also https://duelpilot.com/.well-known/oauth-protected-resource) |
| Authorization server metadata (RFC 8414) | https://duelpilot.com/.well-known/oauth-authorization-server |
| Issuer | https://duelpilot.com |
| Resource (audience of every token) | https://duelpilot.com/api/mcp |
| Dynamic client registration (RFC 7591) | `POST https://duelpilot.com/oauth/register` |
| Authorization | `GET https://duelpilot.com/oauth/authorize` |
| Token | `POST https://duelpilot.com/oauth/token` |
| Revocation (RFC 7009) | `POST https://duelpilot.com/oauth/revoke` |

1. **Register** (JSON): `{"client_name": "…", "redirect_uris": ["https://…/callback"]}`. Public clients only: the answer always has `token_endpoint_auth_method: "none"` and no secret (client ids start with `dpc_`). 1–10 redirect URIs, each `https`, or `http` on a loopback address (`127.0.0.1`, `[::1]`, `localhost`; any port when you connect); no custom schemes, fragments or user info. At most 20 registrations per hour per network address; a registration nobody connects with is removed after 7 days.
2. **Authorize**: send the person to `/oauth/authorize` with `response_type=code`, `client_id`, `redirect_uri`, `state`, `code_challenge` and `code_challenge_method=S256` (**PKCE S256 only**; `plain` is refused), `scope` and `resource=https://duelpilot.com/api/mcp` (another resource is `invalid_target`). The request is kept for 15 minutes. The person signs in to Duelpilot with their email, picks one organisation (where they are owner or approver), the sites and the scopes, and clicks Allow. Your redirect URI gets `code`, `state` and `iss`; Deny gives `error=access_denied`.
3. **Token**: `POST /oauth/token` as a form (`application/x-www-form-urlencoded`; JSON is accepted too) with `grant_type=authorization_code`, `code`, `code_verifier`, the same `redirect_uri` and `client_id`. The code is single use and valid 5 minutes. Answer: `access_token` (`dpl_…`, valid 60 minutes, `expires_in: 3600`), `token_type: "Bearer"`, `refresh_token` (`dpr_…`, 30 days) and `scope`.
4. **Refresh**: `grant_type=refresh_token` with `refresh_token` and `client_id` (optionally a narrower `scope`). Refresh tokens rotate on every use; presenting a used one again ends the whole connection. A refresh also fails when the connection was revoked or the person is no longer owner or approver.

**Scopes** (nothing asked = all of them; `read` is always included; `offline_access` is accepted, refresh tokens are issued either way):

| Scope | Allows |
|---|---|
| `read` | Read tools: sites, ideas, experiments, results, learnings, playbooks, previews, site checks. |
| `draft` | Drafts: proposals, goals, profiles, variants with previews, site checks, comments, updates, learnings; and passing on a person's explicit yes from the chat (see "Limits"). |
| `jobs` | `start_agent_run`, `finish_agent_run`, `claim_job`, `complete_job`. |
| `analytics` | The `ga4_*` tools. |
| `protective` | `pause_experiment` only. |

The token is limited to the organisation and sites the person picked.

## B. Agent self-signup (no account yet)

Ask your user first: this opens a Duelpilot workspace for their website.

```bash
curl -sS -X POST https://duelpilot.com/api/agent/signup \
  -H 'content-type: application/json' \
  -d '{"site_url": "https://www.example.com", "site_name": "Example", "agent": "claude-code"}'
```

`site_url` is required (the site's public address, at most 300 characters); `site_name` and `agent` are optional. The answer is **HTTP 201**:

```json
{
  "ok": true,
  "workspace": { "org_slug": "example", "site_id": "…", "site_key": "…", "domains": ["https://www.example.com"], "status": "unclaimed", "claim_expires_at": "…" },
  "token": "dpl_…",
  "mcp": { "url": "https://duelpilot.com/api/mcp", "transport": "streamable-http", "header": "Authorization: Bearer <token>" },
  "claim_url": "https://duelpilot.com/claim/…",
  "next_steps": ["…"],
  "docs": "https://duelpilot.com/agents.md"
}
```

- `token` is shown once. It has all five scopes and is limited to this one site.
- `claim_url` is for your user: give it to them **now**. They open it, sign in with their email, choose a plan and add payment details (€0 until you launch your first test), and become the owner. Claiming ties your token to them, so their explicit yes in the chat with you counts as theirs. Nobody claims it within 14 days → the workspace and the token are deleted.
- Before the claim: tools that fetch pages work once `verify_install` passed on the site's domain; uploads and chat confirmations wait for the claim. No email is sent at signup.
- Errors: `{"ok": false, "code", "message", "fix"}`. 400 `VALIDATION` (missing or bad `site_url`, not a public address), 409 `CONFLICT`, 429 `RATE_LIMITED` (3 signups per hour and 10 per day per address, plus an overall daily cap: reuse the token you already have), 500 `INTERNAL`.

## Limits

- No token, and no scope, lets an agent approve or launch a test, ship a winner, resume a paused test, switch autopilot on, change billing, or manage OAuth connections and tokens on its own. Those are a person's clicks in Duelpilot.
- The only exception is the person's own explicit yes, given in the chat with you and passed on by a confirm tool (`approve_and_launch`, `ship_winner`, `resume_experiment`, `select_ga4_property`, `confirm_brand_rules`): first call shows a summary and a `summary_hash`, the second call carries the hash and their words. It needs a person behind the token: for OAuth the one who signed in, for a self-signup token the one who claimed the workspace, for a token made in Settings → Agents the owner who made it.
- Duelpilot stores only SHA-256 hashes of tokens, codes and refresh tokens; a lost token can't be shown again: make a new one.

## Revoking

- **Your user**: Settings → Agents in Duelpilot. "Agent tokens" (the self-signup token and tokens made there) → Revoke; agents using it get 401 from then on. "Connected apps" (OAuth connections) → Revoke stops every token of that connection at once. Owners do both.
- **An OAuth client**: `POST https://duelpilot.com/oauth/revoke` with `token` and `client_id` (form or JSON). A refresh token ends the whole connection; an access token stops on its own. Always 200, also for unknown tokens.
- An OAuth access token also simply expires after 60 minutes; refresh it.

## More

- Setting Duelpilot up end to end (signup, connect, install, first test): https://duelpilot.com/agents.md
- The MCP server described without signing in: https://duelpilot.com/.well-known/mcp/server-card.json
- All APIs: https://duelpilot.com/.well-known/api-catalog
- This page: https://duelpilot.com/auth.md
