Skip to Content
These docs describe PRME 0.16.0a8, an alpha. pip install prme installs the latest stable release. To use these docs as written, run pip install prme==0.16.0a8. Changelog
GuidesHTTP API

HTTP API Reference

PRME includes a FastAPI-based REST API for language-agnostic integration.

Installation

pip install prme[api]

Running the Server

python -m prme.api

Or with custom host/port:

python -m prme.api --host 127.0.0.1 --port 8000

The server binds to 127.0.0.1 (loopback only) by default, and with no credentials configured it serves single-user local use without authentication. A non-loopback bind such as 0.0.0.0 exposes the API to the network, so python -m prme.api refuses to start there unless API credentials are configured (see Authentication). prme.api.server.run_server does the same by raising prme.api.server.UnauthenticatedBindError, a ValueError. The check runs before the engine starts or a port is opened. Only literal loopback addresses (anything in 127.0.0.0/8, ::1, and IPv4-mapped forms such as ::ffff:127.0.0.1) and the name localhost (when it resolves only to loopback addresses) count as loopback. Any other host name counts as a network address, because the server resolves it again when it binds.

If an authenticating reverse proxy is the only way to reach the server and it must run without its own credentials, pass --allow-unauthenticated-external-bind (allow_unauthenticated_external_bind=True for run_server). The server then starts with a warning. The API accepts every request it receives and binds no identity to it, so every caller that gets through the proxy has operator access to every user’s memories and can name any user_id. Make sure nothing but the proxy can reach that port, and remove the flag if the proxy goes away. When the proxy fronts several users, keep PRME_API_USER_KEYS configured and have the proxy send each user’s key instead.

Running the app under another ASGI server (uvicorn prme.api.app:create_app --factory, Gunicorn, Hypercorn or your own process) skips this check, because only the server that opens the socket knows the bind address. Protect those deployments yourself: configure credentials, or bind the server to loopback behind an authenticating proxy.

The API reads configuration from environment variables (PRME_* prefix). Set PRME_DB_PATH, PRME_VECTOR_PATH, and PRME_LEXICAL_PATH to point at your memory directory.

Authentication

Authentication is disabled when no credentials are configured, which is only suitable for single-user use on loopback. Configure one of these to require a bearer token on every /v1 endpoint except /v1/health:

  • Per-user keys, which bind each request to its owner (recommended for shared deployments). A caller can then only reach its own owner’s memories, and naming another user_id returns 403:

    export PRME_API_USER_KEYS='{"alice": "<alice key>", "bob": "<bob key>"}'
  • A single global key with operator access, for example for one backend service that acts for many users:

    export PRME_API_API_KEY="<operator key>"

The two cannot be combined. Generate each key rather than reusing an example value, for instance with python -c "import secrets; print(secrets.token_urlsafe(32))", and keep keys out of version control (use an untracked .env file or a secret store). Clients send their own key:

Authorization: Bearer <key>

Requests with a missing or wrong key receive 401 {"detail": "Invalid or missing API key"}. Bearer keys travel in plain text over HTTP, so terminate TLS in front of the API whenever requests cross a network.

Endpoints

All endpoints are under the /v1 prefix.

POST /v1/store

Store a memory node.

Request:

{ "content": "Alice prefers dark mode.", "user_id": "alice", "role": "user", "node_type": "preference", "scope": "personal", "metadata": {"source": "chat"} }
FieldTypeRequiredDefaultDescription
contentstringyesText content
user_idstringyesOwner
rolestringno"user"user (the memory’s owner), participant (another human in the conversation), assistant, tool or system
speakerstringnonullName of who said it, shown by the reader context
node_typestringnonullNode type enum value
scopestringnonullScope enum value
epistemic_typestringnonullEpistemic type
metadataobjectnonullArbitrary metadata
extractbooleannonulltrue also queues the background extraction POST /v1/ingest runs, so entities and claims for the text appear later; false stores without extraction; omitted follows the server’s enable_store_extraction setting (off by default). A value that is not a JSON boolean is a 422

Response (200):

{ "event_id": "550e8400-e29b-41d4-a716-446655440000", "node_id": "660e8400-e29b-41d4-a716-446655440000" }

The reply does not wait for extraction, and the stored node is the only note for the text. GET /v1/events/{event_id}/extraction-status reports the extraction.

POST /v1/ingest

Run the LLM extraction pipeline on content. role and speaker work as for POST /v1/store.

Request:

{ "content": "I switched from VS Code to Neovim last week.", "user_id": "alice", "role": "user", "scope": "personal" }

Response (200):

{ "event_id": "550e8400-e29b-41d4-a716-446655440000" }

POST /v1/reembed

Build an embedding model’s vectors for the caller’s stored memories, so the store can switch models (see switching embedding models ). The model runs locally and no model service is called. The engine’s enable_multi_model_embeddings setting turns it on (PRME_ENABLE_MULTI_MODEL_EMBEDDINGS=true, off by default); without it the route returns 409, as it does while this engine is already filling the model.

Request:

{ "user_id": "alice", "model": "BAAI/bge-base-en-v1.5", "limit": 500, "cursor": null, "dry_run": false, "batch_size": 64, "pause_ms": 0 }

Every field is optional. user_id is required without a bound identity and must match the authenticated owner. model is a registered fastembed model name; omitted, it is the active model. limit caps the memories one call embeds and cursor continues after an earlier result. With dry_run the route writes nothing and reports the progress.

Response (200): embedding_model, embedding_version, dimension, active, total, done, remaining, selected, processed, failed, batches, cursor and elapsed_seconds. The model’s index is complete for the caller’s memories when remaining is 0.

POST /v1/reingest

Re-derive one owner’s stored memories after an engine change improved how they are derived: the batched extraction POST /v1/ingest runs, over the owner’s memories in event order, replaces the claims derived from each with new ones. The source events and raw notes are never changed. Only the engine’s enable_reingest setting turns it on (PRME_ENABLE_REINGEST=true, off by default); without it the route returns 409.

Request:

{ "user_id": "alice", "scope": "personal", "limit": 500, "cursor": null, "dry_run": false }

Every field is optional. user_id is required without a bound identity and must match the authenticated owner. limit caps the memories one call re-derives and cursor continues after an earlier result. With dry_run the route sends no model request and changes nothing.

Response (200):

{ "user_id": "alice", "scope": null, "dry_run": false, "total": 76, "current": 0, "unfinished": 0, "selected": 76, "processed": 76, "failed": 0, "remaining": 0, "cursor": null, "model_calls": 76, "source_tokens": 1491, "fingerprint": "cf4228e0..." }

remaining memories are not yet derived under the engine’s present settings, and cursor is set while any are left after the last memory this call took. A second call over the same data does nothing for memories already derived. 409 also means a re-derivation of this owner is already running. See Re-deriving stored memories.

POST /v1/retrieve

Run hybrid retrieval.

Request:

{ "query": "What editor does Alice use?", "user_id": "alice" }

Response (200):

{ "results": [ { "node_id": "660e8400-...", "content": "Alice switched to Neovim", "score": 0.847, "node_type": "preference", "lifecycle_state": "stable", "confidence": 0.85, "salience": 0.72, "epistemic_type": "asserted", "metadata": null } ], "bundle": { ... }, "metrics": { ... } }

An optional min_score in the request drops results below an inclusive floor. With weighted scoring (PRME_SCORING__FUSION=weighted) it compares against score. With rank fusion (PRME_SCORING__FUSION=rrf, the default), score says where a result ranks among the candidates, not how relevant it is, so the floor compares against semantic_relevance instead. Each result then carries semantic_relevance: the semantic cosine similarity of the memory behind it. A floor tuned on weighted scores does not carry over, and when a floor is set, a result with a low cosine is filtered out even if it is an exact keyword match. If vector search is failing, or the embedding model changed and the index has not been rebuilt yet, no result has a cosine to compare. The floor is then skipped rather than returning nothing: results keep their ranked order, metrics.backend_failures names the vector failure, and metrics.min_score_skipped is true. Treat that flag as a sign that the floor did not filter anything. min_score: 0 keeps everything in both modes.

POST /v1/organize

Run organizer jobs.

Request:

{ "user_id": "alice", "jobs": ["promote", "deduplicate"], "budget_ms": 5000 }

All fields are optional. Omit jobs to run all.

Response (200):

{ "jobs_run": ["promote", "deduplicate"], "per_job": { "promote": {"nodes_processed": 5, "nodes_modified": 2}, "deduplicate": {"nodes_processed": 10, "nodes_modified": 1} }, "duration_ms": 342.5 }

GET /v1/nodes/{node_id}

Get a single node by ID.

Response (200):

{ "id": "660e8400-...", "user_id": "alice", "node_type": "fact", "content": "Python 3.12 was released in October 2023.", "lifecycle_state": "stable", "confidence": 0.9, "salience": 0.65, "epistemic_type": "asserted", "source_type": "user_stated", "scope": "personal", "metadata": null, "created_at": "2024-01-15T10:30:00", "updated_at": "2024-01-15T10:30:00", "superseded_by": null, "evidence_refs": ["550e8400-..."], "pinned": false }

Response (404): {"detail": "Node '...' not found"}

GET /v1/nodes

Query nodes with filters.

ParameterTypeDefaultDescription
typestringFilter by node type
statestringFilter by lifecycle state
user_idstringFilter by user
limitinteger50Max results

Response (200):

{ "nodes": [ ... ], "count": 15 }

PUT /v1/nodes/{node_id}/promote

Promote a node from tentative to stable.

Response (200): Updated node object.

Response (404): Node not found.

Response (422): Invalid state transition.

PUT /v1/nodes/{node_id}/archive

Archive a node.

Response (200): Updated node object.

PUT /v1/nodes/{node_id}/reinforce

Reinforce a node (boost confidence and salience).

Response (200): Updated node object.

GET /v1/nodes/{node_id}/neighborhood

Get nodes within N hops of a starting node.

ParameterTypeDefaultDescription
max_hopsinteger2Maximum graph hops

Response (200):

{ "nodes": [ ... ], "count": 8 }

GET /v1/nodes/{node_id}/chain

Get the supersedence chain for a node.

ParameterTypeDefaultDescription
directionstring"forward""forward" or "backward"

Response (200):

{ "nodes": [ ... ], "count": 3 }

GET /v1/health

Health check.

Response (200):

{ "status": "ok", "version": "0.4.0" }

GET /v1/stats

System statistics. Node counting uses COUNT(*) (no rows are materialized).

ParameterTypeDefaultDescription
user_idstringScope the node count to one user

Response (200):

{ "node_count": 42, "event_count": 0, "backend": "duckdb", "details": {} }

CORS

CORS is disabled by default — browser pages cannot read API responses cross-origin. To allow specific origins:

export PRME_API_CORS_ORIGINS='["http://localhost:3000"]' export PRME_API_CORS_ALLOW_CREDENTIALS=true # optional

Credentialed CORS is only honored when origins are explicitly pinned; a wildcard (*) origin never allows credentials.

Error Responses

All errors follow this format:

{ "detail": "Error description" }
StatusMeaning
401Missing or invalid API key (when auth is enabled)
404Node/resource not found
422Invalid input (bad enum value, invalid state transition)
500Unexpected internal error (details logged server-side only)
503Engine not initialized

Source: documentation/http-api.md  in PRME at bd92166f.