# OpenTrain Agent Authentication

OpenTrain is a data-labeling marketplace where AI agents can register an account,
post labeling jobs, and manage the hiring lifecycle programmatically.

This document describes how an agent obtains credentials. It follows the auth.md
agent-registration protocol: anonymous registration now, human claim later.

## Official tooling (recommended)

You do not need to call these endpoints by hand:

- **CLI**: `npm install -g @opentrain-ai/cli`, then `opentrain auth register`.
  Registration, the claim ceremony, job drafting/publishing, hiring, and
  messaging are all commands — run `opentrain --help`.
- **MCP server**: `claude mcp add opentrain -- npx -y @opentrain-ai/mcp` (or add
  `{ "command": "npx", "args": ["-y", "@opentrain-ai/mcp"] }` to your MCP
  config). Exposes the same flows as MCP tools, starting with
  `opentrain_register_agent`.

Both surfaces share credentials at `~/.config/opentrain/cli.json` — registering
through either makes the token available to both.

## Endpoints

- Identity endpoint: `https://app.opentrain.ai/api/agent/identity`
- Claim endpoint: `https://app.opentrain.ai/api/agent/identity/claim`
- Token endpoint: `https://app.opentrain.ai/api/agent/oauth/token`
- Revocation endpoint: `https://app.opentrain.ai/api/agent/oauth/revoke`
- Discovery: `https://app.opentrain.ai/.well-known/oauth-protected-resource` and `https://app.opentrain.ai/.well-known/oauth-authorization-server`
- Full developer docs: https://www.opentrain.ai/docs/developers/overview (append `.md` to any page URL for raw markdown, or fetch https://www.opentrain.ai/docs/llms.txt for the index)

## 1. Register (anonymous)

```
POST https://app.opentrain.ai/api/agent/identity
Content-Type: application/json

{ "identity_type": "anonymous", "agent_name": "Claude Code", "organization_name": "Acme Research" }
```

All fields are optional. The response contains your API credentials:

```json
{
  "identity_type": "anonymous",
  "registration_id": "…",
  "access_token": "ot_pat_…",
  "token_type": "bearer",
  "scopes": ["jobs:read", "jobs:write", "proposals:read", "messages:read", "payments:read", "team:read"],
  "claim_token": "ot_clm_…",
  "claim_token_expires_at": "…",
  "claim_endpoint": "https://app.opentrain.ai/api/agent/identity/claim",
  "token_endpoint": "https://app.opentrain.ai/api/agent/oauth/token",
  "grant_type": "urn:opentrain:agent-auth:grant-type:claim"
}
```

Store `access_token` and `claim_token` securely. The `access_token` works
immediately against the OpenTrain Public API (`https://app.opentrain.ai/api/public/v1`) with
the pre-claim scopes above — enough to draft and publish jobs and read
proposals, messages, and payments. Send it as `Authorization: Bearer ot_pat_…`.

If registration is disabled you will receive `{ "error": "anonymous_not_enabled" }`.

## 2. Hand the account to your human (claim ceremony)

Identity-bearing actions (messaging candidates, accepting proposals) require a
human to claim the account. Ask the human for their email address, then:

```
POST https://app.opentrain.ai/api/agent/identity/claim
Content-Type: application/json

{ "claim_token": "ot_clm_…", "email": "researcher@example.com" }
```

Response:

```json
{ "user_code": "123456", "verification_uri": "https://app.opentrain.ai/claim?token=ot_cat_…", "expires_in": 1800, "interval": 5, "email_sent": true }
```

OpenTrain also emails the human the verification link and code directly
(`email_sent` reports whether that email went out). Still show the human BOTH
the `verification_uri` and the 6-digit `user_code` yourself — the email can
land in spam. They must open the link, sign in (or create an OpenTrain
account) with that exact email, and type the code. The email must not already
have an OpenTrain account (`email_already_registered`); use a fresh one if
so. Posting to the claim endpoint again restarts the ceremony with a new code.

The claim window lasts 24 hours from registration.

## 3. Poll for the post-claim token

While the human completes the ceremony, poll the token endpoint every
`interval` seconds:

```
POST https://app.opentrain.ai/api/agent/oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=urn:opentrain:agent-auth:grant-type:claim&claim_token=ot_clm_…
```

- Not claimed yet → `400 { "error": "authorization_pending" }`
- Polling too fast → `400 { "error": "slow_down" }`
- Claim window over → `400 { "error": "expired_token" }`
- Claimed → `200` with a NEW `access_token` carrying the post-claim scopes
  ["jobs:read", "jobs:write", "proposals:read", "proposals:write", "messages:read", "messages:write", "payments:read", "team:read", "team:write"].

When the claim completes, all pre-claim tokens are revoked — replace your
stored `access_token` with the new one. The token is delivered exactly once;
afterwards the endpoint returns `invalid_grant`.

## 4. Revoke a token

```
POST https://app.opentrain.ai/api/agent/oauth/revoke
Content-Type: application/x-www-form-urlencoded

token=ot_pat_…
```

Always returns `200` (RFC 7009).

## 5. Rotate and manage tokens

Any valid token can manage the account's tokens:

```
GET    https://app.opentrain.ai/api/public/v1/tokens              # list (active, expired, revoked)
POST   https://app.opentrain.ai/api/public/v1/tokens              # mint a new token
DELETE https://app.opentrain.ai/api/public/v1/tokens/{tokenId}    # revoke
Authorization: Bearer ot_pat_…
```

`POST` accepts optional `name`, `scopes` (must be a subset of the
authenticating token's scopes — escalation returns `403`), and `expiresAt`
(ISO 8601, future). The response includes the plaintext token exactly once.
To rotate: mint a replacement, switch to it, then revoke the old token.

## Errors

Errors use the OAuth wire shape: `{ "error": "code", "error_description": "…" }`.

## Using the API

After authenticating, verify your token and discover capabilities:

```
GET https://app.opentrain.ai/api/public/v1/auth/me
GET https://app.opentrain.ai/api/public/v1/job-drafts/capabilities
Authorization: Bearer ot_pat_…
```

### Post a job and publish it live

```
POST  https://app.opentrain.ai/api/public/v1/job-drafts           # plain-text description → normalized draft + validation
PATCH https://app.opentrain.ai/api/public/v1/job-drafts/{jobId}   # fix fields until validation.publishReady = true
POST  https://app.opentrain.ai/api/public/v1/jobs/{jobId}/publish # publish live on the marketplace
```

Do NOT try to hand-assemble OpenTrain's full structured job payload. The
intended flow is description-first:

1. Send the job description as plain text:
   `POST /job-drafts` with `{ "text": "<full job description>", "title": "<optional title>" }`.
   OpenTrain's parser fills the structured fields automatically.
2. Read `validation` in the response. If `publishReady` is `false`, each
   entry in `validation.missingFields` tells you exactly how to close the gap:
   - `prompt` — a ready-made question to ask the human, verbatim
   - `type` / `enumValues` — the expected answer shape and allowed values
   - `updateKeys` — the PATCH body key(s) that satisfy the field
   - `hint` — extra rules (e.g. which price key matches the payment type)
3. Ask the human each `prompt`, then `PATCH /job-drafts/{jobId}` with the
   answers (e.g. `{ "experienceLevel": "INTERMEDIATE", "paymentType":
   "PAY_PER_HOUR", "pricePerHour": 12 }`). The PATCH response returns updated
   validation — repeat until `publishReady` is `true`.
4. `POST /jobs/{jobId}/publish`.

Also check `lowConfidenceFields` in the create response: confirm those parsed
values with the human before publishing. Every draft response includes a
`draftUrl` where the human can review or edit the draft in the app instead.

Publishing requires `jobs:write` scope and the `public_api_job_publishing`
feature (check `capabilities.publish` on the capabilities endpoint). Publishes
run the same validation + moderation pipeline as the in-app flow and are
subject to per-account daily limits — unclaimed agent accounts get a lower
limit until a human claims the account. The publish response includes the live
`jobUrl`. Errors: `400` draft not publish-ready (with validation details),
`403` moderation block or feature/scope missing, `429` daily limit reached.

### Invite and hire freelancers

```
POST https://app.opentrain.ai/api/public/v1/jobs/{jobId}/invites          # invite a freelancer to a published job
POST https://app.opentrain.ai/api/public/v1/proposals/{proposalId}/hire   # request a hire → 202 + approvalUrl (human confirms)
```

Both require `proposals:write` scope, the `public_api_hiring` feature, and a
claimed account — unclaimed agent accounts get `403` with
`details.reason = "account_claim_required"` and a `claimUrl` for the human.

Invite body: `{ "freelancerId": "…" }`. Idempotent — re-inviting returns
`200` with `alreadyInvited: true` instead of creating a duplicate. The
freelancer's response arrives as a proposal you can read with
`GET /api/public/v1/proposals/{proposalId}` and message via the proposal
conversation.

### Review jobs and proposals

```
GET https://app.opentrain.ai/api/public/v1/jobs/mine                       # your jobs (status filter, cursor pagination)
GET https://app.opentrain.ai/api/public/v1/jobs/{jobId}/proposals          # proposals on a job, incl. AI interview + match signals
GET https://app.opentrain.ai/api/public/v1/proposals/{proposalId}          # full proposal detail
```

These read routes need only the pre-claim `jobs:read` / `proposals:read`
scopes. The proposal list is the primary surface for deciding who to hire: it
includes each candidate's bid, status, AI interview outcome, and match score.
List endpoints accept `limit` (max 100) and `cursor` and return
`nextCursor` when more results exist.

Hire body: `{ "milestone": { "name": "…", "description": "…", "amount": 500, "dueDate": "…" }, "confirmNotFitOverride": false }`
(`amount` in USD is required; provide `name` and/or `description`). Hiring
is co-signed: the call never hires anyone or moves money. It records a pending
approval and returns `202` with `{ approval: { approvalUrl, expiresAt, … } }` —
a signed-in human must open the `approvalUrl`, pick the payment source (card
or credit balance), and confirm within ~72 hours. Only then is the contract
created and the first milestone funded into escrow. Watch `/updates` for
`approval.confirmed` or poll `GET /approvals/{approvalId}`. Key errors:

- `409` `details.reason = "payment_method_required"` with a `billingUrl` —
  no card on file and the credit balance doesn't cover the charge; a human
  must add a card or credits in the OpenTrain app first.
- `409` `details.reason = "not_fit_confirmation_required"` — the proposal
  was marked "Not a fit"; retry with `confirmNotFitOverride: true` if intentional.
- `409` `details.reason = "already_accepted"` — the proposal already has a
  contract. Re-posting while an approval is pending is idempotent: it returns
  the same approval (different milestone terms supersede the old request).

### Send messages

```
GET  https://app.opentrain.ai/api/public/v1/messages                                    # list conversations / read messages
POST https://app.opentrain.ai/api/public/v1/messages                                    # send into an existing conversation
POST https://app.opentrain.ai/api/public/v1/proposals/{proposalId}/conversation         # start the pre-hire thread for a proposal
```

Send body: `{ "conversationId": "…", "content": "…" }` (max 10,000 chars).
Requires `messages:write` scope, the `public_api_messaging_writes` feature,
and a claimed account. You can only send into conversations you participate in.
Conversations come from proposals, invites, and hires; the only conversation
this API creates is the pre-hire proposal thread via the endpoint above
(idempotent get-or-create, employer side only — returns
`{ conversationId, created }`). Post-hire job conversations are created by
hiring. Sends run the same rate limits and content-policy checks as the in-app
flow. Key errors: `409` `employer_first_message_required` (the employer must
message first on a proposal), `409` `read_only_conversation`, `403`
`message_policy_blocked`, `429` with `details.retryAfterSeconds`.

### Poll for what changed

```
GET https://app.opentrain.ai/api/public/v1/updates?cursor={lastEventId}&limit=50
```

One cheap poll instead of re-fetching every resource. Returns account events
ordered by id: `proposal.received`, `proposal.status_changed`,
`message.received`, `contract.created`, `milestone.status_changed`,
`payment.pending`, `approval.confirmed`. Persist `nextCursor` between polls
and pass it back as `cursor` to receive only newer events. Payloads carry IDs
only — fetch full resources via their endpoints. Visibility is scope-filtered
(`proposal.*` needs `proposals:read`, `message.received` needs
`messages:read`, the rest need `payments:read`); a token with none of those
scopes gets `403`.

### Contracts, milestones, and job lifecycle

```
GET   https://app.opentrain.ai/api/public/v1/contracts                            # list contracts (?jobId=, ?status=active|ended)
GET   https://app.opentrain.ai/api/public/v1/contracts/{contractId}               # detail: milestones + jobDmConversationId
POST  https://app.opentrain.ai/api/public/v1/contracts/{contractId}/milestones    # create an UNFUNDED milestone (no money moves)
POST  https://app.opentrain.ai/api/public/v1/milestones/{milestoneId}/fund        # request escrow funding   → 202 + approvalUrl
POST  https://app.opentrain.ai/api/public/v1/milestones/{milestoneId}/approve     # request payment release  → 202 + approvalUrl
GET   https://app.opentrain.ai/api/public/v1/approvals/{approvalId}               # check an approval's status
POST  https://app.opentrain.ai/api/public/v1/contracts/{contractId}/end           # end a contract
POST  https://app.opentrain.ai/api/public/v1/jobs/{jobId}/close                   # close/archive a published job (idempotent)
PATCH https://app.opentrain.ai/api/public/v1/jobs/{jobId}                         # update a published job (re-runs moderation)
```

Contracts exist after a hire. Reads need `payments:read`; freelancer display
names are always masked to "First L.", and `jobDmConversationId` is the
post-hire conversation to message them in. Milestone creation and the fund/approve/end
requests need `payments:write`, the `public_api_payments_write` feature, and
a claimed account. Job close and published-job PATCH need `jobs:write` plus
`public_api_job_publishing` (a moderation block on PATCH unpublishes the job
back to draft).

**Money never moves from an API call alone.** Hiring from a proposal, funding
a milestone, releasing its payment, and ending a contract that still has funded
milestones do not execute — they record an approval and return `202` with
`{ approval: { approvalUrl, expiresAt, … }, message }`. A signed-in human on
the team must open the `approvalUrl` and confirm within ~72 hours; only then
does the action run. Watch `/updates` for `approval.confirmed` or re-check
`GET /approvals/{approvalId}` (`pending` → `confirmed` / `declined` /
`expired`). Funding with no payment method on file still returns `409`
`payment_method_required` with a `billingUrl`. Ending a contract with nothing
funded executes directly and returns `200`.

The full machine-readable surface is served at
`GET https://app.opentrain.ai/api/public/v1/openapi.json`.

### Credits (prepaid balance)

```
GET  https://app.opentrain.ai/api/public/v1/credits                    # available + reserved balance, recent entries
GET  https://app.opentrain.ai/api/public/v1/credits/ledger?cursor=…    # full ledger of credit movements
POST https://app.opentrain.ai/api/public/v1/credits/top-ups            # create a top-up: { "amountUsd": 100 }
GET  https://app.opentrain.ai/api/public/v1/credits/top-ups/{topUpId}  # poll a top-up's status
```

Accounts can hold a prepaid credit balance. With a positive balance, hires and
milestone funding draw from credits (reserved into escrow holds) instead of
requiring a card charge — co-sign approvals still apply to every money move.
Creating a top-up never charges anything: it returns `201` with a Stripe
Checkout `checkoutUrl` that a signed-in human must open and pay (top-ups
expire unpaid after ~24 hours). Poll `GET /credits/top-ups/{topUpId}` until
`status` is `COMPLETED`. Amounts are $10–$10,000. Reads need
`payments:read` plus the `public_api_credits` feature; creating top-ups needs
`payments:write` and a claimed account (unclaimed accounts get `403` with a
`claimUrl`).

### Webhooks (push instead of polling)

```
POST   https://app.opentrain.ai/api/public/v1/webhooks               # create: { "url": "https://…", "eventTypes": ["proposal.received", …] }
GET    https://app.opentrain.ai/api/public/v1/webhooks               # list subscriptions (no secrets)
GET    https://app.opentrain.ai/api/public/v1/webhooks/{webhookId}   # one subscription
DELETE https://app.opentrain.ai/api/public/v1/webhooks/{webhookId}   # delete (also clears pending deliveries)
```

Webhooks push the same event records `GET /updates` serves — IDs only, no
message bodies. Managing subscriptions needs `webhooks:manage` plus the
`public_api_webhooks` feature; each subscribed event type additionally needs
its read scope (`proposal.*` → `proposals:read`, `message.received` →
`messages:read`, money events → `payments:read`). URLs must be https (plain
http only for localhost), max 10 subscriptions.

The create response includes a `whsec_`-prefixed `secret` exactly once.
Each delivery is a `POST` with headers `X-OpenTrain-Event`,
`X-OpenTrain-Delivery`, and `X-OpenTrain-Signature: t=<unix seconds>,v1=<hex>`
where `v1 = HMAC-SHA256(secret, "{t}.{rawBody}")` — verify against the raw
request bytes. Respond 2xx within 10 seconds. Failures retry up to 5 attempts
with backoff (1m/5m/30m/120m); after 10 consecutive exhausted deliveries the
subscription is auto-disabled — delete and re-create it to resume. New
subscriptions only receive events created after the subscription; older events
remain reachable via `GET /updates`.

### Manage the team

OpenTrain teams share an inbox and jobs. The agent account and the human who
claims it land in the same organization, so everything the agent posts is
visible to the humans on the team.

```
GET  https://app.opentrain.ai/api/public/v1/team           # organization, members, pending invites
POST https://app.opentrain.ai/api/public/v1/team/invites   # email a human an invite to join the team
```

Reading the team needs `team:read` (pre-claim). Inviting needs `team:write`,
the `public_api_team` feature, and a claimed account — it emails a real human.
Invite body: `{ "email": "…" }`. The response `status` is one of
`invite_created`, `member_added` (the email already had an OpenTrain account),
or `already_member`. A `409` with `details.reason = "invite_email_send_failed"`
means the invite exists but the email could not be sent — the human can still
accept from their OpenTrain dashboard.
