Skip to content

HTTP API reference

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

  • Profile listing and metadata (/profiles, /profiles/{slug})
  • Paper inventories and per-paper summaries (/profiles/{slug}/papers, .../summary/{paper_id})
  • 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 [embeddings] tier at runtime to answer /search and /api/v1/match. Install the server with pip install "researcher-profiles[api,embeddings]".

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_DIR(required)Directory holding one subdirectory per profile, each with a profile.yaml.
--hostRESEARCHER_PROFILES_HOST127.0.0.1Bind address.
--portRESEARCHER_PROFILES_PORT8109Bind port.
--verbose / -voffSets logging to DEBUG.

Alternate uvicorn entry (env-driven only):

Terminal window
RESEARCHER_PROFILES_DIR=/path/to/profiles \
uvicorn researcher_profiles.api.app:app --host 127.0.0.1 --port 8109
VarRequiredPurpose
RESEARCHER_PROFILES_DIRrequired for the uvicorn entry (or pass --profiles-dir)Profile root directory.
RESEARCHER_PROFILES_TOKENoptionalBearer token. If unset/empty the server runs in open mode and logs a warning.
RESEARCHER_PROFILES_HOSToptionalDefault host.
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.
  • The cache lists every direct subdirectory of profiles_dir that contains a profile.yaml. The directory name becomes the slug.
  • Profiles are loaded lazily via ResearcherProfile.from_files(<dir>) and held in an LRU cache (capacity 32 by default; configurable via create_app(..., cache_capacity=...), not exposed on the CLI).
  • 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 registry 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 applies to every route under /api/v1/. /health is not authenticated.
  • Header format: Authorization: Bearer <token>.
  • Failed auth returns 401 with body {"detail": "invalid or missing bearer token"}.
Terminal window
curl -H "Authorization: Bearer $RESEARCHER_PROFILES_TOKEN" \
http://127.0.0.1:8109/api/v1/profiles

A directory name under profiles_dir (for example jane-doe, john-smith). Slugs are used verbatim in URL paths with no server-side normalization. Path-traversal characters produce a 404 because the filesystem lookup finds no matching directory.

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.

All /api/v1/* routes require the bearer token when one is configured. The order below mirrors routes.py.


Liveness + profile-count probe. Unauthenticated.

Response 200 (HealthResponse):

FieldTypeNotes
statusstringAlways "ok" on success.
profiles_dirstringAbsolute path to the configured profile root.
profile_countintegerNumber of profile subdirectories visible (re-scanned each call).
Terminal window
curl http://127.0.0.1:8109/health
{"status": "ok", "profiles_dir": "/path/to/profiles", "profile_count": 7}

List all profiles in the configured directory.

Response 200list[ProfileSummary]:

FieldTypeNotes
slugstringDirectory name.
namestringprofile.yaml name.
levelstringDepth tier: one of lite, full, deep. Default "full". See profile depth levels.
affiliationstring | null
fieldstring | null
paper_countintegerNumber of sources/papers.yaml entries. 0 when absent.
summary_countintegerPapers that have a summary file.
fulltext_pctfloatPercentage of papers with status == "downloaded".
contaminated_countintegerPapers flagged contaminated.

Profiles that fail to load are logged and silently skipped.

[
{
"slug": "jane-doe",
"name": "Jane Doe",
"level": "full",
"affiliation": "Example University",
"field": "Computational Biology",
"paper_count": 8,
"summary_count": 5,
"fulltext_pct": 62.5,
"contaminated_count": 0
}
]

Status codes: 200, 401.


Full profile detail: metadata + raw expertise.md + raw SOUL.md.

Response 200ProfileDetail:

FieldTypeNotes
slugstring
metadataProfileMetadataPayloadSee Common response shapes.
expertisestringRaw markdown body of personality/expertise.md. May be empty.
soulstringRaw markdown body of personality/SOUL.md. May be empty.
{
"slug": "jane-doe",
"metadata": {
"name": "Jane Doe",
"level": "full",
"orcid": "0000-0002-1825-0097",
"affiliation": "Example University",
"field": "Computational Biology",
"subfields": ["epigenomics", "chromatin"]
},
"expertise": "## Region set analysis\n\n...",
"soul": "## How I think\n\n..."
}

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


Upload (create or replace) a profile. The request body is a (gzipped) tar archive of ONE profile directory’s contentsprofile.yaml at the tar root, not nested inside a directory. The archive is validated and staged, then atomically swapped into <profiles_dir>/<slug>; on any failure the existing profile is left untouched.

After a successful push the server drops the cached profile object, the in-memory registry snapshot, and the on-disk .registry/ caches, so the pushed profile is immediately visible to GET /api/v1/profiles and — when it ships a built meta/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.yaml 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 200PushResponse:

FieldTypeNotes
slugstring
namestringFrom the pushed profile.yaml.
levelstringProfile depth tier (lite, full, …).
indexedbooleanTrue when the push carried meta/embeddings.sqlite, i.e. the profile is immediately matchable.
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 (researcher-profiles 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, unreadable archive, unsafe member, missing profile.yaml, or a staged profile that fails to load.
  • 401 as elsewhere.
  • 413 {"detail": "archive exceeds size cap"}.

List every paper attached to the profile.

Response 200list[PaperEntry]:

FieldTypeNotes
paper_idstring | nullCitation key (e.g. doe2016example).
titlestringRequired.
yearinteger | null
journalstring | null
first_authorstring | null
full_text_linkstring | null
summary_availablebooleanTrue iff paper_id is non-null and a summary file exists.

Status codes: 200, 404, 401.


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

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

Fetch the markdown summary for one paper.

Response 200PaperSummary:

FieldTypeNotes
paper_idstringEchoes the request.
summarystringFull summary markdown.

Status codes: 200; 404 if the profile is missing or paper_id has no summary: {"detail": "summary '<paper_id>' not found for profile '<slug>'"}; 401.


Semantic search over the profile’s chunk-level sqlite-vec index.

Request bodySearchRequest:

FieldTypeRequiredDefaultNotes
querystringyesWhitespace-only queries return an empty hit list.
kintegerno5Number of hits.
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 200SearchResponse with hits: list[SearchHitPayload]:

FieldTypeNotes
textstringRaw chunk text.
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.
  • 500 {"detail": "search failed: <message>"} if .search raises — for example, a profile whose meta/embeddings.sqlite has not been built raises IndexNotBuiltError, surfaced as 500. Treat 5xx as “search not available for this profile” and fall back.
  • 501 {"detail": "search not available on this server"} if the loaded profile has no search method (server built without the embeddings extra).

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

Request bodyMatchRequest:

FieldTypeRequiredDefaultNotes
querystringyesFree-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 200MatchResponse with matches: list[MatchResult]:

FieldTypeNotes
slugstringMatched profile slug.
namestringProfile name.
orcidstring | nullORCID when the profile carries one.
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",
"orcid": "0000-0002-1825-0097",
"score": 0.969,
"evidence": {
"centroid_score": 0.71,
"top_papers": ["doe2016example"],
"overlapping_topics": [],
"top_chunks": []
}
}
]
}

Status codes:

  • 200 on success.
  • 401 if auth fails.
  • 500 {"detail": "match failed: <message>"} if ranking raises.
  • 503 {"detail": "matching unavailable: install researcher-profiles[embeddings] and build indexes"} when the server lacks the embeddings extra or no profile is indexed. Callers should degrade gracefully on 503.

The Python client for this endpoint is the module-level researcher_profiles.client.rank_against; see How to serve the API and use the HTTP client.


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

Request bodyAskRequest:

FieldTypeRequiredDefaultNotes
questionstringyesThe 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 200LLMTextResponse (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 bodyReviewRequest:

FieldTypeRequiredDefaultNotes
materialstringyesText 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 200LLMTextResponse. 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 bodyInnovateRequest:

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

Response 200IdeaList 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).

Note: to resolve related_works to real papers, intersect against GET /api/v1/profiles/{slug}/papers.


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

Request bodyRiffRequest:

FieldTypeRequiredDefaultNotes
seedstringyesPhrase 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 200RiffList with items: list[RiffPayload]:

FieldTypeNotes
anglestringShort free-form label.
textstring2–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.

Wire shape of profile.yaml. extra="allow" — additional keys round-trip unchanged.

FieldTypeNotes
namestringRequired.
levelstringDepth tier: one of lite, full, deep. Default "full". See profile depth levels.
orcidstring | null
affiliationstring | null
scholar_urlstring | null
openalex_idstring | null
fieldstring | null
subfieldslist[string]Default [].
summarystring | null
traininglist[dict]
careerlist[dict]
expertiselist[string]Default []. Distinct from the expertise markdown on ProfileDetail.

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.

  • No rate limiting. The server applies no rate limiting or concurrency caps. Throttle on the client side for multi-profile workflows.
  • LLM token costs. 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 (cache_capacity=32) or process exit. Restart to pick up edits to profile.yaml, expertise.md, SOUL.md, or paper files.
  • Search requires the index. POST .../search returns 500 (sometimes 501) for profiles without a built meta/embeddings.sqlite.
  • /api/v1/match requires embeddings and indexes. It returns 503 on a core-only server or when no profile is indexed.
  • LLM endpoints depend on search. ask/review/innovate/riff call self.search(...) for evidence; if search raises, the exception is swallowed and the LLM call proceeds with no evidence — the response is simply less grounded.
  • Persona endpoints 409 on non-persona profiles. 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.
  • Citation relevance semantics. citations in LLMTextResponse is built from the retrieved chunk set (cosine scores as relevance), not parsed from the model’s text. Parse [paper_id] tokens out of text yourself to know what the model actually cited.
  • No streaming. ask has no streaming variant in v1.
  • Health is open. /health works without auth and reveals profiles_dir.

For cross-profile ranking, prefer POST /api/v1/match, which runs the registry’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.