Skip to content

Dynamic API

The dynamic API extends the static API with endpoints for listing, searching, matching, and interacting with profiles programmatically. It is an OPTIONAL tier. A server that only fulfills the static API is conforming.

A server that provides the dynamic API MUST also satisfy all static API requirements.


This specification defines endpoints as bare paths (e.g. GET /profiles). A deployment SHOULD mount them under a versioned prefix such as /api/v1/, but the prefix is a deployment decision, not part of the specification. The reference implementation uses /api/v1/.


See Authentication for the bearer token mechanism and viewer tiers.

The dynamic API categorizes routes by authentication requirement:

Route categoryToken required
Health checkNo
Read endpoints (list, detail, papers, summaries, content, registry)No
Search, match, persona, upload, archive, identity resolutionYes
Owner edit endpointsYes (owner-level)
Management endpoints (/api/manage/*)Varies by endpoint (see section 14)

A server MAY run in open mode (no token configured), in which case all routes accept any request.

Read endpoints resolve a viewer tier from the caller’s credentials and project each response accordingly.

A read endpoint MAY accept a preview query parameter ?as=anonymous|lab|owner that caps the resolved tier at public, internal, or restricted respectively. The cap MUST only narrow the caller’s tier, never widen it. An unknown value returns 400.


A profile slug is a short identifier (e.g. jane-doe) used in URL paths. Slugs MUST match ^[a-z0-9][a-z0-9-]*$.

Any route that accepts a {slug} also accepts a rid (researcher ID) in its place. The server resolves both to the same profile.


Liveness probe. Unauthenticated. Lives at the root, outside any versioned prefix. A server providing the dynamic API MUST expose this endpoint.

Response 200:

FieldTypeDescription
statusstring"ok", or "degraded"
storestringDisplay locator for the backing store
profile_countintegerNumber of visible profiles
detailstring | nullSet only when degraded: why

Response 503: the same body with status: "degraded", when the server’s own startup checks (for example a query-embedding preflight) have failed. A server MAY never return this.


These endpoints require no authentication. Responses are projected through the caller’s viewer tier.

List all visible profiles in rp:profileList format, the same envelope used by static servers, so consumers can treat both interchangeably.

Response 200:

FieldTypeDescription
rp:profileListstringFormat version string
namestringServer or organization name
urlstringCanonical URL of this listing
updatedstringISO 8601 timestamp of last change
profilesarrayProfile entries (see below)

Each entry in profiles is an object with url (the profile’s base URL) and optional enrichment fields that a dynamic server MAY include:

FieldTypeRequiredDescription
urlstringREQUIREDProfile base URL, from which profile.jsonld and every relative contentUrl resolve
namestringRECOMMENDEDDisplay name
slugstringProfile identifier
ridstring | nullThe researcher ID this profile describes
levelstringlite, full, or deep
affiliationstring | null
fieldstring | null
paper_countintegerNumber of papers
summary_countintegerPapers with a summary
fulltext_pctfloatPercentage with downloaded full text
contaminated_countintegerPapers flagged as contaminated

A static server serves the same format as a profiles.json file with entries as plain URL strings or {url, name} objects. A dynamic server enriches entries with summary fields. Consumers MUST accept both forms.

Nested lists ({list: ...}) are also valid entries, per the profile list format.

Full profile detail.

Response 200:

FieldTypeDescription
slugstring
ridstring | nullThe researcher ID this profile describes
metadataobjectSee Profile metadata
expertisestring | nullRaw personality/expertise.md content. null when the viewer’s tier does not reach that artifact (its contentUrl then appears in withheld); "" when the file is empty
soulstring | nullRaw personality/SOUL.md content. null when withheld, as for expertise
manifestlist[object]The profile manifest entries (hasPart plus subjectOf) as a flat list, each with an added effective_visibility key, so a client knows what the profile contains without walking the directory. Not an envelope: there is no entries key.
withheldlist[string]The contentUrls this viewer’s tier did not reach
content_hashstring | null"sha256:<hex>" for optimistic concurrency (see Owner edit endpoints)

Serve the stored profile.jsonld verbatim: the bytes the server persisted, not a re-serialization. This is what makes the document’s conformsTo claim retrievable. The response carries a strong ETag and Last-Modified; a matching If-None-Match returns 304.

Status codes: 200, 304, 400 (malformed slug), 404.

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

{artifact} MUST be a contentUrl from the profile’s manifest, or profile.jsonld itself. Any other path returns 404. Path traversals return 404.

The response body is the artifact’s raw bytes. The Content-Type header matches the manifest entry’s encodingFormat. The X-RP-Effective-Tier header reports the artifact’s effective privacy tier.

Status codes:

  • 200: artifact served
  • 404: profile not visible, artifact not in manifest, or artifact’s effective tier is above the caller’s viewer tier
  • 403: hard-floor artifacts (paper_fulltext, .cache/, .keys/) that are withheld from all callers

List all papers attached to a profile.

Response 200: array of paper entries:

FieldTypeDescription
paper_idstring | nullCitation key
titlestring
yearinteger | null
journalstring | null
first_authorstring | null
authorslist[string] | nullFull author list when the record carries one
doistring | null
pmidstring | null
openalex_idstring | null
full_text_linkstring | null
summary_availablebooleanWhether a summary exists for this paper and the caller’s tier may fetch it

Fetch the markdown summary for one paper.

Response 200:

FieldTypeDescription
paper_idstringEchoes the request
summarystringFull summary markdown

Returns 404 if the profile or paper summary does not exist.

List all visible profiles in collection bundle format, a static-site-shaped document so a browser client can consume a dynamic server and a static file host interchangeably. This dynamic bundle carries artifacts: [] and no centroids; a client ranks against it by calling /match on the server. The static collection.jsonld a published site writes carries the centroids inline instead.

Response 200:

FieldTypeDescription
@contextstringThe profile context IRI
@idstringThe request URL
generated_atstringISO 8601 timestamp
generatorstringSoftware identifier and version
countintegerNumber of profile cards
cardsarrayOne per visible profile (same fields as profile summary, plus base)

Each card’s base is the absolute content URL prefix (.../profiles/{slug}/content/) from which profile.jsonld and all relative contentUrl paths resolve.

Cache-Control: private, no-store: the list depends on who asked.


Upload a profile. The server dispatches on Content-Type: application/json carries a bare profile document (below); any other content type carries a gzipped tar archive of the profile directory’s contents, with profile.jsonld at the tar root.

Request (archive): Content-Type: application/gzip

The server validates the archive, stages it, and commits it atomically. On failure the existing profile is untouched. The uploaded profile is immediately visible to all read endpoints.

Validation rules:

  • Slug must match ^[a-z0-9][a-z0-9-]*$
  • Archive must contain profile.jsonld at its root
  • Only regular files and directories allowed (no symlinks, hardlinks, device nodes, absolute paths, or .. traversal)
  • The staged profile must load successfully before the swap
  • Maximum archive size: 50 MB (configurable)

By default, sources/papers/ (full paper text) is stripped on ingest. The server may be configured to accept it.

Response 200:

FieldTypeDescription
slugstring
ridstring | nullThe stored profile’s researcher ID
namestringFrom the uploaded profile.jsonld
levelstringProfile depth tier
indexedbooleanWhether the upload included a search index (always false for a JSON body)

Status codes: 200, 400 (validation failure), 401, 413 (size cap exceeded).

Request (JSON document): Content-Type: application/json

The body is one profile.jsonld document. The server validates it against the profile document schema and stores it as a document-only profile (identity plus metadata); papers, summaries, and indexes are added later by an archive upload or a build.

  • The document MUST carry a rid, or the request MUST ask the server to mint a local: identity by sending "mintLocalRid": true in the body or ?mint=local on the URL. Minting requires a non-empty name. Minting when the document already carries a rid is a 400.
  • If-Match: <content_hash> makes the write conditional. When the profile exists and its current content_hash differs, the server returns 409 with the current hash in X-RP-Content-Hash. If-Match is ignored when the profile does not exist yet.

Response 200: the same shape as the archive upload, with indexed: false.

Status codes: 200, 400 (invalid JSON, a body that is not an object, a missing rid without minting, or a minting conflict), 401, 403, 409 (If-Match mismatch, or the store refused the write), 422 (the body is not a valid profile document).

Download a profile as a gzipped tar archive, projected through the caller’s privacy tier. Full text is never included in the download regardless of caller.


Search over one profile’s vector index.

Request body:

FieldTypeRequiredDefaultDescription
querystringyesnoneSearch query
kintegerno5Number of results
filterobject | nullnonullFilter by source_type (string or list)

Response 200: object with hits array:

FieldTypeDescription
textstringChunk text
source_typestringpaper_summary, expertise, soul, paper_abstract, grant, cv, web
source_idstringPaper ID, grant ID, or document name
chunk_indexinteger0-based within the source
sectionstring | nullSection heading
scorefloatCosine similarity, rounded to 4 decimals
metaobjectFree-form metadata

Status codes: 200, 401, 404, 500 (search failed: <message>, for example when the profile’s index is not built), 501 (the profile’s backend has no local directory, so no index can exist; the body names the operation and a remedy).


Rank all indexed profiles against a free-text query.

Request body:

FieldTypeRequiredDefaultDescription
querystringyesnoneFree-text query
kintegerno5Number of matches
prefilterintegerno10Centroid-prefilter width
require_topicslist[string] | nullnonullRestrict to profiles with these topics
diversifybooleannotrueApply MMR diversification
lambda_floatno0.5Relevance/diversity trade-off
topk_chunksintegerno5Chunks per profile in re-rank
normalizebooleannotruePer-profile score calibration
include_chunksbooleannofalseInclude chunk-level evidence

Response 200:

FieldTypeDescription
matchesarrayRanked results (below)
ranked_profilesintegerHow many profiles were ranked, after privacy-tier filtering
total_profilesintegerSize of the indexed corpus the query ran against

The two counts let a caller tell “0 of 47 ranked” from “47 of 47 ranked, none above threshold”; an empty matches list alone cannot.

Each entry in matches:

FieldTypeDescription
slugstringProfile slug (a display handle, not a join key)
namestringProfile name
ridstring | nullThe researcher ID: the key to map a match onto a consumer’s own users
orcidstring | nullORCID when the profile carries one
scorefloatMatch score
evidenceobjectSee below

Each evidence object:

FieldTypeDescription
centroid_scorefloatQuery-to-centroid cosine similarity
top_paperslist[string]Paper IDs of top-matching chunks
overlapping_topicslist[string]Topic overlap with query
top_chunkslistPopulated only when include_chunks=true

Status codes: 200, 401, 500, 503 (no profiles indexed, search not available, or the ranked fraction fell below a server-configured floor).

The reference implementation also serves POST /coi/check, POST /match/reviewers, GET /graph/neighbors/{ref}, and POST /profiles/{slug}/rank-works. They are outside this specification; the HTTP API reference documents them.


Resolve a person descriptor (a rid, or a free-text name) to the rid that identifies them in this registry. Deterministic and cautious: the same person, resolved the same way twice, converges on the same rid. When the evidence cannot decide, the server defers rather than guessing, because an identity system’s worst failure is a silent merge. A true miss (no existing profile plausibly matches) MINTS a new identity: this is a write route, not a query, and is gated accordingly. It never merges two existing profiles into one.

Request body:

FieldTypeRequiredDefaultDescription
ridstring | nullnonullAn ORCID, or a previously minted local: id (the disambiguation round-trip)
namestring | nullnonullA free-text name to resolve
affiliationstring | nullnonullStamped on a minted stub; corroborates or vetoes a name match
create_newbooleannofalseSkip matching and mint a fresh identity for name, the explicit answer to a deferral whose candidates are all wrong

At least one of rid/name MUST be supplied.

Response 200 or 201:

FieldTypeDescription
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)
candidatesarrayPresent 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.

Status codes: 200, 201, 400 (neither rid nor name, a malformed rid, or an unknown local: rid), 401, 403.


Four endpoints generate text in the researcher’s voice. Each injects the profile’s expertise.md and SOUL.md into the system prompt and grounds the response in retrieved evidence from the profile’s corpus.

Precondition: The profile must be persona-ready: a full or deep profile with non-empty expertise.md and SOUL.md. A persona call against a profile that is not persona-ready returns 409 with no LLM call. Clients should check the level field from GET /profiles to avoid this.

Question answering in the researcher’s voice.

Request body:

FieldTypeRequiredDefaultDescription
questionstringyesnoneThe question
kintegerno5Evidence chunks to retrieve
modelstring | nullnonullModel override
strict_corpusbooleannofalseRefuse if top retrieval score is below threshold
refusal_thresholdfloat | nullnonullScore threshold (default 0.4)
historyarray | nullnonullPrior conversation turns as {role, content} objects

Response 200: see LLM text response.

When the refusal gate fires, the response is still 200 with refused=true, the refusal message in text, model="<none>", zeroed usage, and empty citations.

The researcher reviews supplied material.

Request body:

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

Response 200: see LLM text response.

Propose novel research directions on a topic.

Request body:

FieldTypeRequiredDefaultDescription
topicstringyesnoneTopic area
nintegerno3Number of ideas
kintegerno12Evidence chunks
modelstring | nullnonullModel override
temperaturefloatno0.7Sampling temperature

Response 200: object with items array:

FieldTypeDescription
hypothesisstringTestable claim
approachstringData, method, comparison
rationalestringWhy this researcher specifically
related_workslist[string]Citation keys (not guaranteed to match real paper IDs)

Status codes: 200, 401, 404, 409 (not persona-ready), 502 (LLM failed to produce valid JSON after retries), 500.

Generate divergent brainstorm fragments.

Request body:

FieldTypeRequiredDefaultDescription
seedstringyesnonePhrase or paragraph to riff on
nintegerno5Number of riffs
kintegerno4Evidence chunks
modelstring | nullnonullModel override
temperaturefloatno1.0Higher than innovate to encourage divergence

Response 200: object with items array:

FieldTypeDescription
anglestringShort label
textstring2-5 sentences in the researcher’s voice
related_workstring | nullOptional citation key

Status codes: same as innovate.


These endpoints let a profile’s owner, or an agent acting for them, edit the profile. They require owner-level authorization. A server MAY satisfy that with an operator bearer token, a signed-in person’s session, or a scoped agent key (see Management API). When a scoped key is used, each endpoint requires the scope named in the scope catalog, and a request whose key lacks it returns 403 with the insufficient_scope body.

Patch owner-editable metadata fields.

Editable fields: name, affiliation, job_title, field, subfields, summary, expertise (the label list), interests, not_interests, training, career, same_as.

A key outside this set returns 400. Fields like rid, provenance, collaborators, and visibility are not editable through this endpoint.

Optimistic concurrency: Send base_hash (from the content_hash returned by GET /profiles/{slug}) to detect concurrent edits. If the profile changed since the hash was read, the server returns 409 with the current hash. Omitting base_hash is last-writer-wins. Successful edits return the new content_hash.

Replace personality/SOUL.md entirely. Supports the same base_hash optimistic concurrency as metadata edits (the hash spans both the document and the SOUL).

Report the effective visibility of the profile and of every artifact in its manifest, with the reason for each.

Response 200:

FieldTypeDescription
slugstring
ridstring | null
profile_visibilitystringThe document-level tier
profile_floorstring | nullA host-imposed ceiling on this profile, or null
profile_floor_reasonstring | nullThe sentence to show a human when a floor applies
artifactsarrayOne entry per manifest artifact (below)
countsobjectItems each viewer class can see, e.g. {"anonymous": 0, "lab": 12, "you": 63}

Each artifacts entry:

FieldTypeDescription
content_urlstringIdentifies the artifact
rolestring | nullManifest role
namestring | nullDisplay name
paper_idstring | nullSet for per-paper artifacts
declaredstringThe tier written on the manifest entry
effectivestringWhat governs after the legal floor, the profile default, and the derivation rule
lockedbooleanA legal floor nobody, owner included, may raise
lock_reasonstring | nullFull sentence to show when locked
raised_bylist[string]Causes holding effective above declared
visible_tolist[string]Subset of ["anonymous", "lab", "you"]

Set the profile default tier, per-artifact tiers, or both.

Request body:

FieldTypeRequiredDescription
profile_visibilitystring | nullnoNew document-level tier
artifactsarraynoPer-artifact changes (below)
base_hashstring | nullnoConcurrency token, as on the metadata patch

Each artifacts entry supplies exactly one selector plus the target tier:

FieldTypeDescription
content_urlstring | nullSelector: one artifact
paper_idstring | nullSelector: every artifact for one paper
rolestring | nullSelector: every artifact with this manifest role
visibilitystringREQUIRED. public, internal, or restricted

Artifacts not selected keep their current tier. Supports the same base_hash optimistic concurrency as metadata edits.

Response 200:

FieldTypeDescription
slugstring
ridstring | null
updatedlist[string]Names of what changed
artifacts_changedintegerHow many manifest artifacts were re-tiered
content_hashstring | nullThe hash AFTER this write, usable as the next base_hash

A caller holding an agent key with profile:visibility may only NARROW a tier. An attempt to widen one returns 403 naming the artifact, its current tier, and the requested tier.


The following fields appear in profile detail responses (e.g. GET /profiles/{slug}):

FieldTypeDescription
namestringRequired
levelstringlite, full, or deep
ridstring | nullThe researcher ID: an ORCID or a local: id. There is no orcid key
provenancestring | nullWho asserted this profile and on what basis
licensestring | nullReuse terms for the published record, an IRI
urlstring | nullThe published profile URL
affiliationstring | null
fieldstring | null
subfieldslist[string]
summarystring | null
job_titlestring | null
expertiselist[string]Topic labels (distinct from the expertise markdown)
interestslist[string]
not_interestslist[string]Authoritative non-interests
traininglist[object]Educational history
careerlist[object]Career history
collaboratorslist[string | object]Declared connections (see spec)
same_aslist[string]Other URLs for this person
visibilitystringDocument-level privacy tier

Additional keys round-trip unchanged.

Shared by ask and review.

FieldTypeDescription
textstringThe model’s response
citationslistRetrieved evidence chunks (built from retrieval, not parsed from text)
modelstringModel ID used ("<none>" on refusal)
usageobjectToken counts: input_tokens, output_tokens, cache_creation_input_tokens, cache_read_input_tokens
request_idstring | nullProvider request ID
refusedbooleanWhether the strict_corpus gate fired
refusal_reasonstring | nullPopulated only when refused=true
groundedbooleanfalse if the model cited a paper ID not in the profile

Each citation:

FieldTypeDescription
paper_idstring
relevancefloat | nullCosine similarity from retrieval

Errors use the format {"detail": "<message>"} with the appropriate HTTP status code. There is no machine-readable error code beyond the status.

StatusMeaning
400Bad request (invalid slug or ref, unreadable archive, unknown metadata field, unknown ?as= value)
401Invalid or missing bearer token
403Hard-floor artifact (withheld from all callers), or a credential that lacks the required scope
404Profile not found, artifact not in manifest, or access denied (indistinguishable)
409Profile not persona-ready (persona endpoints), concurrent edit detected (owner endpoints), or If-Match mismatch (JSON upload)
413Archive exceeds size cap
422Request body fails schema validation (JSON upload)
500Server error (index not built, LLM failure, etc.)
501Search not available on this backend
502LLM produced invalid output after retries, or an upstream service failed
503Match unavailable (no profiles indexed), or the server reports itself degraded

An OPTIONAL tier for servers that host profiles on behalf of the people they describe. It covers three things a file server has no need of: getting a credential onto a command line, telling a caller what their credential is, and publishing the vocabulary of write scopes.

A server MAY implement the management tier without the dynamic API, and vice versa. A server MAY offer further management endpoints beyond these five; they are outside this specification.

Unlike the /api/v1/ prefix in section 1, the management prefix is fixed at /api/manage/. A client discovers whether a server offers this tier by calling POST /api/manage/cli-auth and reading the status; there is no discovery document to carry a configurable prefix. A server that mounts these endpoints elsewhere is not conforming.

Absence is the signal. A server that does not implement the tier MUST let POST /api/manage/cli-auth answer 404. Clients treat 404 there as “this server offers no command-line login” and MUST NOT retry.

A device-authorization flow. The client cannot receive a browser redirect, so the server issues two codes: a secret the client polls with, and a short code the person types or confirms in a browser.

client server person's browser
| POST /api/manage/cli-auth | |
|------------------------------->| |
| 201 {device_code, user_code, | |
| verify_url, expires_in, | |
| interval} | |
|<-------------------------------| |
| (print verify_url + user_code)| person opens verify_url|
| |<-------------------------------|
| | person approves |
| POST /api/manage/cli-auth/poll| |
|------------------------------->| |
| 200 {"status": "pending"} | |
|<-------------------------------| |
| ... wait `interval` seconds, repeat ... |
| 200 {"status": "approved", "token": "rpk_..."} |
|<-------------------------------| |

Start a login. Unauthenticated: this is how a caller with no credential gets one.

Request body (the whole body is OPTIONAL):

FieldTypeRequiredDescription
labelstring | nullnoDisplay name for this machine on the approval page. A client SHOULD send the hostname. Servers SHOULD trim it and MAY truncate it.

Response 201:

FieldTypeRequiredDescription
device_codestringREQUIREDOpaque polling secret. Never displayed to the person.
user_codestringREQUIREDShort code the person sees.
verify_urlstringREQUIREDAbsolute URL the person opens to approve.
expires_inintegerRECOMMENDEDSeconds until the request expires. Default 600 when absent.
intervalintegerRECOMMENDEDSeconds a client SHOULD wait between polls. Default 3 when absent.

Servers MAY include further fields; clients MUST ignore what they do not know.

The device_code MUST be unguessable, MUST be stored hashed rather than in the clear, and MUST NOT be an API key: it grants nothing but the right to collect the result of this one request. The user_code SHOULD avoid characters people confuse (I, O, 0, 1) and SHOULD be short enough to read aloud.

verify_url MUST point at a page on the server where a signed-in person can approve or ignore the request. What that page looks like, and how the person signs in, is entirely the server’s business and is not specified here. A server MUST require an authenticated person to approve; it MUST NOT approve on the strength of the user_code alone.

Status codes: 201, 404 (server does not implement this tier), 503 (server could not allocate a code; the client SHOULD retry).

Collect the result. Unauthenticated: the device_code is the credential.

Request body:

FieldTypeRequiredDescription
device_codestringREQUIREDFrom the start response

Response 200, not yet approved:

FieldTypeDescription
statusstring"pending"
intervalintegerServers MAY revise the poll interval here

Response 200, approved:

FieldTypeRequiredDescription
statusstringREQUIRED"approved"
tokenstringREQUIREDThe minted key. See the key model.
orcidstring | nullRECOMMENDEDThe approving person’s identifier
namestring | nullRECOMMENDEDThe approving person’s display name
urlstring | nullThe server’s canonical base URL

status is the only field a client branches on. This specification defines "pending" and "approved". A client MUST treat any other value as not yet approved and keep polling until the request expires, so a server MAY add states without breaking existing clients.

The approved response is returned exactly once. The server MUST mint the key and invalidate the device_code in the same operation. A second poll after collection MUST return 404, not the token again.

Status codes:

  • 200 with a status body
  • 404 when the device_code is unknown, has expired, or has already been collected. These three MUST be indistinguishable, and the client SHOULD tell the person to log in again.
  • 410 when the approving identity has been removed
  • 422 when device_code is missing

Servers SHOULD purge expired requests. Servers MAY rate-limit polling; a client that honors interval will not trip a reasonable limit.

Two endpoints answer “what is this credential”. They are split by key family (see the key model) because the answers have different shapes.

Describe an app-family (rpk_) key. Requires Authorization: Bearer <key>.

Response 200:

FieldTypeRequiredDescription
consumerstringREQUIREDStable name of the application or session holding the key
scopeslist[string]REQUIREDGranted app scopes, sorted
ownerobject | nullREQUIRED{orcid, name} of the person the key was minted for, or null for an application key with no owning person
profilesarrayREQUIREDProfiles this key may write. Empty when owner is null.

Each profiles entry:

FieldTypeDescription
slugstring
ridstring
rolestringowner or editor

Status codes: 200; 400 when the credential belongs to the agent family (the response SHOULD name the correct endpoint); 401 when the header is missing, malformed, or the key is unknown, revoked, or expired.

Describe an agent-family (rpa_) key. Requires Authorization: Bearer <key>. An agent is expected to call this at the start of every session, before attempting any write.

Response 200:

FieldTypeRequiredDescription
principalobjectREQUIREDThe agent itself (below)
ownerobject | nullREQUIRED{orcid, name} of the person who minted the key
profilesarrayREQUIREDProfiles this key is bound to (below)
tierstringREQUIREDThe most permissive viewer tier this key reads at
scopeslist[string]REQUIREDGranted agent scopes, sorted
scopes_not_grantedlist[string]RECOMMENDEDEvery agent scope this key does not hold
never_delegablearrayRECOMMENDEDActs no agent key can ever perform, as {act, why} objects

principal:

FieldTypeDescription
labelstring | nullHuman label given when the key was minted
handlestringStable machine identifier for this agent
created_atstring | nullISO 8601
last_used_atstring | nullISO 8601
expires_atstring | nullISO 8601, or null for no expiry

Each profiles entry:

FieldTypeDescription
slugstring
ridstring
rolestringeditor when the key holds any write scope, else viewer-restricted
publishedbooleanPresent when the server tracks a publication decision
profile_visibilitystring | nullThe profile’s document-level tier

scopes_not_granted exists so an agent can state what it cannot do without guessing. never_delegable exists so it can state what nobody can grant it. Servers SHOULD populate both.

Status codes: 200; 400 when the credential belongs to the app family; 401 when the header is missing or malformed, or the key is unknown, revoked, or expired.

The scope catalog. Unauthenticated: it describes the vocabulary, not any particular key, and an agent needs to read it before it has a key.

Response 200 is a flat object keyed by scope name. Each value:

FieldTypeRequiredDescription
descriptionstringREQUIREDOne sentence, written for the person deciding whether to grant it
endpointslist[string]RECOMMENDEDRequests this scope unlocks, as "METHOD /path"
fieldslist[string]RECOMMENDEDMetadata fields this scope covers, when it covers fields
dangerousbooleanREQUIREDWhether granting it can reduce what the world can see
default_onbooleanREQUIREDWhether a minting interface SHOULD pre-select it

The catalog MUST contain the scopes defined in Agent scopes. A server MAY add its own; a client MUST ignore names it does not recognize.