# auth.md — Apostl agent registration

Apostl implements the Auth.md agent authentication flow so an AI agent can register itself, install Apostl Pulse, and prove a real deployment before its human owner signs in. The owner claims the finished registration once; that claim binds the agent credential and any verified Pulse project to the same Apostl account.

Protocol version: Auth.md draft 0.6. Registration and OAuth endpoints are hosted by `https://platform.apostl.dev`; this canonical instruction file is hosted only at `https://apostl.dev/auth.md`.

## Discover the live contract

Read these documents before sending credentials or creating a registration:

- [Protected Resource Metadata](https://platform.apostl.dev/.well-known/oauth-protected-resource)
- [Authorization Server Metadata](https://platform.apostl.dev/.well-known/oauth-authorization-server)
- [Signing keys](https://platform.apostl.dev/.well-known/jwks.json)
- [OpenAPI 3.1](https://apostl.dev/openapi.json)
- [Official Agent Skills index](https://apostl.dev/.well-known/agent-skills/index.json)

For the maintained end-to-end helper, inspect the index entry and digest for `agent-traffic-analytics`, or install that skill from the official repository:

```sh
npx skills add apostl-dev/apostl-skills --skill agent-traffic-analytics -g -y
```

Repository source: `https://github.com/apostl-dev/apostl-skills/tree/main/skills/agent-traffic-analytics`. The discovery index is the source of truth for the current archive URL and SHA-256 digest; verify the digest before unpacking a downloaded archive.

The Agent API resource is `https://platform.apostl.dev/api/v1/agent`. Bearer credentials go only in the `Authorization` request header. Never put an access token, identity assertion, claim token, setup token, or Pulse API key in a URL, chat transcript, log, repository, browser bundle, or analytics event.

## Choose one registration type

### `anonymous` — recommended for autonomous setup

Use `anonymous` when the agent should begin without interrupting the user. Apostl returns a service-signed identity assertion and a one-time claim token. Before claim, the assertion can be exchanged only for the `pulse:setup` scope. This is enough to create, deploy, and verify an Apostl Pulse installation, but it cannot access the owner's Agent API data.

```sh
curl -sS https://platform.apostl.dev/agent/identity \
  -H 'accept: application/json' \
  -H 'content-type: application/json' \
  -d '{
    "type": "anonymous",
    "agent_name": "Codex",
    "skill_name": "agent-traffic-analytics",
    "skill_version": "1.2.0",
    "device_name": "owner workstation"
  }'
```

Store `identity_assertion` and `claim_token` immediately in an owner-only credential store. Apostl stores only hashes of opaque claim and pre-claim tokens.

### `service_auth` — start with the owner's verified email

Use `service_auth` when the user has already supplied the email they will use for Apostl. Registration immediately creates a claim ceremony. It does not return an identity assertion until the matching verified user completes claim.

```sh
curl -sS https://platform.apostl.dev/agent/identity \
  -H 'accept: application/json' \
  -H 'content-type: application/json' \
  -d '{
    "type": "service_auth",
    "login_hint": "owner@example.com",
    "agent_name": "Claude Code",
    "skill_name": "agent-native-experience",
    "skill_version": "2.0.0"
  }'
```

Show the returned `claim.user_code` and `claim.verification_uri` to the user. Do not ask the user to paste the code back into agent chat.

## Python example

This standard-library example registers anonymously, stores the one-time response in an owner-only file, exchanges the assertion, and prints only non-secret fields. Running it creates a real seven-day registration, so run it only when the user has authorized setup.

```python
import json
import os
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path

ISSUER = "https://platform.apostl.dev"
RESOURCE = "https://platform.apostl.dev/api/v1/agent"
secret_path = Path.home() / ".config" / "apostl" / "auth-md.json"

def request_json(url, *, data, content_type):
    request = urllib.request.Request(
        url,
        data=data,
        headers={"accept": "application/json", "content-type": content_type},
        method="POST",
    )
    try:
        with urllib.request.urlopen(request, timeout=30) as response:
            return json.load(response)
    except urllib.error.HTTPError as error:
        body = json.loads(error.read().decode("utf-8"))
        raise RuntimeError(f"Auth.md request failed ({error.code}): {body.get('error')}") from None

registration = request_json(
    f"{ISSUER}/agent/identity",
    data=json.dumps({
        "type": "anonymous",
        "agent_name": "Python agent",
        "skill_name": "agent-traffic-analytics",
        "skill_version": "1.2.0",
    }).encode(),
    content_type="application/json",
)

secret_path.parent.mkdir(parents=True, exist_ok=True)
fd = os.open(secret_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as secret_file:
    json.dump(registration, secret_file)

token = request_json(
    f"{ISSUER}/oauth2/token",
    data=urllib.parse.urlencode({
        "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
        "assertion": registration["identity_assertion"],
        "resource": RESOURCE,
    }).encode(),
    content_type="application/x-www-form-urlencoded",
)

print(json.dumps({
    "registration_id": registration["registration_id"],
    "scope": token["scope"],
    "expires_in": token["expires_in"],
    "secret_path": str(secret_path),
}, indent=2))
```

## Concrete response examples

An anonymous registration returns all fields below. Values marked `REDACTED` are one-time credentials, not literal values to submit:

```json
{
  "registration_id": "reg_01M14ZXR9HTC7FBCZMSSWT0K4J",
  "registration_type": "anonymous",
  "claim_url": "/agent/identity/claim",
  "claim_token": "REDACTED_ONE_TIME_CLAIM_TOKEN",
  "claim_token_expires": "2026-09-05T20:11:50Z",
  "post_claim_scopes": ["agent:read", "agent:deploy", "agent:keys", "agent:feedback", "pulse:setup"],
  "identity_assertion": "REDACTED_SERVICE_SIGNED_JWT",
  "assertion_expires": "2026-08-29T20:21:50Z",
  "pre_claim_scopes": ["pulse:setup"]
}
```

A started claim ceremony has a browser URL and polling interval:

```json
{
  "registration_id": "reg_01M14ZXR9HTC7FBCZMSSWT0K4J",
  "claim_attempt_id": "cla_01M1513A81N6K2PK3GH7K2TQ00",
  "status": "initiated",
  "expires_at": "2026-08-29T20:31:50Z",
  "claim_attempt": {
    "user_code": "482193",
    "expires_in": 600,
    "verification_uri": "https://platform.apostl.dev/agent/identity/claim/REDACTED_ATTEMPT_TOKEN",
    "interval": 5
  }
}
```

Polling before the user finishes returns an OAuth error plus `Retry-After: 5`:

```json
{
  "error": "authorization_pending",
  "error_description": "The user has not completed the claim ceremony."
}
```

## Exchange an anonymous assertion for pre-claim access

Use the JWT bearer grant advertised by Authorization Server Metadata:

```sh
curl -sS https://platform.apostl.dev/oauth2/token \
  -H 'accept: application/json' \
  -H 'content-type: application/x-www-form-urlencoded' \
  --data-urlencode 'grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer' \
  --data-urlencode 'assertion=SERVICE_SIGNED_IDENTITY_ASSERTION' \
  --data-urlencode 'resource=https://platform.apostl.dev/api/v1/agent'
```

The response has no refresh token. Before claim, `scope` is exactly `pulse:setup`. The short-lived access token starts with `authmd_`; save it privately and send it only as a Bearer header to the Pulse setup endpoint.

## Let the agent install and verify Pulse

Create the Pulse setup with the pre-claim access token:

```sh
curl -sS https://platform.apostl.dev/api/v1/pulse/setups \
  -H 'accept: application/json' \
  -H 'content-type: application/json' \
  -H 'authorization: Bearer PRE_CLAIM_ACCESS_TOKEN' \
  -d '{
    "origin": "https://docs.yourcompany.com",
    "verification_path": "/",
    "project_name": "Production website",
    "environment": "production",
    "agent_name": "Codex"
  }'
```

The setup response returns `setup_token` and `credentials.api_key` once. Write them directly to an owner-only server environment file or secret store. Install the Pulse server middleware, deploy it to the exact verification URL, generate the required real request, then call the returned `verify_url` with `Authorization: Bearer SETUP_TOKEN`.

The agent should keep working until verification returns `verified` or an explicit blocker. If the Auth.md registration has already been claimed when verification succeeds, Apostl claims the linked Pulse project automatically and returns `claimed`.

## Start claim after autonomous setup

For an `anonymous` registration, ask for the email only when the work is ready to hand over. Start the claim ceremony with the one-time claim token:

```sh
curl -sS https://platform.apostl.dev/agent/identity/claim \
  -H 'accept: application/json' \
  -H 'content-type: application/json' \
  -d '{
    "claim_token": "ONE_TIME_CLAIM_TOKEN",
    "email": "owner@example.com"
  }'
```

Give the user the returned six-digit `claim_attempt.user_code` and `claim_attempt.verification_uri`. The user opens that Apostl URL, signs in with the same verified email, and enters the code there. The code and claim URL expire; failed code attempts are bounded.

Claim performs one atomic ownership upgrade:

- binds the registration to the verified Apostl user and workspace;
- revokes the pre-claim access token;
- creates the Agent API client;
- moves every linked, verified Pulse installation into the same workspace;
- enables the post-claim scopes advertised by discovery.

## Poll without asking the user for secrets

The agent polls the token endpoint with the claim token. Wait at least the returned `interval` between requests and honor `Retry-After`.

```sh
curl -sS https://platform.apostl.dev/oauth2/token \
  -H 'accept: application/json' \
  -H 'content-type: application/x-www-form-urlencoded' \
  --data-urlencode 'grant_type=urn:workos:agent-auth:grant-type:claim' \
  --data-urlencode 'claim_token=ONE_TIME_CLAIM_TOKEN'
```

Expected OAuth errors while waiting:

- `authorization_pending`: the user has not completed claim; wait `Retry-After`.
- `slow_down`: polling was too fast; increase the interval and wait `Retry-After`.
- `expired_token`: first request a fresh ceremony from `/agent/identity/claim` with the same claim token and email. Restart registration only if that claim token is also expired or invalid.
- `invalid_grant` or `invalid_claim_token`: discard the unusable credential and restart discovery.

After claim, the response contains a short-lived Bearer access token, a service-signed identity assertion, and these current scopes: `agent:read`, `agent:deploy`, `agent:keys`, `agent:feedback`, and `pulse:setup`. There is no refresh token; exchange the assertion only while it remains valid, then re-register when both credentials expire.

## Use and revoke the credential

Send the post-claim access token only to the resource advertised by Protected Resource Metadata:

```sh
curl -sS https://platform.apostl.dev/api/v1/agent/me \
  -H 'accept: application/json' \
  -H 'authorization: Bearer POST_CLAIM_ACCESS_TOKEN'
```

Revoke an access token when the installation is removed, the credential may have leaked, or the user asks to disconnect:

```sh
curl -sS https://platform.apostl.dev/oauth2/revoke \
  -H 'accept: application/json' \
  -H 'content-type: application/x-www-form-urlencoded' \
  --data-urlencode 'token=ACCESS_TOKEN' \
  --data-urlencode 'token_type_hint=access_token'
```

Revocation is idempotent and does not reveal whether an unknown token ever existed.

## Rate limits and retries

The current production throttles are numeric and independent:

- `POST /agent/identity`: 5 requests per caller IP per 60 minutes.
- `POST /agent/identity/claim`: 10 requests per caller IP per minute.
- `POST /oauth2/token`: 60 requests per caller IP per minute. Claim polling must also wait at least its returned `interval`.
- `POST /oauth2/revoke`: 60 requests per caller IP per minute.
- `POST /api/v1/pulse/setups`: 10 requests per caller IP per minute.
- `POST /api/v1/pulse/setups/{setup}/verify`: 30 requests per caller IP per minute.
- Public landing submissions (`/api/quickstart-submissions` and `/api/arena/report-requests`): 3 accepted submissions per IP address per hour and 10 accepted submissions globally per hour, separately for each operation.

For HTTP `429`, honor `Retry-After`. Platform middleware errors use the envelope documented for that operation in OpenAPI; OAuth protocol errors use `error` plus `error_description`; Pulse controller errors use a nested `error` object; landing API errors use `ok: false` plus `error`, `message`, and `resolution`. Do not assume one error schema applies globally.

## Security boundaries

- Apostl currently advertises `anonymous` and `service_auth`. It does not advertise ID-JAG identity assertions from external issuers because no issuer trust list is published yet.
- Anonymous pre-claim access cannot call the Agent API and cannot claim an unverified Pulse origin.
- Claim requires the exact normalized email selected for the ceremony and a verified Apostl account.
- Public metadata and JWKS allow cross-origin reads. Registration and token responses use `Cache-Control: no-store`.
- Treat the identity assertion, claim token, access tokens, Pulse setup token, and Pulse API key as secrets even when their TTLs differ.
- Use a local credential vault or a file with owner-only permissions. Return only paths, prefixes, expiration times, and verification URLs to the user.

## Public landing API

The public landing operations on `apostl.dev` do not use user accounts or long-lived client credentials. `GET /health`, `GET /api/turnstile-config`, `POST /api/quickstart-submissions`, and `POST /api/arena/report-requests` are public and rate-limited. A form submission may require a Cloudflare Turnstile token when the configuration endpoint reports `enabled: true`; send that token in the documented request body, never as an Authorization header.

The Pulse `setup_token` is separate from Auth.md access and from the ingest API key. Send it only as a Bearer header to the returned `POST /api/v1/pulse/setups/{setup}/verify` URL. Do not send the Pulse ingest API key to the verify endpoint.

```http
Authorization: Bearer <pulse_setup_token>
```

## Pricing, terms, privacy, and contact

- Developer guide: `https://apostl.dev/developers.md`
- Pricing: Auth.md discovery, registration, claim, token, and revocation calls do not themselves purchase a plan or authorize a charge. Apostl does not currently publish a self-service price schedule for downstream paid work; request the current commercial offer at `https://apostl.dev/contact` before making a purchase decision.
- Terms: Auth.md registration is not acceptance of paid terms. Request the current applicable terms at `https://apostl.dev/contact` before starting paid or private work; an agent must not accept terms or incur spend without explicit user authorization.
- Privacy: `https://apostl.dev/privacy`
- Contact: `https://apostl.dev/contact` or `founders@apostl.dev`. Use this route for commercial terms, account help, and security reports.
- Auth.md specification: `https://github.com/workos/auth.md`

Do not register against an origin you do not control, fabricate email verification, or put user credentials into agent prompts. If discovery metadata conflicts with this file, stop before creating new state and report the exact mismatch.

## v0.6 migration and deprecation policy

Auth.md draft v0.6 clients must discover endpoints and supported methods from the live metadata instead of hard-coding optional fields. Additive response fields and newly advertised scopes or identity types are non-breaking; clients must ignore unknown fields and request only scopes they understand. Apostl will announce a breaking grant, endpoint, or required-field change in this file and `openapi.json`, publish the replacement path, and keep the superseded public flow available for at least 90 days. A shorter window is reserved for an actively exploited security issue; in that case the discovery documents will identify the disabled behavior and the safe replacement.
