Capture an experience into CortexDB. Replaces /v1/remember and /v1/episodes.

POST /v1/experience

The single write endpoint. Captures one experience — a structured envelope describing something an actor said, did, observed, or imported — and queues it for async indexing, fact extraction, belief revision, and understanding synthesis.

For batch ingest (≤1000 items / call) use POST /v1/experience/bulk. For larger volumes use POST /v1/import/jsonl.

Capability

scope.write (or scope.write.elevated when writing above the actor's natural level).

Headers

Authorization: Bearer <PASETO v4 public token>
X-Cortex-Actor:   user:alice
Content-Type:     application/json

Request body — the Experience Envelope

{
  "scope": "org:acme/dept:eng/user:alice",
  "modality": "conversation",
  "content": {
    "kind": "message",
    "role": "user",
    "text": "Just got off a call with Priya at Acme."
  },
  "context": {
    "observed_at": "2026-05-15T10:42:00Z",
    "labels": ["acme"],
    "intent": "deal_status_update"
  },
  "idempotency_key": "alice-chat-001"
}
FieldTypeRequiredNotes
scopestringyesHierarchical type:id/type:id/... path. See Scopes.
modalitystringyesOne of conversation, document, tool_result, observation, feedback, imported. Unknown values stored verbatim but don't trigger structured extraction.
contentobjectyesDiscriminated union on kind — see below.
contextobjectyesobserved_at (RFC 3339) + optional source_recorded_at, location, preceded_by[], intent, labels[].
observed_actorobjectnoWho performed the experience. Defaults to caller. If different, requires scope.write.on_behalf_of.
subjectobjectnoWho/what the memory is about. Defaults to observed_actor. If different, requires scope.write.about_other.
directivesobjectnoPer-call overrides — extract[], consolidate_into, confidence_floor, ttl_for_belief_layer, embed.
idempotency_keystringyes≤ 64 chars. Repeated calls with the same key + same body are deduplicated. Same key + different body → 409.

Content kinds

{ "kind": "message", "role": "user|assistant|tool|system", "text": "...", "media": [{ "blob_id": "blob_...", "alt": "screenshot" }] }
{ "kind": "text",    "text": "..." }
{ "kind": "json",    "data": { ... } }
{ "kind": "blob_ref","blob_id": "blob_..." }
{ "kind": "triple",  "triple": { "subject": {...}, "predicate": "...", "object": {...} } }

Sync via ?wait=

Select the latency contract with ?wait=:

ValueReturns whenTypical latency
(omitted) or acceptedFully indexed — read-your-writes: /v1/recall sees the event as soon as the 202 returnsscales with payload size
capturedDurable accept point — indexing continues in the background (the fast path for large payloads)~10 ms (200), flat in payload size
indexedBM25 + HNSW insert confirmed~100–500 ms (200)
consolidatedBeliefs/Understanding touched~500–3000 ms (200)

Any other value is rejected with 422 INVALID_BODY; the error message lists the valid values. accepted (server ≥ v0.8.7) is an explicit alias for the omitted default.

Response — async (default)

HTTP/1.1 202 Accepted
X-Cortex-Policy: tier=scope; decision=allow; capability=scope.write
X-Cortex-Stability: stable
{
  "event_id": "evt_01HX...",
  "status": "captured",
  "wal_offset": 134892,
  "lifecycle_stream": "/v1/lifecycle/stream?event_id=evt_01HX..."
}

Response — sync (?wait=indexed)

{
  "event_id": "evt_01HX...",
  "status": "indexed",
  "wal_offset": 134892,
  "stages_completed": ["captured", "extracted", "indexed"],
  "derives": ["fact_01HX...", "belief_01HX..."],
  "elapsed_ms": { "capture": 4, "extract": 410, "index": 88 }
}

Errors

HTTPerror_codeWhen
401actor_mismatchX-Cortex-Actor header doesn't match token sub
403policy_deniedCapability missing — response cites tier + capability
409idempotency_conflictSame key, different body
422invalid_envelopeValidation failed — details.field + details.reason
503wal_unavailableWAL not writable (rare; disk pressure)

POST /v1/experience/bulk

Ingest up to 1000 items per call. Takes the same ?wait= values as the single endpoint (a batch barrier: return once every accepted item reaches the stage). ?wait=captured (server ≥ v0.8.11) is the raw-first fast path for large backfills: every item acks at its WAL-durable capture point and the batched indexing (embedding + derived triple facts) continues in the background — poll GET /v1/experience/status?idempotency_key=… per item for progress; a background indexing failure frees that item's idempotency key so re-sending exactly it re-processes safely. Resolve per-item outcomes from the index-aligned results array, the lifecycle stream, or by polling /v1/experience/by-idempotency-key/{key}.

{
  "scope": "org:acme/dept:eng/user:alice",
  "items": [
    { "modality": "conversation", "content": { ... }, "context": { ... }, "idempotency_key": "k1" },
    { "modality": "conversation", "content": { ... }, "context": { ... }, "idempotency_key": "k2" }
  ],
  "ordering": "strict_temporal"
}
FieldTypeNotes
itemsarray≤ 1000 envelope items
orderingenumstrict_temporal (default — preserve observed_at order, slower) or batch_throughput (out-of-order allowed).
directivesobjectApplied to every item unless the item has its own.

Response:

{
  "batch_id": "batch_01HX...",
  "accepted": 1000,
  "event_ids": ["evt_...", "..."],
  "results": [
    { "index": 0, "event_id": "evt_...", "replayed_from_idempotency": false },
    { "index": 1, "event_id": "evt_...", "replayed_from_idempotency": true }
  ],
  "lifecycle_stream": "/v1/lifecycle/stream?batch_id=batch_01HX..."
}

Status contract: every envelope is validated before anything commits — any invalid item rejects the whole batch (422, per-index details.failures[]), and a 4xx/5xx bulk response guarantees nothing was written. Runtime faults on some items with others committed return 207 Multi-Status with partial: true and per-index failures[]; failed items drop their idempotency records, so re-sending exactly those items re-processes them. On server ≥ v0.8.7, a whole-batch indexing failure (e.g. an embedding-provider outage) tombstones the captured-but-unindexed rows server-side, so the retry leaves no stranded or duplicate events behind.

Items with content.kind: "triple" write their facts deterministically (no LLM) — on server ≥ v0.8.7 this holds in bulk too (one batched embedding call for all of the batch's triples; if the embedder is down the facts are stored without vectors rather than dropped).