---
title: "Create a memory"
method: POST
path: "/v1/projects/{project_id}/memories"
tags: ["Memories"]
---

# Create a memory

`POST /v1/projects/{project_id}/memories`

## Request

- Base URL: `https://api.naturali.ai`
- URL: `https://api.naturali.ai/v1/projects/{project_id}/memories`
- Auth: one of:
  - HTTP bearer
  - OAuth 2 (authorization code, authorization URL `https://api.naturali.ai/authorize`, token URL `https://api.naturali.ai/token`, scopes: `mcp:access`)

Writes a fact to the specified memory store through the standard write algorithm: the content is embedded (or matched to text the store already holds), compared against the most similar currently-valid memory, and resolved to exactly one of three outcomes — `skipped` at or above `duplicate_threshold`, `superseded` at or above `supersede_threshold`, `created` below it. `supersedes` overrides that comparison entirely, retiring the memory it names. Every call records one assertion, whatever the outcome.

## Request body

- object
  - `memory_store_id` string, required: Memory store to add the memory to (mstore_...)
  - `content` string, required: The text content of the memory
  - `source_type` 'manual' | 'conversation': Whether there is a source to point at. `conversation` requires `source_id`; `manual` rejects it.
  - `source_id` string: The conversation this fact was learned in. Required when `source_type` is `conversation`, and rejected otherwise.
  - `tags` TagBag: Key-value labels on a resource. A flat object of string values — an array, a nested object or a number is rejected with `400 VALIDATION_FAILED`, never coerced. Keys are opaque and stored verbatim, so `cost_center` and `costCenter` are two different tags. Matched by JSONB containment wherever tags are read: the `?tags=` filter and knowledge search. Keys beginning `system.` are reserved: the platform writes them to record which conversation, actor, agent and role a row came from, and a write naming one is refused with `400 RESERVED_TAG_KEY`. They are read and filtered like any other tag. The bag is bounded, because every pair reaches the IAM context of every access check on the resource: at most 50 keys, each key at most 128 characters and each value at most 256. A write past a bound — including a merge that would grow the stored bag past the key count — is `400 VALIDATION_FAILED` with `meta.limit` naming the bound it crossed. `system.*` keys are the platform's and do not count against the 50.
  - `metadata` MetadataBag: Caller-owned annotations on a resource, stored as the object they were written as: the types a value was written with are the types a read returns, so a filter can ask an ordering question about a number. Unlike other body fields, keys are stored and returned verbatim in the casing supplied — they are not converted between snake_case and camelCase. No key is reserved, and that is the point: every piece of state the platform owns lives in its own typed column, so nothing written here reaches platform state. The platform never reads the bag — it is not an IAM context, not a policy input and not part of a prompt — which is what separates it from a tag bag.
  - `supersedes` string: The memory this write replaces, named outright. The declaration outranks the thresholds in both directions: the target is retired and the write returns `superseded` however similar or distant the two texts are. This is what reaches a contradiction cosine cannot see ("The office is in Lisbon" then "We closed the Lisbon office"). The target must be a still-valid memory in the same memory store, and the caller needs `memories:UpdateMemory` on it as well as `memories:CreateMemory` on the store.
  - `duplicate_threshold` number: Cosine similarity at or above which the incoming content is a duplicate of an existing memory and the write is skipped. Overrides the store's `duplicate_threshold` for this call; falls back to the store's value, then to `0.95`.
  - `supersede_threshold` number: Cosine similarity at or above which the incoming content restates a fact that has changed: the top match is invalidated and a new memory replaces it. Overrides the store's `supersede_threshold` for this call; falls back to the store's value, then to `0.90`. The effective pair must satisfy `supersede_threshold < duplicate_threshold`, and the check runs against the effective pair — so overriding only one value cannot invert it against the store's other one.

## Response `200`

The write resolved against an existing memory — `action` is `skipped` (the fact was already known) or `superseded` (the fact had changed, and the returned memory is the replacement).

- MemoryWriteResult
  - `id` string
  - `memory_store_id` string
  - `content` string: The text this memory currently holds. Stored once per distinct text per store and shared with every assertion that stated it, so a memory carries no copy of its own.
  - `source_type` 'manual' | 'conversation': Whether there is a source to point at. `conversation` means `source_id` names the conversation this fact was learned in; `manual` means there is nothing to point at — a direct API write, or an agent write made outside a conversation.
  - `source_id` string, nullable: The conversation this fact was learned in, when `source_type` is `conversation`; null when it is `manual`. Deliberately a loose pointer rather than a foreign key, so deleting the conversation leaves the id in place — the record of where the fact came from outlives its source.
  - `tags` TagBag: Key-value labels on a resource. A flat object of string values — an array, a nested object or a number is rejected with `400 VALIDATION_FAILED`, never coerced. Keys are opaque and stored verbatim, so `cost_center` and `costCenter` are two different tags. Matched by JSONB containment wherever tags are read: the `?tags=` filter and knowledge search. Keys beginning `system.` are reserved: the platform writes them to record which conversation, actor, agent and role a row came from, and a write naming one is refused with `400 RESERVED_TAG_KEY`. They are read and filtered like any other tag. The bag is bounded, because every pair reaches the IAM context of every access check on the resource: at most 50 keys, each key at most 128 characters and each value at most 256. A write past a bound — including a merge that would grow the stored bag past the key count — is `400 VALIDATION_FAILED` with `meta.limit` naming the bound it crossed. `system.*` keys are the platform's and do not count against the 50.
  - `metadata` MetadataBag: Caller-owned annotations on a resource, stored as the object they were written as: the types a value was written with are the types a read returns, so a filter can ask an ordering question about a number. Unlike other body fields, keys are stored and returned verbatim in the casing supplied — they are not converted between snake_case and camelCase. No key is reserved, and that is the point: every piece of state the platform owns lives in its own typed column, so nothing written here reaches platform state. The platform never reads the bag — it is not an IAM context, not a policy input and not part of a prompt — which is what separates it from a tag bag.
  - `invalidated_at` string, date-time, nullable: When the memory stopped holding, whether a supersede replaced it or a retraction withdrew it. Null means the memory is currently valid. Invalidated memories are excluded from listing, from write deduplication, and from knowledge search, but remain readable by ID — with their original text — for audit.
  - `superseded_by_memory_id` string, nullable: The memory that replaced this one, when it was superseded. Null for a valid memory and for a retracted one, which nothing replaced.
  - `version` integer: The memory's write version, starting at 1 and incremented on every update. States a precondition against it with `expected_version` or `If-Match` on `PUT /memories/{memory_id}`.
  - `created_at` string, date-time
  - `updated_at` string, date-time
  - `action` 'created' | 'superseded' | 'skipped': The outcome of the write. `created` means the fact was distinct and a new memory holds it. `superseded` means it restated a fact that had changed: the matched memory was invalidated, points at the replacement, and the replacement is what is returned. `skipped` means the fact was already known and the existing memory is returned unchanged. Every outcome is recorded as an assertion, readable at `GET /v1/projects/{project_id}/memories/{memory_id}/assertions`.

## Other responses

- `201`: Memory created
- `400`: Bad request — a missing required field, a threshold pair whose effective values are not `supersede_threshold < duplicate_threshold`, or a `supersedes` that is not a memory id, or names a memory in another memory store or one already superseded.
- `401`: Unauthorized
- `403`: Forbidden — the caller may not write to the memory store, or may not update the memory named by `supersedes`.
- `404`: Memory store not found, or no memory matches `supersedes`
- `409`: The project's `storage_bytes` quota is exceeded (`QUOTA_STORAGE_EXCEEDED`). Delete stored content, or raise the quota — no window reset clears a stored total, so no `Retry-After` is sent.
- `500`: Internal server error

---

[API](https://skmtc.dev/naturali/apis/naturali-ai-api.md) · [All operations](https://skmtc.dev/naturali/apis/naturali-ai-api/llms.txt) · [OpenAPI document](https://skmtc.dev/naturali/apis/naturali-ai-api/revisions/e4905b78f422?raw)
