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), theentity_idthat 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 anETag, 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, andrecallcan 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.
| Operation | Python | TypeScript | .NET | Go |
|---|---|---|---|---|
| Files accessor | memory.files | memory.files | memory.Files | memory.Files() |
| List files | list | list | ListAsync | List |
| Read (whole, section, page, outline) | read | read | ReadAsync | Read |
| Replace the whole file | write | write | WriteAsync | Write |
| Append to the end | append | append | AppendAsync | Append |
| Append under a heading | append_section | appendSection | AppendSectionAsync | AppendSection |
| Replace a section's body | replace_section | replaceSection | ReplaceSectionAsync | ReplaceSection |
| Version history | versions | versions | VersionsAsync | Versions |
| Diff two versions | diff | diff | DiffAsync | Diff |
| Grep the entity's files | grep | grep | GrepAsync | Grep |
| Delete | delete | delete | DeleteAsync | Delete |
Two raw-client methods work across entities — for example every file whose entity_id starts with company::
| Operation | Python | TypeScript | .NET | Go |
|---|---|---|---|---|
| List files across entities | list_memory_files | listMemoryFiles | ListMemoryFilesAsync | ListMemoryFiles |
| Grep across entities | grep_memory_files | grepMemoryFiles | GrepMemoryFilesAsync | GrepMemoryFiles |
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:
| Operation | Go options |
|---|---|
List | WithFilePrefix, WithFileLimit, WithFileCursor |
ListMemoryFiles | the List options plus WithFileEntityID or WithFileEntityPrefix |
Read | WithFileVersion, WithFileSection, WithFileOutline, WithFileOffset, WithFileMaxBytes |
Write, Append, AppendSection, ReplaceSection | WithExpectedVersion, WithAuthor, and WithIdempotencyKey on all four; WithCreateOnly on Write and ReplaceSection; WithCreateIfMissing on the PATCH ops; WithLevel on the section ops |
Versions | WithVersionsLimit |
Diff | WithFromVersion, WithToVersion, WithDiffContext |
Grep | WithRegex, WithIgnoreCase, WithGrepFiles, WithGrepContext, WithMaxMatches |
GrepMemoryFiles | the Grep options plus WithGrepEntityID or WithGrepEntityPrefix |
Delete | WithHardDelete, 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>.
| Method | Path | Description |
|---|---|---|
GET | /v1/memory/files | List 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}/versions | List retained versions, newest first. |
GET | /v1/memory/files/{name}/diff | Unified diff between two versions. |
POST | /v1/memory/files/grep | Line-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 parameter | Required | Notes |
|---|---|---|
entity_id | On every per-file route (read, write, edit, versions, diff, delete) | The owning entity. 1–256 characters; missing, empty, or longer returns 400 invalid_input. |
partition | For multi-tenant keys | 1–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:
- is 1–128 ASCII characters, made of letters, digits,
.,_, and-; - starts with a letter or digit;
- does not contain
..; - is not exactly
grep(that path is the grep route;Grepandgrep.mdare 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).
| Accepted | Rejected |
|---|---|
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:## Acontains### A.1. Text before the first heading belongs to no section.
Each outline entry describes one section:
| Field | Type | Meaning |
|---|---|---|
heading | string | The heading text; "" for a bare #. |
level | integer | 1–6. |
slug | string | Unique within the file. |
start_line | integer | The heading's line number (lines are numbered from 1). |
end_line | integer | The section's last line, inclusive. |
bytes | integer | UTF-8 size of lines start_line through end_line, including line endings. |
For example, this 122-byte file:
# Acme memory
Intro line.
## Agreed terms
- 2026-09-01 — Net 30 payment terms (alice, ISS-12)
## Exceptions
## Notes
has this outline:
heading | level | slug | start_line | end_line | bytes |
|---|---|---|---|---|---|
Acme memory | 1 | acme-memory | 1 | 9 | 122 |
Agreed terms | 2 | agreed-terms | 4 | 6 | 71 |
Exceptions | 2 | exceptions | 7 | 8 | 15 |
Notes | 2 | notes | 9 | 9 | 9 |
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 text | Slug |
|---|---|
Agreed terms | agreed-terms |
Terms & obligations | terms-obligations |
Q3 2026 — Pricing! | q3-2026-pricing |
Café terms | café-terms |
snake_case_heading | snake-case-heading |
Notes, Notes, Notes 2 | notes, 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:
- 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
409with codememory_file_section_ambiguousand acandidatesarray of{heading, slug, start_line}so you can retry with a slug. - Exact slug. Otherwise, the heading whose slug equals the selector exactly is selected. Slugs are unique, so this step is never ambiguous.
- 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:
| Header | Meaning |
|---|---|
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:
PUTrequires a precondition. Send exactly one of the three headers above; with none, the request returns428 precondition_required.PATCHappendandappend_sectionmay 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.PATCHreplace_sectionrequiresIf-Match(existing file) orIf-None-Match: *(create); without one it returns428.- A precondition you send is always enforced. A mismatch returns
412with codememory_file_version_conflictandcurrent_version— the file's current version, ornullwhen no file exists. - Weak tags (
W/"7"), lists ("7", "8"), and sending bothIf-MatchandIf-None-Matchreturn400 invalid_input. - No-op writes succeed. If a write's result is byte-for-byte identical to the current content, Aether returns
200with"unchanged": trueand 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(withRetry-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 sent | Python | TypeScript | .NET | Go |
|---|---|---|---|---|
If-Match: "7" | expected_version=7 | { expectedVersion: 7 } | expectedVersion: 7 | aether.WithExpectedVersion(7) |
If-None-Match: * | create=True | { create: true } | create: true | aether.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:
| Language | Error | Current version |
|---|---|---|
| Python | VersionConflictError | .current_version (int or None) |
| TypeScript | VersionConflictError | .currentVersion (number | null) |
| .NET | VersionConflictException | .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 parameter | Type | Notes |
|---|---|---|
entity_id | string | Only this entity's files. |
entity_prefix | string | Entities whose entity_id starts with this string (byte-wise, case-sensitive) — for example company:. Cannot be combined with entity_id. |
prefix | string | File names starting with this (1–128 characters, case-sensitive). |
limit | integer | 1–1000, default 100. |
cursor | string | The next_cursor from the previous page. |
partition | string | See 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.
curl "https://api.aetherdb.ai/v1/memory/files?entity_prefix=company:&limit=50" \
-H "Authorization: Bearer $AETHER_API_KEY"
{
"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
}
| Field | Notes |
|---|---|
file_id | Stable for the life of the file, including across a soft delete and re-create. |
doc_id | The document that holds this version. |
version | The file version (1, 2, 3, …). Not the same number as a document record's version. |
bytes, content_hash | Size and blake3: hash of this version's content. |
author, principal | Who wrote this version — see Scope parameters. |
op | put, append, append_section, or replace_section. |
created_at | When the file was created. |
updated_at | When 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 parameter | Type | Notes |
|---|---|---|
entity_id | string | Required. |
version | integer ≥ 1 | Read a retained older version. Defaults to the current version. |
section | string | A section selector. Narrows the content to that section, including its heading and any nested subsections. |
outline | boolean | true returns the outline with empty content. Cannot be combined with section, offset, or max_bytes. |
offset | integer | Byte offset into the selected text; default 0. See Paging. |
max_bytes | integer | Page 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.
curl "https://api.aetherdb.ai/v1/memory/files/memory.md?entity_id=company:acme§ion=Agreed%20terms" \
-H "Authorization: Bearer $AETHER_API_KEY"
{
"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)
| Error | When |
|---|---|
404 memory_file_not_found | No live file with this name — including a soft-deleted one. |
404 memory_file_version_not_found | version is no longer retained, or is newer than the current version. |
404 memory_file_section_not_found | The selector matched no section. |
409 memory_file_section_ambiguous | The 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.
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, ortext/plain(an optionalcharset=utf-8is fine). Any other content type returns415 unsupported_media_type; an empty body or invalid UTF-8 returns400 invalid_input. - Query:
entity_id(required),partition, and an optionalauthorlabel. - Headers: exactly one of
If-Match: "<version>",If-Match: *, orIf-None-Match: *; optionallyIdempotency-Key.
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
| Status | When |
|---|---|
201 | The file was created (file_created: true). Re-creating a soft-deleted file continues its version numbering. |
200 | A new version was written. |
200 with unchanged: true | The content is identical to the current version; nothing was stored. |
400 | invalid_input, memory_file_name_invalid, or partition_required. |
409 idempotency_key_reused | The Idempotency-Key was already used for a different request. |
412 memory_file_version_conflict | If-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_large | The body exceeds 512 KiB. Includes limit_bytes. |
415 unsupported_media_type | Wrong Content-Type. |
428 precondition_required | Neither If-Match nor If-None-Match was sent. |
503 memory_file_busy | The 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):
{
"op": "append_section",
"section": "Agreed terms",
"content": "- 2026-10-10 — Net 45 for EU entities (bob, PRJ-3)",
"level": 2,
"create_if_missing": true
}
| Field | Rules |
|---|---|
op | Required: append, append_section, or replace_section. |
section | A section selector. Required for append_section and replace_section; must be absent for append. |
content | Required. 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. |
level | 1–6, default 2. Used only when a section is created. |
create_if_missing | Default 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:
| Request | Result |
|---|---|
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 - first | Status 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.
| Status | When |
|---|---|
201 | The file was created. |
200 | A new version was written. section is the affected section after the write; section_created reports whether it was new. |
200 with unchanged: true | The result equals the current content — for example, replacing a section with the same body. |
400 | invalid_input, invalid_json, memory_file_name_invalid, or partition_required. |
404 | memory_file_not_found or memory_file_section_not_found (with create_if_missing: false). |
409 | memory_file_section_ambiguous (with candidates) or idempotency_key_reused. |
412 memory_file_version_conflict | A precondition you sent didn't hold. |
413 memory_file_too_large | The result would exceed 512 KiB. |
415 unsupported_media_type | The body isn't JSON. |
428 precondition_required | replace_section without If-Match or If-None-Match: *. |
503 memory_file_busy | The 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.
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).
{
"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 parameter | Type | Notes |
|---|---|---|
entity_id | string | Required. |
from | integer ≥ 0 | Defaults to to - 1. 0 means the empty file, so from=0 shows a whole version as added. |
to | integer ≥ 1 | Defaults to the current version. from > to gives a reverse diff; from == to an empty one. |
context | integer | Context lines around each change, 0–20, default 3. |
{
"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.
{
"entity_id": "company:acme",
"pattern": "net\\s+\\d+",
"regex": true,
"ignore_case": true,
"files": ["memory.md"],
"context": 1,
"max_matches": 100
}
| Field | Rules |
|---|---|
entity_id / entity_prefix | At most one. With neither, every live memory file in scope is searched. |
pattern | Required, 1–512 characters. |
regex | Default 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_case | Default false. Applies to literal and regex patterns. |
files | Optional list of exact file names (up to 100). Unknown names are skipped. |
context | Lines of context before and after each match, 0–10, default 0. |
max_matches | 1–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.
{
"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 newfile_id. Use it for erasure requests.
{
"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:
offsetmust be between0and the text's size and fall on a UTF-8 character boundary; otherwise the request returns400 invalid_input.offsetequal to the size returns an empty page.- If the rest of the text fits in
max_bytes, the page runs to the end andnext_offsetisnull. - 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.
| Text | offset | max_bytes | Page | next_offset |
|---|---|---|---|---|
"line1\nline2\nline3\n" | 0 | 8 | "line1\n" | 6 |
| same | 6 | 8 | "line2\n" | 12 |
| same | 12 | 8 | "line3\n" | null |
"abcdefghij" | 0 | 4 | "abcd" | 4 |
"a\nbcdefgh" | 0 | 6 | "a\n" | 2 |
The same rules apply to document text.
Memory files in search
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_filetoGET /v1/search(or a document listing) to match memory files only. - A hit on a memory file adds a
memory_filefield; it is omitted on other hits:
"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, andPOST /v1/documents/{id}/restoreon a memory-file version return409 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. reembedandextractwork as for any document.- Metadata keys that start with
aether.memfile.oraether.summary.are reserved: any write that supplies one returns400 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
| Limit | Value | Error |
|---|---|---|
| File name | 1–128 characters, [A-Za-z0-9][A-Za-z0-9._-]*, no .., not grep | 400 memory_file_name_invalid |
entity_id / entity_prefix | 1–256 characters | 400 invalid_input |
| File size (after any edit) | 524,288 bytes (512 KiB) | 413 memory_file_too_large |
PATCH content | Up to 524,288 bytes | 400 invalid_input / 413 memory_file_too_large |
| Section selector or created heading | 1–256 characters, no line breaks | 400 invalid_input |
level | 1–6 (default 2) | 400 invalid_input |
author | 1–128 characters, no control characters | 400 invalid_input |
| Retained versions per file | 100 (older versions are removed automatically) | — |
List limit | 1–1000 (default 100) | 400 invalid_input |
Versions limit | 1–100 (default 100) | 400 invalid_input |
Read max_bytes | 1–524,288 (default: whole file) | 400 invalid_input |
Grep pattern | 1–512 characters | 400 invalid_input / 400 invalid_pattern |
Grep files | Up to 100 names | 400 invalid_input |
Grep context / max_matches | 0–10 / 1–1000 | 400 invalid_input |
| Grep scan | 500 files, 8 MiB, 2 seconds | — (truncated: true) |
Diff context | 0–20 (default 3) | 400 invalid_input |
| Diff output | 2 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.
| HTTP | code | Context fields | Meaning |
|---|---|---|---|
400 | memory_file_name_invalid | — | The name breaks the naming rules. |
400 | invalid_pattern | — | Grep regex is invalid or too large. |
400 | reserved_metadata_key | key | A write supplied an aether.memfile.* or aether.summary.* metadata key. |
400 | partition_required | — | A multi-tenant key called without a partition. |
404 | feature_not_enabled | feature | Memory files aren't enabled for this environment yet. |
404 | memory_file_not_found | — | No live file with this name. |
404 | memory_file_version_not_found | version | The version isn't retained or doesn't exist. |
404 | memory_file_section_not_found | — | The selector matched no section. |
409 | memory_file_section_ambiguous | candidates | The selector matched several headings; retry with a slug. |
409 | idempotency_key_reused | — | The Idempotency-Key belongs to a different request. |
409 | memory_file_use_memory_routes | — | A generic document update, move, or restore targeted a memory file. |
412 | memory_file_version_conflict | current_version | A precondition didn't hold. Typed as VersionConflictError in the SDKs. |
413 | memory_file_too_large | limit_bytes | The file would exceed 512 KiB. |
415 | unsupported_media_type | — | Wrong Content-Type for the route. |
428 | precondition_required | — | PUT or replace_section without If-Match / If-None-Match. |
503 | memory_file_busy | Retry-After header | The 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.