A hosted memory API, through a grant

Agent memory, with the key left outside the sandbox

An agent memory API is a hosted service that keeps what an AI agent learns (a customer's language, a preference, a past decision) so that a later conversation can recall it. The agent's code needs an API key to call it, and a key handed to the sandbox can be printed, stored or sent anywhere. Here the code calls Cloudflare's Agent Memory through a plimsoll grant (INTERNAL · glossary →), one run's permission to call listed routes of one API: the broker (INTERNAL · glossary →), the part of plimsoll outside the sandbox that makes each call, holds the key, and the code reaches three routes of one customer's memory and nothing else.

The API on this page is a fake. The routes and the client come from the OpenAPI document Cloudflare publishes; the server answering them is a small Go program on loopback (fake.go (EXTERNAL · source repo ↗)) that checks the key and keeps memories in memory. No Cloudflare account, key or network was used, and nothing was spent.

3 of 14routes of Cloudflare's document the grant lists
5runs, each in a fresh sandbox
4 of 4requests at the API carried the key, set by the broker
2calls outside the grant, refused by the broker; none reached the API
0places in the sandbox the key was found
The sandbox the agent's code memory.agent_memory_recall(...) the memory client generated by plimsoll-specgen; a copy of the allow list, no key method, path, body The broker, on the host 1. the path is on the allow list? no: 403, nothing sent 2. yes: add the key, send the key lives here, in Go + the key Agent Memory API stores and recalls memories here: the fake on loopback, which refuses a request without the key The call trace route template, status, sizes; no path, body or key
Where the key is. The client in the sandbox has only a copy of the allow list; the broker holds the key and attaches it after deciding a call is allowed.

Where the routes come from

Cloudflare publishes an OpenAPI document for its whole API: the standard machine-readable description of an HTTP API, listing every route, its parameters and its answers (OpenAPI 3.0.3 (EXTERNAL · official docs ↗)). It lives in cloudflare/api-schemas (EXTERNAL · source repo ↗) under the BSD 3-Clause license, which allows copying with the copyright notice kept. The example keeps its Agent Memory part, 14 operations, unchanged in agent-memory.openapi.json, with the license beside it in LICENSE.cloudflare-api-schemas. It was cut from the file at commit 2d7642070d54 (SHA-256 29f54710427f), and -extract refuses to cut it from any other file. Cloudflare's own pages for the service: Agent Memory HTTP API (EXTERNAL · official docs ↗).

plimsoll-specgen, plimsoll's generator, reads the document and writes three files that would otherwise be written and kept in step by hand: the route list (allow.json), the typed client the code calls (preamble.js), and the text a gateway (the service between the model and plimsoll) shows the model so it knows what exists (description.txt; a tool (INTERNAL · glossary →) description). A path parameter such as {account_id} becomes *, which matches one path segment. Every route the document has:

GET /client/v4/accounts/*/agent-memory/namespaces
POST /client/v4/accounts/*/agent-memory/namespaces
DELETE /client/v4/accounts/*/agent-memory/namespaces/*
GET /client/v4/accounts/*/agent-memory/namespaces/*
GET /client/v4/accounts/*/agent-memory/namespaces/*/profiles
DELETE /client/v4/accounts/*/agent-memory/namespaces/*/profiles/*
POST /client/v4/accounts/*/agent-memory/namespaces/*/profiles/*/ingest
GET /client/v4/accounts/*/agent-memory/namespaces/*/profiles/*/memories
DELETE /client/v4/accounts/*/agent-memory/namespaces/*/profiles/*/memories/*
GET /client/v4/accounts/*/agent-memory/namespaces/*/profiles/*/memories/*
POST /client/v4/accounts/*/agent-memory/namespaces/*/profiles/*/recall
POST /client/v4/accounts/*/agent-memory/namespaces/*/profiles/*/remember
DELETE /client/v4/accounts/*/agent-memory/namespaces/*/profiles/*/sessions/*
POST /client/v4/accounts/*/agent-memory/namespaces/*/profiles/*/summary

Every route starts with /client/v4. The document's server address is https://api.cloudflare.com/client/v4, OpenAPI adds each path to it, and a grant's base_url is the scheme and host only. Before this example, specgen left the server's path out, so a grant built from this document would have sent every call to /accounts/… and missed the API. It now puts the server's path in every route, and refuses a document whose servers disagree about it.

specgen also reported what it could not carry over: three list operations take optional query parameters, and the broker refuses any path with a query, so their generated functions always call the bare route.

GET /client/v4/accounts/{account_id}/agent-memory/namespaces: optional query "per_page", query "order", query "direction", query "cursor" cannot be sent through the broker, so the generated method always calls the bare route
GET /client/v4/accounts/{account_id}/agent-memory/namespaces/{namespace_name}/profiles: optional query "per_page", query "cursor" cannot be sent through the broker, so the generated method always calls the bare route
GET /client/v4/accounts/{account_id}/agent-memory/namespaces/{namespace_name}/profiles/{profile_name}/memories: optional query "per_page", query "cursor", query "session_id", query "type" cannot be sent through the broker, so the generated method always calls the bare route

What the operator grants

A document lists what an API has, not what an agent should reach. The operator writes the grant profile, the grant stored under a name that a run selects, in grants.json:

{
  "profiles": {
    "agent-memory": {
      "base_url": "https://api.cloudflare.com",
      "global": "memory",
      "allowed_callers": ["support-agent"],
      "token": { "type": "static", "env": "CLOUDFLARE_API_TOKEN" },

      "allow": [
        "POST /client/v4/accounts/00000000000000000000000000000042/agent-memory/namespaces/support-agent/profiles/customer-42/remember",
        "POST /client/v4/accounts/00000000000000000000000000000042/agent-memory/namespaces/support-agent/profiles/customer-42/recall",
        "GET /client/v4/accounts/00000000000000000000000000000042/agent-memory/namespaces/support-agent/profiles/customer-42/memories/*"
      ],
      "catalog": [
        "GET /client/v4/accounts/*/agent-memory/namespaces",
        "POST /client/v4/accounts/*/agent-memory/namespaces",
        "DELETE /client/v4/accounts/*/agent-memory/namespaces/*",
        "GET /client/v4/accounts/*/agent-memory/namespaces/*",
        "GET /client/v4/accounts/*/agent-memory/namespaces/*/profiles",
        "DELETE /client/v4/accounts/*/agent-memory/namespaces/*/profiles/*",
        "POST /client/v4/accounts/*/agent-memory/namespaces/*/profiles/*/ingest",
        "GET /client/v4/accounts/*/agent-memory/namespaces/*/profiles/*/memories",
        "DELETE /client/v4/accounts/*/agent-memory/namespaces/*/profiles/*/memories/*",
        "GET /client/v4/accounts/*/agent-memory/namespaces/*/profiles/*/memories/*",
        "POST /client/v4/accounts/*/agent-memory/namespaces/*/profiles/*/recall",
        "POST /client/v4/accounts/*/agent-memory/namespaces/*/profiles/*/remember",
        "DELETE /client/v4/accounts/*/agent-memory/namespaces/*/profiles/*/sessions/*",
        "POST /client/v4/accounts/*/agent-memory/namespaces/*/profiles/*/summary"
      ],
      "preamble_file": "preamble.js"
    }
  }
}

catalog is specgen's list, unchanged: the example fails if it drifts from the document. allow is three of those routes with the account, the namespace and the customer's profile written in, so this run's code can store and recall one customer's memories and read one back, and can do nothing else the key can. memories/* is the one wildcard left: any memory ID within that profile. The account ID is made up: 32 digits, which fits both the pattern the Agent Memory paths give it (digits only) and the one other parts of the document give account IDs (32 hexadecimal characters). The key comes from CLOUDFLARE_API_TOKEN on the host; the profile names the variable, never the value. A grant has no per-caller parameters, so serving many customers this way means one profile each, selected by name per run.

The example loads this file exactly as the plimsoll daemon loads its grants file, and changes one thing for the run: base_url points at the fake.

Five runs

Each run is a fresh sandbox on the WebAssembly (INTERNAL · glossary →) provider: plimsoll's own process runs a QuickJS (INTERNAL · glossary →) JavaScript engine compiled to WebAssembly, chosen so the example runs anywhere. The trace is the broker's record of each call: the allow entry it matched, written as in the profile (the route template), the status and the byte counts. The latency it also records is left off this page, so the page is the same on every run.

1. Store a memory, recall it, read it back by its ID

One run stores a memory, asks the API to recall what it holds about the customer's language, and reads the memory back by the ID the recall returned. All three are routes the grant lists. The third shows what the trace does with a wildcard: the request named the memory's ID, and the trace keeps the allow entry, memories/*.

The code

var A = "00000000000000000000000000000042", NS = "support-agent", ME = "customer-42";
memory.agent_memory_remember(A, NS, ME, {content: "The customer wants replies in Spanish.", sessionId: "chat-7"})
  .then(function (r) {
    console.log("stored " + r.result.id);
    globalThis.lastStored = r.result.id;
    return memory.agent_memory_recall(A, NS, ME, {query: "Which language does the customer want?"});
  })
  .then(function (r) {
    console.log("recalled: " + r.result.answer);
    return memory.agent_memory_memory_get(A, NS, ME, r.result.candidates[0].id);
  })
  .then(function (r) { console.log("read back by ID: " + r.result.content); },
        function (e) { console.log("failed: " + e.message); });

What it printed

stored 01JFAKEMEM0000000000000001
recalled: The customer wants replies in Spanish.
read back by ID: The customer wants replies in Spanish.

The broker's trace

#methodroute template (after /client/v4/accounts/)statusbytes sentbytes back
1POST00000000000000000000000000000042/agent-memory/namespaces/support-agent/profiles/customer-42/remember20073295
2POST00000000000000000000000000000042/agent-memory/namespaces/support-agent/profiles/customer-42/recall20050249
3GET00000000000000000000000000000042/agent-memory/namespaces/support-agent/profiles/customer-42/memories/*2000295

Requests that reached the API

methodpath the API received (after /client/v4/accounts/)keystatus
POST00000000000000000000000000000042/agent-memory/namespaces/support-agent/profiles/customer-42/rememberthe broker's token200
POST00000000000000000000000000000042/agent-memory/namespaces/support-agent/profiles/customer-42/recallthe broker's token200
GET00000000000000000000000000000042/agent-memory/namespaces/support-agent/profiles/customer-42/memories/01JFAKEMEM0000000000000001the broker's token200

2. A later run, in a fresh sandbox, recalls it

A new run starts a new JavaScript engine, so nothing the first run left in the sandbox is there. The memory is, because it was never in the sandbox: the API holds it, and this run recalls it.

The code

var A = "00000000000000000000000000000042", NS = "support-agent", ME = "customer-42";
memory.agent_memory_recall(A, NS, ME, {query: "What language should replies use?"}).then(
  function (r) { console.log(r.result.count + " memory found: " + r.result.answer); },
  function (e) { console.log("failed: " + e.message); });
console.log("left over from the first run: " + (typeof globalThis.lastStored));

What it printed

left over from the first run: undefined
1 memory found: The customer wants replies in Spanish.

The broker's trace

#methodroute template (after /client/v4/accounts/)statusbytes sentbytes back
1POST00000000000000000000000000000042/agent-memory/namespaces/support-agent/profiles/customer-42/recall20045250

Requests that reached the API

methodpath the API received (after /client/v4/accounts/)keystatus
POST00000000000000000000000000000042/agent-memory/namespaces/support-agent/profiles/customer-42/recallthe broker's token200

3. Another customer's memory, through the client and around it

The same recall, for customer-43. The client plimsoll put in the sandbox refuses it before any request is made, since its copy of the allow list has no route for that profile. That check is the guest's own JavaScript, which hostile code can skip, so the code then calls the raw host function under the client. The broker, outside the sandbox, refuses that too and counts one denied call. No request reached the API.

The code

var A = "00000000000000000000000000000042", NS = "support-agent", ME = "customer-42";
var Q = {query: "Which language does the customer want?"};
memory.agent_memory_recall(A, NS, "customer-43", Q).then(
  function (r) { console.log("client answered: " + JSON.stringify(r)); },
  function (e) {
    console.log("client refused: " + e.message);
    var raw = __coderunner_host_call(JSON.stringify({method: "POST",
      path: "/client/v4/accounts/" + A + "/agent-memory/namespaces/" + NS + "/profiles/customer-43/recall", body: Q}));
    console.log("broker answered: " + raw.status + " " + raw.body);
  });

What it printed

client refused: host api call not permitted by sandbox capability: POST /client/v4/accounts/00000000000000000000000000000042/agent-memory/namespaces/support-agent/profiles/customer-43/recall
broker answered: 403 forbidden by sandbox capability allowlist

The broker's trace

No call recorded; 1 denied by the allow list, with no path kept, since a denied call matched no template.

Requests that reached the API

None.

4. A route the key could reach and the grant does not list

The generated client has a function for every operation in the document, deleting a namespace among them. Whether the key may delete one is up to its Cloudflare permissions, which plimsoll does not see; the grant does not list the route, so the client and the broker refuse it, as in the third run.

The code

var A = "00000000000000000000000000000042", NS = "support-agent", ME = "customer-42";
memory.agent_memory_namespace_delete(A, NS).then(
  function (r) { console.log("client answered: " + JSON.stringify(r)); },
  function (e) {
    console.log("client refused: " + e.message);
    var raw = __coderunner_host_call(JSON.stringify({method: "DELETE",
      path: "/client/v4/accounts/" + A + "/agent-memory/namespaces/" + NS}));
    console.log("broker answered: " + raw.status + " " + raw.body);
  });

What it printed

client refused: host api call not permitted by sandbox capability: DELETE /client/v4/accounts/00000000000000000000000000000042/agent-memory/namespaces/support-agent
broker answered: 403 forbidden by sandbox capability allowlist

The broker's trace

No call recorded; 1 denied by the allow list, with no path kept, since a denied call matched no template.

Requests that reached the API

None.

5. The guest looking for the key

The code searches every global of the engine and every function of the client for the key. One global, execArgv, holds all the source the engine was given, the injected client and the generated one included, so a key anywhere in that source would be named: the example's test plants one in a preamble and this same search names it. Here it finds nothing, because the key never leaves Go: the broker sets it on the request to the API after deciding the call is allowed.

The code

var needle = ["fake", "agent", "memory", "token", "held", "by", "the", "broker"].join("-");
function holds(v) { try { return String(v).indexOf(needle) >= 0; } catch (e) { return false; } }
var inGlobals = Object.getOwnPropertyNames(globalThis).filter(function (n) { return holds(globalThis[n]); });
var inClient = Object.keys(memory).filter(function (k) { return holds(memory[k]); });
console.log("functions on memory: " + Object.keys(memory).length);
console.log("globals holding the key: " + (inGlobals.length ? inGlobals.join(",") : "none"));
console.log("client functions holding the key: " + (inClient.length ? inClient.join(",") : "none"));

What it printed

functions on memory: 20
globals holding the key: none
client functions holding the key: none

The broker's trace

No call recorded.

Requests that reached the API

None.

What the trace keeps, and what it leaves out

The trace is what plimsoll's audit log, its metrics and its efficiency advisor (which suggests cheaper ways to make the same calls) are built from. By construction it has no field for a path, a body or a credential, so what it holds is the operator's own text, the allow entry, plus numbers. The API sees everything, as it must. Where each value of the first run went:

valuein the guest's outputat the APIin the trace
the keynoon every requestno
the memory's content, “The customer wants replies in Spanish.”yesstoredno: 73 bytes sent
the memory's IDyesin the path of the third requestno: memories/*
the other customer, customer-43yes, in the refusalnever reached itno: one denied call, counted

When an operator needs to tie a run to the request that caused it, a run can carry a trace_id the caller already uses in its own log; plimsoll writes it on the run's audit line and keeps the content where it already is, one layer up.

What the API saw

Every request the fake received, in order. All of them came from the first two runs, and each carried the key the broker added; the code in the sandbox never had it.

methodpath (after /client/v4/accounts/)keystatus
POST00000000000000000000000000000042/agent-memory/namespaces/support-agent/profiles/customer-42/rememberthe broker's token200
POST00000000000000000000000000000042/agent-memory/namespaces/support-agent/profiles/customer-42/recallthe broker's token200
GET00000000000000000000000000000042/agent-memory/namespaces/support-agent/profiles/customer-42/memories/01JFAKEMEM0000000000000001the broker's token200
POST00000000000000000000000000000042/agent-memory/namespaces/support-agent/profiles/customer-42/recallthe broker's token200

What this does not show

The real service. No request went to Cloudflare. The fake answers in the envelope the document gives (success, errors, messages, result), but its recall is a word match, while the document describes Cloudflare's answer as written by a language model, so real answers and their sizes will differ. When this was written, Cloudflare's documentation said Agent Memory is in private beta and listed no price (Agent Memory overview (EXTERNAL · official docs ↗)).

A strong wall. These runs used the WebAssembly provider, whose isolation tier (INTERNAL · glossary →) is process: an escape from the JavaScript engine would land in plimsoll's own process, where the key is. For hostile code, run behind the docker provider under gVisor (INTERNAL · glossary →) or a virtual-machine provider; the grant and the broker are the same code there.

What goes into memory. The grant decides which routes the code may call; it does not read what the code stores. Code that can write a memory can write anything it has seen, and a later run, or another agent given the same profile, reads it back. Pinning the profile limits who reads it, not what is in it. And a recalled memory is text another run wrote: if a model reads it, it can carry instructions meant to steer the model (prompt injection (INTERNAL · glossary →)), which nothing here checks.

A short-lived key. The profile uses a static token, the same key on every run. Where an API can issue short-lived tokens, a profile can name a minter (INTERNAL · glossary →) that makes one per run, so a key that leaked expires soon after the run; this example does not do that.

Why Cloudflare's document

The example was meant to use whichever memory service publishes an OpenAPI document that may be copied into a public repository. Three were checked on 2026-11-05:

servicedocumentthrough a grant
Cloudflare Agent Memorypublished in cloudflare/api-schemas, BSD 3-Clauseused here: a bearer key, and every path is one a grant can hold
Mem0 Platformpublished at docs.mem0.ai/openapi.json (EXTERNAL · official docs ↗), Apache 2.0; the service has a free plannot today. Every path ends in / (/v3/memories/add/), which the broker refuses as not canonical, so specgen stops at the first one; and the key goes in Authorization: Token <key>, a form none of the grant's ways of placing a key writes
Mem0's self-hosted servernone published: the running server generates its ownits X-API-Key header is one a grant can set, but there is no published document to generate from

Run it yourself

From the repository root, with Go installed; nothing here needs docker, an account or the network, except the download in the last command.

go run ./examples/memory-grant           # rewrite this page and the generated files beside it
go run ./examples/memory-grant -check    # write nothing; fail unless they are current
go test ./examples/memory-grant          # the same run, plus the checks failing when their claim is false

# regenerate the three specgen files by hand, byte for byte the same
go run ./cmd/plimsoll-specgen -global memory -outdir docs/examples/memory-grant \
  docs/examples/memory-grant/agent-memory.openapi.json

# cut the extract again from Cloudflare's whole document (about 27 MB)
mkdir -p tmp && curl -sSLo tmp/cloudflare-openapi.json https://raw.githubusercontent.com/cloudflare/api-schemas/2d7642070d549e2e36be64d7ded20b4ac748f53a/openapi.json
go run ./examples/memory-grant -extract tmp/cloudflare-openapi.json

The full SHA-256 of the upstream file: 29f54710427f411f81c6738c735f3586580f089648f20ca05212d94f4f52bdf0; the commit: 2d7642070d549e2e36be64d7ded20b4ac748f53a.