CortexDB Docs
Docs for CortexDB 0.12.8, the version running on CortexDB Cloud (api.cortexdb.ai).
API Reference

POST /v1/recall

Read a stratified pack — layers, context block, provenance, and diagnostics.

POST
/v1/recall
AuthorizationBearer <token>

PASETO v4 public token (or deployment gate key). Auth-disabled dev instances accept any caller. On a cluster data node a PASETO/JWT bearer (not the static key) gets 503 REVOCATION_UNAVAILABLE (retriable) on any route while the node's revocation lease has expired: the metadata group has not confirmed its revocation set for 10 s, or the node has just started. A single-node server never returns it.

In: header

Request Body

application/json

TypeScript Definitions

Use the request body type in TypeScript.

Response Body

application/json

application/json

application/json

application/json

curl -X POST "https://example.com/v1/recall" \  -H "Content-Type: application/json" \  -d '{    "scope": "string"  }'
{  "pack_id": "string",  "scope": "string",  "context_block": "string",  "layers": {    "events": [      {        "matched_fragments": [          {            "chunk_id": "55e808e1-8ddc-49f1-92c9-6bbdcdff1c83",            "text_source": "event_text",            "byte_range": {              "start": 0,              "end": 1            },            "locator": {              "kind": "conversation",              "session_id": "string",              "turn_index": 0,              "speaker": "string",              "message_id": "string"            },            "language": "string",            "matched_via": "vector"          }        ],        "extract": [          "facts"        ]      }    ],    "facts": [],    "beliefs": [],    "episodes": [],    "understanding": []  },  "provenance": {},  "diagnostics": {    "time_ms": {      "property1": 0,      "property2": 0    },    "policy": {      "tier_attribution": "string",      "capability": "string"    },    "filters_applied": [      "string"    ],    "knapsack_evictions": 0,    "_partial": true,    "degraded_sources": [      "string"    ],    "items": [      {        "event_id": "string",        "final_rank": 0,        "fused_rank": 0,        "variant_fused_rank": 0,        "channels": {          "property1": {            "rank": 0,            "score": 0          },          "property2": {            "rank": 0,            "score": 0          }        },        "variants": [          {            "index": 0,            "vector_rank": 0,            "bm25_rank": 0,            "fused_rank": 0          }        ],        "matched_via": [          "string"        ],        "reorder": {          "property1": {            "from": 0,            "to": 0          },          "property2": {            "from": 0,            "to": 0          }        }      }    ],    "drops": [      {        "event_id": "string",        "reason": "time_range"      }    ],    "channel_lists": [      {        "channel": "vector",        "variant": 0,        "len": 0      }    ],    "items_truncated": 1  },  "warnings": [    "string"  ]}

Views

viewtraversalnotes
rawsingle scopefull event payloads
granular (alias local)single scopeall layers, exact scope only
holistic (default)scope + ancestorsrequires scope.read.holistic
descendscope + descendantsrequires scope.read.descend
lineage—supported (was undocumented in v1)
structuredsingle scopefacts + beliefs only

local is accepted

view: "local" is accepted on recall and behaves as granular.

Behavior notes

Recall is not cached by idempotency_key

Recall always runs fresh: repeated calls return a new pack_id each time, whatever idempotency_key you send. Keep a pack_id yourself if you need to refer back to a pack.

  • Filters take exactly one field, metadata, with attributes observed_actor, caller, modality, labels, intent (ANDed; values ORed). Unknown attributes → 422. No confidence filter.
  • exclude_content: true nulls large content/summary strings.
  • budgets.per_layer_limits hard-caps each layer (events: 0 drops the layer).
  • diagnostics = none (default) / summary / full. Timings are reported only for summary and full: time_ms holds about 47 phase-named keys (coordinator_recall, wal_hydrate, recall_total, …); with none it is an empty object. With capability recall_diagnostics_v1, full also returns per-item ranks, items[] = { event_id, final_rank, fused_rank, channels: { bm25: {rank, score}, vector: {rank, score} }, matched_via }, and channel_lists[] = { channel, len }, plus knapsack_evictions.
  • provenance.trail entries are { phase, elapsed_ms } (+ a context_contributors list; see the context receipt).
  • A body field easy to miss: prefer_exhaustive (exhaustive candidate sweep). The order field has its own section below.
  • Streaming: POST /v1/recall/stream emits plan → layer(×N) → context_block → provenance → diagnostics → done (the done event carries pack_id).
  • pack_id can be reused by POST /v1/answer via use_pack_id (same-scope only; cross-scope → 404).

Context block, budgets and long events

context_block is the text an LLM would see. With capability budget_excerpt_v1, no entry takes it past budgets.max_tokens. Events are placed whole in rank order while they fit; an event that doesn't fit is skipped, and each skipped event then gets the room still free at its rank position — whole if it now fits, otherwise as an excerpt of its best-matching chunks. The default budget is 4,000 tokens, about 14,000 bytes of context (CORTEX_RECALL_DEFAULT_MAX_TOKENS changes it).

For example, a 15,637-byte document queried for its second section gives a 3,320-byte block at max_tokens: 1000, with the excerpt starting at the matched section, and a 13,992-byte block at the default budget. An excerpted event's layers.events item carries the same excerpt, marked as a partial view:

"content": {
  "kind": "text",
  "text": "[…]
# Section 2: VPN troubleshooting …
[…]",
  "_partial": true,
  "_partial_reason": "budget_excerpt",
  "_original_bytes": 15637,
  "_excerpt_ranges": [{ "start": 3013, "end": 6219 }, { "start": 15564, "end": 15636 }]
}

Treat content.text as the whole text only when _partial is absent; GET /v1/events/{id} always returns the whole event. Slices are joined with […] markers; _excerpt_ranges are byte ranges into the stored text. CORTEX_RECALL_BUDGET_EXCERPTS=0 (or off, false) turns excerpts off and restores the pre-0.10.1 render. The server then leaves budget_excerpt_v1 out of capabilities[] and logs RECALL_BUDGET_EXCERPTS_DISABLED at startup, so a client that gates on the name knows excerpts are off.

For every language, English included: an assistant turn's In reply to user: … echo is dropped when that user turn is rendered in the same block; an event too large for the remaining budget does not end the block; and the first-ranked event gets no exception: it is excerpted like any other when it does not fit.

Matched fragments

For a long (chunked) event, layers.events[].matched_fragments[] (capability matched_fragments_v1) says which chunks matched: { chunk_id, byte_range: {start, end}, matched_via: "bm25" | "vector", locator: { kind: "document", section_path: [title, heading] }, language, text_source }. Byte ranges index the stored text, not an excerpt. section_path holds the Markdown headings in force where the chunk starts: the first # heading acts as the document title and later headings follow it. GET /v1/admin/events/{id}/chunks shows how any event was chunked.

The context receipt: which events the block renders

The provenance entry { "phase": "context_contributors", "context_contributors": [{ "event_id", "rank" }] } names every event context_block renders, including one the cross-layer knapsack evicted from layers.events (that row carries evicted_from_layers: true and is the only place the response names it).

Converting receipt ids

context_contributors[].event_id is a hyphenated UUID (01a0e70c-81fd-7cc2-b687-4e42ed600f50), while layers.events[].id and the event routes use evt_01a0e70c81fd7cc2b6874e42ed600f50. To match an entry or fetch the event, strip the hyphens and prefix evt_.

Who said it: recalled event identity

Every layers.events item carries the identity its event was captured with: caller, observed_actor, subject, scope_key, scope and wal_offset are the values GET /v1/events/{id} returns, in every view. What that changes for a client:

  • A filters.metadata.observed_actor recall now keeps events captured on someone else's behalf that those paths used to drop.
  • scope_key is the billing partition the event was captured under (default for the static operator key), not its scope. Compare scope when you need the scope.
  • context_block is unchanged.

A capture cancelled with POST /v1/lifecycle/memory-event/{id}/cancel is left out of every view: granular, raw, holistic, descend, prefer_exhaustive and order: "recency".

Ordering: relevance vs recency

The order field switches how the events layer is selected and rendered:

orderSelectionRendering
relevance (default)ranked retrieval (BM25 + vector + RRF), scored against the queryranked order
recencythe scope's newest eligible events by observed_at — a timeline tailnewest-first

recency replaces ranked retrieval for the events layer with a timeline tail: the newest eligible events by observed_at, capped by budgets.per_layer_limits.events (else CORTEX_RECENCY_TAIL_EVENTS, default 100). Eligibility (scope/tenant, filters, temporal) is identical to ranked mode — only selection changes. It's built for "recent/latest X" questions, where relevance ranking surfaces the most-repeated items and can drop the newest ones. An unknown order value → 422.

Use the holistic view for recency

order: "recency" applies in the holistic view (the recall default): holistic + recency + { events: 3 } returns the three newest events, newest first. The granular and raw views keep relevance order (ascending observed_at for events, and a per_layer_limits.events cap keeps the oldest). Use the default holistic view for "latest / most recent" reads.

/v1/answer forwards order and auto-infers recency for recency-shaped questions — see POST /v1/answer. The tuning knobs (CORTEX_RECENCY_TAIL_EVENTS / _ORDER / _INFERENCE) live in Recall Tuning.

Current values and corrections: newest_of_kinds

Ranked recall returns every past statement that matches the query. For something with one current value — a preference, a setting, a status — that means a late correction comes back beside the statements it corrects, and they can outnumber it:

[2026-01-05] Nigel prefers morning meetings, ideally 9am.
[2026-09-20] Correction from Nigel: he now prefers afternoon meetings after 2pm.
[2026-03-01] Nigel said again he likes early morning meetings at 9am.

Give each such thing a stable kind in context.intent when you write it, corrections included, and ask recall for the newest of that kind (v0.12.4+, capability memory_kinds_v1):

# Every statement of the preference, the correction too, carries the same intent
curl -fsS -X POST "$CORTEX_URL/v1/experience?wait=indexed" -H "$AUTH" -H 'Content-Type: application/json' -d '{
  "scope": "org:acme/user:nigel", "modality": "observation",
  "content": { "kind": "text", "text": "Correction from Nigel: he now prefers afternoon meetings after 2pm." },
  "context": { "intent": "preference.meeting_time" }
}'

curl -fsS -X POST "$CORTEX_URL/v1/recall" -H "$AUTH" -H 'Content-Type: application/json' -d '{
  "scope": "org:acme/user:nigel",
  "query": "What meeting time does Nigel prefer?",
  "newest_of_kinds": ["preference.meeting_time", "preference.seat"]
}'

For each kind listed, the pack holds the newest eligible event of that kind, first, whether or not the query matched it, and none of the older ones: the example returns only the correction. Older events stay listable (GET /v1/events?intent=preference.meeting_time) and erasable. Blank and repeated entries are dropped; more than 16 kinds is 422 INVALID_BODY; it can't be combined with subtree. When the scope can't be read whole in one bounded listing, the newest of each kind among the recalled events is kept and warnings carries newest_of_kinds_partial.

Use one kind per thing that has a single current value (preference.meeting_time, not preference): a kind serves exactly one event. Leave history questions ("what did Nigel prefer before?") on ranked recall.

Reading several scopes: extra_scopes

extra_scopes (v0.12.4+, capability extra_scopes_v1) adds up to 8 more scopes to the same ranked pass as scope: for example an application's own scope plus a shared source:slack and source:jira. /v1/recall/stream, /v1/answer and /v1/compose take the same field for every recall they make.

curl -fsS -X POST "$CORTEX_URL/v1/recall" -H "$AUTH" -H 'Content-Type: application/json' -d '{
  "scope": "org:acme/app:support",
  "extra_scopes": ["org:acme/source:slack", "org:acme/source:jira"],
  "query": "What did we promise Globex about the export bug?"
}'
  • Each extra scope is read exactly (its own events, no ancestors or descendants) and authorized as a direct granular recall of it would be: one the caller can't read refuses the whole request with that recall's 403.
  • Each contributes its own vector and BM25 candidates, and all of them meet in one fusion and one rerank. Their facts, beliefs, episodes and concepts join too.
  • Events keep their real scope in layers.events[].scope, and context_block lines from an extra scope carry its last segment after the time bracket: [2026-06-15 08:30 UTC] [source:slack] ....
  • Every extra scope must lie in the tenant subtree of scope (its root by default; CORTEX_TENANT_DEPTH=2 makes it the first two segments). One outside it, or more than 8, answers 422 INVALID_EXTRA_SCOPES. Repeats and scope itself are dropped; it can't be combined with subtree. Answers that use it are not cached.

On CortexDB Cloud the tenant subtree is your account, so extra_scopes can name any of your tenants and none of another account's.

Self-hosted default retrieval

On a default self-hosted instance recall runs BM25 + vector + RRF fusion only. Graph traversal needs a knowledge graph (CORTEX_ENTITY_GRAPH=1 + enrichment); cross-encoder rerank needs a Cohere key. See Self-hosting defaults.

On this page