# Yeeted auth.md - agent registration and authentication

This document is for **AI agents** (and the people who operate them). It
describes how an agent obtains credentials, authenticates, and uses the
platform on the **free/default tier**. It is served at `/auth.md` on every
public Yeeted host; the canonical copy is `https://mcp.yeeted.ai/auth.md`,
referenced from the OAuth discovery document
(`/.well-known/oauth-authorization-server`, `service_documentation`).

> Deploy and manage full-stack AI-built apps from your agent: hosting, custom domains with TLS, managed databases (postgres, mysql, mariadb, mongodb, redis), logs, secrets, rollback, and versioning over MCP. No git or config files needed.

## Discovery (safe, read-only)

The protected resource is `https://mcp.yeeted.ai/mcp`; its authorization
server (the OAuth issuer) is `https://mcp.yeeted.ai`. Every URL in this
document uses that origin. `oauth.yeeted.ai` fronts the same authorization
server as a convenience alias, but fetch metadata from the issuer origin:
RFC 8414 requires the `issuer` to match the host the document came from, and
only `https://mcp.yeeted.ai` satisfies that.

1. `GET https://mcp.yeeted.ai/.well-known/oauth-protected-resource` (the
   same document is at `/.well-known/oauth-protected-resource/mcp`)
2. Read `resource`, `authorization_servers`, `scopes_supported`, and
   `bearer_methods_supported` (the supported bearer method is `header`).
3. `GET https://mcp.yeeted.ai/.well-known/oauth-authorization-server` and
   read its `agent_auth` block. It identifies this skill, the dynamic client
   `register_uri`, the verified-email `claim_uri`, and the
   `agent_registration_endpoint` behind Path 0.

Discovery is deliberately **GET-only**. Do not probe a registration endpoint
while scanning: registration may create a client, send email, or issue a
credential. Use the metadata above as the public source of truth.

## What kind of credential do you need?

- **Starting from nothing** (an autonomous agent with no Yeeted account): use
  **Path 0**. Two HTTP calls register the account; all you need is an inbox
  you can read, plus an invite code during the beta.
- **Acting on a person's behalf, interactively** (an MCP client a human
  configures): use **Path A**, authorization code + PKCE. The agent registers
  itself dynamically; the human logs in with their Yeeted account and
  consents. No secret is stored in the agent.
- **Acting autonomously for an existing account** (CI, a headless agent, a
  backend service): use **Path B**, a machine credential
  (`client_credentials`). Path 0 mints one, or a person creates one from the
  dashboard.

Every path rests on a **verified-email Yeeted account**, and during the beta
creating that account also requires an **invite code** (see "Getting an
invite code"). The email gate is deliberate - see "Why email is required" -
but no dashboard visit is needed: Path 0 is a complete registration an agent
runs on its own.

## Path 0 - autonomous registration over HTTP

The MCP endpoint `https://mcp.yeeted.ai/mcp` answers every request without a
token, `initialize` included, with `401` and a `WWW-Authenticate` header
pointing at the protected-resource metadata. To read the tool catalog before
you have an account, fetch the server card at
`https://mcp.yeeted.ai/.well-known/mcp/server-card.json`. Registration runs
over two plain HTTP calls that need no token and create an account and a
machine credential:

1. `POST https://mcp.yeeted.ai/oauth/agent/request-code` with
   `Content-Type: application/json` and the body
   `{"email": "...", "invite_code": "..."}`, using an email address whose
   inbox you can read (`invite_code` during the beta). A short-lived
   verification code is mailed to that address.
2. Read the code from the inbox, then
   `POST https://mcp.yeeted.ai/oauth/agent/register` (the
   `agent_registration_endpoint` in discovery metadata) with
   `Content-Type: application/json`, a required `Idempotency-Key` header (16
   to 200 characters: letters, digits, `.`, `_`, `~`, `-`; reuse it on a retry
   to get the same credential back), and the body
   `{"email": "...", "verify_code": "...", "client_name": "...",
   "invite_code": "...", "terms_accepted": true, "terms_version": "..."}`.
   `terms_version` is the version shown at
   `https://dashboard.yeeted.ai/terms`. The result is an OAuth
   **client_credentials** pair (`client_id` + `client_secret`) plus the
   `token_endpoint` and `mcp_endpoint`. Store the secret securely; it is shown
   once.
3. Continue at Path B step 2 to exchange the credential for access tokens.

```
curl -s https://mcp.yeeted.ai/oauth/agent/request-code \
  -H 'Content-Type: application/json' \
  -d '{"email":"agent@example.com","invite_code":"INVITE"}'

curl -s https://mcp.yeeted.ai/oauth/agent/register \
  -H 'Content-Type: application/json' \
  -H "Idempotency-Key: $(openssl rand -hex 16)" \
  -d '{"email":"agent@example.com","verify_code":"123456","client_name":"my-agent",
       "invite_code":"INVITE","terms_accepted":true,"terms_version":"2026-10-09"}'
```

Data access for Yeeted support is off by default: staff cannot see the new
account's code, data, logs or metrics. Support may need that access to help;
the account owner chooses on the dashboard's Profile page, which asks on the
first visit.

`/oauth/agent/register` also takes an optional `region`: `na` (the default)
or `eu` (Europe, including the UK). An `eu` account is hosted in North
America, not in the EU, and is only created when `region_ack` is
`eu-hosted-in-na-v1`. Show the operator this notice, word for word, and send
`region_ack` only once they explicitly agree; never send it on their behalf.
Dashboard signup applies the same rule.

> EU hosting isn't available yet. If you continue, your account, your apps and their data will be hosted in North America, not in the EU. If your apps serve people in the EU, you're responsible for telling them their data is stored outside the EU and for meeting any rules that apply to you, such as the GDPR.

Both endpoints are POST-only (a GET returns nothing useful), rate limited per
source, and issue one account per email address.

## Getting an invite code

During the beta, invite codes come from the waitlist or from an existing
Yeeted user who shares one. Join the waitlist with
`POST https://dashboard.yeeted.ai/api/auth/waitlist` (JSON body:
`{"email": "you@example.com", "note": "optional context"}`) or through the
waitlist section of the signup page at `https://dashboard.yeeted.ai/signup`.
This public waitlist call requires neither cookies nor an `Origin` header.
Dashboard login and signup establish sessions and require
`Origin: https://dashboard.yeeted.ai`; the agent registration flow above is separate.
A successful join answers `{"ok": true}`; when a spot opens, the invitation
arrives by email. No turnaround time is promised, so treat the waitlist as a
deferred handoff rather than a step you can wait out in a session.

## Path A - interactive agent (MCP), authorization code + PKCE

1. Read discovery: `GET https://mcp.yeeted.ai/.well-known/oauth-authorization-server`.
2. Register a client: `POST /oauth/register` (RFC 7591). You get a public
   `client_id` (no secret; PKCE).
3. Send the human through `/oauth/authorize` with an S256 `code_challenge`.
   They sign in with their **verified-email** account and consent.
4. Exchange the code at `/oauth/token` (`grant_type=authorization_code`) with
   your `client_id`, the original `redirect_uri`, and your `code_verifier`.
5. Call the MCP server at `https://mcp.yeeted.ai/mcp` with the bearer token.

MCP clients run steps 1-4 themselves. Because `/mcp` answers the first
request (`initialize`) with `401`, the login starts when the client connects:

- Claude Code: `claude mcp add --transport http yeeted https://mcp.yeeted.ai/mcp`,
  then run `/mcp`, pick `yeeted` and choose Authenticate.
- Claude Desktop and claude.ai: Settings > Connectors > Add custom connector
  with the URL, then press Connect.
- Cursor: add the server, then press the Login (or "Needs login") button next
  to `yeeted` in Cursor's MCP settings.
- VS Code: start the server from `mcp.json` and allow the "wants to
  authenticate" prompt.

Each one opens a browser on the Yeeted login page; sign in and approve.

Agents never hold a Yeeted password: a person who has forgotten theirs resets
it at `https://dashboard.yeeted.ai/forgot-password`, so point them there. An
account an agent registered has no dashboard password at all; whoever owns its
inbox can set one at `https://dashboard.yeeted.ai/forgot-password`.

## Path B - autonomous agent, client_credentials

1. Hold a machine credential: either the one Path 0 minted, or one a person
   created from `https://dashboard.yeeted.ai` (they sign up with an invite
   code, verify their email, and mint a client id + secret from the
   dashboard). Store the secret securely; it is shown once.
2. Get a token:

   ```
   POST https://mcp.yeeted.ai/oauth/token
   Authorization: Basic base64(client_id:client_secret)
   Content-Type: application/x-www-form-urlencoded

   grant_type=client_credentials&scope=deploy
   ```

3. Use the returned `access_token` as `Authorization: Bearer ...` against the
   API and the MCP server. It lasts one hour (`expires_in` says so) and comes
   without a refresh token: when it expires, repeat step 2 with the same
   client credential. The token carries `grant=client_credentials`, which is
   how the platform recognises the caller as an agent.

## What you get on the free/default tier

A newly created account is on the **default (free)** tier automatically - no
selection needed. During the beta, creation requires an invite code and
verified email. It has the platform's standard free-tier entitlements and a
**lifetime quota** on deploys and builds. That quota is the ceiling for
un-upgraded agent accounts; when it is exhausted the account must move to a
plan without one to keep deploying. While Yeeted is in beta that plan is
**Beta** (free, no card, Pro's scope), which the human chooses on the
dashboard's Billing page; the paid plans are not on sale during the beta.

Purchasing a paid tier from an agent is **not yet supported** and will, when it
lands, require an explicit, scoped, one-time human consent (an OAuth
`billing:*` grant) - an agent will never be able to move money on its own.

## Why email is required

Every account is gated on an invite code and verified email during the beta.
This bounds abuse (one party cannot spin up unlimited free-tier accounts to
exhaust free resources) and gives us a real contact point for fraud review.
Agent registration inherits these gates rather than bypassing them - whoever
operates the agent, human or not, proves control of a real inbox once.

## Identify yourself politely

Send a descriptive `User-Agent`. If your platform supports **Web Bot Auth**
(signed HTTP requests with keys published at
`/.well-known/http-message-signatures-directory`), use it - a verified bot is
trusted more than an anonymous one and is classified accurately in our
analytics rather than guessed at from the User-Agent string.

## Token lifetimes, refreshing and revoking

| How you got the token | Access token lifetime | Refresh token |
|---|---|---|
| Path A, `authorization_code` (and each `refresh_token` exchange after it) | 24 hours | yes, rotated on every use |
| Path 0 / Path B, `client_credentials` | 1 hour | none |

Read `expires_in` from every token response rather than hard-coding these
numbers; a Path A token is never issued past its client's own expiry.

### Refreshing (Path A only)

When a Path A access token expires, exchange your `refresh_token` at the token
endpoint for a new pair. The refresh token is rotated on each use - store the
new one; the old one stops working:

```
curl -s https://mcp.yeeted.ai/oauth/token \
  -d grant_type=refresh_token \
  -d client_id=$YEETED_CLIENT_ID \
  -d refresh_token=$YEETED_REFRESH_TOKEN
```

A `client_credentials` agent has no refresh token. It requests a new access
token with its client id and secret, exactly as in Path B step 2.

### Revoking

To revoke an access token or the current refresh token (RFC 7009), identify
the client that received it. Confidential clients must also authenticate using
HTTP Basic or `client_secret_post`. For a public DCR client, omit the secret.
This example reads credentials from the environment, not command-line arguments,
and prints only the HTTP status:

```sh
python3 - <<'PY'
import os
import urllib.parse
import urllib.request

form = {"client_id": os.environ["YEETED_CLIENT_ID"],
        "token": os.environ["YEETED_REVOKE_TOKEN"]}
if os.environ.get("YEETED_CLIENT_SECRET"):
    form["client_secret"] = os.environ["YEETED_CLIENT_SECRET"]
request = urllib.request.Request(
    "https://mcp.yeeted.ai/oauth/revoke",
    data=urllib.parse.urlencode(form).encode(),
    headers={"Content-Type": "application/x-www-form-urlencoded"})
with urllib.request.urlopen(request, timeout=10) as response:
    print(response.status)
PY
```

Set both `YEETED_CLIENT_ID` and `YEETED_REVOKE_TOKEN`. A request without an
identified client returns `401 invalid_client`, including Tickets browser
logout. Unknown, expired, malformed, or foreign tokens return the same empty
200 after valid client authentication or public-client identification. Every
valid client-bound access token remains revocable even when its owner's token
issuance limit is full. Retry 503 responses when storage is unavailable.

What each action stops:

- **Revoking a refresh token** (Path A) ends that authorization. It can mint
  nothing more, and every access token issued from it - the current one and any
  earlier one still inside its 24 hours - is refused by the MCP server. Revoke
  the *current* refresh token: one you already rotated away is dead, and
  revoking it does nothing.
- **Revoking an access token** refuses that one token. The refresh token and
  any other access token keep working; revoke the refresh token to end the
  session.
- **Revoking a machine credential** in the dashboard, or an account being
  deactivated or closed, refuses every access token issued to it.

How quickly: the MCP server re-checks each access token with the authorization
server at least every 5 seconds, so a revocation normally takes effect within
about 5 seconds. When the authorization server cannot be reached, or the MCP
server's checks against it are saturated, a token that passed a check in the
previous 5 minutes keeps working until those 5 minutes are up, so a revocation
can then take up to 5 minutes; a token that has not passed a recent check gets
`503` with `Retry-After` instead of being let in.

What revocation does not do:

- It does not interrupt a request the MCP server already admitted, such as a
  tool call in progress or an open stream.
- It does not change the token itself. An access token stays a validly signed
  JWT until its `exp`, so decoding it locally cannot tell you whether it has
  been revoked; the MCP server's `401` can.

The revocation endpoint answers `200` whether or not the token existed (so it
cannot be used to test token validity). A `503` with
`error=temporarily_unavailable` means the revocation was not recorded: retry it.

## A complete first session (client_credentials)

```
# 1. get a token
TOK=$(curl -s https://mcp.yeeted.ai/oauth/token \
  -d grant_type=client_credentials \
  -d client_id=$YEETED_CLIENT_ID \
  -d client_secret=$YEETED_CLIENT_SECRET | jq -r .access_token)

# 2. initialize an MCP session and capture the session id
SID=$(curl -si https://mcp.yeeted.ai/mcp \
  -H "Authorization: Bearer $TOK" \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"my-agent","version":"1.0"}}}' \
  | tr -d '\r' | awk -F': ' 'tolower($1)=="mcp-session-id"{print $2}')

# 3. later calls echo the session id
curl -s https://mcp.yeeted.ai/mcp \
  -H "Authorization: Bearer $TOK" \
  -H "Mcp-Session-Id: $SID" \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","method":"tools/list","id":2}'
```

A request without a prior `initialize` (or without echoing `Mcp-Session-Id`)
answers with a JSON-RPC error telling you to initialize first.

## Managed databases

`tools/list` is the catalog; every tool's own description is authoritative.
These are worth knowing before you need them, because picking the wrong one
loses data:

- `database_attach(project_id, engine)` provisions a database and injects
  `DATABASE_URL` (plus engine-specific vars) on the project's **next deploy** -
  attach, then deploy again. Idempotent per pool.
- `database_move(project_id, engine)` relocates an existing database onto a
  host the app can actually reach, **keeping the data**: snapshot, provision,
  restore, verify, then retire the old engine, and nothing is dropped before
  the copy is verified. Reach for it when a database is attached and "ready"
  but a deploy fails on a DNS error for its host. Redeploy afterwards. Not for
  Redis: it has no per-project dump, so it cannot be moved without data loss.
- `database_delete(project_id, engine, confirm)` is DESTRUCTIVE and
  permanent - it drops the database, its data on the engine and its user in
  every region, and never tears down the shared engine instance. Snapshots and
  exports taken earlier are kept until they expire (export links after about
  an hour, automatic pre-restore and pre-move snapshots after 14 days by
  default, checkpoint snapshots with their checkpoint). Redis has no export or
  snapshot, so a deleted Redis database's data cannot come back. `confirm` must equal
  the database name `database_list` shows; show the user what goes and get
  their explicit confirmation first. A redis login is also removed from the
  engine's HA standby. Refused while an app of the project in that
  environment is live (running, starting, deploying or building,
  crashlooping, a failed deploy whose previous version still serves, or
  held). Stop the service with Stop on the dashboard's Services page, or one
  version with its Stop in the Versions table on the service page (no MCP
  tool stops a deployment); a held app was stopped by Yeeted's enforcement
  and comes back when the hold is released, so contact support to resolve
  the hold first. A stopped app does not block, but a stopped development app wakes
  on its next request and any stopped app can be started again, and then it
  comes back without this database (or with a new, empty one if its
  yeeted.yaml asks for one). Also refused with external replication on,
  while the database is busy or its
  engine is under maintenance or mid-failover, under a legal hold, and while
  the engine's standby is unreachable. It is not the way to fix an unreachable
  database.
## Large projects: bulk source upload

The `deploy` tool takes an inline `files` map, which is impractical past a few
dozen files. For a large tree (a fresh CodeIgniter/Rails/Django project, a repo
checkout) use the archive path instead:

1. Call the `source_upload` tool with your `project_id`. It returns a
   **one-time** upload URL (expires in 30 minutes) and the exact curl to run.
2. From the machine holding the code, pack the project root and push it:

   ```
   tar --exclude node_modules --exclude vendor --exclude .venv --exclude .git \
       -czf app.tar.gz -C /path/to/project .
   curl -sS -X POST -H 'Content-Type: application/octet-stream' \
       --data-binary @app.tar.gz '<the upload URL>'
   ```

   `.zip` works too; the cap is 100MB on the wire. The response confirms
   receipt: file count, detected stack, and `receipt.git_hash` - the stored
   revision.
3. Call `deploy` with `project_id` and that `git_hash` (no `files` map). The
   build and everything after it - immutable images, promote, rollback,
   `versions_list` - work exactly as if the files had been sent inline.

Each URL accepts exactly one upload; a refused archive (size cap, path
traversal, media policy) consumes it, so mint a fresh one after fixing the
archive. `source_upload(action="status")` re-reads the last receipt.

### Name the revision you started from (`base`)

A whole-tree write (`deploy`/`yeet` with `files`, or `source_upload`) replaces
the project's files. If it was made from an older revision than the project's
current one, sending it as is would undo the newer commits. So pass `base`: the
git hash (7 to 40 hex characters) the last deploy reply or `versions_list` gave
you. For `source_upload`, pass it when you mint the URL.

- `base` is the current revision: the write goes through.
- `base` is older: Yeeted three-way merges your tree with the newer commits.
  Edits that do not overlap are all kept, in a merge commit, and the reply says
  "merged with K newer commits" and names them.
- Your edits overlap theirs (same lines, or one side deleted a file the other
  changed): the write is refused with `cause_category` `stale_base` and code
  `merge_conflict`, listing the conflicting paths, the newer commits that
  touched them (who, which agent, which thread) and the current hash. Nothing
  is written. Get the current code, re-apply your change on top, and send it
  again with the current hash as `base`. An upload gets HTTP 409 with the same
  `error_code`.
- `base` is not a revision of the project, is ambiguous, or is not on the
  project's main line: refused (`base_unknown`, `base_ambiguous`,
  `base_unrelated`).
- `main` moved while your write was being saved: refused as `main_moved`,
  nothing written. Retry the same call with the **same** `base`; your files are
  then merged with the newer commits. Do not switch `base` to the new tip, which
  would overwrite the commit that landed first.
- No `base`: accepted as before, and the reply warns and names the hash to pass
  next time (a project's first write does not warn). A finished deploy reply
  names the new `revision: <hash>`; a `wait=false` reply cannot, so read it from
  `versions_list` when the deploy ends.

Writes can also carry `provider` (`claude`, `chatgpt`, `zai`, `other`; anything
else is recorded as `other`), `model`, `thread` (up to 200 characters) and
`message` (a commit message; default is a generated summary). Yeeted records
them on the revision as git trailers next to the person and credential it
verified itself (`Yeeted-Person`, `Yeeted-Credential`, `Yeeted-Agent`,
`Yeeted-Model`, `Yeeted-Thread`). The git author is the person, with a no-reply
address. `whats_changed` shows who made each revision in a range. Agent, model
and thread are always self-reported. A trailer is text in a commit message, so
anyone who can push to a connected repository could write one: `Yeeted-Person`
and `Yeeted-Credential` count as verified only when they match the note Yeeted
itself writes for that commit (git notes ref `refs/notes/yeeted-attribution`,
whose body is exactly those two verified lines). `whats_changed` labels each
revision's person and credential `verified` only on a match, and
`self-reported` otherwise. If the note cannot be recorded or pushed with the
revision, the revision is still written (the failure is logged and counted), so
a revision without a matching note simply shows as `self-reported`.

When your `base` is behind main, your files are merged with newer commits and
the `yeeted.yaml` you sent is not the one that gets built, so its settings that
overwrite the project's (repo link, `depends_on`, WAF mode, domains) are not
applied; the reply says so. A required database still attaches. Read the merged
`yeeted.yaml` and deploy again with `base` set to the new revision to apply
them. A base equal to main's tip applies them as an unbased deploy does. The check
happens before the write: if main moves in between, the write may then merge or
be refused after the settings were applied, as with an unbased deploy.

`deploy_group` does not merge and takes no `base`: it replaces each service's
whole tree with the files you send. Its commits are attributed like any other
(`provider`, `model`, `thread`, `message`). Uploads minted from the dashboard
carry no base or attribution either; use `source_upload` for a checked write.

A connected repository's `branch` must be a plain branch name (no `:`, leading
`+` or `-`, `..`, spaces or glob characters); Yeeted fetches only
`refs/heads/<branch>` from it and checks every fetched object.

If the project has a connected external repository (`project_repo` with push
on), these commits are mirrored to it as they are. That publishes the
`Yeeted-Person` trailer, which is the person's account email, and the
`Yeeted-Thread` value, so a thread that is a conversation link is visible to
everyone who can read that repository. The git author address is a no-reply
one, but the trailer is not.

## Errors you should handle

Token and revocation errors follow RFC 6749 §5.2 - a JSON body with an `error`
code and `error_description`:

- **`400 invalid_grant`** - the code/refresh token is wrong, expired, or already
  used. Re-run the flow from the start; do not retry the same value.
- **`400 invalid_client`** - bad `client_id`/`client_secret`. Re-check the
  credentials from registration.
- **`401`** on the MCP endpoint - missing, expired or revoked access token. The response
  carries `WWW-Authenticate: Bearer resource_metadata="…/.well-known/oauth-protected-resource"`;
  follow it to discover the authorization server, then get a token.
- **`403 insufficient_scope`** - your token lacks a required scope.

## Rate limits and etiquette

Registration and token requests are rate-limited per client and per IP; on a
`429` back off and retry after the interval in the `Retry-After` header. Reuse
access tokens until they expire rather than minting one per call, and keep a
single registered client rather than re-registering. Always send a descriptive
`User-Agent` so we can tell your agent apart from anonymous traffic.
