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]".
Running the server
Section titled “Running the server”CLI entry point:
python -m researcher_profiles.api \ --profiles-dir /path/to/profiles \ --host 127.0.0.1 \ --port 8109| Flag | Env var | Default | Meaning |
|---|---|---|---|
--profiles-dir | RESEARCHER_PROFILES_DIR | (required) | Directory holding one subdirectory per profile, each with a profile.yaml. |
--host | RESEARCHER_PROFILES_HOST | 127.0.0.1 | Bind address. |
--port | RESEARCHER_PROFILES_PORT | 8109 | Bind port. |
--verbose / -v | — | off | Sets logging to DEBUG. |
Alternate uvicorn entry (env-driven only):
RESEARCHER_PROFILES_DIR=/path/to/profiles \ uvicorn researcher_profiles.api.app:app --host 127.0.0.1 --port 8109Environment variables
Section titled “Environment variables”| Var | Required | Purpose |
|---|---|---|
RESEARCHER_PROFILES_DIR | required for the uvicorn entry (or pass --profiles-dir) | Profile root directory. |
RESEARCHER_PROFILES_TOKEN | optional | Bearer token. If unset/empty the server runs in open mode and logs a warning. |
RESEARCHER_PROFILES_HOST | optional | Default host. |
RESEARCHER_PROFILES_PORT | optional | Default port. |
ANTHROPIC_API_KEY | required for ask/review/innovate/riff | Read 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_THRESHOLD | optional | Float in [0, 1], default 0.4. Top-search-score below this makes ask/review refuse when strict_corpus=true. |
RESEARCHER_PROFILES_EMBEDDING_BACKEND | optional | Embedding backend spec used when opening or rebuilding an index. |
RESEARCHER_PROFILES_DISABLE_INDEX | optional | If "1", disables index builds. |
RESEARCHER_PROFILES_MAX_UPLOAD_MB | optional | Size cap (MB) for PUT /api/v1/profiles/{slug} pushes. Default 50. |
Profile discovery and slugs
Section titled “Profile discovery and slugs”- The cache lists every direct subdirectory of
profiles_dirthat contains aprofile.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 viacreate_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 viaPUT /api/v1/profiles/{slug}, which evicts the cached object and registry snapshot itself.
Authentication
Section titled “Authentication”- 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/./healthis not authenticated. - Header format:
Authorization: Bearer <token>. - Failed auth returns
401with body{"detail": "invalid or missing bearer token"}.
curl -H "Authorization: Bearer $RESEARCHER_PROFILES_TOKEN" \ http://127.0.0.1:8109/api/v1/profilesConcepts
Section titled “Concepts”Profile slug
Section titled “Profile slug”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.
The four persona endpoints
Section titled “The four persona endpoints”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.
| Endpoint | Purpose | Default k | Returns |
|---|---|---|---|
POST .../ask | Single-shot Q&A as the persona, grounded in retrieved chunks. Supports a history list. | 5 | LLMTextResponse |
POST .../review | Persona reviews provided material. Retrieval seeded from focus or the first lines of material. | 5 | LLMTextResponse |
POST .../innovate | Persona proposes n research directions on topic, returned as structured Ideas. Retries once on parse failure. | 12 | IdeaList |
POST .../riff | Persona generates n divergent brainstorm fragments on seed. | 4 | RiffList |
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.
Endpoints
Section titled “Endpoints”All /api/v1/* routes require the bearer token when one is configured. The order
below mirrors routes.py.
GET /health
Section titled “GET /health”Liveness + profile-count probe. Unauthenticated.
Response 200 (HealthResponse):
| Field | Type | Notes |
|---|---|---|
status | string | Always "ok" on success. |
profiles_dir | string | Absolute path to the configured profile root. |
profile_count | integer | Number of profile subdirectories visible (re-scanned each call). |
curl http://127.0.0.1:8109/health{"status": "ok", "profiles_dir": "/path/to/profiles", "profile_count": 7}GET /api/v1/profiles
Section titled “GET /api/v1/profiles”List all profiles in the configured directory.
Response 200 — list[ProfileSummary]:
| Field | Type | Notes |
|---|---|---|
slug | string | Directory name. |
name | string | profile.yaml name. |
level | string | Depth tier: one of lite, full, deep. Default "full". See profile depth levels. |
affiliation | string | null | |
field | string | null | |
paper_count | integer | Number of sources/papers.yaml entries. 0 when absent. |
summary_count | integer | Papers that have a summary file. |
fulltext_pct | float | Percentage of papers with status == "downloaded". |
contaminated_count | integer | Papers 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.
GET /api/v1/profiles/{slug}
Section titled “GET /api/v1/profiles/{slug}”Full profile detail: metadata + raw expertise.md + raw SOUL.md.
Response 200 — ProfileDetail:
| Field | Type | Notes |
|---|---|---|
slug | string | |
metadata | ProfileMetadataPayload | See Common response shapes. |
expertise | string | Raw markdown body of personality/expertise.md. May be empty. |
soul | string | Raw 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.
PUT /api/v1/profiles/{slug}
Section titled “PUT /api/v1/profiles/{slug}”Upload (create or replace) a profile. The request body is a (gzipped) tar
archive of ONE profile directory’s contents — profile.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:
slugmust match^[a-z0-9][a-z0-9-]*$.- The archive must contain
profile.yamlat 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
ResearcherProfilebefore the swap. - Size cap: 50 MB by default (
RESEARCHER_PROFILES_MAX_UPLOAD_MB, orcreate_app(..., max_upload_bytes=...)).
Response 200 — PushResponse:
| Field | Type | Notes |
|---|---|---|
slug | string | |
name | string | From the pushed profile.yaml. |
level | string | Profile depth tier (lite, full, …). |
indexed | boolean | True when the push carried meta/embeddings.sqlite, i.e. the profile is immediately matchable. |
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-doePrefer the CLI (researcher-profiles push) or client.push_profile(...),
which build the tarball for you (excluding dotfiles like .archive/).
Status codes:
200on success.400{"detail": "<reason>"}— bad slug, unreadable archive, unsafe member, missingprofile.yaml, or a staged profile that fails to load.401as elsewhere.413{"detail": "archive exceeds size cap"}.
GET /api/v1/profiles/{slug}/papers
Section titled “GET /api/v1/profiles/{slug}/papers”List every paper attached to the profile.
Response 200 — list[PaperEntry]:
| Field | Type | Notes |
|---|---|---|
paper_id | string | null | Citation key (e.g. doe2016example). |
title | string | Required. |
year | integer | null | |
journal | string | null | |
first_author | string | null | |
full_text_link | string | null | |
summary_available | boolean | True 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 200 — PaperSummary:
| Field | Type | Notes |
|---|---|---|
paper_id | string | Echoes the request. |
summary | string | Full 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.
POST /api/v1/profiles/{slug}/search
Section titled “POST /api/v1/profiles/{slug}/search”Semantic search over the profile’s chunk-level sqlite-vec index.
Request body — SearchRequest:
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
query | string | yes | — | Whitespace-only queries return an empty hit list. |
k | integer | no | 5 | Number of hits. |
filter | object | null | no | null | Only 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]:
| Field | Type | Notes |
|---|---|---|
text | string | Raw chunk text. |
source_type | string | See the known values above. |
source_id | string | Paper id for summaries and abstracts; grant id for grants; page filename stem for web pages; expertise, soul, or cv for those documents. |
chunk_index | integer | 0-based chunk index within the source. |
section | string | null | Section heading for markdown chunks; null otherwise. |
score | float | Cosine similarity in [0, 1], rounded to 4 decimals. |
meta | object | Free-form. May contain {"abstract_only": true}. |
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/searchStatus codes:
200on success (including emptyhits).401,404as elsewhere.500{"detail": "search failed: <message>"}if.searchraises — for example, a profile whosemeta/embeddings.sqlitehas not been built raisesIndexNotBuiltError, surfaced as500. 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 nosearchmethod (server built without theembeddingsextra).
POST /api/v1/match
Section titled “POST /api/v1/match”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 body — MatchRequest:
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
query | string | yes | — | Free-text query. |
k | integer | no | 5 | Number of matches to return. |
prefilter | integer | no | 10 | Centroid-prefilter width before chunk re-rank. |
require_topics | list[string] | null | no | null | Restrict candidates to profiles carrying these topics. |
diversify | boolean | no | true | Apply MMR diversification. |
lambda_ | float | no | 0.5 | MMR relevance/diversity trade-off. |
topk_chunks | integer | no | 5 | Chunks used per profile in the re-rank. |
normalize | boolean | no | true | Apply per-profile score calibration. |
include_chunks | boolean | no | false | Include chunk-level evidence in each result. |
Response 200 — MatchResponse with matches: list[MatchResult]:
| Field | Type | Notes |
|---|---|---|
slug | string | Matched profile slug. |
name | string | Profile name. |
orcid | string | null | ORCID when the profile carries one. |
score | float | Match score (calibrated when normalize=true). |
evidence | MatchEvidencePayload | See below. |
MatchEvidencePayload:
| Field | Type | Notes |
|---|---|---|
centroid_score | float | Cosine of query to the profile centroid. |
top_papers | list[string] | Paper ids of the top-matching summary chunks. |
overlapping_topics | list[string] | Profile topic labels that overlap the query. |
top_chunks | list[SearchHitPayload] | Populated only when include_chunks=true; otherwise []. |
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:
200on success.401if 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 theembeddingsextra 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.
POST /api/v1/profiles/{slug}/ask
Section titled “POST /api/v1/profiles/{slug}/ask”Single-shot question answering in the researcher’s voice, grounded by retrieval.
Request body — AskRequest:
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
question | string | yes | — | The question. |
k | integer | no | 5 | Evidence chunks retrieved. |
model | string | null | no | null | Anthropic model override. Default claude-sonnet-4-6. |
strict_corpus | boolean | no | false | Refuse (no LLM call) if top retrieval score is below refusal_threshold. |
refusal_threshold | float | null | no | null | Overrides the env default (0.4). Only consulted when strict_corpus is true. |
history | array of {role, content} | null | no | null | Prior 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>"}).
POST /api/v1/profiles/{slug}/review
Section titled “POST /api/v1/profiles/{slug}/review”Persona reviews the supplied material. Retrieval is seeded from focus if
provided, otherwise from the first lines of material.
Request body — ReviewRequest:
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
material | string | yes | — | Text to review. |
focus | string | null | no | null | Focus hint; seeds retrieval and shapes the review. |
k | integer | no | 5 | Evidence chunks retrieved. |
model | string | null | no | null | Model override. |
strict_corpus | boolean | no | false | Same refusal semantics as ask. |
refusal_threshold | float | null | no | null | Same 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>"}).
POST /api/v1/profiles/{slug}/innovate
Section titled “POST /api/v1/profiles/{slug}/innovate”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:
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
topic | string | yes | — | Topic area. |
n | integer | no | 3 | Ideas requested. Returned items may be fewer. |
k | integer | no | 12 | Evidence chunks retrieved as prior-work context. |
model | string | null | no | null | Model override. |
temperature | float | no | 0.7 | Sampling temperature. |
Response 200 — IdeaList with items: list[IdeaPayload]:
| Field | Type | Notes |
|---|---|---|
hypothesis | string | One-sentence testable claim. |
approach | string | Data, method, comparison. |
rationale | string | Why this researcher specifically. |
related_works | list[string] | Citation keys. Not guaranteed to match the profile’s real paper_ids. |
Status codes:
200on success.401,404as 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 missingANTHROPIC_API_KEY).
Note: to resolve related_works to real papers, intersect against
GET /api/v1/profiles/{slug}/papers.
POST /api/v1/profiles/{slug}/riff
Section titled “POST /api/v1/profiles/{slug}/riff”Generate n short divergent brainstorm fragments on a seed. JSON-constrained;
retries once on parse failure.
Request body — RiffRequest:
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
seed | string | yes | — | Phrase or short paragraph to riff on. |
n | integer | no | 5 | Riffs requested. |
k | integer | no | 4 | Evidence chunks retrieved (sparser than innovate). |
model | string | null | no | null | Model override. |
temperature | float | no | 1.0 | Higher than innovate to encourage divergence. |
Response 200 — RiffList with items: list[RiffPayload]:
| Field | Type | Notes |
|---|---|---|
angle | string | Short free-form label. |
text | string | 2–5 sentences in the researcher’s voice. |
related_work | string | null | Optional 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.
Common response shapes
Section titled “Common response shapes”ProfileMetadataPayload
Section titled “ProfileMetadataPayload”Wire shape of profile.yaml. extra="allow" — additional keys round-trip
unchanged.
| Field | Type | Notes |
|---|---|---|
name | string | Required. |
level | string | Depth tier: one of lite, full, deep. Default "full". See profile depth levels. |
orcid | string | null | |
affiliation | string | null | |
scholar_url | string | null | |
openalex_id | string | null | |
field | string | null | |
subfields | list[string] | Default []. |
summary | string | null | |
training | list[dict] | |
career | list[dict] | |
expertise | list[string] | Default []. Distinct from the expertise markdown on ProfileDetail. |
LLMTextResponse (shared by ask and review)
Section titled “LLMTextResponse (shared by ask and review)”| Field | Type | Notes |
|---|---|---|
text | string | The model’s prose. |
citations | list[CitationRefPayload] | One entry per retrieved evidence chunk (built from retrieval, not parsed from the text). Empty on refusal. |
model | string | Resolved model id. "<none>" on a refusal with no LLM call. |
usage | object | Token usage: input_tokens, output_tokens, cache_creation_input_tokens, cache_read_input_tokens. Zeroed on refusal. |
request_id | string | null | Anthropic request id when available. |
refused | boolean | True when the strict_corpus gate fired. |
refusal_reason | string | null | Populated only when refused=true. |
grounded | boolean | False if the model cited a [paper_id] not in the profile. Always true for refusals and when no citation was emitted. |
CitationRefPayload
Section titled “CitationRefPayload”| Field | Type | Notes |
|---|---|---|
paper_id | string | |
relevance | float | null | Cosine similarity from retrieval. |
span | string | null | Reserved; typically null. |
Error envelope
Section titled “Error envelope”Errors follow the FastAPI default: {"detail": "<string>"} with the appropriate
HTTP status. There is no global error wrapper or machine-readable error code.
Limits and gotchas
Section titled “Limits and gotchas”- 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/riffcall makes at least one Anthropic API call (innovate/riffup to two on a JSON retry). The persona prefix is sent with prompt caching; checkusage.cache_read_input_tokensto confirm cache hits. ANTHROPIC_API_KEYis not checked at startup. A missing key surfaces only when an LLM endpoint is invoked, as a500.- The profile cache is in-process. Loaded profiles live until LRU eviction
(
cache_capacity=32) or process exit. Restart to pick up edits toprofile.yaml,expertise.md,SOUL.md, or paper files. - Search requires the index.
POST .../searchreturns500(sometimes501) for profiles without a builtmeta/embeddings.sqlite. /api/v1/matchrequires embeddings and indexes. It returns503on a core-only server or when no profile is indexed.- LLM endpoints depend on search.
ask/review/innovate/riffcallself.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/riffreturn409(no LLM call) when the profile has no synthesized persona — for example aliteprofile. Branch on thelevelfield inGET /api/v1/profilesto avoid calling them. strict_corpusonly applies toaskandreview.innovateandriffalways call the model.- Citation
relevancesemantics.citationsinLLMTextResponseis built from the retrieved chunk set (cosine scores asrelevance), not parsed from the model’s text. Parse[paper_id]tokens out oftextyourself to know what the model actually cited. - No streaming.
askhas no streaming variant in v1. - Health is open.
/healthworks without auth and revealsprofiles_dir.
Multi-profile workflows
Section titled “Multi-profile workflows”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.