Troubleshooting
Most problems with a self-hosted RememberStack fall into four groups: the stack did not start, the client talks to the wrong place or with the wrong credential, a document is stuck somewhere in the pipeline, or the answer is correct but not what you expected. This page lists each symptom with the first thing to check and the fix.
Run the commands on this page from the directory that holds your
compose.yaml and .env.
Start here
| Symptom | First check | Section |
|---|---|---|
curl gets no answer, or api is not healthy | docker compose ps -a | The API does not answer |
Connection refused, or documents land somewhere else | echo $REMEMBER_API_URL | Wrong port or wrong address |
401 or 403 | The detail string | 401 and 403 |
| A browser app reports a network error | The browser console | A browser app cannot reach the API |
The ingest result has "parked": "no_route" | The file's MIME type | Parked conversions |
| Ingest succeeded, but questions never find the document | Readiness for the version_id | A document never becomes queryable |
| Documents take a long time | remember ops inspect | Processing is slow |
| Stages fail within seconds of each other | Worker logs | Model key and provider errors |
| Work waits and nothing fails | defer_reason in See what is waiting and why | Work parked by a spend budget |
| The answer is empty or not what you expected | negative, temporal_scope, the operation you called | Empty or surprising answers |
A SQL query returns 200 with no rows and an error_code | termination_reason | A SQL query is rejected |
503 with forget_in_progress | docker compose logs api | 503 forget_in_progress |
setup stops after an upgrade | docker compose logs setup | setup refuses an existing database |
Running operator commands
Several fixes below use remember ops. These commands read PostgreSQL
directly, so they run inside the api container
(docker compose exec api remember ops …). Run anywhere else, remember ops
exits with "'remember ops' runs inside the engine container".
Read the deployment id once per shell:
DEPLOYMENT_ID=$(grep '^REMEMBERSTACK_SELFHOST_DEPLOYMENT_ID=' .env | cut -d= -f2)Then pass it to each command:
docker compose exec -T api \
remember ops inspect --deployment "$DEPLOYMENT_ID" | python3 -m json.toolOperating the pipeline describes every field of the report.
The API does not answer
/healthz answers {"status":"ok"} when the API process is up and can run
a query on PostgreSQL. It needs no token.
docker compose ps -a
curl http://localhost:8000/healthzdocker compose ps -a also lists containers that have stopped. Read it
this way:
| What you see | Meaning | Next |
|---|---|---|
setup exited with code 0 | Normal. setup runs once on every up and exits. | Nothing. |
setup exited with a non-zero code, api and the workers are not running | setup failed, and nothing that depends on it starts. | docker compose logs setup |
api is health: starting | The API is still starting. Compose checks it every 10 seconds, up to 30 times. | Wait, then check again. |
api keeps restarting | The API refused its configuration at start. | docker compose logs api |
A worker-… container keeps restarting | The worker fails at start or crashes. Its stage's work waits as pending meanwhile. | docker compose logs worker-… |
The API, the workers, PostgreSQL and the object store have the restart
policy unless-stopped: Docker starts a container that exits again, until
you stop it yourself. A container that fails at start therefore shows as
restarting rather than exited. /healthz keeps answering ok while a
worker is down: it checks the API and the database, not the pipeline.
Configuration mistakes that stop a container at start:
| In the logs | Cause | Fix |
|---|---|---|
A validation error that names api_key | REMEMBERSTACK_OPENROUTER_API_KEY is empty. | Set it in .env, then docker compose up -d. |
setup refuses with "deployment identity or mapped profile values conflict" or "this database already holds deployment …" | .env no longer matches what the first setup recorded; the message names the changed values or the recorded id. | Restore the original values. See The deployment id is permanent. |
Compose says set REMEMBERSTACK_SELFHOST_DEPLOYMENT_ID | .env has no deployment id. | Generate one as in Install. |
setup refuses because REMEMBERSTACK_P1_EMBEDDING_MODEL differs from the stored vectors | The embedding model changed after documents were embedded. | Set it back to the model the message names. See Changing the embedding model. |
| "browser origins must each be an https origin…" | An entry in REMEMBERSTACK_SELFHOST_BROWSER_ORIGINS is malformed, empty, or http on a host other than localhost, 127.0.0.1 or [::1]. | Fix the list; see A browser app cannot reach the API. |
| The shared secret and the bind disagree | REMEMBERSTACK_SELFHOST_API_BEARER_TOKEN and REMEMBERSTACK_SELFHOST_API_BEARER_BIND describe different secrets. | See Authentication and scopes. |
A validation error that names API_KEY_ISSUER, API_KEY_TENANT_ID, API_SIGNING_KEYS_URL or API_REVOCATION_URL | Signed keys are half configured: an issuer without its tenant id or URLs, or those without an issuer. | Set all four, or none. See Signed keys. |
| "no fresh revocation document; every signed credential is refused" | The API has not accepted a revocation document yet, or the accepted one is older than REMEMBERSTACK_SELFHOST_API_REVOCATION_MAX_AGE_S. Every signed key gets 401; the shared secret still works. | Check that the API can fetch both issuer URLs and that the issuer re-signs the document with a growing seq. The warnings just before it name the reason (a failed fetch, or a rejected document). |
worker-convert names an unknown converter | A route in REMEMBERSTACK_SELFHOST_CONVERSION_ROUTES names a converter that does not exist, or needs a key that is not set. | See File formats and converters. |
A migration error in setup that says the store already holds claims | The new release cannot convert the existing data. | See setup refuses an existing database. |
Wrong port or wrong address
Compose publishes the API on the host port in
REMEMBERSTACK_SELFHOST_API_PORT, on 127.0.0.1 only unless
REMEMBERSTACK_SELFHOST_API_PUBLISH_ADDRESS says otherwise, so another
machine gets "Connection refused" until you
open it. The remember client and CLI do not
read that variable. Without REMEMBER_API_URL they use
http://127.0.0.1:8000, whatever port the API is published on.
If you changed the port, tell the client:
export REMEMBER_API_URL=http://localhost:8080remember doctor prints the address it checks and whether the API
answered:
remember doctorIt asks GET /deployment, which needs a read or write credential. With
an ingest token it reports an authentication failure even though the
address is right.
401 and 403
The API sends one of four detail strings:
Status and detail | Cause | Fix |
|---|---|---|
401 a perimeter credential is required | The deployment has a token configured, and the request had no Authorization header. | Export REMEMBER_API_KEY in the shell or agent configuration that makes the call. |
401 perimeter authentication failed | The token is wrong, expired, revoked, or signed with a key the deployment does not know. | Compare it with the configured secret. If you changed .env, apply it with docker compose up -d. If you use a bind, compute its digest with printf '%s', not echo: a trailing newline changes it. |
403 credential is for another deployment | The shared-secret bind or the token's aud names a different deployment id. | Bind the secret, or mint the token, for the id in your .env. |
403 credential may not perform this operation | The token's scope does not cover the route. | See below. |
A read token can call every route that only reads, including the four
assured operations and GET /deployment. It cannot ingest or change
anything; that needs ingest or write. The full table is in
Authentication and scopes.
A browser app cannot reach the API
A web page on another origin that calls the API gets a network error in the browser, and the request may not show up in the API logs at all. The API is running; the browser refused the response because the API did not name the page's origin.
Check REMEMBERSTACK_SELFHOST_BROWSER_ORIGINS. It is empty by default,
which allows no origin. Each entry must be written exactly as the browser
sends it: lowercase, no path, no trailing slash. It must be https, unless
the app runs on the same machine: http://localhost:3000,
http://127.0.0.1:5173 and http://[::1]:8080 are accepted, and any other
http origin stops the API at start.
Only GET, POST and DELETE with the Authorization and Content-Type
headers are allowed, and cookies are not. See
Browser origins.
A document never becomes queryable
Ingest returns as soon as the bytes are stored. Everything after that runs
in the workers and takes minutes. Before you look for a fault, ask
readiness about the version_id that ingest returned:
import uuid
import remember
from remember import ReadinessRequirements
client = remember.Client.from_env()
report = client.pipeline_readiness(
version_ids=(uuid.UUID("6f1c2d0e-8a3b-4d5e-9f10-2a3b4c5d6e7f"),),
require=ReadinessRequirements(pipeline=True, p1=True, live_graph=True, p3=False),
)
for version in report.versions:
for stage in version.stages:
print(stage.stage, stage.status)| What readiness shows | Meaning | Next |
|---|---|---|
Stages running or succeeded, later ones missing | Still processing. | Wait. See Processing is slow. |
convert is pending with defer_reason no_route | No converter for the file's type. | Parked conversions |
A stage is pending with defer_reason budget | A spend budget parked it. | Work parked by a spend budget |
A stage stays pending with no defer_reason | Its worker is not running. | docker compose ps -a |
failed | The last attempt failed and a retry is scheduled. | Watch the worker logs; a repeated failure becomes dead_letter. |
dead_letter | Out of attempts. Nothing retries it on its own. | Dead letters |
Every stage done, p1 or live_graph not ready | A deployment-wide index or graph check is failing, not your document. | Operating the pipeline |
The document status in GET /documents is not readiness: a document is
ready there once it is converted and structured, before its claims and
facts exist.
See what is waiting and why
Readiness gives each waiting stage a defer_reason, and the routes in
remember ops inspect count work by stage, status and defer_reason, for
the whole deployment:
docker compose exec -T api \
remember ops inspect --deployment "$DEPLOYMENT_ID"defer_reason is no_route for a file no converter accepts, budget for
work a spend budget parked, scheduled for work due later, and
retry_backoff for a failure waiting to be retried. Pending work with no
reason is simply queued for its worker.
Parked conversions
A file whose MIME type has no conversion route is stored and its convert
work is parked with the reason no_route. It uses no attempts and makes no
model calls. The ingest result reports it at once with
"parked": "no_route", and remember ingest prints a warning.
A stock deployment routes Markdown, plain text, HTML, .docx, .pptx and
.xlsx. PDFs and images need an OCR route with your own provider key. Add
a route for the type as described in
File formats and converters, apply it with
docker compose up -d, then release the parked work:
docker compose exec -T api \
remember ops resume-no-route --deployment "$DEPLOYMENT_ID"It prints {"released": [...]} with the processing ids it released. Work
whose MIME type is still unrouted stays parked.
A Markdown file parked as no_route
The MIME type is the one the upload declared, not something the engine detects. These uploads arrive with a type the default routes do not cover:
- a file or
filenamewith no extension, or an extension the client does not map and the sending machine's MIME table does not know:application/octet-stream; - an older
rememberclient on a Python installation that does not know.md:application/octet-stream; - a
curlcall with a type such astext/x-markdown. The match is exact.
Send the same file again with the right type:
remember ingest notes --mime text/markdown, or mime="text/markdown" in
Python. RememberStack stores identical bytes once, with the type of their
first upload, but a type with no route is replaced by a later upload's
type that has one, and every parked conversion of those bytes is released.
The ingest result's mime shows the type now recorded.
Alternatively, add a route for the type that was recorded, for example
"application/octet-stream": "passthrough", then run resume-no-route.
passthrough needs valid UTF-8, so a binary file sent under that type
fails conversion instead of waiting. To see which types were recorded:
docker compose exec -T postgres sh -c 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB"' <<'SQL'
SELECT mime, count(*) FROM content_objects GROUP BY mime;
SQLThe current client sends .md and .markdown as text/markdown on
every Python installation, and takes the type of bytes from their
filename. For a file whose extension does not say what it is, pass the
type: remember ingest notes --mime text/markdown, or mime="text/markdown"
in Python.
Dead letters
Find what failed and why:
docker compose exec -T api \
remember ops inspect --deployment "$DEPLOYMENT_ID" | python3 -m json.tooldead_letters.groups shows the stage and error class; each entry in
dead_letters.items has its processing_id and last_error, the full
traceback of the last attempt. Fix the cause first: a key, a model id, a
converter route, a file that is not UTF-8. Then give the item more
attempts:
docker compose exec -T api \
remember ops replay 3f2a9c1e-8b7d-4e21-9a0f-5c6d7e8f9a0b --deployment "$DEPLOYMENT_ID"replay handles one item. If poison_targets lists the item, it failed
under two releases of the same stage, so an upgrade did not fix it; look
at the input itself. See Operating the pipeline.
Processing is slow
Minutes per document is normal: most stages wait on model calls, and some cannot start until every chunk or claim of the version has finished the stage before. Check where work is piling up:
docker compose exec -T api \
remember ops inspect --deployment "$DEPLOYMENT_ID" | python3 -m json.tool
docker compose logs -f --since 10m worker-extract-claims worker-normalize-relations| Sign | Cause | Fix |
|---|---|---|
A large pending count on one stage in routes | That stage is the bottleneck. | Add replicas: docker compose up -d --scale worker-extract-claims=3. See Scaling. |
Log lines with overloaded (429) | OpenRouter or a provider behind it is rate-limiting you. More replicas make it worse. | Lower the worker rate, or set a provider order; see Scaling and Models and providers. |
pending counts that do not move, no log activity | The worker for that stage is not running, or a budget parked the work. | docker compose ps -a; Work parked by a spend budget. |
| Every call slow | Reasoning models spend time and tokens thinking. | Lower REMEMBERSTACK_OPENROUTER_REASONING_EFFORT_MAP for the small models; see Models and providers. |
Model key and provider errors
A missing key stops the containers at start (see
The API does not answer). A wrong key does not:
the stack starts, and every model call fails. .env.example ships the
placeholder replace-before-real-use, which fails the same way.
What you see: retryable failure in stage … lines in the worker logs, with
OpenRouter /chat/completions returned 401 (or 402 when the account is
out of credit) in the traceback. After three attempts, a few seconds apart,
the items dead-letter.
docker compose logs --since 30m worker-structure worker-extract-claims | grep -i openrouterFix: put a valid key in .env, apply it with docker compose up -d, then
replay the dead letters. Other provider errors follow the
same path:
In last_error | Cause | Fix |
|---|---|---|
returned 401 | The key is wrong or revoked. | Replace the key. |
returned 402 | The OpenRouter account has no credit left. | Add credit on OpenRouter. |
returned 400 or returned 404, with the provider's message about the model | A model id in .env does not exist, or the model does not support structured output. | Use an exact model id of a model with structured output; see Models and providers. |
provider returned an embedding dimension that differs from the request | The embedding model does not return 1,536 dimensions. | See Changing the embedding model. |
mistral ocr | An OCR route's Mistral call failed, or Mistral rejected the file. | Check REMEMBERSTACK_MISTRAL_OCR_API_KEY; see File formats and converters. |
A search that cannot embed its query answers 503 model provider unavailable. The same key fixes it.
Work parked by a spend budget
If you set REMEMBERSTACK_WORK_BUDGETS, a stage that reaches its ceiling
parks its work until the budget window ends. Nothing fails and nothing is
dropped; readiness shows the stage as pending.
A defer_reason of budget (see
See what is waiting and why) shows it.
Wait for the window to end, or raise ceiling_usd in .env and run
docker compose up -d. See Spend budgets.
Empty or surprising answers
First make sure the documents are processed: an answer from before readiness does not include them, and nothing in it says so. Then read the result, not only its list of facts.
negativesays why a result is empty.unknown_entitymeans the name did not resolve;known_emptymeans nothing matches;boundarymeans the question could not be answered as asked, with aworkaround.truncation.truncatedmeans there is more than was returned.dropped_by_hydrationcounts candidates the search found that no longer hold. A high value right after ingest usually means processing is still settling.
Most surprising answers are the result of how the question was asked:
| What happened | Why it is not a bug | What to do instead |
|---|---|---|
| "Who works on the billing migration?" leaves out Ravi, who worked on it until June. | facts_context defaults to the current time mode, which returns only facts true now. | Ask with time={"mode": "history"}, or at a date. See Time. |
| A fact dated "sometime" appears in an answer about March. | Its window is missing or partial, so it comes back with temporal_match: "possible". | Treat possible as a lead, not an answer. |
| The old June deadline still appears after the notes moved it to October. | claims_and_sources_context returns what sources said, including the earlier statement. Claims never change. | Use facts_context for what holds now; use claims to show who said what. See Facts. |
| "Dana" returns facts about the wrong Dana, or nothing. | resolve_entity returns every entity that matches and never picks one for you. | Resolve first; ask which one, or pass the right entity_ids. See Handle unknowns and ambiguity. |
The agent says "Ravi has no tasks" after a boundary negative. | boundary means the lookup could not run as asked, not that nothing exists. | Follow workaround. Brief your agent as in Handle unknowns and ambiguity. |
| "Ravi owns three components" when he owns more. | The list was truncated. | Check truncation; raise k or narrow the question. |
| A SQL query returns no rows, and the agent concludes nothing exists. | SQL results carry no negative; an empty result can come from a rejected statement or a truncated one. | Check termination_reason, error_code and truncated first. |
| The answer picks one side of a disagreement. | Both facts stand, in one contradiction_group. | Report both sides with their sources. See Contradictions. |
422 invalid_parameter "unknown argument(s)" from an operation. | Each operation accepts a fixed set of arguments. | See Assured operations. |
A SQL query is rejected
SQL queries run against the query space, memory_v1: prepared, read-only
views and functions. Every statement is parsed and checked against that
query space before it runs, and anything outside it is rejected.
A statement the API refuses or that fails while running does not come
back as an HTTP error. POST /query/sql answers 200 with
termination_reason set to rejected or failed, an error_code, an
error_message and no rows. The Python client returns that result as it
is; it does not raise. Check termination_reason on every result before
you read rows:
result = client.open_query("SELECT fact_label FROM facts_current LIMIT 5")
if result["termination_reason"] != "completed":
print(result["error_code"], result["error_message"])The codes you meet most often:
error_code | Usual cause | Fix |
|---|---|---|
relation_not_allowed | A table outside memory_v1, such as the internal processing_state. | Use the views. |
function_not_allowed | now() or another function not on the allowlist. | Pass times as parameters ($1). |
statement_not_allowed | Anything but a read-only SELECT, VALUES or WITH. | Rewrite as a read. |
function_placement_not_allowed | A query-space function used outside a top-level FROM item. | Follow the placement rules. |
quota_exceeded, concurrency_exceeded | Too many statements, or too much statement time, in the last minute. | Wait and retry. |
statement_timeout | The statement ran past 5 seconds. | Add filters or a LIMIT. |
Every code is in Errors and status codes.
503 forget_in_progress
The API answers 503 with {"code": "forget_in_progress"} while a hard
forget is running, and the operator commands that copy or replay data
refuse to run. A deployment started from compose.yaml has no command that
starts a hard forget, so it should not enter this state on its own. If
you restored data from an installation that ran one, see
Hard-forget manifests and
When a hard forget is in progress.
setup refuses an existing database
Some releases change what stored data means in a way that cannot be
converted. Their migrations stop instead of guessing. On a database that
already holds claims or chunks, setup stops with an error that ends in
"recreate the deployment and ingest its sources again", and the API and
workers do not start.
docker compose logs setup shows the message. To continue:
- If you may want to go back, take a backup of the old volumes first, as described in Back up, while the old release is still checked out.
- Remove the old data:
docker compose down -v. This deletes the memory. - Start the new release:
docker compose up -d --wait. - Send your source documents again.
To stay on the earlier release instead, check out its tag again and start
it with docker compose up -d. setup applies all pending migrations in
one database transaction, so the failure rolled back the ones before it
too. See Upgrades and migrations.
Before you report a problem
Collect:
build_revisionandmodel_bindingsfromcurl http://localhost:8000/deployment;- the
remember ops inspectoutput; docker compose logs --since 1hfor the services involved, with secrets removed;- the
version_idand its readiness stages, for a stuck document.
last_error tracebacks and logs can quote text from your documents.
Read them before you share them. See Contributing.