Skip to main content

Memory Read API

Overview​

Registered routes (colon form used by the docs freshness check):

  • GET /v1/owners/:owner/namespaces
  • GET /v1/owners/:owner/memories
  • GET /v1/owners/:owner/agents
  • GET /v1/owners/:owner/_token_probe

Owner-scoped, cursor-based, read-only. All three endpoints accept either of two auth mechanisms (see Authentication below); either way, the {owner} path segment must equal the authenticated identity or the request is rejected with 403.

This document covers response shapes, auth mechanics, and error/rate-limit behavior for the three endpoints below. A formal OpenAPI/JSON-Schema spec is a follow-up, not included in this pass.

Authentication​

These three routes accept either of two independent, parallel auth mechanisms, dispatched by auth::verify_read_api_auth (services/server/src/auth.rs) based on whether the request carries an Authorization header:

  1. Ed25519 signed headers, the same verify_signature middleware /api/restore and every other protected route use. This is the mechanism direct SDK/dashboard delegate-key callers use; see "Required headers" and "Canonical signing string" below.
  2. Owner-scoped bearer token (Authorization: Bearer <token>), the mechanism for Console, which structurally never holds a delegate key and so can never produce an Ed25519 signature. See docs/api/owner-token-auth.md for how Console obtains a token; once obtained, a request here is just Authorization: Bearer <token> with no other auth headers. The token's permissions must include memories.read (the only scope currently minted) or the request is rejected 403.

Presence of an Authorization header selects the bearer-token path; its absence selects the Ed25519 path. A request cannot use both.

Required headers​

HeaderDescription
x-public-keyHex-encoded Ed25519 public key (32 bytes)
x-signatureHex-encoded Ed25519 signature (64 bytes) over the canonical message below
x-timestampUnix timestamp in seconds. Must be within ±300s (5 minutes) of server time
x-nonceUUID v4, used once for replay protection (tracked in Redis for 10 minutes)
x-account-idWalrus Memory account object ID. Effectively required: it is signed into the canonical message, so omitting it signs an empty string and does not match a real account on Testnet

Canonical signing string​

The client signs (Ed25519) the following pipe-free, dot-joined string and sends the signature in x-signature:

Source: api/memory-read-api.md
{timestamp}.{method}.{path_and_query}.{body_sha256}.{nonce}.{account_id}
  • timestamp: the exact value sent in x-timestamp.
  • method: HTTP method, for example GET.
  • path_and_query: the request's path plus query string exactly as sent, for example /v1/owners/0xabc.../memories?limit=50. Mismatched query params (including a tampered updated_after cursor) invalidate the signature.
  • body_sha256: hex-encoded SHA-256 of the request body. For these GET-only, bodyless endpoints this is the hash of an empty byte string.
  • nonce: the x-nonce value.
  • account_id: the x-account-id value (empty string if omitted, which does not match a real signed request).

Server-side verification flow (auth.rs::verify_signature): validate the Ed25519 signature against the canonical string → check the nonce hasn't been seen before (Redis, fail-closed on Redis outage) → resolve the account (cache, then the signed x-account-id or MEMWAL_ACCOUNT_ID; a cold key with neither is 401, there is no registry scan) → verify the public key is a registered delegate key on that account's onchain MemWalAccount.delegate_keys (cached in delegate_key_cache, revoked entries evicted).

401 responses​

Ed25519 path (no Authorization header on the request), any of the following returns a bare 401 Unauthorized (no JSON body, constant ~100ms delay on signature/timestamp/nonce failures to prevent timing side-channels distinguishing failure reasons):

  • Missing/malformed x-public-key, x-signature, or x-timestamp.
  • x-timestamp outside the ±300s window.
  • Ed25519 signature verification failure (including a tampered path, query string, or body).
  • Nonce already seen (replay): or the Redis nonce check itself failing (fail-closed).
  • Public key not found among the resolved account's onchain delegate keys, or the account is deactivated.

A request missing x-nonce entirely gets 426 Upgrade Required instead of 401, signaling an unsupported legacy SDK version rather than an auth failure.

Bearer-token path (Authorization: Bearer <token> present), also a bare 401, with no distinction in the response between causes, for:

  • An expired, tampered, wrongly-signed, or wrong-audience token.
  • OWNER_TOKEN_SECRET unconfigured on this deployment.
  • The token's owner_address no longer resolves to a MemWalAccount (for example, deleted between mint and use).

Once authenticated (either path), the three endpoints below additionally return 403 if the {owner} path segment does not equal the resolved identity, or, on the bearer-token path, if the token's permissions don't include memories.read.

Rate limiting​

These three endpoints run on their own router (read_api_routes in main.rs), separate from the write path's protected_routes, behind read_api_rate_limit_middleware (services/server/src/rate_limit.rs) instead of the write path's rate_limit_middleware. They do not share the write path's budget, a routine pagination loop over this API can no longer trip, or contend with, the 30/min per-delegate-key budget that exists to bound the write path's spend-risk (Walrus upload, LLM calls, gas).

There is a single sliding-window layer, keyed by delegate key under its own Redis prefix (rate:read:dk:{public_key}, distinct from the write path's rate:dk:{public_key}), no separate per-account burst/sustained tiers on top. Default limit is 200 weighted-requests/min per delegate key (ReadApiRateLimitConfig::per_delegate_key_per_minute), overridable through the READ_API_RATE_LIMIT_PER_MINUTE env var. Weights for this API:

EndpointWeightWhy
GET /v1/owners/{owner}/namespaces1DB read only
GET /v1/owners/{owner}/memories1DB read only
GET /v1/owners/{owner}/agents2Makes a live onchain sui_getObject RPC call (short-TTL cached, but uncached on a cold/expired cache entry)

Exceeding the limit returns 429 Too Many Requests:

Source: api/memory-read-api.md
{
"error": "Rate limit exceeded",
"layer": "read_delegate_key",
"limit": "200 weighted-requests/min",
"retry_after_seconds": 60
}

with a Retry-After: 60 header. If the rate limiter itself is unavailable (Redis unreachable), requests fail closed with 503 Service Unavailable and a Retry-After: 30 header rather than being allowed through unmetered, there is no in-memory fallback for this middleware, unlike the write path's deliberately-fallback-enabled limiter.

GET /v1/owners/:owner/namespaces​

updated_after, like memories' below, must be the opaque next_cursor value returned by a previous call, not a raw timestamp or namespace name; omit it for the first page. It base64 (URL-safe, unpadded) encodes the JSON watermark {"updated_at": ..., "namespace": ...}: rows are ordered and filtered by the rollup's (MAX(updated_at), namespace), mirroring memories' (updated_at, id) keyset. limit defaults to 100, max 500, 400 for non-positive/non-integer values, same convention as memories.

Breaking change from an earlier version of this endpoint: namespaces are now returned ordered by recency ((MAX(updated_at), namespace)), not alphabetically by name. A client that wants an alphabetical list needs to buffer and sort every page itself rather than relying on response order.

Because the cursor is a recency watermark rather than a name, it is a real incremental-sync token: a namespace you already synced comes back on the next poll if any of its memories were created or updated after that watermark, however early its name sorts. Namespaces untouched since the watermark are not re-sent.

Hard deletes write a row to memory_tombstones and remove the live vector_entries row in one statement. Incremental /namespaces computes the watermark as GREATEST(live MAX(updated_at), tombstone MAX(deleted_at)), so a namespace whose last memory was deleted still resurfaces with a smaller memory_count. Incremental /memories never puts tombstones in memories[]. They arrive in the additive deleted array (memory_id, namespace_id, deleted_at). Old Console clients ignore that key. Tombstones are kept for 30 days; a cursor older than that returns must_resync: true. snapshot_version stays 2.

Response: Source: api/memory-read-api.md

{
"namespaces": [
{
"id": "work",
"name": "work",
"memory_count": 12,
"storage_used": 48213,
"updated_at": "2026-08-04T10:00:00Z"
}
],
"next_cursor": "eyJ1cGRhdGVkX2F0IjouLi4sIm5hbWVzcGFjZSI6IndvcmsifQ",
"has_more": false,
"snapshot_version": 2
}

updated_at is MAX(updated_at) across the namespace's memories, the same value the cursor is built from.

GET /v1/owners/:owner/memories​

updated_after must be the opaque next_cursor value returned by a previous call, not a raw timestamp. limit defaults to 100, max 500.

Response: Source: api/memory-read-api.md

{
"memories": [
{
"memory_id": "abc123",
"namespace_id": "work",
"blob_id": "blob-xyz",
"created_at": "2026-08-04T10:00:00Z",
"updated_at": "2026-08-04T11:30:00Z",
"size": 2048,
"agent_id": "agent-abc",
"package_id": "0xpkg",
"status": "active",
"end_epoch": 900,
"expires_at": "2026-09-15T00:00:00Z",
"importance": 0.5
}
],
"deleted": [],
"must_resync": false,
"next_cursor": "eyJ1cGRhdGVkX2F0IjouLi59",
"has_more": false,
"snapshot_version": 2
}

updated_at is the row's own last-modified time, the same value this page's cursor is built from.

status is "expired" if expires_at is in the past and "active" otherwise (including when end_epoch / expires_at are still null because the expiry sweep has not run yet). There is no "deleted" status: a deleted memory leaves memories[] entirely and appears once in deleted[]. That array is the only deletion signal on this endpoint.

The first time the sweep resolves a row's end_epoch/expires_at from null to a real value, that row's updated_at advances too, so a client that already synced the row while it was still unsynced sees the populated values on its next incremental poll, rather than being stuck with null forever. A later routine re-verification that reconfirms an unchanged value does not advance updated_at (this would otherwise make every synced memory reappear roughly once a day regardless of whether anything changed); only a genuine change does.

Cursor semantics (both paginated endpoints)​

next_cursor is always returned for a non-empty page, including the final page of a traversal and a result that fits entirely in one page. It is the watermark of the last row in that page, and it is what you pass as updated_after on your next poll.

An empty page returns next_cursor: null in exactly one case: the very first page of a fresh walk (no updated_after on the request) that matches nothing at all, there is no prior cursor and no row to build a watermark from. Every other empty page, a continuation page reached with an incoming cursor, which can happen when every remaining row raced past the walk's snapshot boundary between pages, still returns a non-null next_cursor: the same position as the incoming cursor, but with a fresh snapshot boundary. Use that new cursor rather than the one you already held; the one you held is now stale, it's exactly what the reset exists to replace.

So next_cursor: null does not mean "end of data", use the separate has_more boolean for that instead. Keep paginating (pass back the latest next_cursor) while has_more is true; stop once you see has_more: false.

Do not infer end-of-data from page length. limit is silently clamped to each endpoint's max (500), a request for limit=1000 that happens to match exactly 500 real rows returns a page exactly as long as the (clamped) limit, and a request for more than the actual remaining data returns a page shorter than limit while more data still exists elsewhere for that owner. has_more is correct in both cases; page-length heuristics are not.

GET /v1/owners/:owner/agents​

Live onchain read of MemWalAccount.delegate_keys, short-TTL cached per-account (same 30s window sui/client.rs::walrus_epoch() uses) so repeated calls within the TTL window don't re-hit the chain.

Response: Source: api/memory-read-api.md

{
"agents": [
{ "label": "cli", "sui_address": "0xdelegate1" }
],
"snapshot_version": 2
}

Errors​

All errors from these three endpoints (except the shared auth middleware's bare 401/426, and the shared rate limiter's 429/503, see Authentication and Rate limiting above) use the envelope { "error": "<message>" }:

StatusCause
401Auth failed, either an invalid/expired Ed25519 signature/nonce/timestamp, or (bearer-token path) an invalid/expired/unresolvable owner token (see Authentication)
403{owner} path segment does not match the authenticated identity, or (bearer-token path) the token's permissions lack memories.read
400Invalid/malformed cursor, or non-positive/non-integer limit
429Rate limit exceeded (see Rate limiting)
500Internal error, including an onchain RPC failure on /agents

An empty list (owner with no memories/namespaces, or no delegate keys) is a valid 200, not a 404.