Cookbook - Multi-Tenant Server Apps
This is the pattern for SaaS-style apps where one operator runs a server that stores memory on behalf of many end users. For example, a Next.js app on Vercel where users connect a Sui wallet only for sign-in, and the server holds the credentials.
How does the multi-tenant pattern work?
| Layer | What it holds | Why |
|---|---|---|
| Operator | One MemWalAccount + one delegate key | Created once on the dashboard. Owns all memory stored through this account. |
| Server (API routes) | MEMWAL_PRIVATE_KEY + MEMWAL_ACCOUNT_ID as env vars | The delegate key authenticates every relayer call. Never sent to the browser. |
| End user | A connected Sui wallet, used for auth only | The wallet proves identity. The user does not create a MemWalAccount. |
| Organization | One namespace per wallet, for example myapp-{walletAddress} | Groups each user's memories under the shared account. The server enforces who can access each group. |
This pattern uses the operator's account for every end user; signing in with a wallet does not create an account for that user or transfer memory ownership to them. See Ownership and access.
The managed relayer sponsors writes; see Managed relayer costs. The managed service applies its usage limits to this pattern like any other.
Source: sdk/cookbook-multi-tenant.md ┌─────────────────────────────────────┐
End user wallet │ Server (Next.js API route, Vercel) │ Relayer + Walrus
(auth only) │ │
│ sign-in │ MemWal.create({ │
├─────────▶│ key: MEMWAL_PRIVATE_KEY, │
│ │ accountId: MEMWAL_ACCOUNT_ID, │ one MemWalAccount
│ │ }) │ ├─ ns: myapp-0xalice…
│ request │ ns = `myapp-${address.toLowerCase()}`│──▶├─ ns: myapp-0xbob…
├─────────▶│ memwal.remember(text, ns) │ └─ ns: myapp-0xcarol…
│ │ memwal.recall({ query, namespace }) │
└──────────┴──────────────────────────────────────┘
1. Set up the delegate key (not the owner key)
Create the account and a delegate key once, on the Walrus Memory dashboard. The dashboard gives you:
- A MemWalAccount object ID (
0x…) → yourMEMWAL_ACCOUNT_ID - A delegate private key (hex) → your
MEMWAL_PRIVATE_KEY
Use a delegate key, never the owner wallet key, in the application server. The owner manages the account and its delegates; a delegate authenticates memory operations across the account's namespaces. Revocation does not cover already-read data; see Ownership and access and Delegate key management.
Store both as server-side environment variables:
Source: sdk/cookbook-multi-tenant.mdMEMWAL_PRIVATE_KEY=<delegate-private-key-hex>
MEMWAL_ACCOUNT_ID=0x<memwal-account-object-id>
MEMWAL_REGISTRY_ID=0x<account-registry-object-id>
The removeDelegateKey call in the revoke flow reads MEMWAL_REGISTRY_ID. The getMemWal() client in step 3 passes only key, accountId, and serverUrl to MemWal.create().
How to revoke a compromised delegate key
If a delegate key is compromised, revoke it from the dashboard, or programmatically with the owner credentials:
Source: sdk/cookbook-multi-tenant.mdimport { removeDelegateKey } from "@mysten-incubation/memwal/account";
await removeDelegateKey({
packageId: "0x<contract-package-id>",
registryId: process.env.MEMWAL_REGISTRY_ID!,
accountId: process.env.MEMWAL_ACCOUNT_ID!,
publicKey: "<delegate-public-key-hex>", // the leaked key's public key
suiPrivateKey: "suiprivkey1...", // owner key: kept offline, NOT on the server
suiNetwork: "mainnet", // use testnet for a testnet account
});
Then generate a fresh delegate key, register it with addDelegateKey, and rotate MEMWAL_PRIVATE_KEY. See the full lifecycle in Delegate Key Management.
2. How to name namespaces for multi-user apps
Derive a deterministic namespace from the authenticated wallet address. Normalize it so the same wallet always maps to the same namespace:
Source: sdk/cookbook-multi-tenant.md// lib/memory/namespace.ts
export function userNamespace(walletAddress: string): string {
return `myapp-${walletAddress.toLowerCase()}`;
}
Guidelines:
- Prefix with your app name (
myapp-…) so multiple apps can share one account without colliding. - Lowercase the address. Namespaces are matched exactly, with no prefix or hierarchy, so
0xAB…and0xab…are different buckets. - Keep it stable. Never include a timestamp or session ID, or you lose the user's history on the next request.
A namespace serves as a data-organization boundary, not a security boundary between users. The delegate key can read and write every namespace under the account. Cross-user isolation depends entirely on your server mapping each authenticated wallet to the correct namespace. Verify the wallet signature before choosing the namespace. Never take the namespace (or raw address) from an unauthenticated request body.
3. How to use the client in Next.js and serverless apps
Create one client per request (or memoize per warm Lambda) and pass the per-user namespace into each call.
Source: sdk/cookbook-multi-tenant.md// lib/memory/client.ts
import { MemWal } from "@mysten-incubation/memwal";
export function getMemWal() {
return MemWal.create({
key: process.env.MEMWAL_PRIVATE_KEY!,
accountId: process.env.MEMWAL_ACCOUNT_ID!,
serverUrl: "https://relayer.memory.walrus.xyz",
});
}
// app/api/chat/route.ts (Next.js App Router)
import { getMemWal } from "@/lib/memory/client";
import { userNamespace } from "@/lib/memory/namespace";
import { verifyWalletAuth } from "@/lib/auth"; // your wallet-signature check
export async function POST(req: Request) {
// 1. Authenticate the user from a signed message, NOT from a raw address field.
const session = await verifyWalletAuth(req);
if (!session) return new Response("Unauthorized", { status: 401 });
const ns = userNamespace(session.walletAddress);
const { message } = await req.json();
const memwal = getMemWal();
// 2. Load relevant memory for this user.
const recalled = await memwal.recall({
query: message,
namespace: ns,
limit: 5,
});
// 3. Generate a reply with your model, injecting recalled.results as context…
const reply = await generateReply(message, recalled.results);
// 4. Submit new memory and return after the relayer accepts the job.
// Capture the job ID for a background worker to track completion.
// Use rememberAndWait only when the caller needs completion before proceeding.
const accepted = await memwal.remember(`User said: ${message}`, ns);
return Response.json({ reply, recalled: recalled.results, memoryJobId: accepted.job_id });
}
remember waits for acceptance, not for the upload and indexing to finish. Persist the returned job ID if completion must survive a process restart. For retries, stable operation keys, and checking completion outside the chat turn, see Production readiness.
Structured agent state (a single profile JSON): semantic recall is built for fuzzy retrieval by meaning, so it is not a reliable way to fetch one authoritative "current profile" record. If you need deterministic key-based reads/upserts, follow issue #247. For now, prefer a dedicated namespace per structured record and treat free-form semantic lines separately.
Keep the delegate key server-side only
MEMWAL_PRIVATE_KEY must live in server env vars. Never ship it to the browser, a client component, or NEXT_PUBLIC_*. Any code path that reaches the client must not import the memory client.
Use the wallet for identity, not authorization
The user's wallet signature proves who they are. It does not grant memory access; your delegate key does. Verify the signature server-side and derive the namespace from the verified address.
Never trust a client-supplied namespace
Compute the namespace on the server from the authenticated address. If a client could send its own namespace, any user could read another user's bucket.
Keep the owner key offline
The owner wallet (used for createAccount / addDelegateKey / removeDelegateKey) should never run on the server. Run those operations from a local machine or a secure admin tool.
4. How to prove onchain storage for demos and submissions
Your MemWalAccount is a regular Sui object. Share its object ID and view it on a Sui explorer (for example, Suiscan or SuiVision) to prove memory is really onchain, not just in a local cache. Pair it with a live health() check (see the API Reference) so judges can confirm the relayer is reachable.
See also
- Quick Start: install and store your first memory
- Delegate Key Management: full key lifecycle
- Ownership and Access: the trust model behind accounts and delegates
- API Reference: full method signatures
Which key formats do the revoke call and the client take?
The revoke call and the memory client take 2 different keys:
suiPrivateKeyinremoveDelegateKey: The owner's Sui signer key insuiprivkey1...format. You can pass awalletSignerinstead, so the owner key never has to exist as a string.keyinMemWal.create(): The delegate private key in hex, which you store inMEMWAL_PRIVATE_KEY.
Never put the owner key in MEMWAL_PRIVATE_KEY.
How do you implement verifyWalletAuth?
The Walrus Memory example apps implement this check with a server-issued challenge:
- The server issues a single-use challenge message for the wallet address.
- The user signs the message with the wallet and returns the challenge ID and the signature.
- The server verifies the signature against the challenge and that address with
verifyPersonalMessageSignaturefrom@mysten/sui/verify. - The server consumes the challenge before it creates a session, so nobody can replay a captured signature.
Return the verified address as session.walletAddress and derive the namespace only from that value.