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).
Lists, paging and filters
Section titled “Lists, paging and filters”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.
Authentication
Section titled “Authentication”Prosopia accepts three kinds of credentials.
Session cookie
Section titled “Session cookie”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.
API key
Section titled “API key”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 thereadscope, pluspush_ownwhen 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.
Operator token
Section titled “Operator token”Authorization: Bearer <token>Set by RESEARCHER_PROFILES_TOKEN. Superuser — holds every scope and reads
at private tier. Administrative, not a shared app credential.
Parts and access
Section titled “Parts and access”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.
| Caller | Reads |
|---|---|
| No credential | what the Public reads (nothing, if unpublished) |
| Person with no role on the profile | what 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 profile | its entry on the access list (reader: its parts; editor or co-owner: everything), plus what the Public reads |
App (rpk_) on an unclaimed profile | the 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 key | what its person reads, capped by its own table, plus what the Public reads |
| Operator token | everything |
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 and login
Section titled “Session and login”GET /api/v1/manage/session
Section titled “GET /api/v1/manage/session”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.
GET /auth/{provider}
Section titled “GET /auth/{provider}”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.
GET /auth/{provider}/callback
Section titled “GET /auth/{provider}/callback”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. Plusabsorbed=1when access that was shared with that ORCID iD before you had an account (a placeholder account) was moved onto yours. Plusmerged=1when the login belonged to another Prosopia account, which was merged into yours; thenmoved=<kind>:<n>(repeated; kindsprofiles,access,collections,teams,feeds,agent_keys,claim_requests,drafts) says what moved, andprofiles=merge(you now have two profiles about yourself;/meoffers to merge them) orprofiles=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(plusconflict=<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_failedorsuspended.
POST /auth/{provider}/link
Section titled “POST /auth/{provider}/link”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).
GET /api/v1/account/identities
Section titled “GET /api/v1/account/identities”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.
GET /api/v1/account/parts
Section titled “GET /api/v1/account/parts”Unauthenticated. The parts catalog every table is written in:
{"sections": [...], "files": [...], "writable": [...]}.
GET /api/v1/account/apps
Section titled “GET /api/v1/account/apps”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.
POST /api/v1/manage/logout
Section titled “POST /api/v1/manage/logout”Deletes the session cookie. Returns {"authenticated": false}.
Command-line login (rp login)
Section titled “Command-line login (rp login)”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.
Loopback sign-in (the default; RFC 8252)
Section titled “Loopback sign-in (the default; RFC 8252)”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.
Device flow (RFC 8628)
Section titled “Device flow (RFC 8628)”For a machine with no browser: rp login --device, and automatically over SSH
or with no display.
POST /oauth/device_authorization
Section titled “POST /oauth/device_authorization”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.
GET|POST /device
Section titled “GET|POST /device”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.
POST /oauth/token (device grant)
Section titled “POST /oauth/token (device grant)”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.
Tokens, refresh and sign-out
Section titled “Tokens, refresh and sign-out”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.
GET /api/v1/manage/whoami
Section titled “GET /api/v1/manage/whoami”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>.
| Field | Type | Description |
|---|---|---|
app | string | Stable name of the application or connection holding the key |
scopes | list[string] | Granted app scopes, sorted |
owner | object | null | {orcid, name} of the person the key was minted for, or null for an app key with no owning person |
profiles | array | Profiles 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 server and chat-app sign-in
Section titled “MCP server and chat-app sign-in”/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.
| Tool | Changes data? | What it does |
|---|---|---|
get_item(ref, view) | no | A 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) | no | A 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) | no | The few passages in one paper or one profile that answer a question. k 3, at most 10. |
get_profile_guide(profile, goal) | no | A 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() | no | The 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) | adds | The 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) | adds | One new paper. Refuses an existing paper_id, so it never overwrites. |
update_profile(profile, fields, version) | changes | Profile fields, including the narrative (soul). |
update_paper(ref, fields, version) | changes | Fields of one paper. |
remove_paper(ref, version) | removes | One paper. |
add_seed_papers(profile, ids) | adds | Interest 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) | removes | One 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, withscopes_supported:profiles:read,profiles:write. -
GET /.well-known/oauth-authorization-server: RFC 8414 metadata. Grant types
authorization_codeandrefresh_token,S256only, public clients only (none),scopes_supportedas above, andclient_id_metadata_document_supported: true. -
Client ID Metadata Documents. A client may skip registration and use an
httpsURL as itsclient_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 answer200with a JSON content type, at most 10 KB, with no redirects followed, from public addresses only. A valid document is cached perCache-Control(at most a day; ten minutes without one). The document’sclient_idmust equal the URL exactly. It needsclient_nameandredirect_uris(same rules as registration), must not hold a client secret, andtoken_endpoint_auth_methodis absent ornone. 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_nameandredirect_uris(https, or http to localhost; no fragment). Returns201withclient_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, optionallyscope(space-separated; absent means both; an unknown scope sends the app backerror=invalid_scope), and optionallyresource(RFC 8707; when sent it must be<origin>/mcp, with or without a trailing slash, or the app is sent backerror=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 itsclient_idURL (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 andworks, read the rest) or Read only; a request forprofiles:readalone offers only Read only. Approving redirects toredirect_uriwithcodeandstate(a single-use code, ten minutes); denying witherror=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": "..."}, wherescopeis what the connection holds. An optionalresourcemust be<origin>/mcp, elseinvalid_target. Nothing is fetched here. (Calls withclient_id=rp-cli, and the third grant type, the device grant, arerp login’s; see Command-line login.)grant_type=authorization_codewithcode,redirect_uri,client_id,code_verifier: starts a connection (one Privacy-page row, labelled “<client_name> connector”).invalid_grantfor 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 (sameclient_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_tokenwithrefresh_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_grantfor an unknown, lapsed, revoked or replayed token, another app’s token, or a suspended account. An optionalscopemust equal the connection’s current scope; asking for more or for less isinvalid_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
mcpscope: it works only on/mcp, and any other route answers403(“this key works only through the MCP connector”). Revoking the connection on the Privacy page ends both tokens;/mcpthen answers401and the app signs in again. A connection cannot be rotated (POST /api/v1/manage/agents/{id}/rotateanswers400).
Reading profiles
Section titled “Reading profiles”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_size100, 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 orlocal:id, else400),clinical(true/false),affiliation(exact, ignoring case and outer spaces). To read one profile by ORCID, callGET /api/v1/profiles/{orcid}. - GET /api/v1/collection.json: the listing as a bundle of cards with
absolute
baseURLs, for a browser client.private, no-storefor 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 atotal, and there is no file list and no narrative text.?view=fullcarries every field untrimmed plus thesoulandexpertisetext. Both carrycontent_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}), andwithheld(the fields this caller may not see, eachnullinfields). A weakETagletsIf-None-Matchanswer304. - GET /api/v1/profiles/{slug}/files: the file list (manifest), each entry
with its
effective_visibilityandslot, andwithheld(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,summaryandtextsizes for this caller, and the paper’sversion.page_size20, at most 100; passpagination.next_page_tokenback aspage_token. The answer also hasfilters_appliedandnote. Filters:year_min,missing_ids(no DOI and no OpenAlex id),has_text. Withq, rows are ranked by hybrid search (see Search and AI) and come back as one page with nototaland no token (search_mode_usedsays how they were ranked); apage_tokenwithqis400.ids=a,b,creads up to 20 rows by id, as one page. Apage_tokenonly works with the filters it was made under (400page_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=fulluntrims 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 inunavailablewith 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_moreandnext_offset.section=reads one heading;offsetis always an index into the whole text;max_charsup to 200,000. - GET /api/v1/profiles/{slug}/text: the narrative the same way, with
section=soulorsection=expertise, pluscontent_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).
Creating a profile
Section titled “Creating a profile”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.
The ORCID login proof
Section titled “The ORCID login proof”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.
Deleting a profile
Section titled “Deleting a profile”DELETE /api/v1/manage/profiles/{slug} — owner or co-owner, in a session.
Response 204.
Editing a profile
Section titled “Editing a profile”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 fieldsoulreplaces the narrative (partsoul), in the same write as the other fields.career_stageis partbackground; sending{"career_stage": {"current_rank": "postdoc"}}records the person’s own current rank. The server stampssource: self_reported,confidence,evidenceandas_ofitself (whatever the caller sent for those is ignored), and setsindependenceto match.current_rank: nullwithdraws the answer (sourcegoes back toinferred). 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. Sendbase_hash(thecontent_hashyou read) to get409instead of overwriting someone else’s edit; the409body is{"detail": {"error": "conflict", "current": "<hash>", "message"}}. - POST /api/v1/profiles/{slug}/works: add one paper (
paper_idin the body). An existingpaper_idis409; it never overwrites (partworks). - PATCH/DELETE /api/v1/profiles/{slug}/works/{paper_id}: correct or
remove one paper (part
works). Sendbase_version(the paper’sversionfromGET .../papers; a query parameter onDELETE) to get409withX-RP-Paper-Versionif the paper changed. The reply’sversionis 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 throughpublic-accessinstead.
An app or key missing Write on a part gets 403 with
{error: "insufficient_access", required, missing, hint}.
Interest papers
Section titled “Interest papers”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_idis the bare OpenAlex id (W…) ordoi:<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 (400past 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
.bibupload (fieldfile, at most 1 MB); entries are added by DOI and the reply addsno_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.
Claiming a profile
Section titled “Claiming a profile”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).
Claim requests
Section titled “Claim requests”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).201with{id, slug, status, message, created_at, decided_at, decision_note, awaiting_merge}.404a hidden profile nothing ties to you;409already 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.
404for an unknown id or someone else’s;409already decided.
Merging profiles
Section titled “Merging profiles”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"}.200with the survivor and retired profiles,fields(value and where it came from),countsandaccounts(what moves, what was dropped on a clash),cannot_come_along,soul_choice_required,publish_question,blocked_by_buildandpreview_hash. - POST /api/v1/manage/merges: the same inputs plus
preview_hashandpublish.201with{survivor: {slug, rid}, retired_rid, counts}.409something changed (review again) or a build is running;400a SOUL choice is needed;403/404the target is not one of your merge offers. A merge cannot be undone. The retired rid and slug keep resolving to the survivor.
Identity resolution
Section titled “Identity resolution”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
limitedstub) 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 anrpk_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
Section titled “Publication”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.
Access lists
Section titled “Access lists”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}:owneris a principal,rolethe caller’s own,can_managewhether the caller may change the list.invitesand the pendingrequestsare filled only for those who may change the list; an editor gets[]andnullemails. A team principal here (the owner, or an entry) also carriespeople(its head count) andapps([{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 rawprincipal_id. Posting for a principal that already has an entry replaces its role and parts. Response201with 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:400a bad role, parts or preset, not exactly one target, a bad ORCID iD or email, or the owner;404no such team, app, or principal;409an 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 toreaderneedspartsor apreset. Returns the entry.404no 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.404no 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}).404no such invite on this profile.
Access requests
Section titled “Access requests”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?},rolereaderoreditor(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’sprincipal_id), or for a platform app on behalf of such a team (app_idwithasked_by_team_id). An app’s key asks only for itself and names no target. Response201with the request. Errors:400a bad role, parts or preset, more than one target,app_idwithoutasked_by_team_id, a target sent by an app key, or the owner asking;401no credential;403a team you are not in, or an account API key (rpa_; sign in instead);404a 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
pendingbesideresults: the count of every pending request the caller may decide.?profile=(a slug or rid;404unless 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 gets404.
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.
Collections
Section titled “Collections”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?}—owneris"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}) besideresults. Each entry addscan_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=trueaddscontent_digestto 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.201with the entry;404nothing you can see;409the 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);roleisvieweroreditor. 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:404when nobody has signed in with it (add the person to a team instead),409when two accounts share it. GET /{id}/access/choices lists your teams, for a picker. -
GET /{id}/shared — page-based, with
collectionbesideresults: per entry, the access rows on its profile that name the list’s owner (shared: [{role, parts, preset}]), youraccessblock, andmay_edit. -
POST /{id}/grant
{principal_id, role, parts?}— give one person or team one role (reader,editororco-owner;partsa 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}]},reasonone ofnot_yours,owner,gone. Reading lists, at/api/v1/collections(a signed-in person, or a key with thereadscope: 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}) besideresults.?rid=narrows to one person. -
GET /api/v1/collections/{id}/entries/{profile_id}?view=record|full —
{entry, access, profile}, whereprofileis exactly whatGET /api/v1/profiles/{slug}?view=...answers you.404when 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=).
Curated profiles
Section titled “Curated profiles”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 (provenancethird_partyunless the document says otherwise) and its entry.201with{profile_id, slug, content_hash, action: "created", entry}.409when the list already has an entry for that person or its owner already has a profile about them;422a 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 withoutpush_ownmust 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.200returns{profile_id, slug, content_hash, action: "replaced" | "merged", kept_owner_fields, stripped_fields}:actionsays whether the write replaced or merged,kept_owner_fieldsnames differing pushed fields whose stored values won, andstripped_fieldsnames claims removed before storage. A reader or no role gets403 {"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 path | Who | Body | Result |
|---|---|---|---|
GET /api/v1/manage/teams | signed in | the caller’s teams, page-based | |
POST /api/v1/manage/teams | signed in | {name} | 201 team; the caller is its admin |
GET /api/v1/manage/teams/{id} | member | team | |
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}/members | member | {team_id, members, apps, invites} (invites and emails for admins only) | |
POST /api/v1/manage/teams/{id}/members | admin | {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/choices | admin | {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.
Handover
Section titled “Handover”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, ornull),can_hand_over,suggested_lock({orcid, email}to pre-fill) andhas_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_idmust list this profile (it is shown on the link’s page); a lock lets only the matching account accept;messageis up to 1000 characters.201with{handover, link, token}; the token is shown only here. - POST …/link — a fresh token for the pending link; the link sent before
stops working.
404no pending link. - DELETE — cancel the pending link.
404no 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}.fromis the owner ({principal_id, kind, name, members}; a team lists its members by name and ORCID iD).reasonissign_inorsomeone_elsewhen you may not accept.choicesis 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 showsmessage,preview, members andchoicesonly to the account it is locked to.404no such link;410a dead one. - POST /api/v1/handover/{token}/accept
{keep?: [{principal_id, role, parts?}]}— session required. You become the owner.keepsays who keeps access (rolereaderoreditor;partsa 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 andmerge_offeris true. Answers{status: "accepted", profile: {profile_id, slug}, result_profile_id, merge_offer, attached}; a retry answers the same.400akeepentry that is not a choice;403a link locked to someone else;404or410a 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.
On-demand builds
Section titled “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-profileClaude 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 instripped_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 and AI
Section titled “Search and AI”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_usedishybrid,keywordorhybrid+keyword_fulltext, andnotesays 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.kat most 50,prefilterat most 200,topk_chunksat 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,
truncatedwhen cut). Scope:match.kat most 20. - POST /api/v1/profiles/{slug}/rank-works: rank candidate works. Scope:
match.kat most 50,max_pagesat most 10. - POST /api/v1/profiles/{slug}/{ask,review,innovate,riff}: persona
endpoints. Scope:
persona.kat most 20.
GET /api/v1/profiles/{slug}/feed
Section titled “GET /api/v1/profiles/{slug}/feed”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.
Person links
Section titled “Person links”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. Returns404once the linked profile is no longer visible to that app (same rule asGET /api/v1/identity/resolve). - POST /api/v1/manage/apps/{app}/person-links: bind one.
Request:
{external_id, slug?, rid?}. Response201. Returns404, 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.
Following
Section titled “Following”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.
GET /api/v1/account/follows
Section titled “GET /api/v1/account/follows”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.
PUT /api/v1/account/follows/{ref}
Section titled “PUT /api/v1/account/follows/{ref}”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).
DELETE /api/v1/account/follows/{ref}
Section titled “DELETE /api/v1/account/follows/{ref}”Always 204 with an empty body, whether or not the ref exists, so it cannot
be used to probe for profiles.
API keys (account keys)
Section titled “API keys (account keys)”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
Managing API keys (session required)
Section titled “Managing API keys (session required)”- 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}.connectionis true for a key made by a chat app’s sign-in (above) or byrp login;cliis true for anrp loginone;refresh_expires_atis when it lapses if unused. - POST /api/v1/manage/agents: mint one. Request:
{label, parts?, replace_profiles?, expires_at?}. Withoutparts, the key reads every part and writes nothing.replace_profiles(defaultfalse) lets it overwrite a whole profile at once, every part together (thepush_ownscope). Any signed-in person may mint, with or without a profile. Returns the plaintext key once, plus acredentials_tomlblock whoseprofileline (therpCLI’s default target) is the account’s own profile, left out when it has none. Response201. - POST /api/v1/manage/agents/{id}/rotate: revoke and re-mint the key.
400for a connection (a chat app’s orrp 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.
Key self-service (rpa_ key required)
Section titled “Key self-service (rpa_ key required)”-
GET /api/v1/manage/agent/whoami: the key’s authority (
rp prosopia agent whoamiprints 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:Field Type Description principalobject The key: kind,label,handle,created_at,last_used_at,expires_atownerobject | null {orcid, name}of the person the key acts forprofilesarray Every profile the account reaches: slug,rid,role(owner,co-owner,editor, orreader),writes(the parts the key may write there: its table’s writes where the account may write, else none), andpublishedpartsobject The key’s parts table, {part: "read" | "write"}; a part left out isnonereplace_profilesboolean Whether the key may replace whole profiles never_delegablearray What no key may ever do, as {act, why}400forrpk_keys;401for 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.
Push and archive
Section titled “Push and archive”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, ornullwhen 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 instripped_fields([]when there were none;nullfor 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.
App admin
Section titled “App admin”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.
| Method | Path | Purpose |
|---|---|---|
| GET | /api/v1/admin/apps | List all apps with key counts |
| POST | /api/v1/admin/apps | Register 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}/delete | Delete for good (409 while wired into other records) |
| 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, 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.
Claims console
Section titled “Claims console”Operator-only (operator token or an operator session). Both GET lists are
page-based.
| Method | Path | Purpose |
|---|---|---|
| GET | /api/v1/admin/claims?status=pending|decided|all | Requests with the person, the profile and the evidence |
| POST | /api/v1/admin/claims/{id}/approve | Approve, with an optional note; 409 your own request, already decided, owned meanwhile, or a suspended requester |
| POST | /api/v1/admin/claims/{id}/reject | Reject, 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/merges | Every profile merge, newest first (read only) |
Other endpoints
Section titled “Other endpoints”- GET /health: liveness probe. Unauthenticated.
- GET /context/v1.jsonld: JSON-LD
@contextdocument. Cached, anonymous.
Errors
Section titled “Errors”All errors return {"detail": "<string>"} except agent-scope denials, which
return a structured detail with error, required, granted, missing,
and hint.
Errors on profiles you cannot see
Section titled “Errors on profiles you cannot see”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.
Related
Section titled “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).