HTTP API reference (advanced)
The engine's raw HTTP surface — 15 tool endpoints over a bounded concurrent HTTP server, plus a separate /admin/* control-plane surface.
Most agents should use Quickstart or MCP tools instead — this page is the raw HTTP surface underneath, for scripting, debugging, or a self-hosted deployment.
The engine exposes a small HTTP API (stdlib ThreadingHTTPServer, no web framework)
wrapping the claim store, bounded by a threading.BoundedSemaphore(ERGO_HTTP_MAX_WORKERS)
(default 8) so one slow request can't head-of-line-block the rest.
The examples below are self-hosted, using the legacy single-tenant token
(ERGO_API_TOKEN) on http://127.0.0.1:8788 with the token in $TOK — that mode takes
org_id/who from the request body, so every example includes them. Against the
hosted endpoint (https://api.ergomem.com), or any self-hosted deployment using a
tenant token (see Multi-tenancy), the bearer resolves org_id
and who server-side instead — drop both fields from every request body below. Sending
them in that mode has no effect; they're never trusted from the body.
Auth
If ERGO_API_TOKEN is set, every route except GET /health and GET /ready
requires:
Authorization: Bearer <token>If the var is unset, the server logs a warning at boot and runs open (dev/test only).
In a multi-tenant deployment, tokens are minted per-principal through the admin API
(see Multi-tenancy) rather than a single shared secret. The bearer
token resolves server-side to an org_id and who — never self-asserted from the request
body — so a caller can't spoof which tenant it's writing to.
Quotas
Rate limit and active-claim cap apply only to a real tenant token — the legacy
single-tenant token, the superadmin bearer, and GET /health/GET /ready are all
exempt.
-
Rate limit — 120 requests/min per tenant by default, tracked separately for reads (
GET) and writes (POST) so a burst of one kind never eats into the other's budget, and persisted — it survives a process restart, unlike the old single in-memory counter. Some accounts also carry a small burst allowance on top of the steady rate for short spikes. Over the limit on either class:429with aRetry-Afterheader naming the seconds until enough budget is back.429 → {"error":"tenant rate limit exceeded","limit_per_min":120,"retry_after_seconds":37} -
Active-claim cap — 50,000 active claims per tenant by default. Only the routes that add an active claim count against it:
POST /remember,POST /learn,POST /ingest(ingest is per-chunk aware and atomic — a batch that would cross the cap is refused entirely, up front, before any chunk is stored). Over the cap:403naming the cap and the current count.403 → {"error":"tenant active-claim cap of 50000 reached (current: 50000)","max_active_claims":50000,"active_claims":50000} -
POST /supersedeis never subject to the claim cap — it's net-zero (one active claim replaces another), so it can't push a tenant over the ceiling.POST /retract(removal only) and every read endpoint (/recall,/why,/active,/history,/diagnose) are never capped either. All of these still count against the rate limit like any other route. -
Plan metering is a separate, independent axis from the rate limit and active-claim cap above — see Usage & billing for the customer-facing explanation. It applies only to a real tenant token with an explicit
planset; the legacy token, the superadmin token, and a grandfathered tenant (noplanset) are unmetered.POST /remember,POST /learn, andPOST /supersedecan additionally return403for a monthly guarded-write cap or a project-count-limit breach:403 → {"error":"monthly_write_limit_reached","plan":"free","limit":500,"used":500,"credit_balance":0,"resets_at":"2026-08-01T00:00:00Z","upgrade":"https://ergomem.com/pricing"} 403 → {"error":"project_limit_reached","plan":"free","limit":1,"used":1,"resets_at":null,"upgrade":"https://ergomem.com/pricing"}Enforcement order is: auth → tenant rate limit → monthly write cap → active-claims cap.
Write endpoints (contradiction-guarded)
These run the contradiction guard. A hard conflict returns 409 with the prior claim.
POST /remember
Guarded write for decisions / constraints / rejections / conventions.
| field | required | description |
|---|---|---|
org_id | yes | tenant id |
project | yes | project namespace within the tenant |
who | yes | actor (audit row) |
statement | yes | the claim text |
reason | no | why (free text; surfaced via /why) |
force_exception | no | if set, stores even on conflict, with this string as the exception reason |
curl -s -H "$TOK" -X POST http://127.0.0.1:8788/remember -d '{
"org_id":"acme","project":"platform","who":"alice",
"statement":"Deploy on Tuesdays","reason":"team is on-call Mon/Wed"
}'
# 200 → {"result":"stored","claim_id":"...","warnings":[...]}
# 409 → {"result":"conflict","conflicts":[{"claim":{...},"tier":"block","score":0.97,...}]}| code | meaning |
|---|---|
200 | stored (MED-tier warnings included if any) |
409 | blocked — conflict set returned for the client to resolve |
400 | missing required field |
403 | monthly_write_limit_reached or project_limit_reached — see plan metering |
500 | normalizer / judge / store failure |
POST /learn
The bridge between ingest (raw chunks, no gate) and remember (decisions, full gate).
Use it when an agent read something and formed a belief. Same pipeline as remember;
folds source into the stored reason for provenance. The contradiction check runs on the
statement only — the source never pollutes the judge.
| field | required | description |
|---|---|---|
org_id, project, who | yes | as /remember |
statement | yes | the belief to check + store |
source | yes | provenance (file:line, URL, doc id). Blank source is a 400 |
reason | no | extra rationale |
curl -s -H "$TOK" -X POST http://127.0.0.1:8788/learn -d '{
"org_id":"acme","project":"platform","who":"agent",
"statement":"Sessions are never reused across restarts",
"source":"sessions.py:847","reason":"observed in the restart path"
}'
# 200 → {"result":"learned","claim_id":"...","source":"sessions.py:847"}Same 403 plan-metering rows as /remember apply here too — see
plan metering.
POST /supersede
Change of mind. Replaces an active claim with a new one, and runs the guard against all other active claims first — if the new claim would itself contradict another, the supersede is refused.
curl -s -H "$TOK" -X POST http://127.0.0.1:8788/supersede -d '{
"org_id":"acme","project":"platform","who":"alice","existing_id":"<id>",
"statement":"Deploy on Wednesdays","reason":"Tuesdays now collide with standup"
}'
# 200 → {"result":"superseded","claim_id":"<new id>"}Side effects: old row → status='superseded'; new row inserted with supersedes pointing
at the old id (this is what /why walks).
/supersede is exempt from the active-claim cap (it's net-zero), but the monthly
guarded-write cap still applies — the same 403 plan-metering rows as /remember can
come back here too. See plan metering.
POST /retract
Wrong-at-birth removal (typo, test junk, mis-scoped write). Flips an active claim to
retracted. Retract ≠ supersede — if a decision genuinely evolved, use supersede to
keep the why-chain. No gate runs (removal can't create a contradiction). The row is not
deleted — it stays in /history with retract_reason; /active, /recall, /why, and
the judge all exclude it.
curl -s -H "$TOK" -X POST http://127.0.0.1:8788/retract -d '{
"org_id":"acme","project":"platform","who":"alice","claim_id":"<id>",
"reason":"test junk written against the prod scope"
}'
# 200 → {"result":"retracted","claim_id":"<id>","retracted":true}All five fields (org_id, project, who, claim_id, reason) are required; a blank
reason is a 400.
Fast document path
POST /ingest
Bulk reference content. Skips the normalizer + judge (fact = upsert freely). Embeds each chunk and stores it. Requires the embedder to be enabled.
| field | required | description |
|---|---|---|
org_id, project, who | yes | as /remember |
text | yes | the document body (any size) |
source | no | provenance string, appended to each chunk for citations |
curl -s -H "$TOK" -X POST http://127.0.0.1:8788/ingest -d '{
"org_id":"acme","project":"kb","who":"alice","text":"...markdown...","source":"NOTES.md"
}'
# 200 → {"result":"ingested","chunks":49,"stored":49,"source":"NOTES.md"}Chunking is paragraph-aware and word-safe (≈1200 chars, ≈150 overlap). Ingested facts are also skipped on the candidate side — a bulk chunk can never 409 a decision; only prior decisions can.
Export / import
GET /export is available on every plan, including Free. POST /import is
available on Pro and Team plans only. The legacy single-tenant token and the
superadmin token are exempt from this gate, same as every other plan-metering rule.
GET /export
Reads active claims straight off the store — not /recall, which is a semantic-search
read over an embedded subset and would silently drop out-of-topic claims.
| param | required | description |
|---|---|---|
org_id | yes (tenant token: implied by the token) | tenant |
project | no | scope to one project; omitted → every project in the org |
limit | no | opt into paging; omitted stays fully UNBOUNDED — the full scope in one response, deliberately unlike /active, because an export is the backup/migration source of truth POST /import round-trips against and must never be silently truncated. When supplied, clamps to [1, 200]; a non-numeric value degrades to 25 |
offset | no | row-count offset into the head list, same semantics as /active's offset; ignored when limit is omitted |
curl -s -H "$TOK" "http://127.0.0.1:8788/export?org_id=acme"
# 200 → {"ergo_export_version":2,"org_id":"acme","exported_at":"...",
# "embedder":{"provider":"FastEmbedEmbedder","dim":384},
# "claim_count":2,"returned":2,"truncated":false,"next_offset":null,
# "as_of_seq":{"platform":41},
# "claims":[{"id":"...","project":"platform",
# "statement":"Deploy on Tuesdays","reason":"...","source":null,
# "status":"active","who":"alice","tier":null,"created_at":"...",
# "revisions":[{"statement":"Deploy on Wednesdays",
# "reason":"original on-call rotation","who":"bob",
# "status":"superseded","created_at":"..."}]}]}Top-level claims[] is one entry per active claim ("head"). A head that was
superseded at least once carries its supersede history as a nested, oldest-first
revisions[] list — revisions[0] is the first claim ever made in that lineage,
revisions[-1] is the one immediately before the current head. revisions is
omitted entirely (never an empty list) for a head that was never revised — most
claims have no chain. Retracted claims are excluded entirely — never as a top-level
entry, and never inside any head's revisions — a retraction is a deliberate removal,
and re-exporting one would resurrect noise the user removed on purpose; the full
retraction history remains available via GET /history.
claim_count/returned/truncated/next_offset are always present regardless of
whether limit was supplied. claim_count is /export's own name for the scope's
total — the same concept /active calls count — and is always the total, never
just the current page; keep calling with offset=next_offset until truncated is
false.
Pagination is by HEAD, not by raw row: a head's full revisions[] chain always rides
inside its own page atomically and can never be torn across a page boundary.
as_of_seq ({project: max_claim_seq}) is a snapshot marker for detecting a scope
written to mid-walk — compare it across the pages you fetch; if it changed, re-walk
from offset=0 before trusting the composed file as a clean snapshot.
No embedding vectors are ever included; embedder just describes what would
re-embed the claims on import, since vectors are recomputed there, never carried over.
source and tier are reserved fields, always null today.
| code | meaning |
|---|---|
200 | exported |
429 | tenant rate limit exceeded |
POST /import
The value is the "import audit": every incoming claim runs through the same
contradiction gate /remember uses (or /learn, when the claim carries a non-empty
source) — never a reimplemented or bypassed check. A full /export document is
accepted as-is; the extra ergo_export_version/exported_at/embedder/claim_count
fields are ignored.
| field | required | description |
|---|---|---|
org_id | yes (tenant token: implied) | tenant |
claims | yes | list of claim objects; each needs at least statement and a project (its own, or via target_project) |
target_project | no | remap every claim into this one project, overriding each claim's own project |
Per claim: an exact-text active-statement duplicate in scope is skipped
(skipped_duplicate); otherwise the claim runs the guarded write and a blocking
conflict is reported in conflicts, never stored or forced.
Async for real tenants. For a real (provisioned) tenant, this route no longer runs
the merge inline — it enqueues a background job and returns 202 immediately; the
merge itself runs later via the engine's own cron drain. Poll GET /import/status (below)
for progress/results. A legacy/superadmin/pooled caller (no tenants row) is unaffected —
it still gets the old synchronous 200 shape directly, with no job to poll.
curl -s -H "$TOK" -X POST http://127.0.0.1:8788/import -d '{
"org_id":"acme",
"claims":[{"project":"platform","statement":"Deploy on Tuesdays","reason":"on-call Mon/Wed","who":"alice"}]
}'
# 202 (real tenant) → {"job_id":"7e0772adcfc04706bdc03d03baf96ebf","status":"pending","claims_count":1}
# 200 (legacy/superadmin/pooled caller only) → {"imported":1,"skipped_duplicate":0,"conflicts":[],"conflict_count":0,"cap_reached":false}A 202 is success — the claims are enqueued, not an error. Once the job's status is
"succeeded", GET /import/status reports the same {imported, skipped_duplicate, conflicts, conflict_count, cap_reached} shape the old synchronous 200 used to return
directly.
Import is exempt from the monthly guarded-write cap (a restore shouldn't burn a
user's authoring month), but still enforces the tenant's active-claim cap and
project limit — hitting either mid-import stops gracefully with "cap_reached": true,
never a 500. There's also a per-plan import_max_claims size cap checked before any
claim is processed (and before any job is enqueued) — free: 0 (import
unavailable), pro: 10,000, team: 100,000, grandfathered: unlimited — exceeding
it is a 413 with nothing imported. See Usage & billing for
the plan table.
| code | meaning |
|---|---|
202 | real tenant — job enqueued, merge runs later via the cron drain |
200 | legacy/superadmin/pooled caller only — merge ran inline, exactly like before |
400 | missing required field |
403 | {"error":"import_requires_pro_or_team","plan":"free",...} — tenant on the free plan |
413 | claims longer than the plan's import_max_claims |
429 | tenant rate limit exceeded |
GET /import/status — poll a background import job
Tenant-facing, tenant-scoped read (same isolation guarantee as /audit, but keyed off
the job's own tenant rather than org_id). A legacy/superadmin caller has no async job
to poll in the first place — those callers keep the old synchronous /import path.
| param | required | description |
|---|---|---|
job_id | yes | the id returned by POST /import's 202 response |
curl -s -H "$TOK" "http://127.0.0.1:8788/import/status?job_id=7e0772adcfc04706bdc03d03baf96ebf"
# 200 → {"job_id":"7e0772adcfc04706bdc03d03baf96ebf","org_id":"acme","status":"succeeded",
# "claims_count":1,"created_at":"...","last_attempt_at":"...","last_error":null,
# "imported":1,"skipped_duplicate":0,"conflicts":[],"conflict_count":0,"cap_reached":false}status is one of "pending" (queued), "running" (actively merging), "succeeded",
or "failed" — imported/skipped_duplicate/conflicts/conflict_count/cap_reached
stay null until status is "succeeded" (a "failed" job's last_error carries the
reason instead).
| code | meaning |
|---|---|
200 | job found and owned by the caller's own tenant |
400 | missing job_id |
404 | job_id doesn't exist, or belongs to a different tenant — never distinguished |
429 | tenant rate limit exceeded |
Read endpoints
GET /recall
Hybrid semantic + keyword search over active claims.
| param | required | default | description |
|---|---|---|---|
org_id, project | yes | — | scope |
q (alias query) | yes | — | search text |
limit | no | 10 | clamped to [1, 50] |
curl -s -H "$TOK" "http://127.0.0.1:8788/recall?org_id=acme&project=kb&q=when%20to%20deploy&limit=3"Score = 0.7 × vec_sim + 0.3 × keyword_overlap. Superseded / retracted claims are excluded.
GET /why
Like /recall but reasoned-only (a reason-less fact can never win a "why" query),
returns the top hit, and walks the supersede chain.
curl -s -H "$TOK" "http://127.0.0.1:8788/why?org_id=acme&project=kb&q=when%20to%20deploy"
# → {"why":{"claim":"...","reason":"...","score":0.6348,"history":[ {newest}, {superseded} ]}}
# no reasoned claim in scope at all → {"why": null}
# a reasoned claim exists but scores below the relevance floor → suppressed, not a guess:
# {"why": null, "suppressed": {"reason": "below_relevance_floor", "score": 0.4511, "floor": 0.5}}Without a floor, a nonsense query against a populated project would get back a
confident, completely unrelated claim — /why's "returns null when nothing matches"
contract was never actually true on a project with any reasoned claims in it. When the
best reasoned hit scores below the floor, why is null and the response carries
the additive suppressed object above, so a suppressed result is never confused with
"this project holds no reasoned claims." A suppressed answer means "ask a more specific
question," not "there is no decision on file" — the underlying claim still exists; call
/recall directly (no floor applied) to see what was actually nearby.
GET /active
Lists active claims in a scope. Bounded page by default: an unbounded response on a populated project was measured at ~300 KB for ~170 claims — enough to blow a caller's tool-output ceiling. Truncation is loud, never silent — the response always carries the scope's true total alongside whatever page came back.
| param | required | default | description |
|---|---|---|---|
org_id | yes | — | tenant |
project | no | — | scope to one project; omitted → your token's default project, or the fallback "default" |
limit | no | 50 | page size; clamped to [1, 500] server-side; a non-numeric/missing value degrades to the default rather than erroring |
offset | no | 0 | rows to skip, oldest-first; page with offset=next_offset to walk the whole set with no gaps or duplicates |
projection | no | full | full (every column) or summary (each claim reduced to {id, statement}, reason omitted); an unrecognized value degrades to full |
curl -s -H "$TOK" "http://127.0.0.1:8788/active?org_id=acme&project=platform&limit=50"
# 200 → {"state":"has_active_claims","count":170,"returned":50,"truncated":true,
# "truncated_reason":"row_limit","next_offset":50,"claims":[...50 full claims...]}count is the scope's TOTAL active claims regardless of limit; returned is claims in
this page. truncated_reason is "row_limit" (more rows remain — raising limit helps,
up to 500) or "byte_budget" (a ~50 KB served-JSON size cap cut the page short of limit
— raising limit will not help) or null when not truncated. state
(project_missing / empty / has_active_claims) describes the scope, not the
page — a project with 170 claims read at offset=500 still reports has_active_claims
with an empty claims list.
GET /history
Full claim history for a scope — active plus superseded plus retracted, ordered
by claim_seq ascending. Unlike /active, this route is unbounded — it has no
limit/offset pagination.
| param | required | default | description |
|---|---|---|---|
org_id | yes | — | tenant |
project | no | — | scope to one project; omitted → your token's default project, or the fallback "default" |
curl -s -H "$TOK" "http://127.0.0.1:8788/history?org_id=acme&project=platform"
# 200 → {"history":[ {...claim...}, ... ]}GET /audit — tenant-facing audit trail
Read-only self-serve read of your own org's writes + conflict_events rows — every
guarded write your tenant made, and every conflict it triggered — for a compliance or
security need, without going through support.
| param | required | default | description |
|---|---|---|---|
org_id | yes (tenant token: implied) | — | tenant |
project | yes | — | project |
limit | no | 200 | caps each stream independently (not their sum); clamped to [1, 1000] |
Same tenant isolation as /history//active: your token's own org always wins over any
org_id you pass, so you can never read another org's audit rows.
curl -s -H "$TOK" "http://127.0.0.1:8788/audit?org_id=acme&project=platform&limit=50"
# 200 → {"writes":[{"id":1,"who":"alice","action":"remember","claim_id":"...","ts":"..."}],
# "conflict_events":[{"id":1,"who":"bob","incoming":"...","tier":"block","reason":"...","ts":"..."}]}Each stream is ordered oldest → newest.
| code | meaning |
|---|---|
200 | audit rows for your org+project |
400 | missing org_id/project |
429 | tenant rate limit exceeded |
GET /diagnose
The ops view — the running binary answers for itself. Live counts, conflict telemetry,
version-drift detection, and a warnings[] array flagging things the operator must act on
(no backup configured, version drift, etc.). Bearer-authed.
GET /health
Liveness + an allowlisted config snapshot. No auth — so health checks don't need the
secret. Ops metadata (owner, backup path) is deliberately kept off /health and lives on
the authed /diagnose.
GET /ready (also HEAD /ready)
Write-path readiness, unauthenticated like /health. /health only proves the store
is reachable — it never calls the normalizer, so a normalizer outage can leave /health
green while every guarded write (/remember//learn//supersede) is failing. /ready
does the same store probe plus an actual normalizer liveness call, so it reflects whether
guarded writes will actually work, not just whether the process is alive. Useful as a
load-balancer readiness probe alongside /health as the liveness probe.
200 → {"status":"ready","normalizer":{"ok":true},"probe_state":"has_active_claims",...}
503 → {"status":"degraded","normalizer":{"ok":false,"error":"...","retryable":true},...}The normalizer check is TTL-cached (default ~15s) — at most one real normalizer call per
TTL window, so a monitor polling /ready frequently can't turn this unauthenticated route
into an LLM-cost or DoS vector.
Admin / control-plane
Tenant provisioning and token minting live under /admin/*, gated by a separate
ERGO_SUPERADMIN_TOKEN. Tokens can carry an optional expires_at and be atomically
rotated via POST /admin/tokens/rotate (old token revoked, new one minted, in one
transaction — a rotation never leaves both secrets live). See
Multi-tenancy for the full model — silo isolation, roles, and
request/response shapes for POST /admin/tenants, POST /admin/tokens,
POST /admin/tokens/rotate, GET /admin/control-health, and POST /admin/tenants/delete.
Metrics (operator)
GET /metrics returns a Prometheus text exposition of in-process counters —
request counts and latency by route, guarded-write outcomes (stored/conflict/
quota/5xx) for /remember, /learn, and /supersede, contradiction-pipeline
stage timing, and thread-pool saturation.
Operator-only, unlike every other monitoring route on this page: it requires the
operator bearer (ERGO_SUPERADMIN_TOKEN, or ERGO_API_TOKEN if no superadmin token is
configured) and explicitly rejects a real per-tenant token — a tenant's own key must
never be enough to read another tenant's aggregated operational data, since /metrics
counts across every tenant on the process. Counters are in-process only and reset on
restart.
curl -s -H "$SUPER" http://127.0.0.1:8788/metrics
# 200 text/plain; version=0.0.4 → Prometheus exposition formatPair it with ERGO_LOG_FORMAT=json (see Install) for structured JSON
access logs on stderr alongside the same metrics.
MCP endpoint
https://api.ergomem.com/mcp serves the same 15 tools as this API (one tool per route
above) over the MCP streamable-HTTP transport — a second transport on the same engine,
not a separate service. The same auth applies: your own Authorization: Bearer <token>
header, forwarded straight through to the underlying route. See
Quickstart for Claude Code, Codex CLI, and Cursor config, or
MCP tools for the tool-level reference. A self-hosted engine can serve
the same transport:
ERGO_API_TOKEN="your-secret-token" \
python -m ergo.mcp.server --transport streamable-http --host 127.0.0.1 --port 8790Don't bind
--host 0.0.0.0without a token. OmittingERGO_API_TOKENleaves the server open (see Auth); combined with0.0.0.0that exposes everyergo_*tool, unauthenticated, to anyone who can reach the port. Bind0.0.0.0only behind a reverse proxy/tunnel that terminates auth, and always set a token first.
End-to-end
TOK="Authorization: Bearer $ERGO_API_TOKEN"
curl -s -H "$TOK" -X POST .../remember -d '{"org_id":"acme","project":"platform","who":"alice","statement":"Deploy on Tuesdays","reason":"on-call Mon/Wed"}'
curl -s -H "$TOK" -X POST .../remember -d '{"org_id":"acme","project":"platform","who":"bob","statement":"Never deploy on Tuesdays","reason":"incident risk"}'
# → 409 conflict against alice's claim
curl -s -H "$TOK" -X POST .../supersede -d '{"org_id":"acme","project":"platform","who":"alice","existing_id":"<id>","statement":"Never deploy on Tuesdays","reason":"incident risk outweighs on-call"}'
curl -s -H "$TOK" ".../why?org_id=acme&project=platform&q=when%20to%20deploy"
# → history with both entries