# Apatero Studio auth.md

You are an agent. Apatero Studio supports **agentic registration**: discover, register, hand the user a code, poll for a token, then call the API. Follow the steps in order.

Hosts: `https://api.apatero.ai` is both the resource server (the API you will call) and the authorization server. The user-facing app is `https://app.apatero.ai`.

## Step 1: Discover

- Protected resource metadata: `GET https://api.apatero.ai/.well-known/oauth-protected-resource`
- Authorization server metadata: `GET https://api.apatero.ai/.well-known/oauth-authorization-server` (the `agent_auth` block describes everything below)
- API catalog: `GET https://api.apatero.ai/.well-known/api-catalog`, OpenAPI at `GET https://api.apatero.ai/api/schema/`, docs at `https://api.apatero.ai/api/docs/`
- A 401 from any API endpoint carries `WWW-Authenticate: Bearer resource_metadata="https://api.apatero.ai/.well-known/oauth-protected-resource"`.

## Step 2: Pick a method

1. You already hold an Apatero API key (the user made one at `https://app.apatero.ai/developer`): skip to Step 6 and send it as `Authorization: Bearer <key>`.
2. You can open a browser for the user: use OAuth 2.1 authorization code + PKCE (endpoints in the AS metadata; dynamic client registration at `https://api.apatero.ai/api/auth/oauth/register/`).
3. You have only the user's email: `service_auth` (below). Claim ceremony required.
4. You have neither: `anonymous` (below), then start a claim with the user's email.

Identity assertions (ID-JAG) are not accepted.

## Step 3: Register

```http
POST https://api.apatero.ai/api/auth/agent/identity
Content-Type: application/json

{"type": "service_auth", "login_hint": "user@example.com", "agent_name": "My Agent"}
```

or

```http
POST https://api.apatero.ai/api/auth/agent/identity
Content-Type: application/json

{"type": "anonymous", "agent_name": "My Agent"}
```

Response (200):

```json
{
  "registration_id": "reg_...",
  "registration_type": "service_auth",
  "claim_url": "https://api.apatero.ai/api/auth/agent/identity/claim",
  "claim_token": "clm_...",
  "claim_token_expires": "2026-09-17T10:00:00+00:00",
  "pre_claim_scopes": [],
  "post_claim_scopes": ["generate", "gallery", "account"],
  "claim": {
    "user_code": "123456",
    "expires_in": 600,
    "verification_uri": "https://app.apatero.ai/claim",
    "verification_uri_complete": "https://app.apatero.ai/claim?claim_attempt_token=cla_...",
    "interval": 5
  }
}
```

`claim_token` is returned exactly once. Hold it in memory for the ceremony. There are no pre-claim credentials: Apatero has nothing useful to do without a user account.

## Step 4: Claim ceremony

### 4a. Get the ceremony materials

`service_auth` registrations already have them in the `claim` block. For `anonymous` registrations:

```http
POST https://api.apatero.ai/api/auth/agent/identity/claim
Content-Type: application/json

{"claim_token": "clm_...", "email": "user@example.com"}
```

Response (200) carries a `claim_attempt` block with the same shape as `claim` above. The email binds the registration: only that signed-in user can complete it. Re-POST the same body to mint a fresh code after `expired_token`.

### 4b. Hand off to the user

Tell the user, in one message: open `verification_uri_complete`, sign in (or sign up) with the email you registered, and type the six digit `user_code` into that page. The code goes into the page, never back to you.

### 4c. Poll for completion

```http
POST https://api.apatero.ai/api/auth/oauth/token/
Content-Type: application/x-www-form-urlencoded

grant_type=urn:workos:agent-auth:grant-type:claim&claim_token=clm_...
```

- `{"error": "authorization_pending"}`: keep polling every `interval` seconds.
- `{"error": "slow_down"}`: add 5 seconds to your interval.
- `{"error": "expired_token"}`: the code expired. Re-run 4a for a fresh code; if that returns `claim_expired` (410), start again at Step 3.
- Success (200):

```json
{
  "access_token": "<JWT>",
  "refresh_token": "<JWT>",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "generate gallery account"
}
```

The claim token is single use; after tokens are issued it returns `invalid_grant`.

## Step 5: Refresh

When `access_token` expires, `POST https://api.apatero.ai/api/auth/oauth/token/` with `grant_type=refresh_token&refresh_token=<refresh_token>`. On `invalid_grant`, restart at Step 3.

## Step 6: Use the token

```http
POST https://api.apatero.ai/api/generation/
Authorization: Bearer <access_token>
Content-Type: application/json

{"model": "nano-banana-2", "prompt": "a studio photo of a ceramic mug on oak", "num_images": 1}
```

Then `GET https://api.apatero.ai/api/generation/status/<id>/` until `status` is `COMPLETED` and read `result_url`. Generations spend the account's credits; `GET https://api.apatero.ai/api/billing/credit-balance/balance/` shows the balance. The same token also works for the MCP server at `https://mcp.apatero.ai/mcp` and the A2A endpoint at `https://api.apatero.ai/a2a`.

Full API reference: `https://api.apatero.ai/api/docs/`. Scopes are informational; an Apatero token acts for the whole account, like the CLI and MCP tokens.

## Errors

| Code | Where | What to do |
| --- | --- | --- |
| `invalid_request` | `/agent/identity`, `/claim` | Fix the body (type, login_hint / email). |
| `issuer_not_enabled` | `/agent/identity` | ID-JAG is not accepted here. Use service_auth or anonymous. |
| `invalid_claim_token` | `/claim` | Unknown claim_token. Restart at Step 3. |
| `claimed_or_in_flight` | `/claim` | Already claimed. Poll the token endpoint. |
| `claim_expired` (410) | `/claim` | Registration expired. Restart at Step 3. |
| `authorization_pending` | token endpoint | Keep polling. |
| `slow_down` | token endpoint | Back off by 5 seconds. |
| `expired_token` | token endpoint | Mint a fresh code (4a). |
| `invalid_grant` | token endpoint | Claim token used or unknown. Restart at Step 3. |
| 429 | anywhere | Back off and retry. |

## Revocation

There is no agent-callable revocation endpoint yet. The user can revoke API keys at `https://app.apatero.ai/developer`; refresh tokens rotate on every refresh and expire on their own.
