How to serve a profile API
The SDK includes a FastAPI server for serving profiles over HTTP. Use it when you need programmatic access, cross-profile search/match, or the persona endpoints.
To call the server from Python or curl, see How to access profiles over HTTP.
Start the server
Section titled “Start the server”The CLI entry point serves a profiles directory:
python -m researcher_profiles.api \ --profiles-dir /path/to/profiles \ --host 127.0.0.1 \ --port 8109--profiles-dir defaults to the RESEARCHER_PROFILES_ROOT environment variable,
--host to RESEARCHER_PROFILES_HOST (default 127.0.0.1), and --port to
RESEARCHER_PROFILES_PORT (default 8109). See the
API reference for all environment
variables.
Set a bearer token
Section titled “Set a bearer token”Set RESEARCHER_PROFILES_TOKEN to require Authorization: Bearer <token> on
the write, search, match, persona, and edit routes. The read routes stay open
and instead project each response through the caller’s viewer tier, which the
token widens to private:
export RESEARCHER_PROFILES_TOKEN="a-long-random-string"python -m researcher_profiles.api --profiles-dir /path/to/profilesIf the token is unset or empty, the server runs in open mode and logs a
warning; every request is accepted. Do not expose an open-mode server to
untrusted networks. The /health route is always unauthenticated.
Push a built profile to a server
Section titled “Push a built profile to a server”Profiles are built locally by whatever process produces a conforming profile
directory. They are then indexed (rp index <dir>, so /match can rank them)
and pushed to the serving instance. The server never builds profiles itself.
rp push /path/to/profiles/jane-doe \ --url http://127.0.0.1:8109The slug defaults to the directory name (--slug overrides); the token
defaults to RESEARCHER_PROFILES_TOKEN. Or from Python:
from researcher_profiles.client import push_profile
result = push_profile("http://127.0.0.1:8109", "/path/to/profiles/jane-doe")print(result.plan.counts()) # {"added": 12, "changed": 3, "unchanged": 41, ...}print(result.summary) # {"slug": ..., "rid": ..., "name": ..., "level": ..., "indexed": ...}Every push runs a preflight first: it diffs the archive against the server’s
manifest (one GET /api/v1/profiles/{slug}) and refuses, without uploading
anything, if the push would delete files the server holds. rp push --dry-run
prints that plan and stops; --force pushes anyway; --only PATH ... narrows
the archive to profile.jsonld plus the named files.
A push mode (?mode= on the PUT) says what the server does with a live file
the archive does not carry:
| Mode | Flag | A file the archive carries | A file it omits |
|---|---|---|---|
replace | (default) | written | kept only when it belongs to a withheld class (fulltext, the sqlite index) the archive carried no member of; otherwise deleted |
merge | --merge, and implied by --only | written | kept, always |
prune | --prune | written | deleted, always |
replace is the default because a full build push should reproduce the
directory it was built from. --only selects merge on its own: naming a
file to send has never meant “and delete the rest of the profile”, so nothing
appears as a removal and the push does not refuse.
The push builds the archive with build_profile_archive, the whole-record
transfer builder. Only the documented profile members ship. Dotfiles and
non-spec build residue (for example a sources/html/ scrape cache) are
dropped. By default the push is lite: sources/papers/
(the extracted full paper text) is omitted. Pass --include-fulltext (CLI) or
include_fulltext=True (push_profile) to carry paper text. The server
strips fulltext on ingest unless it runs with
RESEARCHER_PROFILES_ACCEPT_FULLTEXT=true. A spec-clean profile is text-only
and a few MB even with fulltext included, so either way it sits comfortably
under the 50 MB cap; there is no need to truncate a profile to fit. A profile
that hits the cap is signalling accidental non-text content, not normal
growth.
The server’s GET .../archive route calls build_viewer_archive, not
build_profile_archive, so it projects the profile through the reader’s
privacy tier instead of handing over the full record. Keeping the two as
separate functions means the tier cannot be bypassed by passing the wrong one.
The server swaps the profile into place atomically. The pushed profile is
immediately visible to GET /api/v1/profiles with no server restart. If
.cache/embeddings.sqlite was included it is also visible to /api/v1/match.
See the
API reference for validation
rules and the size cap.
How the server answers lists
Section titled “How the server answers lists”Every list route answers one page as {results, pagination, note}.
GET /api/v1/profiles and .../seed-papers page by number (page,
page_size); .../papers pages by token (page_token, and
pagination.next_page_token in the answer). An oversized page_size is
lowered to the route’s maximum, never refused, and note says so. A query
parameter the route does not know is a 400, so a mistyped filter fails
instead of returning everything. See
Lists, paging and filters.