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.
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
| # | method | route template (after /client/v4/accounts/) | status | bytes sent | bytes back |
|---|---|---|---|---|---|
| 1 | POST | 00000000000000000000000000000042/agent-memory/namespaces/support-agent/profiles/customer-42/remember | 200 | 73 | 295 |
| 2 | POST | 00000000000000000000000000000042/agent-memory/namespaces/support-agent/profiles/customer-42/recall | 200 | 50 | 249 |
| 3 | GET | 00000000000000000000000000000042/agent-memory/namespaces/support-agent/profiles/customer-42/memories/* | 200 | 0 | 295 |
Requests that reached the API
| method | path the API received (after /client/v4/accounts/) | key | status |
|---|---|---|---|
| POST | 00000000000000000000000000000042/agent-memory/namespaces/support-agent/profiles/customer-42/remember | the broker's token | 200 |
| POST | 00000000000000000000000000000042/agent-memory/namespaces/support-agent/profiles/customer-42/recall | the broker's token | 200 |
| GET | 00000000000000000000000000000042/agent-memory/namespaces/support-agent/profiles/customer-42/memories/01JFAKEMEM0000000000000001 | the broker's token | 200 |
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
| # | method | route template (after /client/v4/accounts/) | status | bytes sent | bytes back |
|---|---|---|---|---|---|
| 1 | POST | 00000000000000000000000000000042/agent-memory/namespaces/support-agent/profiles/customer-42/recall | 200 | 45 | 250 |
Requests that reached the API
| method | path the API received (after /client/v4/accounts/) | key | status |
|---|---|---|---|
| POST | 00000000000000000000000000000042/agent-memory/namespaces/support-agent/profiles/customer-42/recall | the broker's token | 200 |
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:
| value | in the guest's output | at the API | in the trace |
|---|---|---|---|
| the key | no | on every request | no |
| the memory's content, “The customer wants replies in Spanish.” | yes | stored | no: 73 bytes sent |
| the memory's ID | yes | in the path of the third request | no: memories/* |
the other customer, customer-43 | yes, in the refusal | never reached it | no: 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.
| method | path (after /client/v4/accounts/) | key | status |
|---|---|---|---|
| POST | 00000000000000000000000000000042/agent-memory/namespaces/support-agent/profiles/customer-42/remember | the broker's token | 200 |
| POST | 00000000000000000000000000000042/agent-memory/namespaces/support-agent/profiles/customer-42/recall | the broker's token | 200 |
| GET | 00000000000000000000000000000042/agent-memory/namespaces/support-agent/profiles/customer-42/memories/01JFAKEMEM0000000000000001 | the broker's token | 200 |
| POST | 00000000000000000000000000000042/agent-memory/namespaces/support-agent/profiles/customer-42/recall | the broker's token | 200 |
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:
| service | document | through a grant |
|---|---|---|
| Cloudflare Agent Memory | published in cloudflare/api-schemas, BSD 3-Clause | used here: a bearer key, and every path is one a grant can hold |
| Mem0 Platform | published at docs.mem0.ai/openapi.json (EXTERNAL · official docs ↗), Apache 2.0; the service has a free plan | not 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 server | none published: the running server generates its own | its 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.