POST /v1/experience
Capture an experience envelope — the single write path into the WAL.
Authorization
bearer PASETO v4 public token (or deployment gate key). Auth-disabled dev instances accept any caller.
In: header
Query Parameters
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": {} }'Authorization
bearer PASETO v4 public token (or deployment gate key). Auth-disabled dev instances accept any caller.
In: header
Query Parameters
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 '{}'Authorization
bearer PASETO v4 public token (or deployment gate key). Auth-disabled dev instances accept any caller.
In: header
Query Parameters
Response Body
curl -X GET "https://example.com/v1/experience/status"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=indexedorconsolidated): 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.detailssays 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_onisindexer(the indexer had not reached the event) orstage(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 answered200). A two-item batch answers after 32 s withdetails { batch_id, target_stage, waited_ms, waiting_on: "indexing", items, event_ids };waiting_on: "stage"carriesacceptedinstead ofitems.event_idslists the batch's events in item order, the same list a200returns. -
After a 408, poll
status_url, orGET /v1/experience/status?event_id=for each id inevent_ids. Don't re-send blindly: a re-send replays (never duplicates) only a capture or bulk item that carried its ownidempotency_key. One sent without a key gets a key the server mints and never returns, so re-sending it writes it again. Send anidempotency_keyon every capture that waits, bulk items included. -
A bulk sent with no
waithas no deadline, as before: it still indexes its batch before it answers202, 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) andscripts[](ISO 15924, at most 8), capabilitycapture_language_v1. Values are canonicalized (HI-latnis stored ashi-Latn,latnasLatn). A malformed tag or more than 8 scripts →422 INVALID_ENVELOPE.- Every event gets one language tag. Your
languagewins, then alang:<tag>label incontext.labels, then detection: Devanagari text is stored asund-Deva, and Latin-script Hinglish ashi-Latn. Event reads and recall items returnlanguage,scriptsandlanguage_source(hint,source,labelordetected). 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/Kolkataand the older aliasAsia/Calcuttawork;IST,+05:30andAsia/Mumbai→422 INVALID_ENVELOPE.context.refers_to[](capabilityrefers_to_v1): the dates or times the memory talks about, as local dates (YYYY-MM-DD) or date-times without an offset, infrom,toorcandidates[], with an optionalzoneandprecision. Recall can boost them withtemporal.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/experienceand/v1/experience/bulksharing 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
modalityis stored verbatim (202) — the field is a free string, not an enum. - Bulk ingest:
POST /v1/experience/bulk(in the spec above) and its aliasPOST /v1/experiencesshare one handler, with identical validation and response. The body takesitems(or the aliasexperiences), an array of the same envelopes, plus an optionalorderingstring (strict_temporal|batch_throughput); a non-string value →422. An empty array →422; the only accepted top-level fields areexperiences,items, andordering(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=indexedit is a200that addsstage: "indexed", or a408 WAIT_TIMEOUTlisting the batch'sevent_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.extractthrough/v1/experience/bulk(a one-item bulk request works): the bulk route applies it, while a singlePOST /v1/experienceruns enrichment as if it were unset. Since v0.10.1extract: ["beliefs"]or["understanding"]run enrichment (they used to switch it off);extract: []still turns it off for the event.directives.embedis validated but doesn't change how the event is embedded.