POST /v1/recall
Read a stratified pack — layers, context block, provenance, and diagnostics.
Authorization
bearer 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
| view | traversal | notes |
|---|---|---|
raw | single scope | full event payloads |
granular (alias local) | single scope | all layers, exact scope only |
holistic (default) | scope + ancestors | requires scope.read.holistic |
descend | scope + descendants | requires scope.read.descend |
lineage | — | supported (was undocumented in v1) |
structured | single scope | facts + 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 attributesobserved_actor,caller,modality,labels,intent(ANDed; values ORed). Unknown attributes →422. No confidence filter. exclude_content: truenulls largecontent/summarystrings.budgets.per_layer_limitshard-caps each layer (events: 0drops the layer).diagnostics=none(default) /summary/full. Timings are reported only forsummaryandfull:time_msholds about 47 phase-named keys (coordinator_recall,wal_hydrate,recall_total, …); withnoneit is an empty object. With capabilityrecall_diagnostics_v1,fullalso returns per-item ranks,items[] = { event_id, final_rank, fused_rank, channels: { bm25: {rank, score}, vector: {rank, score} }, matched_via }, andchannel_lists[] = { channel, len }, plusknapsack_evictions.provenance.trailentries are{ phase, elapsed_ms }(+ acontext_contributorslist; see the context receipt).- A body field easy to miss:
prefer_exhaustive(exhaustive candidate sweep). Theorderfield has its own section below. - Streaming:
POST /v1/recall/streamemitsplan → layer(×N) → context_block → provenance → diagnostics → done(thedoneevent carriespack_id). pack_idcan be reused byPOST /v1/answerviause_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_actorrecall now keeps events captured on someone else's behalf that those paths used to drop. scope_keyis the billing partition the event was captured under (defaultfor the static operator key), not its scope. Comparescopewhen you need the scope.context_blockis 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:
order | Selection | Rendering |
|---|---|---|
relevance (default) | ranked retrieval (BM25 + vector + RRF), scored against the query | ranked order |
recency | the scope's newest eligible events by observed_at — a timeline tail | newest-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
granularrecall 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, andcontext_blocklines 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=2makes it the first two segments). One outside it, or more than 8, answers422 INVALID_EXTRA_SCOPES. Repeats andscopeitself are dropped; it can't be combined withsubtree. 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.