Skip to content

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).

Every route that returns a list answers with the same envelope:

{"results": [...], "pagination": {...}, "note": null}

A few lists add a key beside results (for example collection on a list’s entries, pending on access requests); each route below names it.

Most lists are page-based. Send page (from 0) and page_size; the answer has pagination = {page, page_size, total, total_pages}. Ask for the next page until page + 1 reaches total_pages. Unless a route says otherwise, page_size defaults to 100 and is at most 500.

The paper lists are token-based. Send page_size and, after the first page, page_token; the answer has pagination = {page_size, total, next_page_token}. Pass next_page_token back as page_token until it is null.

A page_size above the route’s maximum is lowered to it, never refused. pagination.page_size is the size actually used, and note says it was lowered. A page_size below 1 or a page below 0 is 422.

Filters are exact matches on the field of the same name in each row. Different filters AND together; a filter you repeat ORs its values (for example ?rid=A&rid=B). A query parameter the route does not know is 400 {"detail": {"error": "invalid_argument", ...}}, so a mistyped filter fails instead of returning everything. A page-based list can shift if the data changes between pages.

To read one profile by ORCID, call GET /api/v1/profiles/{orcid}; do not list and scan.

Prosopia accepts three kinds of credentials.

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

Authorization: Bearer <key>

Two key types, distinguished by prefix:

  • rpk_ (app key): operator-minted, carries app scopes (read, match, persona, push, push_own, resolve, feed). What it reads of a claimed profile is its app’s entry on that profile’s access list (see Parts and access and Access lists).
  • rpa_ (account API key): minted by a signed-in person on the Privacy page (/privacy). It acts for that person’s whole account, not for one profile: it reaches every profile the account owns or holds a role on (itself or through a team), and lists the collections the account can see. Its own parts table caps what it may read and write there: never more than the person may. It holds the read scope, plus push_own when its “Replace whole profiles” switch is on. It never carries operator powers, and cannot use other app scopes (403).

The plaintext is returned only once at mint time; the server stores its hash.

Errors: 401 missing or invalid key; 403 insufficient scope, or no Write on a part the request changes.

Authorization: Bearer <token>

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

Every read is decided per part, per audience. An audience is the Public, an entry on the profile’s access list (a person, a team, or an app), or one API key. A part is a section of the profile or a kind of file:

  • sections: summary, expertise, focus, methods, soul, clinical, site_capabilities, regulatory_experience, contact, background;
  • files: works, paper_summary, paper_abstract, paper_fulltext, grants, cv, interview, web, trials, citations, other_files.

Each audience holds one level per part: none, read, or write (write includes read). The Public and readers only read; an editor or co-owner holds Write on every part. Every part can be written by an app or key holding Write on it:

  • sections, SOUL and the publication list through the edit routes (Editing a profile), field by field;
  • any part, files included, through a push (Push and archive), which lands only if every part it changes is Write.

An app or key may also hold the “Replace whole profiles” switch (a key’s push_own scope; an app that is a co-owner of the profile), which lets a push change everything, what the Public reads included.

CallerReads
No credentialwhat the Public reads (nothing, if unpublished)
Person with no role on the profilewhat the Public reads
The owner, a co-owner, or an editor of the profile (directly or through a team)everything
A reader on the profile’s access list (directly or through a team)its parts, plus what the Public reads
App (rpk_) on a claimed profileits entry on the access list (reader: its parts; editor or co-owner: everything), plus what the Public reads
App (rpk_) on an unclaimed profilethe same: its entry on the access list, which the platform account (the owner) writes for the apps in PLATFORM_OWNER_GRANTS, plus what the Public reads
Account API keywhat its person reads, capped by its own table, plus what the Public reads
Operator tokeneverything

An app or key never reads less than the Public. No app or key can publish, unpublish, delete a profile, or give access, and only one allowed to replace whole profiles can change what the Public reads.

Response headers: X-RP-Viewer-Tier on every read; X-RP-Effective-Tier on single-artifact reads. An app or key that reads some parts but not all is served at public with a copy of the document rewritten for it: the parts it may read are declared public, the rest private. Those declarations describe that caller’s view, not the open web’s.

A part or artifact the caller may not read returns 404. A profile the caller may read nothing of returns 404 too.

Session probe. Always 200, never 401. Returns authenticated, providers (every installed provider, in registry order: orcid before google — each {name, label, login_url}; empty when no provider is configured), user ({orcid, name, login_provider: {name, label}} or null), owned (profiles you own, your teams’ included: [{slug, rid, name, visibility, published, is_self, role, via_team}], role always owner, via_team the {id, name} of the team you hold it through, else null), managed (profiles where you, or a team you are in, are a co-owner or editor, same shape, role is co-owner or editor), self_profile ({slug, rid} of your one profile about yourself, or null), claimable (profiles you may claim right now: [{slug, name, rid_kind, via}]), claim_suggestions (profiles carrying your Google email that need more: [{slug, name, rid_kind, code}]), merge_offers (other profiles that are yours, to merge: [{kind, retired, survivor, proof, target}], kind is merge or convert), claim_requests (your requests to an operator), creatable, create_rid_kind (orcid, local, or null), pending_claim_requests (operators: requests waiting; 0 otherwise), pending_access_requests (access requests waiting for your decision on profiles you own or co-own — the Privacy page’s badge; 0 otherwise), collections (how many collections you can see), build_available, is_operator, account_url (/account, the settings page) and identity_count (how many logins the account has; 0 when signed out). An anonymous probe has the same keys, empty.

Every browser sign-in route lives under /auth/. Every path under it is a provider name.

Redirects to the named provider’s OAuth authorize page. {provider} is one of the names in the session probe’s providers list (orcid, google or github). Several providers can be installed at once — ORCID, Google and GitHub run side by side, and a login with any of them lands on its own account; accounts are never joined automatically by matching email across providers (see the account-link flow for the only way to join two logins). Optional next is a same-origin path to land on afterwards. 404 for any name that is unknown or not installed — there is no separate “not configured” status; an uninstalled provider and a nonexistent one look the same.

OAuth callback. Verifies state against the browser’s cookie, checks the state row’s own provider matches this callback path, exchanges code, sets the session cookie, redirects to next or /. Errors: 400 invalid, expired, already-used, or wrong-provider state; 401 identity token rejected; 502 token exchange failure; 403 the account is suspended.

For a link flow (started by POST /auth/{provider}/link) the callback never sets a session cookie and always answers with a 302 back to next (or /account), with the outcome merged into that path’s query:

  • linked=<provider> — the login is now on your account. Plus absorbed=1 when access that was shared with that ORCID iD before you had an account (a placeholder account) was moved onto yours. Plus merged=1 when the login belonged to another Prosopia account, which was merged into yours; then moved=<kind>:<n> (repeated; kinds profiles, access, collections, teams, feeds, agent_keys, claim_requests, drafts) says what moved, and profiles=merge (you now have two profiles about yourself; /me offers to merge them) or profiles=separate (an operator has to combine them) appears when both accounts had one.
  • link_error=<code>&provider=<provider>, where <code> is one of: merge_conflict_provider (plus conflict=<provider>: the other account also has a different login for that provider; remove it there, then link again), merge_suspended (the other account is suspended), absorb_conflict (the two accounts hold data that cannot be combined; nothing changed), in_use (the other account changed mid-link; try again), session_changed (this browser is no longer signed in as the person who started the link), provider_already_linked, token_rejected, exchange_failed, link_failed or suspended.

Session required. Starts linking {provider} to your signed-in account. Optional ?next= is a same-origin path to return to. Returns {"authorize_url": ...} plus the state cookie; the browser then navigates to the URL. The URL forces a fresh account choice (prompt=select_account for Google, prompt=login for ORCID). A POST, not a GET, so another site cannot start a link with your cookie. Errors: 401 not signed in; 404 unknown or uninstalled provider; 409 your account already has a login for this provider; 429 too many sign-in method changes (PROSOPIA_RL_IDENTITY_CHANGE_PER_USER).

Session required. Your account’s logins and every provider you could link: {"identities": [{provider, label, subject_display, email, created_at, last_login_at}], "providers": [{name, label, available, linked, can_unlink}]}. subject_display is the ORCID iD, or the Google email (the raw Google subject is never sent). providers also lists a linked provider that is no longer installed (available: false). Cache-Control: no-store.

DELETE /api/v1/account/identities/{provider}

Section titled “DELETE /api/v1/account/identities/{provider}”

Session required. Removes one login and answers the same body as the GET. Unlinking ORCID ends ORCID self-claim and any PROSOPIA_ADMIN_ORCIDS operator access; profiles you own stay yours. Errors: 401 not signed in; 404 unknown provider, or none linked; 409 it is your only way to sign in; 429 too many changes.

Unauthenticated. The parts catalog every table is written in: {"sections": [...], "files": [...], "writable": [...]}.

Session required. The directory of active platform apps, the ones a profile’s access list can name, as a page-based list of {id, name, description} rows. People’s own API keys are not listed. What an app may do with a profile is its entry on that profile’s list. Cache-Control: no-store.

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

rp login is a registered public OAuth client, client_id=rp-cli (no secret), on the same OAuth server as the chat-app sign-in. It finds every endpoint in /.well-known/oauth-authorization-server. It signs in one of two ways, and both give it a connection, like a chat app’s: a one-hour access token (an account key, rpa_, with the push_own scope and a parts table that reads everything) and a refresh token (rpr_) that rotates on every use. A refresh token presented again after its 120-second grace window revokes the whole connection, and a connection unused for 90 days lapses. The person sees it on the Privacy page under API keys as “rp CLI on

The OAuth scope is profiles:push: replace whole profiles the person owns or edits, and read everything their account reaches. It implies profiles:read. A connection whose “Replace whole profiles” switch is turned off on the Privacy page holds only profiles:read.

Every endpoint below takes form-encoded bodies (JSON also accepted for the device flow), answers with Cache-Control: no-store, and reports errors as {"error": "<code>", "error_description": "..."} rather than {"detail": ...}. A client_id other than rp-cli gets 401 invalid_client.

rp listens on http://127.0.0.1:<random port>/callback and opens the system browser at GET /oauth/authorize with client_id=rp-cli, response_type=code, redirect_uri, state, code_challenge, code_challenge_method=S256, scope=profiles:push, and label (the machine’s name for the consent page and the Privacy page). The registered redirect URIs are http://127.0.0.1/callback and http://[::1]/callback; any port matches (RFC 8252 section 7.3) and nothing else may differ. Any other return address is shown as an error page and never redirected to. Signed out, the person signs in first. Then a short page asks “Allow rp CLI to edit your profile?” with Allow and Deny. Allow redirects back with code, state and iss; Deny with error=access_denied.

rp then trades the code at POST /oauth/token: grant_type=authorization_code, code, redirect_uri (the same one, port included), client_id=rp-cli, code_verifier. A code works once; a code presented again revokes the connection it made.

For a machine with no browser: rp login --device, and automatically over SSH or with no display.

Start a login. Fields: client_id=rp-cli (required), scope (profiles:push, the default; anything else is invalid_scope), label (shown on the approve page and the Privacy page, e.g. the hostname). Returns 200 with device_code, user_code, verification_uri (/device), verification_uri_complete (the same with ?code=<user_code>), expires_in (600) and interval (5). 400 invalid_request without client_id; 429 with Retry-After when too many requests are pending.

The browser page where a signed-in person approves or denies a request. Signed out, it redirects to login_url and comes back. Without ?code it shows a code-entry form. The POST takes code and action (approve, the default, or deny), form-encoded or JSON; 409 if the request was already answered.

Collect the tokens. Fields: grant_type (urn:ietf:params:oauth:grant-type:device_code), device_code, client_id=rp-cli. Otherwise 400 with error: authorization_pending (wait interval seconds), slow_down (polling too fast), expired_token (unknown, expired or already collected), access_denied (the person pressed Deny, or the approving account is gone), unsupported_grant_type or invalid_request.

A successful trade answers 200 with exactly the RFC 6749 fields: access_token (rpa_...), token_type (Bearer), expires_in (3600), refresh_token (rpr_...) and scope (profiles:read profiles:push). It names no person; rp asks the whoami_endpoint the metadata lists, which is GET /api/v1/manage/whoami (below). Logging in again with the same label revokes that machine’s previous CLI connection.

POST /oauth/token with grant_type=refresh_token, refresh_token, client_id=rp-cli (and optionally the same scope) returns a new pair in the same shape. invalid_grant for an unknown, expired, revoked or replayed token.

POST /oauth/revoke (RFC 7009) takes token (a refresh or access token), client_id, and an optional token_type_hint, and revokes the whole connection the token belongs to when it was issued to that client. It answers 200 with an empty body whatever the token, so it reveals nothing. It works for chat-app connections too and is listed as revocation_endpoint in the metadata. rp logout calls it.

Describe a push credential: an app key (rpk_), or the access token of an rp login connection. rp login calls it to say who signed in, and rp whoami prints it. Requires Authorization: Bearer <key>.

FieldTypeDescription
appstringStable name of the application or connection holding the key
scopeslist[string]Granted app scopes, sorted
ownerobject | null{orcid, name} of the person the key was minted for, or null for an app key with no owning person
profilesarrayProfiles this key may push, each {slug, rid, role} with role owner or editor. Empty when owner is null

401 when the header is missing or malformed, or the key is unknown, revoked or expired.

/mcp is an MCP server (streamable HTTP, stateless, JSON responses) that chat apps add as a connector. It acts as an account API key (rpa_), sent as Authorization: Bearer rpa_... (never in the URL). Without a live key it answers 401 with WWW-Authenticate: Bearer resource_metadata="<origin>/.well-known/oauth-protected-resource", scope="profiles:read profiles:write", plus error="invalid_token" when the key sent is unknown, expired or revoked. A read-only OAuth connection that calls a write tool gets HTTP 403 with WWW-Authenticate: Bearer error="insufficient_scope", scope="profiles:read profiles:write", ... and the tool does not run. That tells the app to ask the person for edit access. Every tool calls the same function the matching route below calls, with the caller the key is, so the key’s parts table, the viewer tiers and the publication floor apply unchanged, and no tool calls a route. See Connect an AI chat.

Tools. Fourteen, listed in this fixed order (reads first, the two interest-paper tools last). Every one maps to a task chat apps do.

ToolChanges data?What it does
get_item(ref, view)noA profile or paper by ref: the trimmed record (about 1K tokens, never over 8 KB), a paper’s summary inline, the version for edits, and parts sizes. view="full" adds untrimmed lists. ref may be a list of up to 20.
list_papers(profile, query, year_min, missing_ids, has_text, page_size, page_token, collection)no{profile, results, pagination, filters_applied, search_mode_used, note}: paper rows (about 100 tokens each) with summary and full-text sizes and each paper’s version. Newest first, paged by passing pagination.next_page_token back as page_token; or, when query is set, ranked by hybrid search as one page with no token. page_size 20, at most 100. collection="seed" lists the interest papers instead, with refs seed:<slug>/<seed_id>.
read_text(ref, section, offset, max_chars)noA paper’s full text, or a profile’s narrative (soul, expertise). Whole up to about 20K tokens, else the first chunk plus next, the exact call that continues.
find_passages(ref, query, k)noThe few passages in one paper or one profile that answer a question. k 3, at most 10.
get_profile_guide(profile, goal)noA script for working with the person: goal="interview" (fill gaps) or "update" (new papers, re-check facts). The same script is the MCP prompt interview_me / update_me. Also returns stage ("trainee", "faculty", or "unknown"), current_rank (a rank or "unknown") and current_rank_confirmed (true when it is the person’s own answer). The interview topics depend on stage: a trainee is asked about their position, lab, projects and skills, and is never asked for papers; with an unknown stage the script asks it first. The interview ends by drafting the summary and soul narrative.
whoami()noThe profiles this connection reaches, as refs, with the role and the parts it may write.
get_feed(profile, since, page, page_size)no{profile, results, pagination, hint}: the person’s own literature feed for a profile, the new papers the nightly run found for them, best score first (work_id, title, year, journal, score, evidence, run_date, feedback). since is an ISO date; page from 0; page_size 50, at least 1 (else invalid_argument), at most 200. Empty with a hint when the person has no subscription to that profile. Takes no scope: it reads the calling person’s own rows. work_id is an OpenAlex work id, not a paper on a Prosopia profile, so get_item cannot read it; look it up at OpenAlex. Usual next call: look the work up at OpenAlex.
create_my_profile(name, slug, current_rank)addsThe person’s own empty, private profile. current_rank (one of assistant_professor, associate_professor, professor, research_faculty, staff_scientist, postdoc, graduate_student, undergraduate, clinician, industry, other) is stored as their own, self-reported answer.
add_paper(profile, paper)addsOne new paper. Refuses an existing paper_id, so it never overwrites.
update_profile(profile, fields, version)changesProfile fields, including the narrative (soul).
update_paper(ref, fields, version)changesFields of one paper.
remove_paper(ref, version)removesOne paper.
add_seed_papers(profile, ids)addsInterest papers (papers that define the person’s interests, not papers they wrote), by DOI or OpenAlex id, up to 50. Needs Write on seed_papers.
remove_seed_paper(profile, seed)removesOne interest paper, by its seed: ref or seed id.

Read tools are marked readOnlyHint. create_my_profile, add_paper and add_seed_papers are marked additive (destructiveHint: false); the four tools that change or remove data are marked destructiveHint: true, so a chat app asks the person first. Every write needs the version from the read it was composed against; a stale one is refused with conflict and the current version.

Refs. Every result carries one canonical ref: profile:<slug> or paper:<slug>/<paper_id>; a curated profile, whose slug is never public, is profile:#<profile_id> and paper:#<profile_id>/<paper_id>. Tools also accept, tried in this order: the prefixed forms (with a slug, a rid or me before the slash, and paper:<slug>/doi:<doi>); me (your own profile); a URL on this site (/manage/<slug>, /p?u=..., /api/v1/profiles/<slug>[/papers/<id>], .../content/sources/papers/<id>.md, .../content/sources/summaries/<id>.summary.md); an ORCID iD or orcid.org URL, or a local: id; a DOI; a profile slug; and finally a bare paper id, looked up among the profiles you reach. More than one match is the error ambiguous_ref with candidates.

Output. Each result is compact JSON in the text block, the same data in structuredContent, and a real outputSchema. read_text puts the raw text in the text block and its paging data in structuredContent. Text from profiles and papers is stripped of control and invisible characters.

Errors are tool results with isError: true and a JSON body {error, message, hint?, ...}, never a protocol error. The error codes: not_found, not_permitted (with missing_parts), insufficient_scope (a read-only OAuth connection, when the HTTP 403 above did not apply), conflict (with current_version), invalid_argument (with valid), invalid_ref, ambiguous_ref (with candidates), unknown_section (with valid), page_token_mismatch, rate_limited (with retry_after_s), unavailable, timeout, internal. Messages and hints are fixed server text.

Limits. Each key may make 300 tool calls a minute (rate_limited beyond that); each call times out after 30 seconds (timeout). Counts are clamped, not refused, and the result says what ran (pagination.page_size, k_applied). Each call writes one JSON log line: tool, key id, argument names, result bytes, latency and error code.

A chat app gets its key through OAuth 2.1 with PKCE. Errors on the two POSTs are {"error", "error_description"}.

Scopes. profiles:read reads everything the account reaches. profiles:write also edits sections, narrative and papers, and implies read. A connection’s scope follows its access table on the Privacy page: it is profiles:read profiles:write whenever the table writes anything, else profiles:read.

  • GET /.well-known/oauth-protected-resource (also .../mcp): RFC 9728 metadata naming this origin as the authorization server, with scopes_supported: profiles:read, profiles:write.

  • GET /.well-known/oauth-authorization-server: RFC 8414 metadata. Grant types authorization_code and refresh_token, S256 only, public clients only (none), scopes_supported as above, and client_id_metadata_document_supported: true.

  • Client ID Metadata Documents. A client may skip registration and use an https URL as its client_id (path required; no query, fragment, userinfo or dot segments; a domain name, not an IP address). Prosopia fetches it at /oauth/authorize: it must answer 200 with a JSON content type, at most 10 KB, with no redirects followed, from public addresses only. A valid document is cached per Cache-Control (at most a day; ten minutes without one). The document’s client_id must equal the URL exactly. It needs client_name and redirect_uris (same rules as registration), must not hold a client secret, and token_endpoint_auth_method is absent or none. Any failure shows an error page and never redirects. Fetches (cache misses) are rate-limited per IP and per host. The document is checked only at sign-in: a connection keeps working if it later changes or disappears.

  • POST /oauth/register: RFC 7591 registration, for clients that do not support metadata documents. JSON client_name and redirect_uris (https, or http to localhost; no fragment). Returns 201 with client_id; no secret. Rate-limited per IP.

  • GET /oauth/authorize: client_id, redirect_uri (exact match), response_type=code, state, code_challenge, code_challenge_method=S256, optionally scope (space-separated; absent means both; an unknown scope sends the app back error=invalid_scope), and optionally resource (RFC 8707; when sent it must be <origin>/mcp, with or without a trailing slash, or the app is sent back error=invalid_target). Signed out, it sends the person to sign in and back. Signed in, a consent page shows the app’s name, the host of its client_id URL (for a metadata-document app), and the host it will return to, with a warning when that is the person’s own computer. It offers Edit my profile (write every profile section and works, read the rest) or Read only; a request for profiles:read alone offers only Read only. Approving redirects to redirect_uri with code and state (a single-use code, ten minutes); denying with error=access_denied.

  • POST /oauth/token: for chat apps, two grant types, both answering {"access_token": "rpa_...", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "rpr_...", "scope": "..."}, where scope is what the connection holds. An optional resource must be <origin>/mcp, else invalid_target. Nothing is fetched here. (Calls with client_id=rp-cli, and the third grant type, the device grant, are rp login’s; see Command-line login.)

    • grant_type=authorization_code with code, redirect_uri, client_id, code_verifier: starts a connection (one Privacy-page row, labelled “<client_name> connector”). invalid_grant for an unknown, used, expired or mismatched code, or a wrong verifier; a code is spent on its first use either way. When the new connection can edit, the same app’s (same client_id’s) older read-only connections for that person are revoked, so approving again for edit access does not leave two rows.
    • grant_type=refresh_token with refresh_token, client_id: a new access key under the same connection, with the parts table and settings the Privacy page holds for it, and a new refresh token. The refresh token rotates: the one just used keeps working for 120 seconds (for overlapping calls), and presenting it after that revokes the whole connection, since a replayed token means a copy leaked. A connection not refreshed for 90 days lapses. invalid_grant for an unknown, lapsed, revoked or replayed token, another app’s token, or a suspended account. An optional scope must equal the connection’s current scope; asking for more or for less is invalid_scope (sign in again for more; change it on the Privacy page for less), and the refresh token is not used up. Rate-limited per IP.

    The access key lasts one hour; the previous one is not cut off early. Every key a connection gets carries the mcp scope: it works only on /mcp, and any other route answers 403 (“this key works only through the MCP connector”). Revoking the connection on the Privacy page ends both tokens; /mcp then answers 401 and the app signs in again. A connection cannot be rotated (POST /api/v1/manage/agents/{id}/rotate answers 400).

These routes have no credential gate. What the caller sees depends on the parts it may read (see Parts and access). A profile the caller may not see is the same 404 as one that does not exist. {slug} also accepts a rid (an ORCID iD or a local: id). Reads addressed by rid are never cached at the edge; use the slug for links you publish.

  • GET /api/v1/profiles: the profiles the caller may see, in slug order, page-based (page_size 100, at most 1000). Each row: url, slug, rid, name, level, affiliation, field, paper_count, summary_count, fulltext_pct, contaminated_count, clinical. Filters: rid (repeatable; an ORCID iD or local: id, else 400), clinical (true/false), affiliation (exact, ignoring case and outer spaces). To read one profile by ORCID, call GET /api/v1/profiles/{orcid}.
  • GET /api/v1/collection.json: the listing as a bundle of cards with absolute base URLs, for a browser client. private, no-store for every caller, because who is in it depends on who asked.
  • GET /api/v1/profiles/{slug}: the profile record. ?view=record (the default) is trimmed to the fields an app commonly needs and stays under 8 KB: long lists (interests, career, training) are cut to their top entries with a total, and there is no file list and no narrative text. ?view=full carries every field untrimmed plus the soul and expertise text. Both carry content_hash (the edit version), parts (whether this caller may fetch the narrative and the paper list, and how big each is: {available, bytes, approx_tokens} or {available: false, reason}), and withheld (the fields this caller may not see, each null in fields). A weak ETag lets If-None-Match answer 304.
  • GET /api/v1/profiles/{slug}/files: the file list (manifest), each entry with its effective_visibility and slot, and withheld (the files this caller may not read).
  • GET /api/v1/profiles/{slug}/content/{artifact}: one artifact, if the caller may read its part. The same artifact also answers at /api/v1/profiles/{slug}/{artifact}, so the document’s relative links resolve from either URL it is served at.
  • GET /api/v1/profiles/{slug}/papers: paper rows, token-based: paper_id, title, year, journal, first author, DOI, OpenAlex id, a short summary, summary and text sizes for this caller, and the paper’s version. page_size 20, at most 100; pass pagination.next_page_token back as page_token. The answer also has filters_applied and note. Filters: year_min, missing_ids (no DOI and no OpenAlex id), has_text. With q, rows are ranked by hybrid search (see Search and AI) and come back as one page with no total and no token (search_mode_used says how they were ranked); a page_token with q is 400. ids=a,b,c reads up to 20 rows by id, as one page. A page_token only works with the filters it was made under (400 page_token_mismatch).
  • GET /api/v1/profiles/{slug}/papers/{paper_id}: one paper: its fields, its summary inline, its sizes, its version, and its full text’s section names when you may read the text. ?view=full untrims authors, topics and the abstract.
  • GET /api/v1/profiles/{slug}/summaries?ids=a,b: up to 20 summaries at once; the rest are listed in not_processed, and each one you cannot have is in unavailable with its reason.
  • GET /api/v1/profiles/{slug}/papers/{paper_id}/text: a paper’s full text, bounded. Whole up to 80,000 characters; above that, the first chunk (cut at a paragraph) with has_more and next_offset. section= reads one heading; offset is always an index into the whole text; max_chars up to 200,000.
  • GET /api/v1/profiles/{slug}/text: the narrative the same way, with section=soul or section=expertise, plus content_hash.
  • POST /api/v1/profiles/{slug}/papers/{paper_id}/passages and POST /api/v1/profiles/{slug}/passages: {query, k}; the passages that best answer it (see Search and AI).

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

Request: {"name"?: str, "slug"?: str, "current_rank"?: str}. All optional; name falls back to login name. current_rank (one of assistant_professor, associate_professor, professor, research_faculty, staff_scientist, postdoc, graduate_student, undergraduate, clinician, industry, other; anything else is a 422) is stored as a self-reported career_stage, which no build or merge replaces. Creates your one self profile, minimal and unpublished (lite, limited), with the ownership row in one transaction. With a linked ORCID iD the rid is that ORCID iD (claimed_via self-create-orcid); without one it is a new local: id (self-create-local). The stored document carries no proof. There is no /api/v1/manage/profiles/local any more.

POST /api/v1/manage/agent/create-profile — account API key (rpa_).

The same create, for a key: same body, rules and response. The key must hold Write on at least one profile section (403 otherwise). The key reaches the new profile at once.

When a profile’s rid is an ORCID iD and the profile’s owner has signed in to Prosopia with that ORCID iD (or linked it), every served copy of the document carries one registry-issued proof:

{"kind": "orcid_login", "issuer": "https://prosopia.databio.org",
"orcid": "0000-0002-1825-0097", "verifiedAt": "2026-09-25T12:00:00+00:00"}

It appears in GET /api/v1/profiles/{slug}/profile.jsonld, /content/profile.jsonld, and fields.proof of GET /api/v1/profiles/{slug}?view=full. Prosopia computes it on every read and never stores it; the archive does not carry it. A copy you push, upload or ingest yourself is dropped. Trust it only when you fetched the document from the issuer origin.

Response 201: {slug, rid, rid_kind, level, visibility, edit_urls}. Errors: 400 bad name or slug; 401 not logged in; 409 you already have a profile, a profile with your ORCID iD exists, or the slug is taken.

DELETE /api/v1/manage/profiles/{slug} — owner or co-owner, in a session. Response 204.

The owner, a co-owner, or an editor, in a session; an app that is an editor or co-owner of the profile; or an API key whose person may edit and whose table holds Write on every part the edit touches:

  • PATCH /api/v1/profiles/{slug}/metadata: edit metadata fields, and the narrative. Each field belongs to a part: summary; expertise; focus (field, subfields, interests, not_interests, research_interests); methods (methodological_commitments); clinical (therapeutic_areas); site_capabilities; regulatory_experience; background (training, career, job_title, affiliation, name, same_as). The field soul replaces the narrative (part soul), in the same write as the other fields. career_stage is part background; sending {"career_stage": {"current_rank": "postdoc"}} records the person’s own current rank. The server stamps source: self_reported, confidence, evidence and as_of itself (whatever the caller sent for those is ignored), and sets independence to match. current_rank: null withdraws the answer (source goes back to inferred). A push (PUT /api/v1/profiles/{slug}, the build runner’s included) never replaces a self-reported stage; it only fills facts the stage leaves empty. Send base_hash (the content_hash you read) to get 409 instead of overwriting someone else’s edit; the 409 body is {"detail": {"error": "conflict", "current": "<hash>", "message"}}.
  • POST /api/v1/profiles/{slug}/works: add one paper (paper_id in the body). An existing paper_id is 409; it never overwrites (part works).
  • PATCH/DELETE /api/v1/profiles/{slug}/works/{paper_id}: correct or remove one paper (part works). Send base_version (the paper’s version from GET .../papers; a query parameter on DELETE) to get 409 with X-RP-Paper-Version if the paper changed. The reply’s version is the paper’s new version.
  • PATCH /api/v1/profiles/{slug}/visibility: set visibility tiers. People only: no app or key may change it (403 not_delegable). Owners normally set what the Public reads through public-access instead.

An app or key missing Write on a part gets 403 with {error: "insufficient_access", required, missing, hint}.

Interest papers (“seed papers”) are papers that define what the person wants to work on. They are never publications: they never appear in the works list, they are private by default (part seed_papers), and they tune the feed and matching. Feed upvotes add to them.

Owner, co-owner or editor, in a session (a stranger gets 404, signed out 401):

  • GET /api/v1/manage/seeds/{slug}: a page-based list of {seed_id, title, year, journal, doi, openalex_id, added_via, added_at} rows. seed_id is the bare OpenAlex id (W…) or doi:<doi>.
  • POST /api/v1/manage/seeds/{slug}: body {"ids": "<pasted text>"}, DOIs, OpenAlex ids or links separated by spaces, commas, semicolons or lines; at most 50 per request (400 past that). Each is looked up on OpenAlex. The reply is {added: [{seed_id, title}], already_seed, already_authored, not_found, unrecognized, over_cap}. A work already among the person’s publications is never also added as a seed. An OpenAlex budget or key failure answers like the Feed tab’s (503 / 502).
  • POST /api/v1/manage/seeds/{slug}/bibtex: a multipart .bib upload (field file, at most 1 MB); entries are added by DOI and the reply adds no_doi, the citation keys of entries without one.
  • DELETE /api/v1/manage/seeds/{slug}/{seed_id}: remove one.

rp-sdk also serves GET /api/v1/profiles/{slug}/seed-papers (tier-projected: 404 unless the caller may read the part; page-based, page_size 100, at most 500) and POST/DELETE /api/v1/profiles/{slug}/seed-papers[/{seed_id}] with already-resolved records, for an app or key holding Write on seed_papers.

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

Claim an unowned profile proven to be yours: its rid is your linked ORCID iD (orcid-match); or it is a local: profile whose stored email equals a fresh (24 hours), Google-managed email of your Google sign-in, on a profile never released and carrying an email no other profile has (google-email-match); or an operator approved your request and you have no other profile. Request: {"slug": "<slug>"}. Response 201: {slug, rid, via, owned}. Errors: 401 not signed in; 403 nothing proves it yet, with one sentence naming the way forward (link ORCID, sign in with Google again, or ask for review); 404 no such profile, or a hidden one nothing ties to you; 409 it already has an owner, or you already have a profile (merge instead); 429 too many claims (PROSOPIA_RL_CLAIM_PER_USER).

Session required. Ask an operator to review a claim the server cannot prove.

  • POST /api/v1/manage/claim-requests: {"slug", "message"?} (message up to 2000 characters). 201 with {id, slug, status, message, created_at, decided_at, decision_note, awaiting_merge}. 404 a hidden profile nothing ties to you; 409 already owned, instantly claimable, a duplicate, or 3 requests already waiting; 429 (PROSOPIA_RL_CLAIM_REQUEST_PER_USER).
  • GET /api/v1/manage/claim-requests: your requests, newest first, page-based.
  • DELETE /api/v1/manage/claim-requests/{id}: withdraw a pending one. 404 for an unknown id or someone else’s; 409 already decided.

Session required. Every account has one self profile; a second profile that is also yours is merged into it (the targets are the probe’s merge_offers).

  • POST /api/v1/manage/merges/preview: {"target": <slug or "orcid">, "survivor"?: "mine", "soul_choice"?: "survivor"|"retired"|"both"}. 200 with the survivor and retired profiles, fields (value and where it came from), counts and accounts (what moves, what was dropped on a clash), cannot_come_along, soul_choice_required, publish_question, blocked_by_build and preview_hash.
  • POST /api/v1/manage/merges: the same inputs plus preview_hash and publish. 201 with {survivor: {slug, rid}, retired_rid, counts}. 409 something changed (review again) or a build is running; 400 a SOUL choice is needed; 403/404 the target is not one of your merge offers. A merge cannot be undone. The retired rid and slug keep resolving to the survivor.

GET /api/v1/identity/resolve — gated. Query: rid= or orcid=. Returns {slug, rid, claimed}, plus owner_orcid for the owner, co-owners and editors. Who sees what follows the read plane:

  • A public profile (published AND declared public) answers anyone.
  • A hidden profile (unpublished, unclaimed, or a limited stub) answers its owner, co-owners and editors; a person, app or key that reads every part of it (a reader with every part, or a key capped by its table); the operator token; and an rpk_ key whose app the access list lets read any part (on an unclaimed profile, the platform account’s sharing). A signed-in stranger sees only what anonymous sees.
  • A private profile answers only a signed-in person who reads it whole: its owner, co-owners, editors, and readers given every part. No key and not the operator.

owner_orcid is returned only to the owner, co-owners, editors, and the operator; for everyone else the key is absent. Everyone else gets a 404 that looks exactly like a miss. A 404 does NOT prove the identity is absent, so never treat it 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. A resolve key learns whether internal identities exist, including the rid of a private profile it supplies; this is inherent to matching. The POST never returns a slug, claim status, or owner.

Publication is the owner’s decision to put a profile on the open web. The Public reading at least one part is what “published” means. Until published, anonymous callers get 404.

GET /api/v1/manage/profiles/{ref}/public-access

Section titled “GET /api/v1/manage/profiles/{ref}/public-access”

What the Public reads of this profile, part by part. Owner, co-owner or editor. Returns {slug, rid, published, parts: [{id, read}], floor_reason, can_edit}. There is one row per part the profile has; read is null for a kind of file whose files disagree. can_edit is true for the owner and co-owners. Cache-Control: no-store.

PUT /api/v1/manage/profiles/{ref}/public-access

Section titled “PUT /api/v1/manage/profiles/{ref}/public-access”

Set what the Public reads, and publish or unpublish to match. Owner or co-owner, in a session — editors, apps, and the operator token are refused.

Request: {"parts": {part: "read" | "none"}, "confirm"?: str}. Parts left out keep what they have. When the result reads any part, the profile is published, and publishing requires confirm equal to the slug. When it reads nothing, the profile is unpublished; that never needs confirm. Returns the same body as the GET. Errors: 400 an unknown part, a level other than read or none, or a missing or wrong confirm.

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

Section titled “GET /api/v1/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/v1/manage/profiles/{ref}/publication

Section titled “POST /api/v1/manage/profiles/{ref}/publication”

Publish or unpublish without changing the parts. Owner or co-owner, in a session — 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 takes the profile off the open web but is not a blackout: co-owners and editors, the people, teams and apps on the profile’s access list, the API keys their people allowed, and operators keep reading what they were allowed.

Every profile has one owner and one access list. Each entry gives one principal (a person, a team, or a platform app) one role: reader (reads the parts in parts), editor (edits content and pushes; may not publish, delete, or change the list), or co-owner (everything but handing the profile over). The owner is not an entry. A person holds their own entry and each team’s; the strongest counts. An account API key never has an entry: it acts for its person (Parts and access). Only the owner (or a member of the owning team) and co-owners change the list, in a session; apps and keys never do. The web UI is each profile’s Access tab (/manage/<slug>/access).

A reader’s parts is {part: "read"} over the profile parts (never write). A preset (basics, extended, or read_all) fills it instead; with neither, a new reader gets read_all. Other roles ignore both.

An entry is {id, principal, role, parts, preset, asked_by, granted_by, created_at, updated_at}. A principal is {id, kind, name, ...}: a person (kind: "user") adds user_id, orcid, email (their verified sign-in email, shown only to those who may change the list) and platform; a team adds team_id; an app adds app_id and description. preset names the preset a reader’s parts equal, else null. asked_by is the team that asked on an app’s behalf, or null.

  • GET /api/v1/manage/profiles/{slug}/access — owner, co-owner or editor, in a session. Returns {slug, rid, profile_id, owner, role, can_manage, entries, invites, requests}: owner is a principal, role the caller’s own, can_manage whether the caller may change the list. invites and the pending requests are filled only for those who may change the list; an editor gets [] and null emails. A team principal here (the owner, or an entry) also carries people (its head count) and apps ([{app_id, name}]): the apps on the team reach what the team reaches.
  • POST /api/v1/manage/profiles/{slug}/access — owner or co-owner. Request: {role, parts?, preset?} plus exactly one target: orcid (the account is made if the person never signed in), email, team_id, app_id (an active platform app, see GET /api/v1/account/apps), or a raw principal_id. Posting for a principal that already has an entry replaces its role and parts. Response 201 with the entry. By email, the one account whose verified login email matches gets the entry now ({entries: [...], invite: null}); with none, a pending invite is saved ({entries: [], invite: {id, email, role, parts, preset, granted_by, created_at}}) and turns into an entry the first time someone signs in or links a login with that verified email. Errors: 400 a bad role, parts or preset, not exactly one target, a bad ORCID iD or email, or the owner; 404 no such team, app, or principal; 409 an email that matches more than one account (add by ORCID instead).
  • PATCH /api/v1/manage/profiles/{slug}/access/{access_id} — owner or co-owner. Request {role, parts?, preset?}. A reader keeps its parts when you leave them out; changing another role to reader needs parts or a preset. Returns the entry. 404 no such entry on this profile.
  • DELETE /api/v1/manage/profiles/{slug}/access/{access_id} — owner or co-owner. Returns {removed: true, id}; it takes effect on the next request. 404 no such entry on this profile.
  • PATCH and DELETE /api/v1/manage/profiles/{slug}/access/invites/{invite_id} — owner or co-owner. Change what a pending invite will grant (same body as the PATCH above; returns the invite), or withdraw it ({removed: true, invite_id}). 404 no such invite on this profile.

A principal asks for a role; the profile’s owner or a co-owner decides. A request is {id, profile: {profile_id, slug, rid}, principal, asked_by, requested_role, requested_parts, preset, message, status, requested_by, decided_by, created_at, decided_at}. status is pending, approved, denied, or withdrawn; requested_by and decided_by are {id, name} or null (an app’s own key asking has no person).

  • POST /api/v1/manage/profiles/{slug}/access-requests — a signed-in person, or a platform app’s own rpk_ key. Request: {role, parts?, preset?, message?}, role reader or editor (nobody asks to be a co-owner). A person asks for themselves (no target), for a team they belong to (team_id, or that team’s principal_id), or for a platform app on behalf of such a team (app_id with asked_by_team_id). An app’s key asks only for itself and names no target. Response 201 with the request. Errors: 400 a bad role, parts or preset, more than one target, app_id without asked_by_team_id, a target sent by an app key, or the owner asking; 401 no credential; 403 a team you are not in, or an account API key (rpa_; sign in instead); 404 a profile the caller cannot see, or no such team or app; 409 {"error": "pending_request_exists"} when one is already pending for that principal and profile.
  • GET /api/v1/account/access-requests — session required. Requests on every profile the caller may manage (owns, through a team too, or co-owns), newest first, page-based, with pending beside results: the count of every pending request the caller may decide. ?profile= (a slug or rid; 404 unless the caller manages it) narrows to one profile; ?status= to one status.
  • POST /api/v1/account/access-requests/{id}/approve — owner or co-owner of the request’s profile, in a session. Optional body {role?, parts?, preset?}; by default it grants the role and parts asked for. Returns {request, entry}.
  • POST /api/v1/account/access-requests/{id}/deny — the same people. Returns {request}.
  • POST /api/v1/account/access-requests/{id}/withdraw — the asking side: a person who holds the principal (themselves, or a team they are in), is in the team that asked, or sent it; or the app’s own key. Returns {request}; anyone else gets 404.

Deciding or withdrawing a request that is no longer pending is 409. An unknown request, or one on a profile you cannot see, is 404.

A collection is a list of profiles, owned by a person or a team, with at most one entry per person. A list grants nothing: opening an entry shows the profile at your own level on it (Access lists). The list itself is seen and changed by its owner (the person, or every member of the owning team) and by its own access rows: viewer reads the list and every entry’s notes; editor also adds and removes entries, edits notes, and makes curated profiles. Only the owner renames or deletes the list and changes its access. No role on a list is 404; a role that falls short is 403.

An entry is {profile_id, rid, name, affiliation, slug, is_self, content_hash, notes, owner, access}. owner is {principal_id, kind, name} (platform: true for an unclaimed profile). notes is the list’s own private content about the person (an object, {} when empty); every role on the list reads it. affiliation is what you would see on opening the profile, else null. access is YOUR access to the profile: {role, parts, level}, with role one of owner, co-owner, editor, reader or null, and level full (every part), partial (the parts listed) or public (what the open web reads). A curated profile’s slug is never public: link to the entry, or open it in MCP as profile:#<profile_id>.

Managing lists, at /api/v1/manage/collections (a session, or an account API key acting for its person; a platform app gets 403 and uses the read routes below):

  • GET — every list you own or may see, page-based: rows {id, name, role, entry_count, owner}.

  • POST {name, owner?} — owner is "me" (the default) or {"team_id": n} for a team you are in. 201.

  • GET /{id}; PATCH /{id} {name} (owner); DELETE /{id} (owner; its entries and access rows go, the profiles stay).

  • GET /{id}/entries — the entries, page-based, with collection ({id, name, role}) beside results. Each entry adds can_request: you see only the public view and the list’s owner is a team you are in, so you may ask the profile’s owner for access (Access requests). ?rid= narrows to one person. ?digest=true adds content_digest to each entry whose profile you may write (Curated profiles).

  • POST /{id}/entries {profile_id}, {slug} or {rid} (editor) — put a profile on the list. A rid finds the person’s own profile, else the one curated profile about them that you (or your team) own. Grants nothing. 201 with the entry; 404 nothing you can see; 409 the list already has an entry for that person.

  • PATCH /{id}/entries/{profile_id} {notes} (editor); DELETE /{id}/entries/{profile_id} (editor; the profile stays).

  • POST /{id}/profiles — make a curated profile and its entry (Curated profiles).

  • GET /{id}/access (any role) — {owner, access: [{principal_id, kind, name, role, granted_by, created_at}]}. POST /{id}/access {role, orcid | email | team_id}, PUT /{id}/access/{principal_id} {role} and DELETE /{id}/access/{principal_id} (owner); role is viewer or editor. An app is refused (400): add it to the team that owns the list, and it sees what the team sees. An app’s older row is listed and may be deleted, not changed. By email, the one account with that verified login email: 404 when nobody has signed in with it (add the person to a team instead), 409 when two accounts share it. GET /{id}/access/choices lists your teams, for a picker.

  • GET /{id}/shared — page-based, with collection beside results: per entry, the access rows on its profile that name the list’s owner (shared: [{role, parts, preset}]), your access block, and may_edit.

  • POST /{id}/grant {principal_id, role, parts?} — give one person or team one role (reader, editor or co-owner; parts a table or a preset name) on every entry whose profile you may change access on. Written once: an entry added later gets nothing. An app is refused (400): add it to a team instead. {granted: [profile_id], skipped: [{profile_id, reason}]}, reason one of not_yours, owner, gone. Reading lists, at /api/v1/collections (a signed-in person, or a key with the read scope: an app sees the lists given to it, an account key its person’s):

  • GET /api/v1/collections — page-based: rows {id, name, role, entry_count, owner: {kind, name}}.

  • GET /api/v1/collections/{id}/entries — the entries, page-based, with collection ({id, name}) beside results. ?rid= narrows to one person.

  • GET /api/v1/collections/{id}/entries/{profile_id}?view=record|full — {entry, access, profile}, where profile is exactly what GET /api/v1/profiles/{slug}?view=... answers you. 404 when the profile is not on the list, the list is not yours to see, or you may read nothing of the profile.

  • GET /api/v1/collections/{id}/entries/{profile_id}/papers — the profile’s works at your level, token-based like GET /api/v1/profiles/{slug}/papers (?page_size=, ?page_token=).

A curated profile is a profile whose owner is not the person it is about: a team (or a person) keeps it about someone else. It is never public (publishing it is 409, an anonymous read 404), and it never says who the person is: identity and published-record fields (sameAs, identifier, proof, verifiedAt, url, license, conformsTo, orcid, scholarUrl, openalexId) are refused on a create and on any hand edit of a team’s profile (422). Automated writes to a team’s profile drop them instead and name them in stripped_fields: a build’s push (Push and archive) and the content write below. The person can be handed the profile (Handover).

  • POST /api/v1/manage/collections/{id}/profiles {rid, document, papers?, grants?, expertise_topics?, slug?, notes?} — an editor of the list who belongs to its owner. Makes a curated profile owned by the list’s owner (provenance third_party unless the document says otherwise) and its entry. 201 with {profile_id, slug, content_hash, action: "created", entry}. 409 when the list already has an entry for that person or its owner already has a profile about them; 422 a refused field.
  • PUT /api/v1/manage/profiles/{slug}/content {document, papers?, grants?, expertise_topics?} — what the rp publish plugin sends. The owner or a co-owner may replace the document and any papers or grants sent. An editor may push too, but the server merges the push: it keeps every document field the owner edited by hand, every field outside the part-level access table (including provenance and visibility), and the stored file-manifest entries; papers and grants are added or updated, never deleted. An account key without push_own must have Write on every part the push changes. On a non-curated profile, every push also keeps the stored identity, reuse and provenance fields; build flags are always kept. On a curated profile, identity and published-record claims are removed from the pushed document instead. 200 returns {profile_id, slug, content_hash, action: "replaced" | "merged", kept_owner_fields, stripped_fields}: action says whether the write replaced or merged, kept_owner_fields names differing pushed fields whose stored values won, and stripped_fields names claims removed before storage. A reader or no role gets 403 {"error": "insufficient_access", "role", "hint"}; a profile you cannot see, 404.

content_digest (on entries, with ?digest=true) is a sha256: digest of what a content write controls: the document without dateModified, the file lists (hasPart, subjectOf), the identity fields, the build flags and career_stage.source, plus the papers and grants, with null and empty values dropped. The rp publish plugin computes the same digest for what it would send; equal digests mean the write would change nothing. Unlike content_hash, it does not move when only dateModified does.

A team is a named set of people. Everyone in it holds the team’s access equally; is_admin on a member only lets them manage the team. An admin may also add an active platform app: an app on a team reaches what the team reaches. An app’s access says which profiles it reaches; its key’s scopes say what it may do there, and writing needs push. An app is never an admin. Session only: an account API key or a platform app gets 401, because teams decide who holds access. A non-member gets 404 (never 403); a member without the admin flag gets 403 on admin routes.

Method and pathWhoBodyResult
GET /api/v1/manage/teamssigned inthe caller’s teams, page-based
POST /api/v1/manage/teamssigned in{name}201 team; the caller is its admin
GET /api/v1/manage/teams/{id}memberteam
PATCH /api/v1/manage/teams/{id}admin{name}team
DELETE /api/v1/manage/teams/{id}admin{deleted, team_id}; 409 for a team that owns profiles or collections
GET /api/v1/manage/teams/{id}/membersmember{team_id, members, apps, invites} (invites and emails for admins only)
POST /api/v1/manage/teams/{id}/membersadmin{orcid} or {email}, is_admin; or {app_id}201 {members, invite}; for an app 201 {apps: [app]}, 404 for a missing or inactive app or an account key, 400 with is_admin
DELETE /api/v1/manage/teams/{id}/members/app/{app_id}admin{removed, team_id, app_id}
GET /api/v1/manage/teams/{id}/apps/choicesadmin{apps: [{app_id, name, description}]}: every active platform app
PATCH /api/v1/manage/teams/{id}/members/{user_id}admin{is_admin}member; 409 for the last admin
DELETE /api/v1/manage/teams/{id}/members/{user_id}admin, or the member themselves{removed, team_id, user_id}; 409 for the last admin
PATCH /api/v1/manage/teams/{id}/invites/{invite_id}admin{is_admin}invite
DELETE /api/v1/manage/teams/{id}/invites/{invite_id}admin{removed, invite_id}

A team is {id, name, is_admin, member_count, created_by, created_at} (member_count counts people). A member is {team_id, user_id, orcid, name, email, is_admin, is_you, added_by, created_at}; an app on the team is {team_id, app_id, name, description, active, added_by, created_at}. Adding by email adds the one account carrying that verified login email, stores an invite when there is none, and is 409 when two accounts share it.

A curated profile stays its owner’s until an owner hands it to the person it is about. Nothing about the profile (its rid, an email in it, an ORCID match) makes it claimable; only a handover link does. Accepting changes the owner of the same profile: its papers, grants, files, builds and list entries stay.

The owner’s side, at /api/v1/manage/profiles/{slug}/handover (you must own the profile, yourself or through the owning team; a person’s own profile is 409):

  • GET — the latest link (handover, or null), can_hand_over, suggested_lock ({orcid, email} to pre-fill) and has_self_profile_elsewhere (the person already has a profile; accepting still works).
  • POST {collection_id?, lock_orcid?, lock_email?, message?} — a new link, cancelling a pending one. collection_id must list this profile (it is shown on the link’s page); a lock lets only the matching account accept; message is up to 1000 characters. 201 with {handover, link, token}; the token is shown only here.
  • POST …/link — a fresh token for the pending link; the link sent before stops working. 404 no pending link.
  • DELETE — cancel the pending link. 404 no pending link.

The person’s side:

  • GET /api/v1/handover/{token} — anyone with the link. {status, from, collection, invited_by, locked, expires_at, can_accept, reason, has_self_profile, message, preview, choices}. from is the owner ({principal_id, kind, name, members}; a team lists its members by name and ORCID iD). reason is sign_in or someone_else when you may not accept. choices is who may keep access afterwards: [{principal_id, kind, name, roles, default_role, default_parts, ticked, current_role, previous_owner, asked_by?}]; the owning team and each app with access are ticked. A locked link shows message, preview, members and choices only to the account it is locked to. 404 no such link; 410 a dead one.
  • POST /api/v1/handover/{token}/accept {keep?: [{principal_id, role, parts?}]} — session required. You become the owner. keep says who keeps access (role reader or editor; parts a list, a table or a preset, every part by default); left out, the ticked choices keep it; [], nobody does. With no profile of your own, it becomes yours. If your own profile is about the same person, the lists and the kept access move to it and the handed-over profile is removed (attached: true). If your own profile is about another rid, this one becomes a second profile of yours and merge_offer is true. Answers {status: "accepted", profile: {profile_id, slug}, result_profile_id, merge_offer, attached}; a retry answers the same. 400 a keep entry that is not a choice; 403 a link locked to someone else; 404 or 410 a dead link.
  • POST /api/v1/handover/{token}/decline — session required (the locked account, if locked). The profile is unchanged.

Links are single use and stop working after 30 days unless re-issued. Claiming an unclaimed profile (Claiming a profile) is the same owner change, with the same choices and keep.

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.
  • Admin-triggered: an operator builds any profile from the admin panel, a curated profile included. The runner pushes its result through PUT /api/v1/profiles/{slug}; on a team’s profile that push drops the identity fields a build adds and names them in stripped_fields.

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

Section titled “POST /api/v1/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/v1/manage/profiles/admin/build/{ref}

Section titled “GET /api/v1/manage/profiles/admin/build/{ref}”

Latest build status. Operator only.

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

Section titled “POST /api/v1/manage/profiles/{ref}/build (deprecated)”

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

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

Section titled “GET /api/v1/manage/profiles/{ref}/build (deprecated)”

Owner-facing status. Deprecated.

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

Section titled “POST /api/v1/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 inside one profile needs no special scope: the paper list with q and the two passages routes (see Reading profiles) search only what the caller may already read.

  • Hybrid. Where stored vectors exist (paper summaries and abstracts, the narrative, and public cv, web and grant chunks), a query is ranked by meaning (cosine similarity, kept at 0.30 or above) and by keywords (BM25, exact words, so gene names and acronyms match), and the two rankings are merged by reciprocal rank (RRF, k = 60). Each row or passage says which side found it (matched_by).
  • Keyword only for paper full text, which has no vectors, and for an owner’s private sources (cv, web pages, grants), which by policy have no stored vectors. A paper search that read the full text reports search_mode_used: "hybrid+keyword_fulltext".
  • Always reported. search_mode_used is hybrid, keyword or hybrid+keyword_fulltext, and note says why whenever the server fell back (no vectors, the encoder timed out after 3 s, a different embedding space) or left something out (“Full text not available to you”). Passages carry at most 1,200 characters each.

These require an API key with the appropriate scope. Counts are clamped and reported (k_applied, prefilter_applied, max_pages_applied), never refused.

  • POST /api/v1/match: cross-profile ranking. Scope: match. k at most 50, prefilter at most 200, topk_chunks at most 10.
  • POST /api/v1/profiles/{slug}/search: semantic search over one profile’s stored vectors, with the text of each hit (at most 1,200 characters, truncated when cut). Scope: match. k at most 20.
  • POST /api/v1/profiles/{slug}/rank-works: rank candidate works. Scope: match. k at most 50, max_pages at most 10.
  • POST /api/v1/profiles/{slug}/{ask,review,innovate,riff}: persona endpoints. Scope: persona. k at most 20.

Read a profile’s literature feed (the digest the nightly run builds) with an API key. Scope: feed. Page-based, page_size 50, at most 200. Query: since (ISO-8601; a bare date is midnight UTC), page, page_size.

Feed rows belong to the person who subscribed, so the key reads them as its owning user (apps.owned_by), and only for a profile that user owns. Returns the same page as GET /api/v1/manage/feed/items/{slug}: results rows with work_id (an OpenAlex work id, or a doi: fallback; look the DOI up from OpenAlex), title, year, journal, score, evidence, run_date, feedback, best score first.

Errors: 401 no or unknown key; 403 the key lacks feed (insufficient_scope), has no owning user, or its owner does not own the profile (detail.error is forbidden); 404 unknown or hidden profile; 400 bad since or an unknown query parameter; 422 page_size below 1. The operator token has no owning user and reads nothing here. A personal rpa_ key never holds feed; over MCP use get_feed.

Minting a feed key for an app run by one person (for example Sierra, stored as PROSOPIA_API_KEY in its .env, never in a note). The operator registers an app owned by that person, then mints one key on it:

POST /api/v1/admin/apps
{"name": "sierra", "owned_by": <the person's users.id>}
POST /api/v1/admin/apps/{id}/keys
{"scopes": ["feed", "persona", "read"]}

The plaintext is returned once. The person must also have a feed subscription to the profile (Feed on the Prosopia website); without one the key reads an empty page.

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

  • GET /api/v1/manage/apps/{app}/person-links/{external_id}: resolve the app’s id to {app_id, external_id, slug, rid}. Must authenticate as the named app. Returns 404 once the linked profile is no longer visible to that app (same rule as GET /api/v1/identity/resolve).
  • POST /api/v1/manage/apps/{app}/person-links: bind one. Request: {external_id, slug?, rid?}. Response 201. Returns 404, exactly like a missing profile, for a profile the calling app may not see under the identity-resolve rule.
  • GET /api/v1/manage/profiles/{slug}/person-links: owner/editor view of which apps reference this profile, page-based.

Private follows of published profiles. All routes are session-only (browser cookie): API keys, agents and MCP get no access. Every response is Cache-Control: no-store. Nothing about a follow appears on the followed profile, and no route counts followers.

A page-based list of {rid, slug, name, affiliation, field, url, followed_at} rows, newest first. Public fields only. A profile that is missing, unpublished or limited is left out silently (no count, no reason); it returns if republished.

Follow a profile by slug or rid. Idempotent: 200 {"following": true, "profile": {...}}. A profile the open web cannot see answers exactly like a missing one (404); 403 (“Only published profiles can be followed.”) goes only to a caller who can see it privately, such as a co-owner. 409 when the account is at its limit (1000 follows).

Always 204 with an empty body, whether or not the ref exists, so it cannot be used to probe for profiles.

The rp prosopia commands (from rp-prosopia-plugin) use these keys: rp prosopia agent whoami|config, rp prosopia profile pull|diff|push|visibility and rp prosopia work show|set|rm. They find a key in RESEARCHER_PROFILES_AGENT_KEY with RESEARCHER_PROFILES_API_URL, the nearest .env, or ~/.config/researcher-profiles/credentials.toml (the credentials_toml block below).

An API key is an app owned by a person, reached with an rpa_ key. It acts for that person’s whole account: it reaches the profiles the person reaches, and its own parts table caps which parts it may read and write there. It never reads or writes more than the person may. No key is bound to a profile. rp login connections (labelled “rp CLI on

  • GET /api/v1/manage/agents: every key this account minted, page-based: rows {id, handle, label, parts, replace_profiles, active, created_at, last_used_at, expires_at, key_prefix, connection, refresh_expires_at, client_host, cli}. connection is true for a key made by a chat app’s sign-in (above) or by rp login; cli is true for an rp login one; refresh_expires_at is when it lapses if unused.
  • POST /api/v1/manage/agents: mint one. Request: {label, parts?, replace_profiles?, expires_at?}. Without parts, the key reads every part and writes nothing. replace_profiles (default false) lets it overwrite a whole profile at once, every part together (the push_own scope). Any signed-in person may mint, with or without a profile. Returns the plaintext key once, plus a credentials_toml block whose profile line (the rp CLI’s default target) is the account’s own profile, left out when it has none. Response 201.
  • POST /api/v1/manage/agents/{id}/rotate: revoke and re-mint the key. 400 for a connection (a chat app’s or rp login’s), which renews itself.
  • PATCH /api/v1/manage/agents/{id}: change any of label, parts, replace_profiles.
  • DELETE /api/v1/manage/agents/{id}: revoke the key.
  • GET /api/v1/manage/agents/{id}/activity: the key’s edits and audit.

parts errors are 400: an unknown part or level.

  • GET /api/v1/manage/agent/whoami: the key’s authority (rp prosopia agent whoami prints it). A key should call it at the start of every session, before any write, and again after a refusal; the table can change at any time, so never cache it across sessions. Fields:

    FieldTypeDescription
    principalobjectThe key: kind, label, handle, created_at, last_used_at, expires_at
    ownerobject | null{orcid, name} of the person the key acts for
    profilesarrayEvery profile the account reaches: slug, rid, role (owner, co-owner, editor, or reader), writes (the parts the key may write there: its table’s writes where the account may write, else none), and published
    partsobjectThe key’s parts table, {part: "read" | "write"}; a part left out is none
    replace_profilesbooleanWhether the key may replace whole profiles
    never_delegablearrayWhat no key may ever do, as {act, why}

    400 for rpk_ keys; 401 for a missing, unknown, revoked or expired key.

  • /api/v1/manage/collections and /api/v1/collections: the same routes a signed-in person uses (Collections), for the key’s account. The lists carry no profile content; what each entry shows is capped by the key’s own table, as every profile read is.

Scope: push (API key or operator token).

  • PUT /api/v1/profiles/{slug}: push a full profile. The response includes kept_owner_fields: the fields this push left alone because the owner or a co-owner edited them by hand, or null when none were. A push by anyone but the owner or a co-owner (an editor, an app, a key acting for either, the operator’s rebuild) never changes those fields or a hand-edited SOUL narrative. A push to a team’s curated profile (a build’s result, for one) drops the identity fields a curated profile may not carry instead of failing, and names them in stripped_fields ([] when there were none; null for any other profile).
  • GET /api/v1/profiles/{slug}/archive: download archive.

An app or account key without push (or push_own) may still PUT to an existing profile it may write: an app that is an editor or co-owner of the profile, or a key whose account may write the profile and whose table has Write on some part. Such a push is compared with the profile it replaces, part by part, before anything is written. It lands only if every part it changes is Write; send one file with ?mode=merge (rp push --only <file> --mode merge) to change just that file. Otherwise the answer is 403, nothing changes, and the body names what is missing: {"detail": {"error": "insufficient_access", "required": [...], "missing": [...], "needs_replace": [...], "hint": "..."}}. needs_replace lists changes no part covers (what the Public reads, a field such as provenance_note, an infrastructure file); only the “Replace whole profiles” switch allows those. Such a push never creates a profile, and a caller that may write nothing of the target gets the ordinary missing-scope 403.

A push_own key (an rp login connection) that may not write the target gets 403 with one fixed message, whatever the target: this login may write only profiles it owns or manages, or create one at its own ORCID. It names no slug or rid. An archive GET for a profile the key cannot see is 404, the same as a missing one.

Operator-only CRUD for app apps and their keys. Requires the operator token or a session on PROSOPIA_ADMIN_ORCIDS. The two GET lists are page-based; .../audit defaults to page_size 50.

MethodPathPurpose
GET/api/v1/admin/appsList all apps with key counts
POST/api/v1/admin/appsRegister an app (409 duplicate)
GET/api/v1/admin/apps/{id}Full detail with all keys and audit
PATCH/api/v1/admin/apps/{id}Update description or active status
DELETE/api/v1/admin/apps/{id}Deactivate and revoke all keys
POST/api/v1/admin/apps/{id}/deleteDelete for good (409 while wired into other records)
POST.../keysMint an rpk_ key
POST.../keys/{key_id}/rotateRevoke and re-mint
POST.../keys/{key_id}/revokeRevoke a key
GET.../auditRecent attributed calls

Valid key scopes: read, match, persona, push, push_own, resolve, feed. POST /api/v1/admin/apps takes an optional owned_by (a users.id); a feed key reads nothing unless its app has one.

Operator-only (operator token or an operator session). Both GET lists are page-based.

MethodPathPurpose
GET/api/v1/admin/claims?status=pending|decided|allRequests with the person, the profile and the evidence
POST/api/v1/admin/claims/{id}/approveApprove, with an optional note; 409 your own request, already decided, owned meanwhile, or a suspended requester
POST/api/v1/admin/claims/{id}/rejectReject, with an optional note
POST/api/v1/admin/claims/release{slug, reason}: undo a mistaken claim (unown and unpublish); 404 if unowned
GET/api/v1/admin/mergesEvery profile merge, newest first (read only)
  • GET /health: liveness probe. Unauthenticated.
  • GET /context/v1.jsonld: JSON-LD @context document. Cached, anonymous.

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

A profile you may not see (unpublished, limited or private above your tier, or an unclaimed stub) answers exactly like a profile that does not exist, on every route: 404 with {"detail": "profile '<ref>' not found"}, where <ref> is the slug or rid you sent. The response never contains that profile’s slug or rid unless you sent it.

A 403 therefore means: you CAN see this profile, but you may not do this to it (edit it, manage it, publish it, claim it). Too many failed credentials from your address is a 429 before any profile is looked up, so it too is the same for existing and missing profiles.

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).