CortexDB Docs
API Reference

POST /v1/experience

Capture an experience envelope — the single write path into the WAL.

POST
/v1/experience
AuthorizationBearer <token>

PASETO v4 public token (or deployment gate key). Auth-disabled dev instances accept any caller.

In: header

Query Parameters

wait?string

Synchronous barrier. Omitted (or 'accepted') = async accept, no wait. 'consolidated' additionally waits for deferred enrichment to apply this event. Any other value is rejected with 422 INVALID_BODY.

Value in

  • "accepted"
  • "captured"
  • "indexed"
  • "consolidated"
  • "settled"

Request Body

application/json

TypeScript Definitions

Use the request body type in TypeScript.

Envelope on the default router; NativeCaptureRequestV1 on the native host.

Experience envelope — full schema at /v1/schemas/experience_envelope

Response Body

application/json

application/json

application/json

application/json

application/json

application/json

application/json

curl -X POST "https://example.com/v1/experience" \  -H "Content-Type: application/json" \  -d '{    "scope": "string",    "modality": "string",    "content": {}  }'
Empty
POST
/v1/experience/bulk
AuthorizationBearer <token>

PASETO v4 public token (or deployment gate key). Auth-disabled dev instances accept any caller.

In: header

Query Parameters

wait?string

Batch barrier: returns once every accepted item reaches the stage. Omitted (or 'accepted') = fully indexed before returning. 'captured' = raw-first fast path (v0.8.11): ack at WAL-durable capture, batched indexing continues in background — poll /v1/experience/status per idempotency key. Any other value is rejected with 422 INVALID_BODY.

Value in

  • "accepted"
  • "captured"
  • "indexed"
  • "consolidated"
  • "settled"

Request Body

application/json

TypeScript Definitions

Use the request body type in TypeScript.

Response Body

application/json

application/json

application/json

curl -X POST "https://example.com/v1/experience/bulk" \  -H "Content-Type: application/json" \  -d '{}'
Empty
GET
/v1/experience/status
AuthorizationBearer <token>

PASETO v4 public token (or deployment gate key). Auth-disabled dev instances accept any caller.

In: header

Query Parameters

event_id?string
idempotency_key?string

Response Body

curl -X GET "https://example.com/v1/experience/status"
Empty

Required fields

Only scope, modality, content are required

The three required fields are scope, modality, content. context, context.observed_at, and idempotency_key are optional — the server returns 202 without them (omitting any of the three required fields → 422).

Wait semantics

Omitted ?wait= returns captured, not fully indexed

With ?wait= omitted the write returns status: captured (202) — the event is in the WAL and read-your-writes holds on recall, but it is not yet indexed. Pass ?wait=indexed to block until BM25 + HNSW have the event, ?wait=consolidated to block on consolidation. ?wait=accepted is an explicit alias for the omitted default (server ≥ v0.8.7), and ?wait=captured blocks only until the WAL append; any other value 422s listing the valid set (accepted, captured, indexed, consolidated, settled). Every response includes replayed_from_idempotency; the lifecycle_stream URL comes only with the async 202 response, not with a waited 200.

Use wait=indexed on a single-node server

wait=settled needs a coordinator process with artifact workers; the default single-node server answers it with 503 SETTLEMENT_UNAVAILABLE. Use wait=indexed. The same release added a second request body shape on this route, NativeCaptureRequestV1, plus POST /v1/experience/resume and a POST form of /v1/experience/status; all three belong to the native memory host profile. On the default router POST /v1/experience/resume is 404 and POST /v1/experience/status is 405 (the GET form above is served) — see Native memory host.

Wait deadline and 408 WAIT_TIMEOUT

A waiting capture has one budget (v0.10.2). When it runs out the server answers 408 WAIT_TIMEOUT (retriable: true). The event is already captured and durable, and indexing carries on in the background.

  • Single capture (?wait=indexed or consolidated): about 30 s, shared by the wait for the indexer to reach the event and the wait for the requested stage after it. Before v0.10.2 each wait had its own 30 s, so the 408 came after about 60 s. details says what it waited for:

    {
      "error_code": "WAIT_TIMEOUT",
      "retriable": true,
      "details": {
        "event_id": "evt_01a0e9fbdebf7d50bbacdfa90dcab9fc",
        "status_url": "/v1/experience/status?event_id=evt_01a0e9fbdebf7d50bbacdfa90dcab9fc",
        "target_stage": "indexed",
        "waited_ms": 30004,
        "waiting_on": "indexer"
      }
    }

    waiting_on is indexer (the indexer had not reached the event) or stage (indexed, but not yet at the requested stage).

  • Bulk (/v1/experience/bulk?wait=indexed|consolidated): min(30 s + 1 s per item, 600 s), and it now covers the batch's own inline indexing as well (before v0.10.2 that indexing had no time limit, so a batch behind a slow embedding provider waited, then answered 200). A two-item batch answers after 32 s with details { batch_id, target_stage, waited_ms, waiting_on: "indexing", items, event_ids }; waiting_on: "stage" carries accepted instead of items. event_ids lists the batch's events in item order, the same list a 200 returns.

  • After a 408, poll status_url, or GET /v1/experience/status?event_id= for each id in event_ids. Don't re-send blindly: a re-send replays (never duplicates) only a capture or bulk item that carried its own idempotency_key. One sent without a key gets a key the server mints and never returns, so re-sending it writes it again. Send an idempotency_key on every capture that waits, bulk items included.

  • A bulk sent with no wait has no deadline, as before: it still indexes its batch before it answers 202, so a slow embedding provider delays that answer by as long as its calls take.

Source descriptor

An optional source object (schema version 2) records the authoritative source and exact fragments the envelope represents: source_id, content_kind, a sha256:/blake3: fingerprint of the bytes, and per-fragment locators (document page, conversation turn, code file and lines, and so on). It is stored verbatim and echoed on reads. source.modality must equal the envelope modality or the write is 422 INVALID_ENVELOPE; unknown locator fields are 422 INVALID_BODY. Field reference and example: Experience Envelope.

Content kinds

content.kind is one of message, text, json, blob_ref, triple. A triple is nested under content.triple (a flat subject/predicate/object directly in content → 422 "missing field triple"), and its object is a full struct — type + datatype + value:

{
  "kind": "triple",
  "triple": {
    "subject": { "type": "entity", "id": "ent_acme" },
    "predicate": "seats",
    "object": { "type": "literal", "datatype": "string", "value": "200" }
  }
}

Omitting datatype → 422 "missing field datatype".

A triple capture writes its fact directly, with no enrichment and no LLM call: the extraction model never sees it, whatever directives.extract says. With ?wait=indexed the fact is on GET /v1/facts as soon as the write returns, with or without enrichment.

Language, time zone and referenced times

Three optional hints (v0.10.1), each gated on a capability in GET /v1/admin/version capabilities[]:

  • language (BCP-47, e.g. hi-Latn) and scripts[] (ISO 15924, at most 8), capability capture_language_v1. Values are canonicalized (HI-latn is stored as hi-Latn, latn as Latn). A malformed tag or more than 8 scripts → 422 INVALID_ENVELOPE.
  • Every event gets one language tag. Your language wins, then a lang:<tag> label in context.labels, then detection: Devanagari text is stored as und-Deva, and Latin-script Hinglish as hi-Latn. Event reads and recall items return language, scripts and language_source (hint, source, label or detected). An event that is English by default stores no tag, so it reads back exactly as before; an explicit "language": "en" is stored.
  • context.timezone: an exact IANA zone name. Asia/Kolkata and the older alias Asia/Calcutta work; IST, +05:30 and Asia/Mumbai → 422 INVALID_ENVELOPE.
  • context.refers_to[] (capability refers_to_v1): the dates or times the memory talks about, as local dates (YYYY-MM-DD) or date-times without an offset, in from, to or candidates[], with an optional zone and precision. Recall can boost them with temporal.refers_during. A phrase such as "next friday" → 422 INVALID_ENVELOPE.

None of these is part of the write identity. A retry with the same idempotency_key and body but different hints replays the first write (the first write's hints win) and adds warnings: ["conflicting_hint_on_replay: …"] to both the 202 and a waited 200.

Gate new fields on capabilities, not on the version

A server that does not list a capability refuses its fields with 422 INVALID_BODY (v0.9.13 refuses each as an unknown field). Check capabilities[] before sending language, scripts, context.timezone or context.refers_to.

Text blobs

A blob_ref capture of a text blob decodes the bytes by byte-order mark, a declared charset=, or strict UTF-8 (capability blob_text_decode_v1). Bytes that are none of these fail the write with 422 BLOB_TEXT_ENCODING_UNKNOWN (retriable: false; details { blob_id, content_type, reason, charset }, e.g. reason: "undeclared_non_utf8"). Declare the charset on upload (Content-Type: text/plain; charset=windows-1252) and the same bytes decode correctly. In /v1/experience/bulk one such blob fails the whole batch, and none of its items is stored. The event records the blob it came from in source_blob_ids; forgetting or erasing the event deletes the blob unless another live event still uses it (see Blobs).

Errors

Error codes are UPPERCASE

Error codes are uppercase: INVALID_BODY (e.g. a bad scope — must be type:name like org:acme), IDEMPOTENCY_CONFLICT (same key + different body — 409, retriable: no), POLICY_DENIED, RATE_LIMITED. The error body is flat: { "error_code", "message", "retriable", "request_id", "details": { "field_path", "reason", "schema" } }. details is present only when the error has any: INVALID_BODY carries it, INVALID_ENVELOPE does not.

A capture that would register a new scope inside a running forget or erasure waits for it (up to CORTEX_DESTRUCTIVE_LEASE_WAIT_SECS, default 120 s) and then gets 409 DESTRUCTIVE_OPERATION_IN_PROGRESS (retriable: true, Retry-After: 5); nothing was written, so re-sending is safe. Captures into existing scopes never wait. (Before v0.10.1 this case answered 403 POLICY_DENIED.)

Notes

  • Idempotency keys are scoped to the scope, actor and key, with /v1/experience and /v1/experience/bulk sharing one bucket (a bulk item replays a single write with the same key and body). The same key and body sent to another scope, or by another actor, writes a new event. Keys are at most 64 characters (a longer one → 422 INVALID_ENVELOPE) and expire after 24 hours; a repeat within the window with the same body replays (replayed_from_idempotency: true).
  • An unknown modality is stored verbatim (202) — the field is a free string, not an enum.
  • Bulk ingest: POST /v1/experience/bulk (in the spec above) and its alias POST /v1/experiences share one handler, with identical validation and response. The body takes items (or the alias experiences), an array of the same envelopes, plus an optional ordering string (strict_temporal | batch_throughput); a non-string value → 422. An empty array → 422; the only accepted top-level fields are experiences, items, and ordering (anything else → 422). The response is a batch envelope: { batch_id, accepted, lifecycle_stream, event_ids, results: [{ index, event_id, replayed_from_idempotency }] }; with ?wait=indexed it is a 200 that adds stage: "indexed", or a 408 WAIT_TIMEOUT listing the batch's event_ids.
  • Facts/Beliefs/Understanding extracted from text are derived asynchronously and require enrichment — on a content-only self-host they stay empty (triple captures, above, are the exception). See Self-hosting defaults.
  • Send directives.extract through /v1/experience/bulk (a one-item bulk request works): the bulk route applies it, while a single POST /v1/experience runs enrichment as if it were unset. Since v0.10.1 extract: ["beliefs"] or ["understanding"] run enrichment (they used to switch it off); extract: [] still turns it off for the event. directives.embed is validated but doesn't change how the event is embedded.

On this page