Skip to content

HTTP API reference

Conformance requirements for the API are defined in the Dynamic API specification. This document is the wire-detail reference for the SDK’s implementation. The reference implementation mounts all endpoints under /api/v1/ (see URL prefix).

The researcher_profiles.api package (the [api] extra) exposes a FastAPI server over a ProfileStore: a directory of on-disk profiles or a SQL store. On a directory, each profile is a slug-named subdirectory containing a profile.jsonld, paper metadata, expertise / SOUL markdown, and optionally a .cache/embeddings.sqlite vector index. The API surfaces:

  • Profile listing and the sized profile record (/profiles, /profiles/{slug}, .../files)
  • Paged paper rows, one paper, batched summaries (.../papers, .../papers/{paper_id}, .../summaries)
  • Bounded text reads and passages (.../papers/{paper_id}/text, .../text, POST .../passages)
  • Semantic search over a researcher’s corpus (POST .../search)
  • Cross-profile ranking (POST /api/v1/match)
  • Four persona-grounded LLM endpoints: ask, review, innovate, riff

Base URL convention. All resource routes are mounted under /api/v1/. The /health route lives at the root and is unauthenticated. The default bind is 127.0.0.1:8109.

The [api] extra needs the [vectors,st] tier at runtime to answer /search and /api/v1/match. Install the server with the api,vectors,st extras; see the SDK overview for the checkout install.

CLI entry point:

Terminal window
python -m researcher_profiles.api \
--profiles-dir /path/to/profiles \
--host 127.0.0.1 \
--port 8109
FlagEnv varDefaultMeaning
--profiles-dirRESEARCHER_PROFILES_ROOT(one of the two required)Directory holding one subdirectory per profile, each with a profile.jsonld. Same variable as the SDK/CLI’s local profiles root below: one directory, one name, for every command and every host.
--database-urlRESEARCHER_PROFILES_DATABASE_URL(one of the two required)Serve a SQL profile store instead of a directory. Needs the [sql] extra, and wins over --profiles-dir when both are given.
--hostRESEARCHER_PROFILES_HOST127.0.0.1Bind address.
--portRESEARCHER_PROFILES_PORT8109Bind port.
--verbose / -vnoneoffSets logging to DEBUG.

Alternate uvicorn entry (env-driven only):

Terminal window
RESEARCHER_PROFILES_ROOT=/path/to/profiles \
uvicorn researcher_profiles.api.app:app --host 127.0.0.1 --port 8109

In Python, create_app takes a store, not a path:

from researcher_profiles.api.app import create_app
from researcher_profiles.store import FilesystemProfileStore
app = create_app(FilesystemProfileStore("/path/to/profiles"), token="...")
from researcher_profiles.store.sql import SqlProfileStore # [sql]
app = create_app(SqlProfileStore("postgresql://user@host/db"))
VarRequiredPurpose
RESEARCHER_PROFILES_ROOTrequired for the uvicorn entry (or pass --profiles-dir)Profile root directory.
RESEARCHER_PROFILES_DATABASE_URLalternative to the aboveSQL profile store URL. Wins over RESEARCHER_PROFILES_ROOT.
RESEARCHER_PROFILES_TOKENoptionalBearer token. If unset/empty the server runs in open mode and logs a warning.
RESEARCHER_PROFILES_HOSToptionalDefault bind address.
RESEARCHER_PROFILES_PORToptionalDefault port.
ANTHROPIC_API_KEYrequired for ask/review/innovate/riffRead by the Anthropic SDK at call time. Not validated at startup; a missing key surfaces as a 500 from the affected endpoint.
RESEARCHER_PROFILES_REFUSAL_THRESHOLDoptionalFloat in [0, 1], default 0.4. Top-search-score below this makes ask/review refuse when strict_corpus=true.
RESEARCHER_PROFILES_EMBEDDING_BACKENDoptionalEmbedding backend spec used when opening or rebuilding an index.
RESEARCHER_PROFILES_DISABLE_INDEXoptionalIf "1", disables index builds.
RESEARCHER_PROFILES_MAX_UPLOAD_MBoptionalSize cap (MB) for PUT /api/v1/profiles/{slug} pushes. Default 50.
RESEARCHER_PROFILES_MATCH_MIN_RANKED_FRACTIONoptionalFloat, default 0 (disabled). Minimum ranked_profiles / total_profiles /match must clear before it fails with a 503 instead of returning a suspiciously thin result.
RESEARCHER_PROFILES_ACCEPT_FULLTEXToptionalIf unset/false, PUT /api/v1/profiles/{slug} strips any paper_fulltext-role artifact from the uploaded archive instead of storing it.
RESEARCHER_PROFILES_LLM_TIMEOUToptionalSeconds. Timeout for the Anthropic SDK call behind ask/review/innovate/riff.
RESEARCHER_PROFILES_EMBEDDING_DEVICEoptionalOverrides the auto-detected device (cpu/cuda/mps) for the st local encoder backend.
OPENAI_API_KEYrequired for the openai embedding backendRead at call time; unset raises MissingEmbeddingBackendError.
VOYAGE_API_KEYrequired for the voyage embedding backendRead at call time; unset raises MissingEmbeddingBackendError.
SOURCE_DATE_EPOCHoptionalUnix timestamp. When set, every generated dateModified and generated_at uses it instead of the current time, for reproducible builds.

These are read by the SDK and rp CLI rather than the HTTP server above. RESEARCHER_PROFILES_ROOT and RESEARCHER_PROFILES_DATABASE_URL are the same two variables the server reads above, not a second pair with a similar name: one directory setting and one database setting, each with a single name, used by every command and every host that means it.

VarPurpose
RESEARCHER_PROFILES_ROOTThe local profiles root (rp install, rp list, rp seek, rp where, …), and the server’s --profiles-dir default above.
RESEARCHER_PROFILES_REGISTRY_URLDefault registry base URL(s) for rp install / rp listr.
RESEARCHER_PROFILES_DATABASE_URLDefault SQL profile store URL for rp db, and the server’s --database-url default above.
XDG_CONFIG_HOMEOverrides where credentials.json is read and written (default ~/.config).

See the CLI reference for the commands that read these.

  • The server is built over a ProfileStore, and every lookup goes through store.get(ref): a slug, a former slug, a rid (the one record about that person), or (on SQL) a record key. On a store that keeps several records about one person (SqlProfileStore(one_profile_per_rid=False)), the host’s hooks.resolve_ref says which record a bare rid means; with no hook such a rid is a 404.
  • FilesystemProfileStore lists every direct subdirectory of its root that contains a profile.jsonld; the directory name becomes the slug. It loads profiles lazily via ResearcherProfile.from_files(<dir>) and holds them in an LRU (capacity 32 by default: FilesystemProfileStore(root, capacity=...), not exposed on the CLI). SqlProfileStore reads rp_profiles.slug and caches nothing.
  • New profile directories added after startup appear in GET /api/v1/profiles (the filesystem is re-scanned each call) but each profile is constructed only on first request. Restart to pick up out-of-band edits to already-loaded profiles, or push the profile via PUT /api/v1/profiles/{slug}, which evicts the cached object and roster snapshot itself.
  • A single static bearer token, configured via RESEARCHER_PROFILES_TOKEN.
  • If the token is unset/empty when the app is built, the server runs in open mode and accepts any request.
  • Auth gates the write / heavy / LLM routes and the interactive edit routes. /health is not authenticated.
  • The read routes have no credential gate. They resolve a viewer tier and project each response through it. A caller with no credential is the viewer whose tier is public. The operator token widens that tier to private.
  • Header format: Authorization: Bearer <token>.
  • Failed auth returns 401 with body {"detail": "invalid or missing bearer token"}.
  • A read refusal is 404, indistinguishable from a nonexistent profile. Every read response carries X-RP-Viewer-Tier.
  • Any read route accepts ?as=anonymous|lab|owner, a preview cap that maps to the public, limited, and private tiers. It can only narrow the tier the caller already holds, never widen it, so it needs no credential of its own. An unknown value is a 400 {"detail": "unknown viewer '<value>' (anonymous | lab | owner)"}.
Terminal window
curl -H "Authorization: Bearer $RESEARCHER_PROFILES_TOKEN" \
http://127.0.0.1:8109/api/v1/profiles

A profile’s display handle (for example jane-doe, john-smith): a directory name on a filesystem store, or the rp_profiles.slug column on a SQL one. Slugs are used verbatim in URL paths with no server-side normalization, and a rid is accepted anywhere a slug is (see “Profile discovery and slugs” for which record it names). Path-traversal characters produce a 404 because the store’s lookup finds no matching profile.

All four role-play as the researcher by injecting expertise.md + SOUL.md into the Anthropic system prompt. They differ in the mode instruction, how retrieved evidence is rendered, and the response shape.

EndpointPurposeDefault kReturns
POST .../askSingle-shot Q&A as the persona, grounded in retrieved chunks. Supports a history list.5LLMTextResponse
POST .../reviewPersona reviews provided material. Retrieval seeded from focus or the first lines of material.5LLMTextResponse
POST .../innovatePersona proposes n research directions on topic, returned as structured Ideas. Retries once on parse failure.12IdeaList
POST .../riffPersona generates n divergent brainstorm fragments on seed.4RiffList

ask and review support a strict_corpus refusal gate: when the top retrieval score is below refusal_threshold (default from RESEARCHER_PROFILES_REFUSAL_THRESHOLD, else 0.4), the endpoint returns a refusal with refused=true and makes no LLM call. innovate and riff have no refusal gate.

ask and review also compute a grounded flag: any [paper_id] citation the model emits that does not match a known paper id flips grounded to false.

Persona precondition (409). All four endpoints require a persona-ready profile: a full/deep profile with a synthesized persona (non-empty expertise.md and SOUL.md). When the profile is not persona-ready (for example a lite profile, or one missing SOUL or expertise), the endpoint returns 409 with {"detail": "profile '<slug>' has no synthesized persona ..."} and makes no LLM call. This is distinct from the 500/502 failure modes, which mean an LLM or JSON-parse failure on a persona-ready profile. Clients should branch on the level returned by GET /api/v1/profiles to avoid calling persona endpoints on lite profiles.

Route authentication varies. GET /health and the read routes are unauthenticated. Search, match, persona, upload, and archive require a bearer token when one is configured. The edit routes are owner-gated. The handlers live in api/routes_read.py, routes_search.py, routes_generative.py, routes_push.py, routes_identity.py, and routes_edit.py; GET /health is defined in api/app.py.


Liveness + profile-count probe. Unauthenticated.

Response 200 (HealthResponse):

FieldTypeNotes
statusstring"ok", or "degraded" on a 503.
storestringDisplay locator for the store being served: an absolute directory path, or a database URL.
profile_countintegerNumber of profiles visible (re-counted each call).
detailstring | nullSet only when degraded, explaining why.
Terminal window
curl http://127.0.0.1:8109/health
{"status": "ok", "store": "/path/to/profiles", "profile_count": 7}

Response 503: the same body with status: "degraded". Bare rp-sdk never returns this: it is an override point for a host that runs its own startup checks and sets app.state.embedding_healthy = False (with app.state.embedding_health_detail) when, say, the query-embedding backend is missing. A container HEALTHCHECK then stops routing traffic to a deployment whose /match would otherwise return an empty list forever.


What this server can be asked for. Unauthenticated: it says nothing about any profile, and a push client needs the answer before it has decided whether to authenticate.

Response 200 (CapabilitiesResponse):

FieldTypeNotes
versionstringAPI version served (v1).
push_modesarrayThe ?mode= values PUT /profiles/{slug} accepts.
featuresarrayNamed behaviours a client can require. manifest_splice: the server adds a manifest entry back for every file it keeps, so a partial incoming manifest cannot delete artifacts the server holds.
{"version": "v1", "push_modes": ["replace", "merge", "prune"], "features": ["manifest_splice"]}

A 404 here means a build that predates this route, which is itself the answer: rp push refuses --merge and --prune against such a server rather than sending a request it would answer 200 to and run as a replace.


The profiles the caller may see, in slug order, as one page-based list. This is not the rp:profileList envelope; a static site’s profiles.json keeps that format.

Query: page (from 0), page_size (default 100, clamped to 1000), and the filters rid (repeatable; any of these identities, each an ORCID or a local: id, else 400), clinical (true or false), and affiliation (exact after trimming, ignoring case). Different filters AND together. Any other query parameter except as is a 400 invalid_argument.

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

Response 200 (Paged[ProfileListEntry]):

FieldTypeNotes
resultslist[ProfileListEntry]This page’s rows.
paginationobject{page, page_size, total, total_pages}. page_size is the size used after clamping; total counts the matches this caller may see.
notestring | nullSet when page_size was clamped.

Each ProfileListEntry:

FieldTypeNotes
urlstringAbsolute .../api/v1/profiles/{slug}/content/, the base URL profile.jsonld and every relative contentUrl resolve from.
slugstringDirectory name.
ridstring | nullThe profile’s identity: an ORCID or a local: id.
namestringprofile.jsonld name.
levelstringDepth tier: one of lite, full, deep. Default "full". See profile depth levels.
affiliationstring | null
fieldstring | null
paper_countintegerNumber of sources/papers.jsonld entries. 0 when absent.
summary_countintegerPapers that have a summary file.
fulltext_pctfloatPercentage of papers with status == "downloaded".
contaminated_countintegerPapers flagged contaminated.
clinicalbooleanTrue when this caller can see a clinical facet: therapeutic areas, site capabilities, or a trials source.

Profiles that fail to load are logged and skipped. The response carries Cache-Control: private, no-store and Vary: Authorization, Cookie: who is in the list depends on who asked. A page can shift if profiles change between requests.

{
"results": [
{
"url": "http://127.0.0.1:8109/api/v1/profiles/jane-doe/content/",
"slug": "jane-doe",
"rid": "0000-0002-1825-0097",
"name": "Jane Doe",
"level": "full",
"affiliation": "Example University",
"field": "Computational Biology",
"paper_count": 8,
"summary_count": 5,
"fulltext_pct": 62.5,
"contaminated_count": 0,
"clinical": false
}
],
"pagination": {"page": 0, "page_size": 100, "total": 1, "total_pages": 1},
"note": null
}

The SDK’s list_remote() walks every page and returns the rows. researcher_profiles.client.iter_results(http, path, params) walks any list endpoint, page-based or token-based.

Status codes: 200, 400 (unknown parameter or malformed rid), 422 (page below 0 or page_size below 1).


One profile, sized for this caller. Query view=record (default) or view=full.

Response 200 (ProfileRecord):

FieldTypeNotes
slugstring
ridstring | nullThe profile’s identity: an ORCID or a local: id.
content_hashstring | null"sha256:<hex>" over the document and the SOUL together. Send it back as base_hash on the next edit to get a 409 instead of overwriting somebody else’s write.
view"record" | "full"
fieldsobjectThe metadata. record: the common fields only, long lists as {"top": [...], "total": n} (interests: top 8 by weight as {label, weight}; career and training: 3 most recent), career_stage scalars, paper_count, summary_count; no JSON-LD plumbing. full: every field untrimmed. Never has_part/subject_of (see /files).
partsobjectsoul, expertise, papers: each {available, bytes, approx_tokens} or {available: false, reason} with reason one of not_permitted, none, not_uploaded. files_withheld: {role: count} of files this viewer may not read.
withheldlist[string]Field names this viewer may not see (each null in fields), plus soul when the narrative is withheld.
soul, expertisestring | nullview=full only: the narrative bodies. null when withheld, "" when empty.

The record view stays under 8 KB for any profile. The response carries a weak ETag built from content_hash, the viewer tier and the view; send it as If-None-Match to get 304. Cache-Control: private, no-store.

Status codes: 200, 304; 404 {"detail": "profile '<slug>' not found"}.


The profile manifest (hasPart plus subjectOf), served whole at every tier. Response 200 (FileList): files (each entry carries the on-disk fields plus effective_visibility, the tier that governs it after the derivation rule, and slot, hasPart or subjectOf) and withheld (the contentUrls this viewer may not read). A push client reads this to learn what the server holds.


GET /api/v1/profiles/{slug}/profile.jsonld

Section titled “GET /api/v1/profiles/{slug}/profile.jsonld”

Serve the profile document: the stored record plus any registry-issued proofs (orcid_login), which the registry computes on each request and never stores. A host supplies them through the app.state.hooks.registry_proofs hook, a callable (rid) -> list[Proof] given the rid of the document being served; bare rp-sdk leaves it None. A hook that raises is logged and treated as no proofs. When there is no section projection and no registry proof, the response is the exact bytes the store persisted, not a re-serialization, so a crawler or agent gets the published document byte for byte. Any registry-issued proof found in a stored document is dropped; only the hook’s are served.

/api/v1/profiles/{slug}/content/profile.jsonld returns the same bytes with the same headers. GET /api/v1/profiles/{slug} carries the same proofs in metadata.proof. The archive (GET /api/v1/profiles/{slug}/archive) is the stored record and carries none.

Supports conditional requests: the response carries a strong ETag (a SHA-256 over the served bytes, so it changes when a registry proof changes) and Last-Modified; a matching If-None-Match gets a 304.

Status codes: 200; 304 (conditional hit); 400 bad slug; 404 {"detail": "profile '<slug>' not found"}.


Upload (create or replace) a profile. The route dispatches on Content-Type: application/json takes a bare profile document (see JSON body below); any other content type is read as a tarball.

Tarball body. A (gzipped) tar archive of one profile directory’s contents: profile.jsonld at the tar root, not nested inside a directory. The archive is validated and staged, then committed into the store: an atomic directory swap on a filesystem store, one transaction on a SQL one. On any failure the existing profile is left untouched.

Client-side, push_profile / rp push builds the archive with the whole-record build_profile_archive, and by default omits sources/papers/ (lite push). Pass --include-fulltext (CLI) or include_fulltext=True (Python) to include extracted paper text. Server-side, sources/papers/ members are stripped on ingest unless the server runs with RESEARCHER_PROFILES_ACCEPT_FULLTEXT=true (default false). The registry refuses to store copyrighted paper text merely because a client sent it.

Query parameter mode (tarball only): what the server does with a live file the archive does not carry. Any other value is a 400.

modeA file the archive omits
replace (default)Deleted, unless it belongs to a withheld class (sources/papers/ fulltext, .cache/embeddings.sqlite) the archive carried no member of; those are kept.
mergeKept, always. This is what rp push --only and --merge send.
pruneDeleted, always.

A file the archive does carry is always written, in every mode.

Keeping a file means keeping its manifest entry. The manifest inside profile.jsonld is the index every reader works from, and the SQL backend writes artifact rows by that manifest, not by what is in staging. So a kept file the incoming manifest does not list is a file nobody can fetch and the SQL store never even persists: keeping the bytes alone is a silent delete. Under merge and replace the committed manifest is therefore the incoming manifest plus an entry for every kept file, taken from the live profile. The spliced field counts how many entries were added back; a nonzero value means the pushed profile.jsonld was not the whole index. Under prune nothing is kept, so nothing is spliced.

GET /api/v1/profiles/{slug}/archive uses a different builder, build_viewer_archive, which projects the profile through the caller’s privacy tier and never ships full text to anyone. See Privacy.

After a successful push the server drops the cached profile object, the in-memory roster snapshot, and the on-disk <root>/.cache/ caches, so the pushed profile is immediately visible to GET /api/v1/profiles and (when it ships a built .cache/embeddings.sqlite index) to POST /api/v1/match, without a restart.

Validation:

  • slug must match ^[a-z0-9][a-z0-9-]*$.
  • The archive must contain profile.jsonld at its root.
  • Members may only be regular files and directories: symlinks, hardlinks, device nodes, absolute paths, and .. traversal are all rejected.
  • The staged profile must load as a ResearcherProfile before the swap.
  • Size cap: 50 MB by default (RESEARCHER_PROFILES_MAX_UPLOAD_MB, or create_app(..., max_upload_bytes=...)).

Response 200 (PushResponse):

FieldTypeNotes
slugstring
ridstring | nullThe stored profile’s identity.
namestringFrom the pushed profile.jsonld.
levelstringProfile depth tier (lite, full, …).
indexedbooleanTrue when the push carried .cache/embeddings.sqlite, i.e. the profile is immediately matchable. Always false for a JSON body.
keptobjectCount of live files the archive omitted but the server kept, keyed by class (fulltext, index, or other under merge). Empty for a JSON body.
splicedintegerManifest entries the server added back for kept files the incoming manifest did not list. Nonzero means the pushed document’s manifest was incomplete.
manifest_countsobject{role: count} over the manifest the server holds after the commit: what a reader can now fetch.
modestringThe push mode that was applied: replace, merge, or prune.

A host adds its own top-level fields by setting a dict on request.state.push_response_extra in its app.state.push_gate.

Terminal window
tar -C /path/to/profiles/jane-doe -czf - . | curl -X PUT \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/gzip" \
--data-binary @- \
http://127.0.0.1:8109/api/v1/profiles/jane-doe

Prefer the CLI (rp push) or client.push_profile(...), which build the tarball for you (excluding dotfiles like .archive/).

Status codes:

  • 200 on success.
  • 400 {"detail": "<reason>"}: bad slug, empty body, unreadable archive, unsafe member, missing profile.jsonld, unknown ?mode=, or a staged profile that fails to load.
  • 401 as elsewhere.
  • 413 {"detail": "archive exceeds size cap"}.

With Content-Type: application/json the body is one profile.jsonld document. The server parses it as a ProfileDocument and writes it with store.put_document, creating a document-only profile (identity plus metadata) or replacing the document of an existing one. Papers, summaries, and indexes are added later by a tarball push or a build.

  • The document must carry a rid. To mint a local: identity instead, send "mintLocalRid": true in the body or ?mint=local on the URL; the name must be non-empty. Minting when the document already has a rid is a 400.
  • If-Match: <content_hash> makes the write conditional: when the profile exists and its current content_hash differs, the response is a 409 {"detail": "content hash mismatch (concurrent edit)"} with the current hash in X-RP-Content-Hash. If-Match is ignored when the profile does not exist yet.
Terminal window
curl -X PUT -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
--data-binary @profile.jsonld \
"http://127.0.0.1:8109/api/v1/profiles/jane-doe?mint=local"

Response 200 (PushResponse), with indexed: false.

Status codes:

  • 200 on success.
  • 400 {"detail": "<reason>"}: bad slug, invalid JSON, a body that is not an object, a missing rid without minting, or a minting conflict.
  • 401/403 missing or insufficient scope.
  • 409 on an If-Match mismatch, or when the store refuses the write.
  • 422 {"detail": "<pydantic errors>"} when the body is not a valid ProfileDocument.

Download a profile as a gzipped tarball, the inverse of PUT. Requires the push scope. The archive is projected through the caller’s viewer tier: it contains exactly what the JSON read surface would serve that caller. Nothing above their tier is included, and the build-local hard floors (.cache/, .keys/) never are. Paper full text is an ordinary tier-governed artifact: it ships to a caller entitled to its effective tier.

The response carries X-RP-Archive-Digest (MD5 of the body, so the client can verify the transfer before committing it to its cache), X-RP-Archive-Tier (naming the tier the archive was built for), and X-RP-Profile-Level (the profile’s depth tier).

Status codes: 200 (Content-Type: application/gzip); 400 bad slug; 401/403 missing or insufficient scope; 404 profile not found.


Resolve a person descriptor (a rid, or a free-text name) to the rid that identifies them in this registry. Requires the resolve scope: a write scope, not match, because a true miss mints a stub profile (level=lite, visibility=limited, provenance=third_party). Putting this behind the read-tier match scope would let every match-keyed consumer create people. Resolution is deterministic and cautious: the same person resolved the same way twice converges on the same rid, and undecidable evidence defers rather than guessing. See researcher_profiles.resolve for the pipeline this route calls, and client.resolve_rid for the client-side half.

Request body (ResolveRequest):

FieldTypeNotes
ridstring | nullAn ORCID, or a previously minted local: id (the disambiguation round-trip).
namestring | nullA free-text name to resolve.
affiliationstring | nullStamped on a minted stub; corroborates or vetoes a name match.
create_newbooleanSkip matching and mint a fresh identity for name, the explicit answer to a deferral whose candidates are all wrong. Default false.

At least one of rid/name is required.

Response 200 or 201 (ResolveResponse):

FieldTypeNotes
ridstring | nullThe resolved rid, or null when the request defers.
createdbooleanWhether this call minted a new profile.
confidencestring"exact" (a rid identity), "high" (a corroborated name match or a fresh mint), or "low" (a deferral).
candidateslist[object]Present only when non-empty: the profiles an undecidable name could mean, each {rid, name, affiliation}.

201 on a true miss (a new identity was minted); 200 otherwise, including a deferral.

Terminal window
curl -X POST http://127.0.0.1:8109/api/v1/identity/resolve \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "Grace Hopper"}'

Prefer client.resolve_rid(...), which builds the request and unpacks the response into a ResolveResult for you.

Status codes:

  • 200 on a rid identity, a corroborated name match, or a deferral.
  • 201 on a true miss (a new identity was minted).
  • 400 {"detail": "<reason>"}: neither rid nor name, a malformed rid, or an unknown local: rid.
  • 401/403 missing or insufficient scope.

The same listing as GET /api/v1/profiles, in the collection bundle shape a static site publishes. It exists for a browser client whose home view fetches a document rather than calling an API, so one build reads a hosted registry and a rendered directory of files without knowing which it is talking to. It is not identical to the static collection.jsonld: this dynamic bundle carries no centroids (see the artifacts row below) and a client ranks against it by calling /match, while the static file carries the centroids inline.

Response 200, a collection bundle:

FieldTypeNotes
@context / @idstringThe profile context IRI; the request URL.
generated_atstringISO 8601, when this response was built.
generatorstringresearcher-profiles/<version>.
countintegerNumber of cards.
cardslistOne per visible profile: the ProfileSummary fields plus base.
backend_spec / dim / artifactsnull / null / []No centroid blob is served over HTTP: it is derived from .cache/, a hard floor. Clients fall back to server-side /match.

Each card’s base is the absolute .../api/v1/profiles/{slug}/content/, the URL that profile.jsonld and every relative contentUrl resolve from. It is absolute rather than root-relative because a client parses it with a bare URL constructor, which has no document to resolve a relative path against. X-Forwarded-Proto and X-Forwarded-Host win over what the ASGI server saw, so TLS terminated at a proxy does not produce http:// bases on an https:// page.

Membership is exactly the membership of GET /api/v1/profiles for the same caller, computed by the same walk. Cache-Control: private, no-store: who is in the list depends on who asked.

Status codes: 200.


GET /api/v1/profiles/{slug}/content/{artifact}

Section titled “GET /api/v1/profiles/{slug}/content/{artifact}”

Serve one manifest artifact, projected through the caller’s viewer tier.

/api/v1/profiles/{slug}/content/ is a base URL: the literal profile.jsonld resolves out of it, and so does every relative contentUrl the manifest names. That is what lets a client point at one base and follow the document, exactly as against a static site.

{artifact} must be a contentUrl present in the manifest (or profile.jsonld itself); any other path, including a traversal, is 404.

profile.jsonld here is the same response as GET /api/v1/profiles/{slug}/profile.jsonld: projected by section visibility, with registry-issued proofs, a strong ETag, and the same cache headers.

/api/v1/profiles/{slug}/ is a base URL too: a GET there that no other route answers (.../personality/SOUL.md, .../sources/papers.jsonld) is served as .../content/<artifact>. So the document’s links resolve from either URL it is served at, as they do on a static site.

Response 200: the artifact bytes, with the manifest’s encodingFormat as the content type. X-RP-Effective-Tier reports the artifact’s effective tier with the derivation rule applied, so a derivative never advertises a looser tier than its sources. Cache-Control: private, no-store.

Status codes: 200; 404 when the profile is not visible to this caller, the artifact is not in the manifest, or the artifact’s effective tier is above this caller’s viewer tier, all with indistinguishable bodies; 403 for the hard floors (paper_fulltext, .cache/, .keys/), which are withheld from every caller including the owner.


Paper rows, token-based, gated on the tier of sources/papers.jsonld.

Query: page_size (default 20, clamped to 100), page_token (the previous page’s pagination.next_page_token), q (rank by hybrid search), year_min, missing_ids (no DOI and no OpenAlex id), has_text, and ids (comma list, at most 20, one page). Any other query parameter except as is a 400 invalid_argument.

Response 200 (PaperPage): results (rows), pagination ({page_size, total, next_page_token}; total counts matching rows before paging; next_page_token is null on the last page), filters_applied, note (a clamped page_size, a search fallback, or a cut ranked list), and with q: search_mode_used (hybrid or keyword).

Each row (PaperRow):

FieldTypeNotes
paper_idstringCitation key (e.g. doe2016example).
titlestring
year, journal, first_author, doi, openalex_id
summary_shortstring | nullThe first ~160 characters of the summary this viewer may read, else of the abstract.
summary, textobjectSizes of the summary and the full text for this viewer: {available, bytes, approx_tokens} or {available: false, reason}.
versionstring16-hex version of this paper, for base_version on an edit.
score, matched_byOnly with q: the fused score and which ranking kept the row (keyword, semantic).

Without q, rows are newest first (year desc, then paper_id) and the token is keyset. With q, keyword ranking (BM25 over title, summary, abstract and journal) and semantic ranking (cosine over the stored summary and abstract vectors, kept at 0.30 or above) are merged by reciprocal rank (k = 60) and returned as one top-N page: pagination has no total and no next_page_token, and a page_token sent with q is a 400. Narrow with year_min or has_text, or raise page_size, to see more. A page_token used with other filters is a 400 with {"detail": {"error": "page_token_mismatch", ...}}.

Status codes: 200, 400, 404.


GET /api/v1/profiles/{slug}/papers/{paper_id}

Section titled “GET /api/v1/profiles/{slug}/papers/{paper_id}”

One paper (PaperRecordView): fields (on-disk names such as datePublished and isPartOf; the record view keeps the first 10 authors and 5 topics and cuts the abstract at 1,500 characters with abstract_truncated), summary inline (the generated summary when this viewer may read it, else the record’s own; summary_source says which), parts (summary and text sizes), sections (full-text headings, when the viewer may read the text), version, and withheld. ?view=full returns the whole record. Gated like /papers.


?ids=a,b,c (at most 20). Response 200 (SummaryBatch): summaries ({paper_id: markdown}), unavailable ({paper_id: reason}), limit_applied, not_processed (ids past the cap).


GET /api/v1/profiles/{slug}/papers/{paper_id}/text and GET /api/v1/profiles/{slug}/text

Section titled “GET /api/v1/profiles/{slug}/papers/{paper_id}/text and GET /api/v1/profiles/{slug}/text”

Bounded reads of a paper’s full text and of the narrative. Each is gated exactly like the content route for that file.

Query: section (a heading name from sections; for the narrative, soul or expertise), offset (an index into the whole stored text, also when section is given), max_chars (default 80,000, clamped to 200,000).

Response 200 (TextPage): text, section, offset, returned_chars, total_chars (of the section, or of the whole text), has_more, next_offset (pass it as offset to continue), sections ([{name, offset, chars}]), and for the narrative content_hash. A cut page ends at a paragraph break when one is near. An unknown section is a 400 {"detail": {"error": "unknown_section", "valid": [...]}}.


POST /api/v1/profiles/{slug}/papers/{paper_id}/passages and POST /api/v1/profiles/{slug}/passages

Section titled “POST /api/v1/profiles/{slug}/papers/{paper_id}/passages and POST /api/v1/profiles/{slug}/passages”

{"query": str, "k": int} (k default 3, clamped to 10). The passages in one paper (full text, summary, abstract) or one profile (soul, expertise, and the cv, web and grant chunks this viewer may read) that best answer the query.

Response 200 (PassageList): passages (each {source, source_id, section, offset, text, score, matched_by}, text at most 1,200 characters; offset works with the text routes for full_text, soul and expertise), k_applied, search_mode_used (hybrid, keyword, or hybrid+keyword_fulltext when full text was searched), searched, and note. Full text and private sources have no stored vectors, so they are searched by keyword only, and the note says so. Only sources this viewer may read are searched.


Semantic search over one profile’s stored chunk vectors (the SQL store’s rp_chunk_vectors, or a directory’s index), returning each hit’s text.

Request body (SearchRequest):

FieldTypeRequiredDefaultNotes
querystringyesnoneWhitespace-only queries return an empty hit list.
kintegerno5Number of hits; clamped to 20 (k_applied says what ran).
filterobject | nullnonullOnly source_type is recognized (string or list). Candidates are fetched at k*4 then post-filtered.

Known source_type values, which depend on the profile’s level: paper_summary, expertise, soul (full and deep); paper_abstract (lite only); grant, cv, web (deep only, and only for sources that profile actually carries).

Response 200 (SearchResponse, with hits: list[SearchHitPayload]):

FieldTypeNotes
textstringChunk text, at most 1,200 characters (truncated: true when cut).
source_typestringSee the known values above.
source_idstringPaper id for summaries and abstracts; grant id for grants; page filename stem for web pages; expertise, soul, or cv for those documents.
chunk_indexinteger0-based chunk index within the source.
sectionstring | nullSection heading for markdown chunks; null otherwise.
scorefloatCosine similarity in [0, 1], rounded to 4 decimals.
metaobjectFree-form. May contain {"abstract_only": true}.
Terminal window
curl -X POST -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"query": "region set enrichment", "k": 3, "filter": {"source_type": "paper_summary"}}' \
http://127.0.0.1:8109/api/v1/profiles/jane-doe/search

Status codes:

  • 200 on success (including empty hits).
  • 401, 404 as elsewhere.
  • 503 when the profile has no stored vectors or no query encoder is available. Treat it as “search not available for this profile” and fall back.

Rank all indexed profiles against a free-text query. Runs the store’s centroid prefilter + chunk re-rank + optional MMR diversification (store.match.rank). This route is not profile-scoped.

Request body (MatchRequest):

FieldTypeRequiredDefaultNotes
querystringyesnoneFree-text query.
kintegerno5Number of matches to return.
prefilterintegerno10Centroid-prefilter width before chunk re-rank.
require_topicslist[string] | nullnonullRestrict candidates to profiles carrying these topics.
diversifybooleannotrueApply MMR diversification.
lambda_floatno0.5MMR relevance/diversity trade-off.
topk_chunksintegerno5Chunks used per profile in the re-rank.
normalizebooleannotrueApply per-profile score calibration.
include_chunksbooleannofalseInclude chunk-level evidence in each result.

Response 200 (MatchResponse):

FieldTypeNotes
matcheslist[MatchResult]The ranked results.
ranked_profilesintegerHow many profiles were actually ranked, after privacy-tier filtering.
total_profilesintegerSize of the indexed corpus this query ran against.

The two counts let a caller tell “0 of 47 ranked” (a broken embedding path) from “47 of 47 ranked, none above threshold.” An empty matches list alone cannot make that distinction. Setting RESEARCHER_PROFILES_MATCH_MIN_RANKED_FRACTION makes the route fail loud with a 503 when ranked_profiles / total_profiles falls below that fraction; it defaults to 0 (disabled), since a narrow result is usually legitimate.

Each MatchResult:

FieldTypeNotes
slugstringMatched profile slug. Useful for links; not a join key.
namestringProfile name.
ridstring | nullThe join key: an ORCID or a local: id. Map matches onto your own users by this, never by name.
orcidstring | nullORCID when the profile carries one; null for a local: rid.
scorefloatMatch score (calibrated when normalize=true).
evidenceMatchEvidencePayloadSee below.

MatchEvidencePayload:

FieldTypeNotes
centroid_scorefloatCosine of query to the profile centroid.
top_paperslist[string]Paper ids of the top-matching summary chunks.
overlapping_topicslist[string]Profile topic labels that overlap the query.
top_chunkslist[SearchHitPayload]Populated only when include_chunks=true; otherwise [].
Terminal window
curl -X POST -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"query": "region set enrichment analysis", "k": 5}' \
http://127.0.0.1:8109/api/v1/match
{
"matches": [
{
"slug": "jane-doe",
"name": "Jane Doe",
"rid": "0000-0002-1825-0097",
"orcid": "0000-0002-1825-0097",
"score": 0.969,
"evidence": {
"centroid_score": 0.71,
"top_papers": ["doe2016example"],
"overlapping_topics": [],
"top_chunks": []
}
}
],
"ranked_profiles": 7,
"total_profiles": 7
}

Status codes:

  • 200 on success.
  • 401 if auth fails.
  • 500 {"detail": "match failed: <message>"} if ranking raises.
  • 503 {"detail": "matching unavailable: ..."} when the server lacks the vectors/st extras, the roster failed to build, or no profile is indexed; also 503 {"detail": "match ranked <n>/<total> profiles, below the configured floor ..."} when RESEARCHER_PROFILES_MATCH_MIN_RANKED_FRACTION is set and the ranked fraction falls below it. Callers should degrade gracefully on 503.

The Python client for this endpoint is the module-level researcher_profiles.client.rank_against; see How to access profiles over HTTP.


Conflict-of-interest check between a candidate reviewer and a manuscript’s author set. Requires the match scope. Returns every COI edge between them: coauthorship within years, a shared institution, or an advising relationship, with the reason and parameters that fired. An author supplied with only a name and affiliation (no profile) still trips a same-institution COI.

Request body:

FieldTypeDefaultDescription
author_setlist of author descriptors[]Each: name, orcid, rid, affiliation, affiliation_id, any subset.
candidatestringnoneThe reviewer to check: a slug or rid.
yearsinteger4Coauthorship recency window.

Response 200: {candidate, rid, has_coi, reasons: [...]}, where each reason names its type (coauthor | shared_institution | advised), the author it fired against, and supporting detail.

Status codes: 200; 401/403 missing or insufficient scope; 404 {"detail": "candidate '<candidate>' not found in graph"}.


Expertise ranking (as /match) composed with a COI filter. This is a thin wrapper over the same ranking code. Requires the match scope.

Request body: every /match field, plus:

FieldTypeDefaultDescription
author_setlist of author descriptors[]The manuscript’s authors, for COI filtering.
yearsinteger4Coauthorship recency window.
mode"drop" | "annotate""drop"drop removes conflicted candidates; annotate keeps them and attaches a coi block.

Response 200: {matches: [...]}, each a /match result plus a coi block (present when a candidate has a COI: always in annotate mode, only on retained-but-flagged candidates otherwise; drop mode omits conflicted candidates entirely).

Status codes: 200; 400 mode is neither drop nor annotate; 500 {"detail": "match failed: <message>"}.


Neighborhood of one person: coauthors and collaborators-of-collaborators. Requires the match scope. Backs team assembly and the collaboration recommender.

Query parameters:

ParameterDefaultDescription
typesall typesComma-separated edge types.
since_yearnoneRecency filter on coauthor edges.
max_hops11 for direct neighbors, 2-3 for the reachable-but-not-direct frontier.

Response 200: {center, neighbors: [{node, hops, edges}, ...]}.

Status codes: 200; 400 an invalid types value, or since_year / max_hops not an integer, or max_hops outside 1-3; 404 {"detail": "ref '<ref>' not found in graph"}.


Rank candidate works against ONE profile, the inverse of /match. Requires the match scope.

Two modes: supply candidate works (PaperRecord-shaped dicts), or set use_openalex=true to fetch works published since since (default: the last 30 days) from OpenAlex, seeded by the profile’s own topics and citation neighborhood.

Request body:

FieldTypeDefaultDescription
sincestring | null30 days backYYYY-MM-DD, for use_openalex.
kinteger10Number of ranked works to return.
kind"centroid""centroid"Which profile vector to rank against. Only the stored centroid is served; any other value is a 422.
diversifybooleantrueMMR-style diversification.
lambda_float0.5Diversification tradeoff.
thresholdfloat | nullnoneDrop works scoring below it.
use_openalexbooleanfalseFetch candidates from OpenAlex instead of works.
workslist of objects | nullnoneCandidate works, PaperRecord-shaped.
max_pagesinteger5OpenAlex pagination cap.

With use_openalex, the server calls OpenAlex with its own API key from the OPENALEX_API_KEY environment variable, sent as an Authorization: Bearer header. Without it, OpenAlex allows only about $0.10 of usage a day.

Response 200: {slug, rid, works: [...]}, each a ranked work with its score.

Status codes: 200; 400 an invalid candidate work in works, neither works nor use_openalex supplied, or a profile with no subfields, interests, or OpenAlex work ids to query with; 422 a kind other than "centroid"; 502 {"detail": "OpenAlex fetch failed"}; 503 {"detail": "OpenAlex's daily limit is used up; try again after midnight UTC"}; 500 {"detail": "work ranking failed: <message>"}; 503 {"detail": "matching unavailable: ..."} when the server lacks the vectors/st extras or the profile has no stored centroid. That check runs before any OpenAlex call, so it spends none of the daily budget.


Single-shot question answering in the researcher’s voice, grounded by retrieval.

Request body (AskRequest):

FieldTypeRequiredDefaultNotes
questionstringyesnoneThe question.
kintegerno5Evidence chunks retrieved.
modelstring | nullnonullAnthropic model override. Default claude-sonnet-4-6.
strict_corpusbooleannofalseRefuse (no LLM call) if top retrieval score is below refusal_threshold.
refusal_thresholdfloat | nullnonullOverrides the env default (0.4). Only consulted when strict_corpus is true.
historyarray of {role, content} | nullnonullPrior turns. Trimmed from the head when total content exceeds ~12000 chars.

Response 200: LLMTextResponse (see Common response shapes).

On a tripped refusal gate the response is still 200 with text set to the refusal message, refused=true, refusal_reason populated, model="<none>", zeroed usage, and empty citations.

Status codes: 200, 401, 404, 409 (profile has no synthesized persona), 500 ({"detail": "ask failed: <message>"}).


Persona reviews the supplied material. Retrieval is seeded from focus if provided, otherwise from the first lines of material.

Request body (ReviewRequest):

FieldTypeRequiredDefaultNotes
materialstringyesnoneText to review.
focusstring | nullnonullFocus hint; seeds retrieval and shapes the review.
kintegerno5Evidence chunks retrieved.
modelstring | nullnonullModel override.
strict_corpusbooleannofalseSame refusal semantics as ask.
refusal_thresholdfloat | nullnonullSame as ask.

Response 200: LLMTextResponse. On refusal, text is the review-refusal message and refused=true.

Status codes: 200, 401, 404, 409 (profile has no synthesized persona), 500 ({"detail": "review failed: <message>"}).


Generate n novel research directions on a topic, as the researcher. The model is told to return strict JSON; retries once on parse failure.

Request body (InnovateRequest):

FieldTypeRequiredDefaultNotes
topicstringyesnoneTopic area.
nintegerno3Ideas requested. Returned items may be fewer.
kintegerno12Evidence chunks retrieved as prior-work context.
modelstring | nullnonullModel override.
temperaturefloatno0.7Sampling temperature.

Response 200 (IdeaList, with items: list[IdeaPayload]):

FieldTypeNotes
hypothesisstringOne-sentence testable claim.
approachstringData, method, comparison.
rationalestringWhy this researcher specifically.
related_workslist[string]Citation keys. Not guaranteed to match the profile’s real paper_ids.

Status codes:

  • 200 on success.
  • 401, 404 as elsewhere.
  • 409 (profile has no synthesized persona): no LLM call is made.
  • 502 {"detail": "LLM parse error: <message>"} if the model fails to produce valid JSON on both attempts (GenerativeParseError).
  • 500 {"detail": "innovate failed: <message>"} for any other error (e.g. a missing ANTHROPIC_API_KEY).

To map related_works to real papers, intersect the keys with GET /api/v1/profiles/{slug}/papers.


Generate n short divergent brainstorm fragments on a seed. JSON-constrained; retries once on parse failure.

Request body (RiffRequest):

FieldTypeRequiredDefaultNotes
seedstringyesnonePhrase or short paragraph to riff on.
nintegerno5Riffs requested.
kintegerno4Evidence chunks retrieved (sparser than innovate).
modelstring | nullnonullModel override.
temperaturefloatno1.0Higher than innovate to encourage divergence.

Response 200 (RiffList, with items: list[RiffPayload]):

FieldTypeNotes
anglestringShort free-form label.
textstring2 to 5 sentences in the researcher’s voice.
related_workstring | nullOptional single citation key; "" and "null" are normalized to null.

Status codes: 200, 401, 404, 409 (profile has no synthesized persona), 502 (parse error), 500. Same semantics as innovate.

Gated by the service’s edit gate rather than by the consumer token (see Authentication). On bare rp-sdk that falls back to the operator bearer token. A host with accounts sets app.state.hooks.edit_gate and these become per-person and per-profile.

MethodPathDescription
PATCH/api/v1/profiles/{slug}/metadataPatch owner-editable metadata; the field soul replaces personality/SOUL.md in the same write.
GET / PATCH/api/v1/profiles/{slug}/visibilityRead / set artifact tiers.
POST/api/v1/profiles/{slug}/worksAdd one new work; an existing paper_id is a 409.
PATCH/api/v1/profiles/{slug}/works/{paper_id}Patch owner-editable fields of one work.
DELETE/api/v1/profiles/{slug}/works/{paper_id}Remove one work from the corpus.
POST/api/v1/profiles/{slug}/seed-papersAdd interest seed papers, {"records": [...]} of already-resolved metadata (a title plus an openalex_id or doi). Returns added, already_seed, already_authored, over_cap.
DELETE/api/v1/profiles/{slug}/seed-papers/{seed_id}Remove one seed by seed_id (W… or doi:<doi>).

GET /api/v1/profiles/{slug}/seed-papers (the read surface) lists the interest seed papers: papers that define the person’s interests, never works they wrote. It is page-based (page, page_size default 100, clamped to 500), in seed_id order, answered as {results, pagination, note}. It is gated on the tier of sources/seed_papers.jsonld, private by default, so the public gets a 404. Seeds feed the centroid and the feed query, never the works list, the narrative, or any search hit (seed_abstract chunks are rank-only).

To read a work before editing it, use the public GET /api/v1/profiles/{slug}/papers/{paper_id}?view=full.

Every field of the profile document except a short locked list. That includes every field the build AI writes (summary, expertise, career_stage, critiques, collaborators, research_outputs, and the rest): an owner can always correct what a model guessed about them, without a rebuild.

The locked fields are researcher_profiles.profile.edit.LOCKED_METADATA_FIELDS:

  • identity and proof: rid, @id, provenance, provenanceNote, proof, verifiedAt, identifier
  • facts code computes from the corpus: paper_stats, anchor, level, hasCitationGraph, hasEmbeddingIndex, expertiseCitesPaperIds
  • the file manifest: hasPart, subjectOf
  • format markers and bookkeeping: @context, @type, conformsTo, url, dateModified
  • visibility, which has its own route: visibility, rp:sectionVisibility

Everything else is editable, and researcher_profiles.profile.edit.EDITABLE_METADATA_FIELDS is that set. A patch names fields by their Python names (job_title, license_), not their JSON-LD terms.

interests and not_interests are a view of research_interests. Patching either list writes declared text-only entries (+0.5 / -0.5, generator user) in place of the earlier text-only entries for that list, and the list is rebuilt from them.

A locked key is a hard 400, never a silent drop. slug is popped by the route, so renaming is not an edit. personality/expertise.md is unrouted: it is an artifact, not a document field, it cites paper ids, and it is a different thing from the editable expertise labels despite the shared name.

Structured fields arrive as objects and are validated against their schema models: training (schema.Training), career (schema.CareerEntry), research_interests, therapeutic_areas, research_outputs (schema.ResearchOutput), site_capabilities, and career_stage (schema.CareerStage). A malformed entry is a 400 naming the field or index (career[0] is not a valid CareerEntry: ...) and nothing is written. A patch replaces a list field whole. A single-object field (career_stage, site_capabilities) is merged instead: {"career_stage": {"current_rank": "professor"}} changes that one fact and keeps the rest. A key sent as null clears that key; the field sent as null clears the whole object.

researcher_profiles.profile.edit.EDITABLE_WORK_FIELDS, exactly:

name, doi, openalex_id, datePublished, type, citation, full_text_link, access, summary, first_author, author_position, is_corresponding.

These are the names the record carries on disk, so a caller patches what it read out of sources/papers.jsonld. Anything outside the set is a 400 (cited_by_count and the other build-derived counts included); a paper_id that is not in the corpus is a 404. The patched record is re-validated before anything is written, so a bad value is a 400 and the file is untouched. rp work set <paper_id> key=value ... is the CLI for this.

Optimistic concurrency (base_hash, base_version -> 409)

Section titled “Optimistic concurrency (base_hash, base_version -> 409)”

GET /api/v1/profiles/{slug} returns content_hash. Send it back as base_hash on a metadata patch (the narrative included) and a concurrent change becomes a 409 carrying the current digest in the body and in X-RP-Content-Hash:

{"detail": {"error": "conflict", "current": "sha256:...", "message": "this profile changed since you loaded it; reload it and re-apply your edit"}}

A work has its own token: each paper row and record carries version. Send it as base_version on PATCH /works/{paper_id} (in the body) or DELETE (a query parameter); a stale one is a 409 with the current version in X-RP-Paper-Version. The reply’s version is the new one.

Omit the token and the write is last-writer-wins. A single-owner CLI does not need one. Every successful edit returns the new content_hash on EditResult, so a form held open can chain writes without re-reading.


Wire shape of profile.jsonld. extra="allow": additional keys round-trip unchanged.

FieldTypeNotes
namestringRequired.
levelstringDepth tier: one of lite, full, deep. Default "full". See profile depth levels.
ridstring | nullIdentity: an ORCID or a local: id. There is no orcid key; derive it from rid with orcid_of(rid).
provenancestring | nullWho asserted this profile and on what basis (schema.Provenance).
licensestring | nullReuse terms for the published record, an IRI.
urlstring | nullThe published profile URL.
affiliationstring | null
scholar_urlstring | null
openalex_idstring | null
fieldstring | null
subfieldslist[string]Default [].
summarystring | null
job_titlestring | null
traininglist[dict]Authored history; entries match schema.Training.
careerlist[dict]Authored history; entries match schema.CareerEntry.
expertiselist[string]Default []. Distinct from the expertise markdown body of the full view.
interestslist[string]Default []. Rebuilt from research_interests (positive weights) when that is set.
not_interestslist[string]Default []. Authoritative: a consumer must not improvise around them. Rebuilt from research_interests (negative weights) when that is set.
research_interestslist[ResearchInterest]Default []. Typed, weighted interests, in the same key form as profile.jsonld (@type, @id, assertedAt).
same_aslist[string]Default []. Other URLs for the same person.
visibilitystringThe document’s OWN declared tier. Read-only here. Set it through PATCH .../visibility, never a metadata patch.

LLMTextResponse (shared by ask and review)

Section titled “LLMTextResponse (shared by ask and review)”
FieldTypeNotes
textstringThe model’s prose.
citationslist[CitationRefPayload]One entry per retrieved evidence chunk (built from retrieval, not parsed from the text). Empty on refusal.
modelstringResolved model id. "<none>" on a refusal with no LLM call.
usageobjectToken usage: input_tokens, output_tokens, cache_creation_input_tokens, cache_read_input_tokens. Zeroed on refusal.
request_idstring | nullAnthropic request id when available.
refusedbooleanTrue when the strict_corpus gate fired.
refusal_reasonstring | nullPopulated only when refused=true.
groundedbooleanFalse if the model cited a [paper_id] not in the profile. Always true for refusals and when no citation was emitted.
FieldTypeNotes
paper_idstring
relevancefloat | nullCosine similarity from retrieval.
spanstring | nullReserved; typically null.

Errors follow the FastAPI default: {"detail": "<string>"} with the appropriate HTTP status. There is no global error wrapper or machine-readable error code.

The server applies no rate limiting or concurrency caps. Throttle on the client side for multi-profile workflows.

Every successful ask/review/innovate/riff call makes at least one Anthropic API call (innovate/riff up to two on a JSON retry). The persona prefix is sent with prompt caching; check usage.cache_read_input_tokens to confirm cache hits.

ANTHROPIC_API_KEY is not checked at startup. A missing key surfaces only when an LLM endpoint is invoked, as a 500.

The profile cache is in-process. Loaded profiles live until LRU eviction (capacity=32) or process exit. Restart to pick up edits to profile.jsonld, expertise.md, SOUL.md, or paper files.

POST .../search returns 500 (sometimes 501) for profiles without a built .cache/embeddings.sqlite.

/api/v1/match requires embeddings and indexes. It returns 503 on a core-only server or when no profile is indexed.

ask/review/innovate/riff call prof.index.search(...) for evidence. If search raises, the exception is swallowed and the LLM call proceeds with no evidence. The response is less grounded.

ask/review/innovate/riff return 409 (no LLM call) when the profile has no synthesized persona, for example a lite profile. Branch on the level field in GET /api/v1/profiles to avoid calling them.

strict_corpus only applies to ask and review. innovate and riff always call the model.

citations in LLMTextResponse reflects the retrieved chunk set (cosine scores as relevance). Parse [paper_id] tokens out of text to know what the model actually cited.

ask has no streaming variant in v1.

/health works without auth and reveals store (a directory path or a database URL, which may carry a username).

For cross-profile ranking, prefer POST /api/v1/match, which runs store.match’s centroid prefilter + chunk re-rank + MMR server-side and returns ranked profiles with evidence. For richer signals you can still combine per-profile /search or /ask calls and synthesize externally.

Because the LLM endpoints share the persona-prefix prompt cache per profile, alternating between profiles defeats the cache for the swapped-out persona. Batch all calls to one persona before switching where possible.

Command-line login is not implemented here

Section titled “Command-line login is not implemented here”

rp login and rp whoami call the endpoints a server names in /.well-known/oauth-authorization-server, as specified in Command-line login. The server in this package does not implement them: it has no accounts, no identity provider, and no key store, so it publishes no OAuth metadata and the client reports that the server offers no command-line login. Point those commands at a server that offers it.