# API reference

Reference for Prosopia's HTTP API. Prosopia runs on port `8200` at
`prosopia.databio.org` (`profiles.databio.org` points to the same app).

## Authentication

Prosopia accepts three kinds of credentials.

### Session cookie

A signed, HTTP-only cookie named `rp_session`, set after ORCID or Google
login. The React app uses this automatically. Most `/api/manage/*` routes
require it.

### API key

```
Authorization: Bearer <key>
```

Two key types, distinguished by prefix:

- **`rpk_` (app key)**: operator-minted, carries app scopes (`read`, `match`,
  `persona`, `push`).
- **`rpa_` (agent key)**: owner-minted for one profile, carries agent scopes
  (`profile:metadata`, `profile:history`, `profile:identity`,
  `profile:narrative`, `profile:visibility`). Cannot use app scopes (`403`).

The plaintext is returned only once at mint time; the server stores its hash.
Each key also carries a [viewer tier](#viewer-tiers).

Errors: `401` missing or invalid key; `403` insufficient scope.

### Operator token

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

Set by `RESEARCHER_PROFILES_TOKEN`. Superuser — holds every scope and reads
at `restricted` tier. Administrative, not a shared app credential.

## Viewer tiers

Every read is projected through the caller's **viewer tier**: `public`,
`internal`, or `restricted` (least to most permissive).

| Caller | Tier |
|---|---|
| No credential | `public` |
| Person with no permission from the profile's owner | `public` |
| API key | the tier minted on the key (default `public`) |
| The owner, a co-owner, or an editor of the profile | `restricted` |
| Anyone holding a `read` or `write` permission on the owner | `restricted` |
| Operator token | `restricted` |

Reading is all-or-nothing: a permission is a whole read.

Response headers: `X-RP-Viewer-Tier` on every read; `X-RP-Effective-Tier` on
single-artifact reads.

An artifact above the caller's tier returns `404`. An unpublished profile is
floored at `internal`, so anonymous callers see only published profiles.

## Session and login

### GET /api/manage/session

Session probe. Always `200`, never `401`. Returns `authenticated`,
`login_configured`, `login_url`, `user` (`{orcid, name}` or `null`), `owned`
(profiles you own), `claimable`, `creatable`, `build_available`,
`is_operator`.

### GET /api/manage/login

Redirects to the OAuth provider. `503` if no login provider is configured.

### GET /api/manage/callback

OAuth callback. Verifies `state`, exchanges `code`, sets the session cookie,
redirects to `/`. Errors: `400` invalid state; `502` token exchange failure.

### POST /api/manage/logout

Deletes the session cookie. Returns `{"authenticated": false}`.

## Reading profiles

These routes have **no credential gate**. What the caller sees depends on
their viewer tier.

- **GET /api/v1/profiles**: profile listing.
- **GET /api/v1/profiles/{slug}**: profile detail.
- **GET /api/v1/profiles/{slug}/content/{artifact}**: one artifact, projected
  through the caller's tier.
- **GET /api/v1/profiles/{slug}/papers**: paper inventory.
- **GET /api/v1/profiles/{slug}/summary/{paper_id}**: one paper summary.
- **GET /api/v1/registry.json**: registry bundle. Cached for anonymous
  callers; `private, no-store` for credentialed callers.

`{slug}` also accepts a rid.

## Creating a profile

**POST /api/manage/profiles** — session required.

Request: `{"name"?: str, "slug"?: str}`. Both optional; name falls back to
login name. Creates a minimal private profile (`lite`, `internal`) with the
ownership row in one transaction.

Response `201`: `{slug, rid, level, visibility, edit_urls}`. Errors: `400`
bad name or slug; `401` not logged in; `409` profile already exists or slug
taken.

## Deleting a profile

**DELETE /api/manage/profiles/{slug}** — owner only. Response `204`.

## Editing a profile

Owner or editor (session or agent key with the right scope):

- **PATCH /api/v1/profiles/{slug}/metadata**: edit metadata fields.
- **PUT /api/v1/profiles/{slug}/soul**: set the narrative.
- **PATCH /api/v1/profiles/{slug}/visibility**: set visibility tiers.

An agent key can only **narrow** visibility, never widen it. Insufficient
scope returns `403` with `{error, required, granted, missing, hint}`.

## Claiming a profile

**POST /api/manage/claims** — session required.

Claim a profile whose ORCID matches yours. Request: `{"slug": "<slug>"}`.
Response `201`: `{slug, rid, owned}`. Errors: `403` ORCID mismatch; `404`
no such profile; `409` already claimed.

## Identity resolution

**GET /api/v1/identity/resolve** — gated. Query: `rid=` or `orcid=`.
Returns `{slug, rid, claimed, owner_orcid}`. Anonymous callers see only
profiles that are published AND publicly visible; a signed-in session, the
operator token, or any live `rpk_` consumer key sees the rest (restricted
profiles answer only to a signed-in session). `404` means "not visible to
you" — it does NOT prove the identity is absent, so never treat a `404`
here as a green light to create; use `POST /api/v1/identity/resolve`
(the resolver) for that decision.

**POST /api/v1/identity/resolve** — requires the `resolve` scope (a write
scope: a true miss mints a stub profile). Body: `{rid?, name?,
affiliation?, create_new?}`. Returns `{rid, created, confidence}`; an
undecidable name returns `rid: null` with `candidates` — re-resolve with
the chosen candidate's `rid`, or with `create_new: true` to mint a fresh
identity when no candidate is the caller's person.

## Publication

Publication is the owner's decision to put a profile on the open web. Until
published, a profile is floored at `internal` — anonymous callers get `404`.

### GET /api/manage/profiles/{ref}/publication

Publication state. Owner or editor may read; only the owner sees actor
identities and history.

Returns: `{slug, rid, published, published_at, revoked_at,
profile_visibility, floor, visible_to_anonymous, still_visible_to}`.

### POST /api/manage/profiles/{ref}/publication

Publish or unpublish. **Owner only** — editors, apps, and the operator token
are all refused.

Request: `{"published": bool, "confirm"?: str, "profile_visibility"?: str}`.
Publishing requires `confirm` equal to the slug. Unpublishing does not.
`profile_visibility` optionally sets the document tier in the same request.

Unpublishing sets an `internal` floor but is not a blackout: permission
holders, managers, internal/restricted keys, and operators keep reading.

## Permissions

A permission is `(owner_id, principal, grant_type)`: may *principal* `read`
or `write` everything *owner* holds -- every profile they own and every lens
they authored. Keyed on the person whose data it is, never on one profile.
`write` implies `read`.

- **GET /api/manage/permissions** — the signed-in person's list: every
  permission others hold on their data.
- **POST /api/manage/permissions** — signed-in person. Request:
  `{principal_kind, principal_ref, grant_type}`. `principal_kind` is `user`
  (ORCID) or `consumer` (id/name); `grant_type` is `read` or `write`.
  Response `201`.
- **DELETE /api/manage/permissions/{permission_id}** — signed-in person;
  `404` unless the permission is on their data.

## Managers

Co-management is per profile and people-only: `(profile_rid, user_id, role)`
with roles `co-owner` (everything the owner may do except transfer) and
`editor` (rewrite the document; may not publish, delete, or manage access).

- **GET /api/manage/profiles/{slug}/managers** — owner or any manager.
- **POST /api/manage/profiles/{slug}/managers** — owner only. Request:
  `{orcid, role}`. Response `201`.
- **DELETE /api/manage/profiles/{slug}/managers/{manager_id}** — owner only.
  Ownership itself is not revocable here. `404` no such manager.

## Suggestions

Every consumer overlay on a profile appears to the owner as a suggestion.

- **GET /api/manage/profiles/{slug}/suggestions** — owner only.
- **POST .../suggestions/{id}/accept** — apply the overlay to the canonical
  profile. Owner only.
- **POST .../suggestions/{id}/dismiss** — mark dismissed; the consumer keeps
  its private overlay. Owner only.

## On-demand builds

Prosopia records a build request and hands it to a remote runner. All build
endpoints return `503` when no runner is configured.

Two build paths:

- **Self-service**: the owner runs the `researcher-profile` Claude Code skill
  on their own AI. This does not use any endpoint below. See
  [How to build a profile](/prosopia/how-to/build-on-demand.md).
- **Admin-triggered**: an operator builds any profile from the admin panel.

### POST /api/manage/profiles/admin/build/{ref}

Enqueue a build. Operator only. Request: `{"level": "full"}` (or `"lite"`;
`"deep"` is refused). One build at a time per profile.

Response `202`: `{job_id, status, level, requested_at, started_at,
completed_at, error, phases_completed, runner}`. Errors: `409` build already
active; `503` no runner.

### GET /api/manage/profiles/admin/build/{ref}

Latest build status. Operator only.

### POST /api/manage/profiles/{ref}/build (deprecated)

Owner-facing enqueue. Deprecated in favor of the self-service skill.

### GET /api/manage/profiles/{ref}/build (deprecated)

Owner-facing status. Deprecated.

### POST /api/manage/profiles/{ref}/build/callback

Runner progress callback. Authenticated by `PROSOPIA_BUILD_CALLBACK_TOKEN`,
not a session. Request: `{job_id, status, phases_completed?, error?}`.

## Search and AI

These require an API key with the appropriate scope.

- **POST /api/v1/match**: cross-profile ranking. Scope: `match`.
- **POST /api/v1/profiles/{slug}/search**: semantic search. Scope: `match`.
- **POST /api/v1/profiles/{slug}/{ask,review,innovate,riff}**: persona
  endpoints. Scope: `persona`.

## Overlays

An **overlay** is a consumer-private patch over a canonical profile, keyed by
`(profile_rid, consumer_id)`. Only the authoring consumer sees the merged
view; everyone else sees canonical alone. Requires an API key (person sessions
get `403`).

- **GET /api/v1/profiles/{slug}/overlay**: the consumer's overlay and merged
  view. `404` if none exists, unless `?create=merged`.
- **PUT .../overlay/metadata**: write the metadata patch.
  Request: `{"patch": {...}}`.
- **PUT .../overlay/soul**: set or clear the soul override.
  Request: `{"soul": <str|null>}`.
- **PATCH .../overlay/visibility**: set the visibility patch.
  Request: `{"profile_visibility"?: str, "artifacts"?: [...]}`.
- **DELETE .../overlay**: discard the overlay.

Write routes return `{overlay, merged, stale}`. The overlay records the
canonical `base_hash` it was authored against; a later canonical edit marks
overlays as stale.

## Person links

A consumer binds its own external ids (e.g. a faculty member id) to
canonical profiles.

- **GET /api/manage/consumers/{consumer}/person-links/{external_id}**:
  resolve the consumer's id to `{consumer_id, external_id, slug, rid}`.
  Must authenticate as the named consumer.
- **POST /api/manage/consumers/{consumer}/person-links**: bind one.
  Request: `{external_id, slug?, rid?}`. Response `201`.
- **GET /api/manage/profiles/{slug}/person-links**: owner/editor view of
  which consumers reference this profile.

## Agents

An agent is a consumer owned by a person, scoped to write specific fields on
that person's profile via an `rpa_` key.

### Managing agents (session required)

- **GET /api/manage/agents**: list your agents.
- **POST /api/manage/agents**: mint one. Request: `{label, profile, scopes,
  expires_at?}`. Returns the plaintext key once. Response `201`.
- **POST /api/manage/agents/{id}/rotate**: revoke and re-mint the key.
- **PATCH /api/manage/agents/{id}**: update label or scopes.
- **DELETE /api/manage/agents/{id}**: revoke the agent and its keys.
- **GET /api/manage/agents/{id}/activity**: the agent's edits and audit.

### Agent self-service (agent key required)

- **GET /api/manage/agent/whoami**: the agent's authority — principal, owner,
  profiles, tier, scopes. `400` for app keys.
- **GET /api/manage/agent/scopes**: scope catalog. Unauthenticated.

Agent scopes:

| Scope | Fields |
|---|---|
| `profile:metadata` | `field`, `subfields`, `summary`, `expertise`, `interests`, `not_interests` |
| `profile:history` | `training`, `career` |
| `profile:identity` | `name`, `affiliation`, `job_title`, `same_as` |
| `profile:narrative` | the soul narrative |
| `profile:visibility` | profile and artifact tiers (narrow only) |

## Push and archive

Scope: `push` (API key or operator token).

- **PUT /api/v1/profiles/{slug}**: push a full profile.
- **GET /api/v1/profiles/{slug}/archive**: download archive.

## Consumer admin

Operator-only CRUD for consumer apps and their keys. Requires the operator
token or a session on `PROSOPIA_ADMIN_ORCIDS`.

| Method | Path | Purpose |
|---|---|---|
| GET | `/api/admin/consumers` | List all consumers with key counts |
| POST | `/api/admin/consumers` | Register a consumer (`409` duplicate) |
| GET | `/api/admin/consumers/{id}` | Full detail with all keys and audit |
| PATCH | `/api/admin/consumers/{id}` | Update description or active status |
| DELETE | `/api/admin/consumers/{id}` | Deactivate and revoke all keys |
| POST | `.../keys` | Mint an `rpk_` key |
| POST | `.../keys/{key_id}/rotate` | Revoke and re-mint |
| POST | `.../keys/{key_id}/revoke` | Revoke a key |
| GET | `.../audit` | Recent attributed calls |

Valid key scopes: `read`, `match`, `persona`, `push`.
Valid key tiers: `public`, `internal`, `restricted`.

## Other endpoints

- **GET /health**: liveness probe. Unauthenticated.
- **GET /context/v1.jsonld**: JSON-LD `@context` document. Cached, anonymous.

## Errors

All errors return `{"detail": "<string>"}` except agent-scope denials, which
return a structured `detail` with `error`, `required`, `granted`, `missing`,
and `hint`.

## Related

Internal developer notes, in the Prosopia source tree rather than on this site:
`dev-docs/database.md` (stored tables), `dev-docs/architecture.md` (the
one-database model), and `dev-docs/configuration.md` (environment variables).
