API Reference

Memory Files API

Named, versioned markdown files scoped to one entity. Read a file whole, by section, or a page at a time; edit it with section-aware appends that never lose a concurrent write; review it with version history, diffs, and grep.

A memory file is the shape to reach for when an agent needs a short, curated document it can read top to bottom — "what did we agree with Acme?" — rather than the top-k passages of a semantic search. For the patterns behind it (one file per customer, small-context agents, review workflows), see the Markdown memory files guide.

SDK 0.7.0 and engine configuration

Memory files are available on the hosted API and in all four SDKs from version 0.7.0. Self-hosted engines must enable memory_files_enabled; a disabled route answers 404 with code feature_not_enabled and "feature": "memory_files".

How memory files work

  • Identity. A file is identified by its name (for example memory.md), the entity_id that owns it (a customer, company, user, or agent), and the partition it lives in. The same name under two entities is two different files.
  • Content. UTF-8 markdown, up to 512 KiB per file.
  • Versions. Every change writes a new numbered version — 1, 2, 3, … — and the newest is the current version. The last 100 versions are retained; older ones are removed automatically. Reads and writes return the version as an ETag, so writers can say "only apply this if the file is still at version 7".
  • Sections. Markdown headings divide a file into sections. You can read one section, append under a heading, or replace a section's body, addressing it by heading text or by slug.
  • Search. Only the current version is indexed, so search, retrieve, and recall can find a memory file's latest text but never return an outdated version. Search hits on a memory file say which file, version, and section matched — see Memory files in search.

No per-file reader lists

Memory files do not carry reader lists: a file is visible to every key and acting principal in its workspace and partition. Keep each end client in its own partition (or workspace) when files must not be shared, and don't store content in a memory file that only some users of a partition may read.

SDK methods

Memory files live on the Memory facade. Construct a Memory for the owning entity and use its files accessor; every call is scoped to that entity_id and to the client's partition.

OperationPythonTypeScript.NETGo
Files accessormemory.filesmemory.filesmemory.Filesmemory.Files()
List fileslistlistListAsyncList
Read (whole, section, page, outline)readreadReadAsyncRead
Replace the whole filewritewriteWriteAsyncWrite
Append to the endappendappendAppendAsyncAppend
Append under a headingappend_sectionappendSectionAppendSectionAsyncAppendSection
Replace a section's bodyreplace_sectionreplaceSectionReplaceSectionAsyncReplaceSection
Version historyversionsversionsVersionsAsyncVersions
Diff two versionsdiffdiffDiffAsyncDiff
Grep the entity's filesgrepgrepGrepAsyncGrep
DeletedeletedeleteDeleteAsyncDelete

Two raw-client methods work across entities — for example every file whose entity_id starts with company::

OperationPythonTypeScript.NETGo
List files across entitieslist_memory_fileslistMemoryFilesListMemoryFilesAsyncListMemoryFiles
Grep across entitiesgrep_memory_filesgrepMemoryFilesGrepMemoryFilesAsyncGrepMemoryFiles

Python's AsyncMemory has the same accessor (AsyncMemoryFiles, with awaitable methods). Result types — MemoryFile, MemoryFileInfo, MemoryFileSection, MemoryFileWriteResult, MemoryFileVersion, MemoryFileVersions, MemoryFileDiff, MemoryFilePage, MemoryGrepMatch, MemoryGrepResult, MemoryFileDeleteResult, and MemoryFileHit (the search-hit field) — are exported from each package root. Result fields use the wire names: snake_case in Python and TypeScript (next_offset, current_version), PascalCase in .NET and Go (NextOffset). Timestamps are returned as RFC 3339 strings.

In Go, options are functional and typed per operation:

OperationGo options
ListWithFilePrefix, WithFileLimit, WithFileCursor
ListMemoryFilesthe List options plus WithFileEntityID or WithFileEntityPrefix
ReadWithFileVersion, WithFileSection, WithFileOutline, WithFileOffset, WithFileMaxBytes
Write, Append, AppendSection, ReplaceSectionWithExpectedVersion, WithAuthor, and WithIdempotencyKey on all four; WithCreateOnly on Write and ReplaceSection; WithCreateIfMissing on the PATCH ops; WithLevel on the section ops
VersionsWithVersionsLimit
DiffWithFromVersion, WithToVersion, WithDiffContext
GrepWithRegex, WithIgnoreCase, WithGrepFiles, WithGrepContext, WithMaxMatches
GrepMemoryFilesthe Grep options plus WithGrepEntityID or WithGrepEntityPrefix
DeleteWithHardDelete, WithExpectedVersion

The document-text options (WithTextSection, WithOutline, WithOffset, WithMaxBytes) are a different option type and don't apply to memory files. The facade's List and Grep are already scoped to the Memory's entity, so they reject the entity options.

import os
from aether import AetherClient, Memory

client = AetherClient(api_key=os.environ["AETHER_API_KEY"])
memory = Memory("company:acme", client=client)

result = memory.files.append_section(
    "memory.md",
    "Agreed terms",
    "- 2026-10-10 — Net 45 for EU entities (bob)",
    author="user:bob",
)
print(result.version)

The SDKs validate file names and required arguments before sending a request, send the precondition headers described in Versions and safe concurrent writes, attach a per-call Idempotency-Key to every write, and retry 503 memory_file_busy automatically.

REST routes

The SDKs call these routes for you. Use them directly for debugging, custom clients, or languages without an SDK. Direct requests authenticate with Authorization: Bearer <api-key>.

MethodPathDescription
GET/v1/memory/filesList the current version of every live memory file in scope, by entity or entity prefix.
GET/v1/memory/files/{name}Read a file — whole, one section, one page, its outline, or a retained older version.
PUT/v1/memory/files/{name}Replace the whole file; requires If-Match or If-None-Match: *.
PATCH/v1/memory/files/{name}Section-aware edit: append, append_section, or replace_section.
GET/v1/memory/files/{name}/versionsList retained versions, newest first.
GET/v1/memory/files/{name}/diffUnified diff between two versions.
POST/v1/memory/files/grepLine-based literal or regex search over current versions.
DELETE/v1/memory/files/{name}Soft-delete a file, or permanently erase every version with hard=true.

Scope parameters

Query parameterRequiredNotes
entity_idOn every per-file route (read, write, edit, versions, diff, delete)The owning entity. 1–256 characters; missing, empty, or longer returns 400 invalid_input.
partitionFor multi-tenant keys1–256 characters. On per-file routes, omitting it means your workspace's default partition. On list and grep, omitting it searches the whole workspace (single-tenant keys only).

A multi-tenant key must name a partition on every memory-file route; otherwise the request returns 400 partition_required. A partition-scoped client handle supplies it for you.

Each write records who made it: principal is the acting principal your request asserted, or key:<key-prefix> when none was asserted. Writes can also carry a free-text author label (1–128 characters) — for example the end user an agent is working for. author is stored for attribution only and is never used for authorization.

File names

A valid name:

  1. is 1–128 ASCII characters, made of letters, digits, ., _, and -;
  2. starts with a letter or digit;
  3. does not contain ..;
  4. is not exactly grep (that path is the grep route; Grep and grep.md are fine).

Names are case-sensitive (Memory.md and memory.md are different files) and never need URL encoding. An invalid name returns 400 memory_file_name_invalid; the SDKs reject it before sending a request. To check a name yourself — for example one an agent proposed — call validate_memory_file_name (Python), validateMemoryFileName (TypeScript), MemoryFiles.ValidateName (.NET), or ValidateMemoryFileName (Go).

AcceptedRejected
memory.md, notes_2026-10.md, x.y.z, A, 0, grep.md.hidden, -x, _x, a..b, a/b, a b, naïve.md, grep, a trailing space, anything over 128 characters

Sections, slugs, and selectors

Aether builds a file's outline from its top-level markdown headings:

  • A heading is a line with up to three spaces of indentation, one to six #, then a space, a tab, or the end of the line. An optional closing run of # is removed from the heading text.
  • These are not headings: #NoSpace, seven or more #, lines indented four or more spaces, setext (underlined) headings, headings inside block quotes or lists, anything inside a fenced code block, and anything inside YAML front matter (a block that starts with --- on line 1).
  • A section runs from its heading to the line before the next heading of the same or higher level (fewer or equal #), or to the end of the file — so sections nest: ## A contains ### A.1. Text before the first heading belongs to no section.

Each outline entry describes one section:

FieldTypeMeaning
headingstringThe heading text; "" for a bare #.
levelinteger1–6.
slugstringUnique within the file.
start_lineintegerThe heading's line number (lines are numbered from 1).
end_lineintegerThe section's last line, inclusive.
bytesintegerUTF-8 size of lines start_line through end_line, including line endings.

For example, this 122-byte file:

Markdown
# Acme memory
Intro line.

## Agreed terms
- 2026-09-01 — Net 30 payment terms (alice, ISS-12)

## Exceptions

## Notes

has this outline:

headinglevelslugstart_lineend_linebytes
Acme memory1acme-memory19122
Agreed terms2agreed-terms4671
Exceptions2exceptions7815
Notes2notes999

Slugs

A slug is the heading text lowercased, with every run of characters that are not letters or digits replaced by a single -, and leading and trailing - trimmed. Letters and digits from any script are kept. An empty result becomes section. When a slug repeats, later headings get -2, -3, … in document order.

Heading textSlug
Agreed termsagreed-terms
Terms & obligationsterms-obligations
Q3 2026 — Pricing!q3-2026-pricing
Café termscafé-terms
snake_case_headingsnake-case-heading
Notes, Notes, Notes 2notes, notes-2, notes-2-2

Selecting a section

A section selector — the section query parameter on reads, or the section field of a PATCH — resolves in this order:

  1. Heading text. The selector and each heading are compared after trimming, collapsing runs of whitespace, and lowercasing. If exactly one heading matches, it is selected. If more than one matches, the request returns 409 with code memory_file_section_ambiguous and a candidates array of {heading, slug, start_line} so you can retry with a slug.
  2. Exact slug. Otherwise, the heading whose slug equals the selector exactly is selected. Slugs are unique, so this step is never ambiguous.
  3. Otherwise the section is not found.

With headings Notes, Notes, and notes, the selector notes is ambiguous, while notes-2 selects the second heading. Prefer slugs when an agent saves a reference to a section for later.

Versions and safe concurrent writes

Reads and successful writes carry the file's version as a strong ETag, for example ETag: "7". Writes use standard HTTP preconditions:

HeaderMeaning
If-Match: "7"Apply the write only if the current version is 7. A bare 7 is also accepted for curl convenience; the SDKs always send the quoted form.
If-Match: *Apply the write only if the file currently exists.
If-None-Match: *Create only: apply the write only if the file does not exist.

The rules:

  • PUT requires a precondition. Send exactly one of the three headers above; with none, the request returns 428 precondition_required.
  • PATCH append and append_section may be sent without a precondition. Aether applies them atomically to whatever the current version is, so two agents appending at the same moment both land — neither write is lost.
  • PATCH replace_section requires If-Match (existing file) or If-None-Match: * (create); without one it returns 428.
  • A precondition you send is always enforced. A mismatch returns 412 with code memory_file_version_conflict and current_version — the file's current version, or null when no file exists.
  • Weak tags (W/"7"), lists ("7", "8"), and sending both If-Match and If-None-Match return 400 invalid_input.
  • No-op writes succeed. If a write's result is byte-for-byte identical to the current content, Aether returns 200 with "unchanged": true and the current version, and stores nothing — even if your precondition was stale. The state you asked for already holds, so nothing was lost. This makes retried writes safe.
  • 503 memory_file_busy (with Retry-After: 1) means the file stayed busy with other writes for too long. Nothing was written; retry. The SDKs retry it automatically.

Idempotency keys

PUT and PATCH accept an optional Idempotency-Key header (1–256 printable ASCII characters). Replaying the same key with the same request returns the original status, body, and ETag, plus Idempotent-Replayed: true; nothing is written or billed again. Reusing a key for a different request on the same file returns 409 idempotency_key_reused. Keys are scoped to one file and honored while the version they produced is still retained. The SDKs send a fresh key on every write call and reuse it across that call's retries, so a retried append whose first response was lost cannot append twice. DELETE does not deduplicate: a retried delete may see 404.

To make your own retries safe too — a queue redelivering a message, or a job that resumes after a crash — pass a key derived from the event you are recording: idempotency_key= (Python), { idempotencyKey } (TypeScript), idempotencyKey: (.NET), or aether.WithIdempotencyKey(…) (Go) on write, append, append_section, and replace_section.

memory.files.append_section(
    "memory.md",
    "Agreed terms",
    "- 2026-10-10 — Net 45 for EU entities (bob)",
    idempotency_key=f"crm-note-{note_id}",
)

In the SDKs

Header sentPythonTypeScript.NETGo
If-Match: "7"expected_version=7{ expectedVersion: 7 }expectedVersion: 7aether.WithExpectedVersion(7)
If-None-Match: *create=True{ create: true }create: trueaether.WithCreateOnly()

write and replace_section need exactly one of expected_version and create; passing neither or both raises an argument error before any request is sent. append and append_section send If-Match only when you pass expected_version.

A 412 memory_file_version_conflict surfaces as a typed error carrying the current version:

LanguageErrorCurrent version
PythonVersionConflictError.current_version (int or None)
TypeScriptVersionConflictError.currentVersion (number | null)
.NETVersionConflictException.CurrentVersion (int?)
Go*aether.VersionConflictError (match with errors.As; errors.Is(err, aether.ErrVersionConflict) is also true).CurrentVersion (*int)

Every other error code on this page surfaces as the SDK's base API error with its code.

Read, modify, write

To rewrite a file safely: read it, edit the content, write it back with expected_version set to the version you read, and on a version conflict re-read and try again.

from aether import VersionConflictError

for attempt in range(3):
    current = memory.files.read("memory.md")
    edited = current.content.replace("Net 30", "Net 45")
    try:
        memory.files.write("memory.md", edited, expected_version=current.version)
        break
    except VersionConflictError as err:
        print(f"Changed underneath us (now v{err.current_version}); retrying")

For adding an entry under a heading, skip the read entirely: append_section without a precondition never loses a concurrent write.

List files

GET /v1/memory/files lists the current version of every live file in scope. It never returns deleted files or older versions.

Query parameterTypeNotes
entity_idstringOnly this entity's files.
entity_prefixstringEntities whose entity_id starts with this string (byte-wise, case-sensitive) — for example company:. Cannot be combined with entity_id.
prefixstringFile names starting with this (1–128 characters, case-sensitive).
limitinteger1–1000, default 100.
cursorstringThe next_cursor from the previous page.
partitionstringSee Scope parameters.

With neither entity_id nor entity_prefix, every memory file in scope is listed. Results are ordered by partition (default partition first), then entity_id, then name. Sending both entity_id and entity_prefix, or a malformed cursor, returns 400 invalid_input.

curlbash
curl "https://api.aetherdb.ai/v1/memory/files?entity_prefix=company:&limit=50" \
  -H "Authorization: Bearer $AETHER_API_KEY"
JSON
{
  "files": [
    {
      "entity_id": "company:acme",
      "partition": null,
      "name": "memory.md",
      "file_id": "3d1f2a9c-5b7e-4c1d-8e2f-6a9b0c1d2e3f",
      "doc_id": "a8c2e4f6-1b3d-4f5a-9c7e-2d4f6a8b0c1e",
      "version": 7,
      "bytes": 122,
      "content_hash": "blake3:9f…",
      "author": "user:alice",
      "principal": "key:ak_1a2b3c4d",
      "op": "append_section",
      "created_at": "2026-09-01T10:00:00Z",
      "updated_at": "2026-10-10T09:12:44Z"
    }
  ],
  "next_cursor": null
}
FieldNotes
file_idStable for the life of the file, including across a soft delete and re-create.
doc_idThe document that holds this version.
versionThe file version (1, 2, 3, …). Not the same number as a document record's version.
bytes, content_hashSize and blake3: hash of this version's content.
author, principalWho wrote this version — see Scope parameters.
opput, append, append_section, or replace_section.
created_atWhen the file was created.
updated_atWhen this version was written.

next_cursor is null on the last page. In the SDKs, memory.files.list lists one entity's files; list_memory_files / listMemoryFiles takes entity_id or entity_prefix to list across entities.

Read a file

GET /v1/memory/files/{name} returns a file's content — the whole file by default — along with its outline.

Query parameterTypeNotes
entity_idstringRequired.
versioninteger ≥ 1Read a retained older version. Defaults to the current version.
sectionstringA section selector. Narrows the content to that section, including its heading and any nested subsections.
outlinebooleantrue returns the outline with empty content. Cannot be combined with section, offset, or max_bytes.
offsetintegerByte offset into the selected text; default 0. See Paging.
max_bytesintegerPage size, 1–524,288. Defaults to the whole file.

Send If-None-Match: "7" to get 304 Not Modified (empty body, with ETag) when the version you would read is still 7 — a cheap way for an agent to check whether its cached copy is current.

curlbash
curl "https://api.aetherdb.ai/v1/memory/files/memory.md?entity_id=company:acme&section=Agreed%20terms" \
  -H "Authorization: Bearer $AETHER_API_KEY"
JSON
{
  "entity_id": "company:acme",
  "partition": null,
  "name": "memory.md",
  "file_id": "3d1f2a9c-5b7e-4c1d-8e2f-6a9b0c1d2e3f",
  "doc_id": "a8c2e4f6-1b3d-4f5a-9c7e-2d4f6a8b0c1e",
  "version": 7,
  "is_current": true,
  "bytes": 122,
  "content_hash": "blake3:9f…",
  "author": "user:alice",
  "principal": "key:ak_1a2b3c4d",
  "op": "append_section",
  "created_at": "2026-09-01T10:00:00Z",
  "updated_at": "2026-10-10T09:12:44Z",
  "content": "## Agreed terms\n- 2026-09-01 — Net 30 payment terms (alice, ISS-12)\n\n",
  "section": { "heading": "Agreed terms", "level": 2, "slug": "agreed-terms", "start_line": 4, "end_line": 6, "bytes": 71 },
  "offset": 0,
  "next_offset": null,
  "total_bytes": 71,
  "truncated": false,
  "sections": [
    { "heading": "Acme memory", "level": 1, "slug": "acme-memory", "start_line": 1, "end_line": 9, "bytes": 122 },
    { "heading": "Agreed terms", "level": 2, "slug": "agreed-terms", "start_line": 4, "end_line": 6, "bytes": 71 },
    { "heading": "Exceptions", "level": 2, "slug": "exceptions", "start_line": 7, "end_line": 8, "bytes": 15 },
    { "heading": "Notes", "level": 2, "slug": "notes", "start_line": 9, "end_line": 9, "bytes": 9 }
  ]
}

The response has every list field plus is_current (false when you asked for an older version), content (the selected page), section (the selected section, or null), the paging fields, and sections — always the whole version's outline with whole-file line numbers, whatever you selected. total_bytes is the size of the selected text: the whole version, or the section.

outline = memory.files.read("memory.md", outline=True)
for s in outline.sections:
    print(s.level, s.heading, s.slug, s.bytes)

terms = memory.files.read("memory.md", section="agreed-terms")
print(terms.content)
ErrorWhen
404 memory_file_not_foundNo live file with this name — including a soft-deleted one.
404 memory_file_version_not_foundversion is no longer retained, or is newer than the current version.
404 memory_file_section_not_foundThe selector matched no section.
409 memory_file_section_ambiguousThe selector matched more than one heading; see candidates.

Raw markdown

Send Accept: text/markdown (or text/plain) to receive the selected page as the response body instead of JSON. This is a REST-only mode — the SDKs always read JSON, where content carries the same text alongside the paging fields. Raw responses carry Content-Type: text/markdown; charset=utf-8, the ETag, X-Aether-Total-Bytes, and — only when more pages follow — X-Aether-Next-Offset. An absent Accept, */*, or application/json returns JSON.

curlbash
curl "https://api.aetherdb.ai/v1/memory/files/memory.md?entity_id=company:acme" \
  -H "Authorization: Bearer $AETHER_API_KEY" \
  -H "Accept: text/markdown"

Replace a whole file

PUT /v1/memory/files/{name} writes the request body as the file's new content.

  • Body: the raw file content, 1–524,288 bytes of valid UTF-8, with Content-Type: text/markdown, text/x-markdown, or text/plain (an optional charset=utf-8 is fine). Any other content type returns 415 unsupported_media_type; an empty body or invalid UTF-8 returns 400 invalid_input.
  • Query: entity_id (required), partition, and an optional author label.
  • Headers: exactly one of If-Match: "<version>", If-Match: *, or If-None-Match: *; optionally Idempotency-Key.
curlbash
curl -X PUT "https://api.aetherdb.ai/v1/memory/files/memory.md?entity_id=company:acme&author=user:alice" \
  -H "Authorization: Bearer $AETHER_API_KEY" \
  -H "Content-Type: text/markdown" \
  -H "If-None-Match: *" \
  --data-binary @memory.md
StatusWhen
201The file was created (file_created: true). Re-creating a soft-deleted file continues its version numbering.
200A new version was written.
200 with unchanged: trueThe content is identical to the current version; nothing was stored.
400invalid_input, memory_file_name_invalid, or partition_required.
409 idempotency_key_reusedThe Idempotency-Key was already used for a different request.
412 memory_file_version_conflictIf-Match didn't match the current version (or the file doesn't exist), or If-None-Match: * and the file exists. Includes current_version.
413 memory_file_too_largeThe body exceeds 512 KiB. Includes limit_bytes.
415 unsupported_media_typeWrong Content-Type.
428 precondition_requiredNeither If-Match nor If-None-Match was sent.
503 memory_file_busyThe file was busy; nothing was written. Retry after Retry-After.

Every success carries ETag: "<new current version>" and a write result: the file fields for the resulting current version, plus unchanged, previous_version (the version this write replaced, or null on create), file_created, section and section_created (used by PATCH), and sections — the outline of the resulting content.

created = memory.files.write("memory.md", "# Acme memory\n", create=True)

updated = memory.files.write(
    "memory.md",
    "# Acme memory\n\n## Agreed terms\n",
    expected_version=created.version,
    author="user:alice",
)

Edit a section

PATCH /v1/memory/files/{name} applies one section-aware edit. The body is JSON (Content-Type: application/json):

JSON
{
  "op": "append_section",
  "section": "Agreed terms",
  "content": "- 2026-10-10 — Net 45 for EU entities (bob, PRJ-3)",
  "level": 2,
  "create_if_missing": true
}
FieldRules
opRequired: append, append_section, or replace_section.
sectionA section selector. Required for append_section and replace_section; must be absent for append.
contentRequired. Must be non-empty for append and append_section; may be "" for replace_section (clears the section's body). A trailing newline is added if missing. Inserted verbatim — headings inside it become part of the outline.
level1–6, default 2. Used only when a section is created.
create_if_missingDefault true. Creates a missing file and a missing section. With false, a missing file returns 404 memory_file_not_found and a missing section returns 404 memory_file_section_not_found.

Query parameters and headers are the same as for PUT: entity_id, partition, author, an optional Idempotency-Key, and the preconditions described in Versions and safe concurrent writes.

What each op does

append adds content to the end of the file (after adding a newline if the file doesn't end with one).

append_section adds content at the end of the named section's own body — after its last non-blank line and before any nested subsection — so new entries land under the heading, in order, ahead of its sub-headings. Blank lines and later sections stay where they were. If the section doesn't exist and create_if_missing is true, a new heading (level # characters plus the section text) is created at the end of the file, separated by a blank line, with content under it.

replace_section replaces the section's body — everything after the heading up to its last non-blank line, including nested subsections — with content. The heading and the section's trailing blank lines are kept. A missing section is created as for append_section.

Starting from the example file:

RequestResult
append_section, section Agreed terms, content - 2026-10-10 — Net 45 for EU entities (bob, PRJ-3)The new line lands directly below the Net 30 line, before the blank line and ## Exceptions.
append_section, section Key decisions (missing), content - Use Stripe for billing\n## Key decisions\n- Use Stripe for billing\n is added at the end; section_created: true.
replace_section, section agreed-terms, content - Net 60## Agreed terms now contains only - Net 60, followed by the blank line and ## Exceptions.
append_section on a file that doesn't exist yet, section Notes, content - firstStatus 201; the file is ## Notes\n- first\n with file_created and section_created both true.

With nesting, given ## A\nown\n\n### A.1\nchild\n## B\n: append_section to A with - x produces ## A\nown\n- x\n\n### A.1\nchild\n## B\n (the entry goes into A's own body, above ### A.1), while replace_section of A with new body produces ## A\nnew body\n## B\n (the nested ### A.1 is replaced too).

The size limit applies to the result: an edit that would grow the file past 512 KiB returns 413 memory_file_too_large.

StatusWhen
201The file was created.
200A new version was written. section is the affected section after the write; section_created reports whether it was new.
200 with unchanged: trueThe result equals the current content — for example, replacing a section with the same body.
400invalid_input, invalid_json, memory_file_name_invalid, or partition_required.
404memory_file_not_found or memory_file_section_not_found (with create_if_missing: false).
409memory_file_section_ambiguous (with candidates) or idempotency_key_reused.
412 memory_file_version_conflictA precondition you sent didn't hold.
413 memory_file_too_largeThe result would exceed 512 KiB.
415 unsupported_media_typeThe body isn't JSON.
428 precondition_requiredreplace_section without If-Match or If-None-Match: *.
503 memory_file_busyThe file was busy; nothing was written.
# Blind append under a heading: safe with concurrent writers
memory.files.append_section(
    "memory.md",
    "Exceptions",
    "- Acme Ltd (UK) invoices in GBP",
    author="user:bob",
)

# Replace one section, guarded by the version you read
current = memory.files.read("memory.md", outline=True)
memory.files.replace_section(
    "memory.md",
    "Agreed terms",
    "- Net 60",
    expected_version=current.version,
)

# Append to the end of the file
memory.files.append("memory.md", "Reviewed 2026-10-10.")

The SDKs send level and create_if_missing explicitly (defaults 2 and true); pass level / create_if_missing (createIfMissing, WithLevel, WithCreateIfMissing) to change them.

curlbash
curl -X PATCH "https://api.aetherdb.ai/v1/memory/files/memory.md?entity_id=company:acme&author=user:bob" \
  -H "Authorization: Bearer $AETHER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"op": "append_section", "section": "Exceptions", "content": "- Acme Ltd (UK) invoices in GBP"}'

Version history

GET /v1/memory/files/{name}/versions lists the retained versions, newest first. Query parameters: entity_id, partition, and limit (1–100, default 100).

JSON
{
  "entity_id": "company:acme",
  "partition": null,
  "name": "memory.md",
  "file_id": "3d1f2a9c-5b7e-4c1d-8e2f-6a9b0c1d2e3f",
  "current_version": 7,
  "versions": [
    {
      "doc_id": "a8c2e4f6-1b3d-4f5a-9c7e-2d4f6a8b0c1e",
      "version": 7,
      "bytes": 122,
      "content_hash": "blake3:9f…",
      "author": "user:alice",
      "principal": "key:ak_1a2b3c4d",
      "op": "append_section",
      "updated_at": "2026-10-10T09:12:44Z",
      "is_current": true
    }
  ]
}

Once older versions age out of retention, the history simply starts at the oldest retained version. Read any retained version with read(name, version=N) or ?version=N. A missing file returns 404 memory_file_not_found.

Diff versions

GET /v1/memory/files/{name}/diff returns a line-based unified diff.

Query parameterTypeNotes
entity_idstringRequired.
frominteger ≥ 0Defaults to to - 1. 0 means the empty file, so from=0 shows a whole version as added.
tointeger ≥ 1Defaults to the current version. from > to gives a reverse diff; from == to an empty one.
contextintegerContext lines around each change, 0–20, default 3.
JSON
{
  "entity_id": "company:acme",
  "partition": null,
  "name": "memory.md",
  "file_id": "3d1f2a9c-5b7e-4c1d-8e2f-6a9b0c1d2e3f",
  "from_version": 7,
  "to_version": 8,
  "context": 3,
  "diff": "--- memory.md@v7\n+++ memory.md@v8\n@@ -3,6 +3,7 @@\n \n ## Agreed terms\n - 2026-09-01 — Net 30 payment terms (alice, ISS-12)\n+- 2026-10-10 — Net 45 for EU entities (bob, PRJ-3)\n \n ## Exceptions\n \n",
  "added_lines": 1,
  "removed_lines": 0,
  "approximate": false,
  "truncated": false
}

The diff has --- <name>@v<from> / +++ <name>@v<to> headers, @@ -a,b +c,d @@ hunks, lines prefixed with a space, -, or +, and \ No newline at end of file where it applies. Very large diffs stay correct but may not be minimal after a 2-second budget (approximate: true), and output is capped at 2 MiB (truncated: true). Over REST, send Accept: text/x-diff to receive the diff text as the body (with X-Aether-Diff-Approximate: true when approximate); the SDKs always use JSON and return the text in the result's diff field. A version that is no longer retained returns 404 memory_file_version_not_found with the missing version.

d = memory.files.diff("memory.md", from_version=7, to_version=8)
print(d.diff)

Grep

POST /v1/memory/files/grep searches the current version of live memory files line by line. Use it for exact lookups — an invoice number, a contract clause, a person's name — that semantic search may rank poorly.

JSON
{
  "entity_id": "company:acme",
  "pattern": "net\\s+\\d+",
  "regex": true,
  "ignore_case": true,
  "files": ["memory.md"],
  "context": 1,
  "max_matches": 100
}
FieldRules
entity_id / entity_prefixAt most one. With neither, every live memory file in scope is searched.
patternRequired, 1–512 characters.
regexDefault false: a literal substring match. With true, a linear-time regular expression (Rust regex syntax — no back-references or look-around; Unicode-aware). An invalid or oversized pattern returns 400 invalid_pattern.
ignore_caseDefault false. Applies to literal and regex patterns.
filesOptional list of exact file names (up to 100). Unknown names are skipped.
contextLines of context before and after each match, 0–10, default 0.
max_matches1–1000, default 100.

Matching is per line: ^ and $ anchor to the line, a match never spans lines, and a line that matches yields one result. Files are scanned in list order and lines in ascending order; context never crosses into another file. The query parameter partition scopes the scan as for list.

JSON
{
  "matches": [
    {
      "entity_id": "company:acme",
      "partition": null,
      "name": "memory.md",
      "version": 7,
      "line": 5,
      "section": "Agreed terms",
      "section_slug": "agreed-terms",
      "text": "- 2026-09-01 — Net 30 payment terms (alice, ISS-12)",
      "before": ["## Agreed terms"],
      "after": [""]
    }
  ],
  "truncated": false,
  "truncated_reason": null,
  "files_scanned": 1,
  "bytes_scanned": 122
}

section and section_slug name the innermost section containing the line (null before the first heading), so an agent can follow a hit with a section read. Each returned line is cut to at most 1,000 bytes. A scan stops at the first limit it reaches — max_matches, 500 files, 8 MiB scanned, or 2 seconds — and reports it in truncated_reason (max_matches, max_files, max_bytes, or deadline). Narrow the scan with entity_id, entity_prefix, or files when you see truncated: true.

# One entity's files
own = memory.files.grep("net 45", ignore_case=True)

# Every company's files
hits = client.grep_memory_files("GBP", entity_prefix="company:")
for m in hits.matches:
    print(m.entity_id, m.name, m.line, m.text)

Grep errors: 400 invalid_input, invalid_json, invalid_pattern, or partition_required; 415 unsupported_media_type for a non-JSON body.

Delete a file

DELETE /v1/memory/files/{name} takes entity_id, partition, hard (default false), and an optional If-Match: "<version>" (412 on mismatch).

  • Soft delete (default). The file disappears from list, read, grep, and search. Its history is kept but can't be read through these routes until the name is created again; a re-create continues the version numbering and keeps the same file_id.
  • hard=true. Permanently erases every version of the file through the same path as document hard deletion: indexes are cleared and the encryption keys destroyed. It also works on a file that was already soft-deleted. Afterwards the name starts fresh at version 1 with a new file_id. Use it for erasure requests.
JSON
{
  "entity_id": "company:acme",
  "partition": null,
  "name": "memory.md",
  "file_id": "3d1f2a9c-5b7e-4c1d-8e2f-6a9b0c1d2e3f",
  "version": 7,
  "hard": false,
  "versions_deleted": 1
}

versions_deleted is 1 for a soft delete and the number of versions purged for a hard delete. A missing file returns 404 memory_file_not_found (with hard=true, only when no history remains either).

memory.files.delete("memory.md")              # soft delete
memory.files.delete("memory.md", hard=True)   # erase every version

Hard deletion is irreversible

A hard delete cannot be undone — by you or by Aether. Retention also hard-deletes versions beyond the newest 100 automatically, so don't rely on very old versions being available.

Paging

Reads (max_bytes, offset) return one page of the selected text at a time:

  • offset must be between 0 and the text's size and fall on a UTF-8 character boundary; otherwise the request returns 400 invalid_input. offset equal to the size returns an empty page.
  • If the rest of the text fits in max_bytes, the page runs to the end and next_offset is null.
  • Otherwise the page ends just after the last newline within max_bytes, so pages are line-aligned. If there is no newline in range, the page ends at the last whole character that fits (always at least one character).

Every paged response carries offset, next_offset (an integer, or null on the last page), total_bytes, and truncated (true while more pages follow). To read everything, repeat with offset = next_offset until it is null.

Textoffsetmax_bytesPagenext_offset
"line1\nline2\nline3\n"08"line1\n"6
same68"line2\n"12
same128"line3\n"null
"abcdefghij"04"abcd"4
"a\nbcdefgh"06"a\n"2

The same rules apply to document text.

Each memory file version is stored as an ordinary text/markdown document tagged kind:memory_file and memfile:<name>, owned by the file's entity_id. Only the current version is active, so search, retrieve, and recall see a file's latest content and never an outdated version.

  • Pass kind=memory_file to GET /v1/search (or a document listing) to match memory files only.
  • A hit on a memory file adds a memory_file field; it is omitted on other hits:
JSON
"memory_file": { "name": "memory.md", "version": 7, "section": "Agreed terms", "section_slug": "agreed-terms" }

section is the innermost section containing the start of the matched passage. It is best effort and may be null. In the SDKs, search and retrieve results expose it as a typed MemoryFileHit — hit.memory_file in Python and TypeScript, hit.MemoryFile in .NET and Go — and it is absent (None, undefined, null, nil) on every other hit. Follow a hit with a section read: memory.files.read(hit.memory_file.name, section=hit.memory_file.section_slug).

Tags are filter hints, not identity: any document can carry a kind:memory_file tag, but only files written through these routes have a memory_file field and appear in the memory-file routes.

Interaction with document routes

Memory files are documents, but you must change them through the routes on this page:

  • PUT /v1/documents/{id} (update), partition move, and POST /v1/documents/{id}/restore on a memory-file version return 409 memory_file_use_memory_routes.
  • Generic DELETE /v1/documents/{id} stays allowed so erasure is never blocked. Soft-deleting the current version is the same as deleting the file; hard-deleting a single version ends the file's history at that version.
  • reembed and extract work as for any document.
  • Metadata keys that start with aether.memfile. or aether.summary. are reserved: any write that supplies one returns 400 reserved_metadata_key.
  • Reads of memory files (read, versions, diff, grep) are recorded in the access audit for workspaces that have it enabled.

Billing

Each write stores and indexes a complete new version of the file, and is billed as a document insert; retained versions count toward stored bytes until they age out. Grep is billed as a query. Keep files focused — one file per customer or topic rather than one ever-growing file — so writes stay cheap. Billing limits return the usual 402, 403, or 429 responses described in Errors.

Limits

LimitValueError
File name1–128 characters, [A-Za-z0-9][A-Za-z0-9._-]*, no .., not grep400 memory_file_name_invalid
entity_id / entity_prefix1–256 characters400 invalid_input
File size (after any edit)524,288 bytes (512 KiB)413 memory_file_too_large
PATCH contentUp to 524,288 bytes400 invalid_input / 413 memory_file_too_large
Section selector or created heading1–256 characters, no line breaks400 invalid_input
level1–6 (default 2)400 invalid_input
author1–128 characters, no control characters400 invalid_input
Retained versions per file100 (older versions are removed automatically)—
List limit1–1000 (default 100)400 invalid_input
Versions limit1–100 (default 100)400 invalid_input
Read max_bytes1–524,288 (default: whole file)400 invalid_input
Grep pattern1–512 characters400 invalid_input / 400 invalid_pattern
Grep filesUp to 100 names400 invalid_input
Grep context / max_matches0–10 / 1–1000400 invalid_input
Grep scan500 files, 8 MiB, 2 seconds— (truncated: true)
Diff context0–20 (default 3)400 invalid_input
Diff output2 MiB; minimal within 2 seconds— (truncated / approximate)

Error codes

Errors use the shared error envelope — { "error", "code", "request_id" } — plus the context fields listed here.

HTTPcodeContext fieldsMeaning
400memory_file_name_invalid—The name breaks the naming rules.
400invalid_pattern—Grep regex is invalid or too large.
400reserved_metadata_keykeyA write supplied an aether.memfile.* or aether.summary.* metadata key.
400partition_required—A multi-tenant key called without a partition.
404feature_not_enabledfeatureMemory files aren't enabled for this environment yet.
404memory_file_not_found—No live file with this name.
404memory_file_version_not_foundversionThe version isn't retained or doesn't exist.
404memory_file_section_not_found—The selector matched no section.
409memory_file_section_ambiguouscandidatesThe selector matched several headings; retry with a slug.
409idempotency_key_reused—The Idempotency-Key belongs to a different request.
409memory_file_use_memory_routes—A generic document update, move, or restore targeted a memory file.
412memory_file_version_conflictcurrent_versionA precondition didn't hold. Typed as VersionConflictError in the SDKs.
413memory_file_too_largelimit_bytesThe file would exceed 512 KiB.
415unsupported_media_type—Wrong Content-Type for the route.
428precondition_required—PUT or replace_section without If-Match / If-None-Match.
503memory_file_busyRetry-After headerThe file was busy; nothing was written. Retryable.

Only 503 memory_file_busy is retryable as-is (the SDKs retry it). For 412, re-read the file and decide what to write — don't blindly resend.

Known limitation: content the edge firewall rejects

The API's edge firewall may reject a request body that contains path-traversal sequences such as ../../, or links whose host is a raw IP address (for example http://203.0.113.7/report). The rejection is a 403 from the edge with a non-JSON body — no code or request_id — and it applies to memory-file writes and any other request with a body.

If your application stores arbitrary pasted text, strip or rewrite such content before writing — for example, link to a hostname instead of an IP address, or describe a relative path in words — and treat a non-JSON 403 on a write as rejected content rather than an authentication failure. See Troubleshooting.