Skip to content

Memory

Memory is the persistent fact store behind the desktop Memory page: named banks of short entries that the app injects into a chat turn's system prompt so a model recalls preferences, project conventions, and domain facts across conversations. The Management API exposes all of it: 27 endpoints under /memory, with full Tauri parity.

Every endpoint on this page is reachable at the Management API base URL, http://127.0.0.1:8001/api/v1 by default, and appears in Swagger UI at /api/docs under the Memory tag. The aigo memory command group wraps the same endpoints and reaches every one of them except the event stream, which waits on a streaming transport for the CLI; the flags are in the CLI reference.

The desktop UI calls a namespace a memory bank. The API calls it a namespace, and so does this page.

How this surface behaves headless

Three things are worth knowing before you build against it.

The store itself works headless. aigo-server initializes memory with the same shared event emitter the desktop app installs, so namespaces, entries, search, injected context, export, import, extraction, and consolidation all run on a headless server, and the two memory:* events below reach the SSE stream rather than only the desktop WebView.

The memory tools are not reachable from /tools/execute. read_memory, write_memory, and search_memory are listed in headless_unavailable_reason (src-tauri/crates/aigo-core/src/tool_identity.rs), so both POST /tools/execute and the MCP endpoint refuse them with the reason the tool catalog gives. That is a gap in the tool surface, not in the memory surface: an agent running headless cannot call the memory tools, while a client can still read and write the same store through the endpoints on this page. Lifting the restriction is tracked separately and is not part of epic #4598.

Extraction and LLM consolidation need a running router. POST /memory/extract and POST /memory/consolidation/run both check is_router_running() and answer 503 when it is not, rather than dialing a cached endpoint that a router stop left pinned. Extraction validates its transcript first, so an empty or oversized messages array is a 400 even with the router down. Everything else on this page is served from the local store and needs no model at all.

Authentication and scopes

Requests authenticate the same way as the rest of the Management API: an X-API-Key header, an Authorization: Bearer token, or the aigo_session cookie obtained from POST /api/v1/auth/login.

Access keys carry scopes, and this surface uses two:

  • memory_read for every GET, plus one POST.
  • memory_write for every POST, PUT, PATCH, and DELETE, with that one exception.

The exception is POST /memory/namespaces/{ns_id}/export, which is deliberately memory_read: it renders a namespace and its entries and stores nothing. It is a POST for historical reasons rather than because it mutates. The authoritative table is ROUTE_MANIFEST in src-tauri/crates/aigo-rest/src/route_scope.rs; the scope column in the tables below is copied from it.

The scoped memory stream has SSE and WebSocket transports. GET /memory/events and GET /memory/ws carry only memory:* and require memory_read; the global /events pair carries every domain and requires admin. A desktop app that also runs the embedded management API carries both, since issue #5040: every desktop subsystem emits through one shared sink, and that server attaches its event bus as a second surface while it is listening, so the desktop UI and the stream receive the same events.

In a managed install, the page id that gates this transport surface is /data, not /memory. Listing /data in features.hiddenPages gates the /memory route prefix along with /data, /text-creations, and /artifact-creations, because the Data page owns memory management. The /memory page id hides the standalone Memory page and gates no route and no command: a route owned by two pages is gated only when both are hidden, so naming /memory as a second owner would have weakened the /data gate rather than strengthened it.

Resource model

Namespace

A namespace is a named bank of entries with a description, an enabled flag, and created and updated timestamps. Its id is a UUID. Only enabled namespaces contribute to injected context; disabling one keeps every entry and stops it reaching a prompt. Deleting a namespace deletes its entries with it.

Some namespaces are per-agent experiential banks, marked by a reserved prefix in their description. The Layer-A read-back resolves that marker to build an agent's system prompt, which is why import refuses a document that would write into one from either side of the match.

Entry

An entry is one remembered fact: id, namespaceId, content, a source of auto or manual, a tags array, a free-form metadata object, and the timestamps. source says who wrote it, and it is load-bearing rather than decorative: consolidation and pruning only ever touch auto entries, so a manual entry is never merged away or aged out.

Injected context

GET /memory/context returns the block a chat turn would inject: content (the formatted text), tokenCount, entryCount, and namespaceCount. Building it normally stamps the entries it included as referenced, which feeds recency ranking in the relevance scorer. Pass recordReference=false for a display-only build, so repeatedly inspecting the context does not push every entry's recency to "just now".

Export document

Two shapes, and both are accepted by the one import endpoint.

  • Single namespace: { "namespace": {...}, "entries": [...] }, what POST /memory/namespaces/{ns_id}/export writes.
  • Whole store: { "version": 1, "exportedAt": "...", "namespaces": [ { "namespace": {...}, "entries": [...] }, ... ] }, what GET /memory/export writes.

The namespaces key is the discriminator. An export carries this machine's ids, so an import stamps every namespace and entry with a fresh id and matches an existing namespace by name instead. Under the default onConflict=merge that means importing the same document twice appends rather than overwrites; under skip the second import leaves the matched namespaces alone.

Wire enum values

All lowercase on the wire, and rejected rather than coerced when misspelled.

  • MemorySource: auto, manual.
  • Entry listing sort: created, updated. order: asc, desc.
  • Import onConflict: merge, skip.
  • ExtractionEmptyReason.reason: nothingWorthSaving, allDeduped, parseRecoveryFailed, validationEmpty.

Endpoints

Paths below omit the /api/v1 prefix. {id} is the namespace id under /memory/namespaces/{id} and the entry id under /memory/namespaces/{ns_id}/entries/{id}.

Namespaces

Method Path Scope Body or query Returns
GET /memory/namespaces memory_read - MemoryNamespace[]
POST /memory/namespaces memory_write CreateNamespaceRequest MemoryNamespace (201)
GET /memory/namespaces/{id} memory_read - MemoryNamespace
PUT /memory/namespaces/{id} memory_write UpdateNamespaceRequest MemoryNamespace
DELETE /memory/namespaces/{id} memory_write - 204, no body
PATCH /memory/namespaces/{id}/toggle memory_write ToggleNamespaceRequest MemoryNamespace

PATCH .../toggle sets a state, it does not flip one: ToggleNamespaceRequest declares a required enabled bool, so a body without it, {} included, fails to deserialize and is a 422; a literally empty (zero-byte) body fails to parse as JSON at all, before the missing field is ever checked, and that one is a 400. A client that wants a flip reads the namespace first and sends the inverse, which is what aigo memory namespace toggle does; a provisioning script wants enable or disable instead, because setting a state is idempotent and flipping one is not.

PUT leaves an absent field untouched, so send only what changes. DELETE removes the namespace and every entry in it.

Entries

Method Path Scope Body or query Returns
GET /memory/namespaces/{ns_id}/entries memory_read ?tag=&source=&q=&limit=&offset=&sort=&order= MemoryEntry[] or MemoryEntryPage
POST /memory/namespaces/{ns_id}/entries memory_write CreateEntryRequest MemoryEntry (201)
DELETE /memory/namespaces/{ns_id}/entries memory_write - 204, no body
POST /memory/namespaces/{ns_id}/entries/bulk-delete memory_write BulkDeleteEntriesRequest MemoryBulkDeleteResult
POST /memory/namespaces/{ns_id}/entries/move memory_write MoveEntriesRequest MemoryMoveResult
GET /memory/namespaces/{ns_id}/entries/{id} memory_read - MemoryEntry
PUT /memory/namespaces/{ns_id}/entries/{id} memory_write UpdateEntryRequest MemoryEntry
DELETE /memory/namespaces/{ns_id}/entries/{id} memory_write - 204, no body

DELETE /memory/namespaces/{ns_id}/entries clears the namespace and keeps it; DELETE /memory/namespaces/{id} removes the namespace too.

The two bulk verbs report rather than stop. MemoryBulkDeleteResult is { deleted, missing } and MemoryMoveResult is { moved, missing }, where missing names the ids the namespace did not hold, so a partly stale selection still acts on the rest. Both write each touched namespace file once instead of once per id. Both also bound the list: ids must carry at least one id and at most 1000, and either extreme is a 400.

A move keeps every entry's id, content, source, tags, metadata, and timestamps, which is what deleting and re-creating would lose. The target namespace must exist, must not be the source, and must not carry the agent-experience marker. That last refusal is the same reach import refuses: a marked namespace's entries are folded into an agent's system prompt by the Layer-A read-back, so moving arbitrary entries into one would write into that prompt.

Search, statistics, and injected context

Method Path Scope Body or query Returns
GET /memory/entries memory_read ?q=&namespace=&limit= MemoryEntry[]
GET /memory/entries/enabled memory_read - MemoryEntry[]
GET /memory/stats memory_read - MemoryStats
GET /memory/context memory_read ?maxTokens=&recordReference= MemoryInjection

GET /memory/entries is the relevance search, and its query key is q, not query. Results come back best-first from the shared relevance scorer, so a client gets the same ordering the Tauri command returns. namespace restricts it to one namespace and limit is a top-K cap applied after ranking.

GET /memory/entries/enabled is the flat set the injection draws from: every entry in every enabled namespace, unranked and unfiltered. It takes no parameters at all.

GET /memory/stats reports total entries, total and enabled namespace counts, the auto and manual split, a breakdown by kind, an estimated token total, a per-namespace table, a createdPerDay series, and the recentlyUpdated entries. Agent-experience namespaces stay visible in perNamespace with isAgentExperience set, are summarized separately under agentExperience, and contribute to none of the headline totals, the breakdowns, the token estimate, the daily series, or the recently-updated list.

Export and import

Method Path Scope Body or query Returns
POST /memory/namespaces/{ns_id}/export memory_read - { namespace, entries }
GET /memory/export memory_read - MemoryExportDocument
POST /memory/import memory_write ImportRequest, ?onConflict=merge\|skip MemoryImportResponse (201)

ImportRequest carries the document as a JSON string under data, not as a nested object. Sending the parsed document itself, so that data arrives as a nested object rather than a string, fails to deserialize into the declared type and is a 422. A data value that is a string but does not itself parse as JSON is what import_namespace checks by hand, and that failure is a 400.

onConflict applies to the whole-store shape only, where a namespace is matched by name: merge (the default) appends the incoming entries to the stored namespace, skip leaves it alone and reports the name. The single-namespace path always creates a new namespace, so the parameter does nothing there. ImportMemoryQuery is deny_unknown_fields, so ?on_conflict=skip in snake_case is a 400 rather than a silent fall back to merge.

The response has two shapes, matching the two document shapes. A whole-store document answers with MemoryImportSummary: { created, merged, skipped, refused, entriesImported }, four disjoint namespace lists that together account for every namespace in the document. A single-namespace document answers with the created MemoryNamespace, which is the pre-existing shape. Branch on the presence of created.

refused is the security-relevant one. A namespace whose description carries the reserved agent-experience marker is refused on both sides of the name match: the incoming namespace cannot introduce a marked bank, and merge cannot append into a stored marked one. Both refusals are named in the response rather than dropped quietly.

An export is sensitive. GET /memory/export returns the user's entries verbatim, including whatever an extraction saved from their conversations, and the whole-store document carries all of it at once. Nothing is filtered out, agent-experience namespaces included, because a backup that silently drops part of the store is worse than one whose import reports what it will not take back. Treat the response the way you would treat a credential dump: do not write it into a world-readable path, a shared temp directory, or a log. aigo memory export --all -o <PATH> writes it through write_owner_only, which applies mode 0600 on Unix; on Windows there is no mode to apply and the file inherits the directory's ACL. A client writing the document itself should do the same. (--all is not optional there: an invocation naming neither a namespace id nor --all is an argument error rather than a whole-store dump nobody asked for.)

Extraction and consolidation

Method Path Scope Body or query Returns
POST /memory/extract memory_write ExtractRequest ExtractionResult
POST /memory/ensure-model-available memory_write EnsureModelAvailableRequest (optional) outcome string
POST /memory/namespaces/{ns_id}/consolidate memory_write ConsolidateRequest ConsolidationResult
POST /memory/consolidation/run memory_write RunConsolidationRequest LlmConsolidationReport
GET /memory/consolidation/status memory_read - LlmConsolidationStatus

POST /memory/extract runs the extraction pipeline over a transcript: messages is an array of { role, content } objects, required and non-empty, at most 100 of them, and model and config are optional overrides. ExtractionResult is { created, updated, skipped, emptyReason? }. emptyReason is present only when nothing was saved, and it is an object tagged by reason, not a bare string: {"reason": "nothingWorthSaving"}, or {"reason": "allDeduped", "candidateCount": 4}. A client that reads it as a string sees nothing.

POST /memory/ensure-model-available loads the recommended extraction model when it is not already loaded, and answers with one JSON string: alreadyLoaded, loaded, disabled, modelNotFound, insufficientRam, timedOut, or failed. The body is optional and carries only model.

Two consolidations, and they are different passes. POST /memory/namespaces/{ns_id}/consolidate is the lexical sweep over one namespace: it merges near-duplicate auto entries and prunes stale ones, needs no model, and returns { mergedCount, removedCount, unchangedCount, prunedCount }. Its optional config carries similarityThreshold (0.0 to 1.0, refused outside that range) and maxEntryAgeDays; either may be given alone and the other stays at the server's default. Its clustering is quadratic, so it processes at most the 500 most recently updated auto entries in the namespace and leaves the rest alone. POST /memory/consolidation/run is the LLM pass over every eligible namespace, rewriting clusters of similar auto entries into canonical facts, and returns LlmConsolidationReport with the namespace, cluster, and entry counters.

Manual entries are untouched by both passes. Both passes also protect Layer-A agent-experience namespaces from caller-selected consolidation: POST /memory/consolidation/run filters them out of its scope, and POST /memory/namespaces/{ns_id}/consolidate returns 400 before mutation when the namespace carries the reserved description marker or the Agent Experience: name prefix. The extraction owner still performs its threshold-triggered Layer-A sweep with its fixed 0.8 similarity threshold and 60-day age limit.

GET /memory/consolidation/status reports { enabled, intervalHours, due, lastRunAt, lastAttemptAt?, lastError? } and needs no router, so an unattended server can tell that maintenance is failing without log diving. lastAttemptAt and lastRunAt are separate on purpose: a pass that pruned and lexically consolidated but deferred the LLM step because the router was down advances the attempt and not the run.

Event stream

Method Path Scope Body or query Returns
GET /memory/events memory_read ?types= text/event-stream
GET /memory/ws memory_read ?types=, ?since= WebSocket (101)

See Events.

What the request types do and do not accept

Several of these are places where the obvious spelling is not the one the server declares. Check this section before assuming a field travels.

source is required on create, and there is no default. CreateEntryRequest declares source: MemorySource with no serde default, so a body of {"content": "..."} alone is a deserialization failure rather than an entry with an inferred source. Send manual for something a person wrote and auto for something a pipeline produced. tags and metadata are optional and travel only when given.

description is required on namespace create. CreateNamespaceRequest declares it as a plain String, so an omitted key fails to deserialize and is a 422, not a 400; an empty string is what "no description" means once the key is present. UpdateNamespaceRequest is all-optional and leaves an absent field alone, so use PUT to change one thing without restating the rest.

Search is q, not query. SearchQuery declares q as required. A request spelling it query fails, which is exactly the defect issue #4595 fixed in the CLI. The value must be non-empty and at most 500 bytes; the server's message says characters, but the check is on the byte length.

Entry listing has two response shapes, and the parameter set chooses. With no query parameter, GET /memory/namespaces/{ns_id}/entries returns the bare MemoryEntry[] in storage order that it has always returned, because every client written against it reads response[0]. Any parameter opts into { "entries": [...], "total": n }, where total counts the whole match before limit and offset are applied. The default switches to the page in a later release, so read the shape rather than assuming the array. tag is an exact match against one of the entry's tags, q is a case-insensitive substring of its content, and neither ranks: for relevance across namespaces use GET /memory/entries.

A misspelled filter key on the entry listing is a 400. MemoryEntryListQuery is deny_unknown_fields, so ?tags=tooling is refused rather than answered with an unfiltered listing that looks like a filter matched everything. The Tauri side gets the same guard from MemoryEntryFilter. That is not the rule everywhere on this surface, which is why the next two paragraphs exist.

GET /memory/entries/enabled takes no parameters. It has no query extractor, so a filter sent there is dropped without a word and the caller reads an unfiltered listing as a filtered one. aigo memory entry list --enabled refuses every filter flag at the argument layer for that reason.

Context accepts both casings. ContextQuery shipped as snake_case before the project settled on camelCase, so maxTokens and max_tokens both work, as do recordReference and record_reference. That alias is deliberate and is not the pattern elsewhere on this surface. maxTokens of 0 is a 400; larger values are clamped to 100000 rather than refused, and an absent one is 2000. An absent recordReference means "record", matching every caller that predates the flag.

Whole-store import applies no per-entry count or size cap, deliberately. The name, description, tag, and content limits below are enforced at the create endpoints and not by the store itself, so a namespace populated through the Tauri command can already hold an entry the REST create path would refuse. An import that re-applied those checks would reject documents this build's own GET /memory/export produced, which is the one thing a backup must never do. So the rule is that the import accepts what the export can produce, and what bounds the work is the payload size alone. Two checks remain, because neither can reject a self-produced document: the schema version, and the agent-experience marker refusal.

The import route carries its own body limit, and it is 50 MiB rather than 1 MiB. Every other Management API route inherits a 1 MiB DefaultBodyLimit from protect_bound in src-tauri/crates/aigo-rest/src/router.rs, which is the right size for a request body and the wrong size for a restore: a store whose export exceeded it could be backed up and never put back. POST /memory/import is therefore layered closer to the handler with MEMORY_IMPORT_BODY_LIMIT_BYTES, 50 MiB, which is also the ceiling aigo memory import reads a file under, so anything the command will send the server will accept. A larger document is a 413. Nothing else under /memory is raised.

Events

Two events, both delivered through the runtime-neutral event emitter, so the desktop app receives them over Tauri and a headless client receives the identical payload over SSE.

GET /memory/events and GET /memory/ws are the scoped stream's SSE and WebSocket transports. They require memory_read, the same scope as GET /memory/entries, and carry only memory:*: nothing from the Data Hub, the squads, or the scheduler crosses onto them. GET /events still carries the same two events among everything else and still requires admin, so a key that only reads memory should use a scoped route.

?types= narrows to a comma-separated subset, for example ?types=memory:entries-changed. A name outside the memory: family is a 400 rather than a connection that silently delivers nothing. It honours Last-Event-ID and ?since= and reports a truncated replay with stream:gap and a lagging consumer with stream:lagged, exactly as GET /events does; replayed events pass the same domain filter the live stream applies. See Event Streams.

curl -N -H "X-API-Key: $AIGO_API_KEY" \
  "http://127.0.0.1:8001/api/v1/memory/events"

This stream works on the desktop app's own embedded Management API too. Every desktop subsystem emits through one shared event sink, and that server attaches its own event bus as a second surface when it starts and releases it when it stops (issue #5040), so a restart moves delivery to the new server's bus and the desktop UI keeps receiving throughout. Before that fix this stream stayed open and silent there, and so did GET /events for these events.

Event Payload Fired when
memory:namespaces-changed {} A namespace create, update, delete, or toggle succeeded, or an import created at least one namespace. The payload is an empty object; refetch the namespace list.
memory:entries-changed { namespaceId } Entries in one namespace changed. namespaceId says which to refetch.

namespaceId is camelCase and a snake_case payload is rejected at the boundary. A whole-store import that only merged into existing namespaces creates none, so it fires memory:entries-changed per touched namespace and no memory:namespaces-changed at all. Both events have a polling equivalent, so a client that cannot hold an SSE connection loses timeliness and nothing else.

Walkthroughs

Each assumes AIGO=http://127.0.0.1:8001/api/v1 and an access key in KEY.

Create a namespace and an entry

description and source are the two fields that are easy to leave out and that the server requires:

NS=$(curl -sS -X POST "$AIGO/memory/namespaces" \
  -H "X-API-Key: $KEY" -H 'Content-Type: application/json' \
  -d '{"name": "Coding style", "description": "Conventions for this codebase"}' \
  | jq -r '.id')

curl -sS -X POST "$AIGO/memory/namespaces/$NS/entries" \
  -H "X-API-Key: $KEY" -H 'Content-Type: application/json' \
  -d '{
        "content": "Prefer pnpm over npm in this repository.",
        "source": "manual",
        "tags": ["tooling"]
      }'

Both answer 201 with the created record. List the namespace back with a filter to get the page shape and its total:

curl -sS -G "$AIGO/memory/namespaces/$NS/entries" -H "X-API-Key: $KEY" \
  --data-urlencode 'tag=tooling' --data-urlencode 'sort=created' --data-urlencode 'order=desc' \
  | jq '{total, shown: (.entries | length)}'

Drop every parameter and the same path returns the bare array instead.

Ranked search first, then the block a chat turn would inject for the same store:

curl -sS -G "$AIGO/memory/entries" -H "X-API-Key: $KEY" \
  --data-urlencode 'q=package manager' --data-urlencode 'limit=5'

curl -sS -G "$AIGO/memory/context" -H "X-API-Key: $KEY" \
  --data-urlencode 'maxTokens=2000' --data-urlencode 'recordReference=false' \
  | jq '{tokenCount, entryCount, namespaceCount}'

The query key is q. recordReference=false is what makes the second call an inspection rather than a use: without it, reading the context stamps every entry it included as referenced and moves it up the recency ranking.

Export the store and restore it elsewhere

The export is the whole store in one document, and it carries the entries verbatim, so write it somewhere only you can read:

umask 077
curl -sS "$AIGO/memory/export" -H "X-API-Key: $KEY" > memory-backup.json
jq '{version, exportedAt, namespaces: (.namespaces | length)}' memory-backup.json

aigo memory export --all -o memory-backup.json does the same and applies mode 0600 itself.

Import it on the other machine. The document travels as a string under data, so encode it rather than nesting it:

jq -Rs '{data: .}' memory-backup.json > import-body.json

curl -sS -X POST "$AIGO/memory/import?onConflict=merge" \
  -H "X-API-Key: $KEY" -H 'Content-Type: application/json' \
  --data-binary @import-body.json \
  | jq '{created: (.created | length), merged: (.merged | length), skipped, refused, entriesImported}'

A response carrying created is the whole-store summary; one carrying id and name is a single-namespace import. skipped lists the names left alone under onConflict=skip, and refused lists the ones the agent-experience marker rules would not take.

Limits

Enforced server-side, but not all in the same place, and the difference is visible. The bullets marked Shared. below live in the memory domain and hold on the Tauri transport too. The rest are checked at the REST boundary, so they bound what these endpoints accept rather than what the store can hold: a namespace populated through the Tauri command can already carry an entry the REST create path would refuse, which is the same fact the import section rests on.

  • Request body: 1 MiB for the whole Management API, raised to 50 MiB on POST /memory/import alone.
  • Namespace name: 200 bytes. Namespace description: 2000 bytes.
  • Entry content: 102400 bytes. Tags per entry: 50. Tag length: 100 bytes.
  • Search query: 500 bytes, and it may not be empty.
  • Shared. Extraction transcript: at least 1 and at most 100 messages.
  • Shared. Extraction message content: at most 50000 bytes per message.
  • Shared. Consolidation similarityThreshold: 0.0 to 1.0 inclusive. The lexical sweep also processes at most the 500 most recently updated auto entries per namespace.
  • Shared. Entry listing page: limit defaults to 100, is refused above 1000, and is refused at 0 rather than read as unlimited.
  • Shared. Bulk delete and move: ids carries at least 1 and at most 1000 entry ids.
  • Shared. Injected context: maxTokens defaults to 2000 and is clamped to 100000. A maxTokens of 0 is refused at the REST boundary with a 400; the Tauri command passes it straight through, and the shared core reads it as a zero-token budget and returns an empty injection rather than erroring.
  • Whole-store import: no namespace count, entry count, or per-field size cap. The body limit above is the bound.

See also

  • Memory: what memory is for and how injection works.
  • Memory page: the desktop page these endpoints sit behind.
  • Data Hub: the sibling surface, and the page id that gates this one in a managed install.
  • CLI reference: the aigo memory command group, which covers all 26 endpoints on this page.