Put, get and list files in an NMTS account from your own program — end-to-end encrypted storage on the Walrus network, with the keys on your machine.
If you are an AI agent, read AGENTS.md instead. It has the cost of each method and the refusal codes.
Status: early. The interface may still change before 1.0. A method added after this file was written is in
dist/index.d.ts.
npm install @needmoretruth/nmts-sdkimport { Nmts } from "@needmoretruth/nmts-sdk";
const nmts = Nmts.device({ accountCode: process.env.NMTS_ACCOUNT_CODE, apiKey: process.env.NMTS_API_KEY });
await nmts.put("./report.pdf"); // spends credits — see "What put() costs"
const bytes = await nmts.get("report.pdf"); // a Uint8Array, checked before it is handed over
console.log(await nmts.list()); // [{ path: "report.pdf", kind: "file", size: 1234, ... }]Node 22 or newer. Nothing is compiled at install time: the encryption engine is a WebAssembly module carried by the package this one is built on.
Two things have to exist before the first call, and both are made once, by a person, at nmts.me: an account (its NMTS key is printed once and never again) and an API key for it (on the account screen). Nothing here can make either — see What only a person can do.
Storage where the encryption happens in your process and the keys never leave it. The server
receives sealed bytes it cannot open. File contents, names and folders all live inside a sealed
list that only the NMTS key opens — which is why list() needs it and not just the API key.
The bytes live on Walrus, a public storage network, paid for on the Sui chain.
- Storage is bought for a period, not forever. A file has a lease. It can be extended, and NMTS warns before one runs out.
- There is no password reset. The NMTS key is the account. It cannot be recovered or changed while keeping the files.
- NMTS charges nothing. Storage is bought from the Walrus network; nothing is paid to NMTS.
An upload through this package spends credits — storage a donation pool has already paid the
network for, which are not sold and cannot be bought, resold or transferred — or WAL and SUI from
a wallet: the account's own (
pay: "wallet") or one you hold the key to (pay: { signer }).
NMTS is built and run by one developer. This package, the command-line tool it is built on, the encryption engine and the recovery program are open source under Apache-2.0; the server and the web app are not published.
An account is its NMTS key (the accountCode in this library's calls): the file keys, the wallet and the public code are all derived
from it. Where you make the client, you choose which process holds that key.
| The NMTS key is held by | The client you make | Who can read the files |
|---|---|---|
| the person, on their own machine | Nmts.device({ accountCode, apiKey }) |
only them — not you, not NMTS |
| your service, sealed in a store of your own | Nmts.managed({ openCode, apiKey }) |
your service, and whoever it lets in — not NMTS |
| the person, in a page of yours | Nmts.device({ accountCode, apiKey }) from @needmoretruth/nmts-sdk/browser |
only them — but the page's code is yours, so they are as safe as their trust in you |
Every method works the same on a device client and a managed client. A managed client calls openCode() once per call on the account and keeps nothing between
calls, so your store stays the one place the NMTS key rests; how you seal it — a cloud key service, a
master key, a hardware module — is yours to decide and nothing here reaches into it.
const nmts = Nmts.managed({ openCode: () => myVault.open(customerId), apiKey: process.env.NMTS_API_KEY });The browser row is the same client as the first: the NMTS key is typed into your page and stays in the tab's memory, and NMTS never sees it. The difference from the managed row is where the key passes through, not whether you could read the files — a page you serve can do what its code says. Tell your users that your page's code can read their files.
Which row a product is on is that product's own statement. NMTS does not inspect or certify what is built with this library, and the server cannot tell the rows apart: the NMTS key never reaches it. The library comes as the licence says, without warranty.
Runnable files in examples/, one per shape:
| Shape | File | What it shows |
|---|---|---|
| Node, device | quickstart.mjs |
put, get and list from a shell with the two credentials in the environment |
| Node, device, wallet-paid | pay-from-wallet.mjs |
the price with dryRun, then an upload paid in WAL and SUI from the account's own wallet, no credits |
| Browser, device | next-embedded/UploadButton.jsx |
a Next.js client component: a file picker, put({ name, blob }), the key never leaves the page |
| Node, managed | node-managed/server.mjs |
a service holding its customers' keys in a store of its own, openCode once per call |
| Node, S3 gateway | gateway/server.mjs |
a bucket and a key pair per customer, so a Django or Rails storage adapter, rclone or a backup tool stores into that customer's account |
Products one person or a small team could build on this, each with the calls it uses and what is not in the library yet: IDEAS.md.
| What it does | Where it comes from | |
|---|---|---|
| NMTS key | Opens the files. Derives the wallet. Never leaves the process that holds it. | Printed once when the account is made |
| API key | Makes the server answer. Opens nothing. Can be revoked; expires on its own. | The account screen at nmts.me |
The API key is the cheap, revocable thing you hand to a program; the NMTS key is the account. Keep them apart: a leaked API key is revoked in one click and opens no file, a leaked NMTS key is the account, for good.
The NMTS key can also be written as a 15-word recovery phrase, from the BIP-39 English or Korean
word list. It is the same key in another form: every call that takes an NMTS key takes the phrase,
and phraseOf(accountCode, "en" | "ko") returns the words (new in 0.11.0). The phrase opens the
account exactly as the key does, so it is kept the same way.
A device client whose two credentials come from the same variables the command-line tool reads, in the same order:
| Variable | Holds | |
|---|---|---|
NMTS_ACCOUNT_CODE_FILE |
a path to a file holding the NMTS key | preferred |
NMTS_ACCOUNT_CODE |
the NMTS key itself | |
NMTS_API_KEY_FILE |
a path to a file holding the key | preferred |
NMTS_API_KEY |
the key itself | |
NMTS_SERVER, NMTS_NETWORK |
another server; mainnet or testnet |
read by every call |
A variable holding a path shows anyone who can read the environment a filename; a variable
holding the value shows them the value (docker inspect prints the whole environment, and so
do most CI logs). That is why the file form is preferred and why the command-line tool stops once
for an agreement before reading the NMTS key from NMTS_ACCOUNT_CODE. This package does not stop —
a library has nobody to ask — so calling fromEnv() is that agreement.
const nmts = Nmts.fromEnv();put() is the one method that spends.
Credits, by default — one credit per started MiB of sealed bytes, for the storage period the account buys uploads for. Credits do not come back.
The account's own wallet, with pay: "wallet" — WAL buys the storage on the Walrus network and
SUI pays the relay's tip and the chain fees, out of the wallet this account pays from.
walletAddress() is where to send coins to fund it, and wallet: n pays from another of this key's
wallets for one upload. No credits are touched, and the term is yours:
epochs buys that many of the storage network's epochs (two by default), and
storage: "fit" | "whole" | "<object id>" uses a storage resource the wallet already holds instead
of buying new storage. Nothing is paid to NMTS on either rail.
A wallet you hold the key to, with pay: { signer } (new in 0.10.0) — the same WAL and SUI, out
of a wallet this package holds no key to: your user's browser extension, a hardware wallet, a keypair
your service keeps. signer is an address and a function that signs transaction bytes; epochs and
storage work exactly as above, and wallet: n is refused, because that names a wallet of the NMTS
key and a call has one payer.
Three things come with it, and they are why it is not the default:
- Every transaction asks that wallet to sign. Nothing is approved silently or automatically, and nothing can be batched: one part of a file is two signatures — registering the blob, then certifying it — so a four-part file asks eight times. The parts are not uploaded in parallel either; the wallet is asked one transaction at a time, in order. The account's own wallet signs without asking anybody, which is why large files are faster on that rail.
- A file paid for this way is not found from the NMTS key alone. The storage belongs to the signer's address, so the recovery program needs that address as well as the key to list what was stored. Storage the account's own wallet paid for is derived from the key itself.
- What that wallet paid for, that wallet extends and reshapes.
extend()has to be signed by the address that bought the storage — the chain lets nobody else touch it — so pass it the samepay: { signer }, and the same forstorage,splitStorage,mergeStorageandtransferStorage.erase({ releaseStorage: true })destroys only storage credits bought: storage a wallet bought is never touched by it and stays on Walrus until its term ends, whichever wallet paid.
In a page, the wallet the person already uses pays — with @mysten/dapp-kit, the wiring is the
signer object:
import { useCurrentAccount, useSignTransaction } from "@mysten/dapp-kit";
import { toBase64 } from "@mysten/sui/utils";
import { Nmts } from "@needmoretruth/nmts-sdk/browser";
function Upload({ accountCode, apiKey }) {
const account = useCurrentAccount();
const { mutateAsync: sign } = useSignTransaction();
async function upload(file) {
if (!account) return; // nothing is connected: nothing can pay
const signer = {
address: account.address,
// `sign` answers { bytes, signature }, which is what this package takes.
signTransaction: ({ bytes, chain }) => sign({ transaction: toBase64(bytes), chain }),
};
const nmts = Nmts.device({ accountCode, apiKey });
const price = await nmts.put({ name: file.name, blob: file }, { pay: { signer }, dryRun: true });
console.log(price.wal, price.sui, price.parts); // what the wallet will be asked for, and how often
await nmts.put({ name: file.name, blob: file }, { pay: { signer } });
}
return <input type="file" onChange={(e) => e.target.files?.[0] && upload(e.target.files[0])} />;
}On Node, anything that signs fits the same object — a @mysten/sui keypair already answers
{ bytes, signature }:
import { Ed25519Keypair } from "@mysten/sui/keypairs/ed25519";
import { Nmts } from "@needmoretruth/nmts-sdk";
const keypair = Ed25519Keypair.fromSecretKey(process.env.SUI_PRIVATE_KEY);
const signer = {
address: keypair.toSuiAddress(),
signTransaction: ({ bytes }) => keypair.signTransaction(bytes),
};
const nmts = Nmts.fromEnv();
await nmts.put("/tmp/report.pdf", { pay: { signer }, epochs: 4 });A wallet that declines — somebody pressed cancel, it is on the other network, it cannot sign this
shape — comes back as a refusal whose message starts with WALLET_REFUSED, before anything is
submitted. This package submits the signed transaction itself and reads the chain's effects: a digest
is not a success.
There is no confirmation step on any of the three: calling put() is the agreement. dryRun: true
answers with the price and spends nothing — on a wallet rail it also reports the address, what the
wallet holds, and a shortfall sentence when that is not enough.
A wallet-paid put() reads the price, the chain fee and both balances of the wallet that will
sign — whichever rail it is — before the first signature, and a wallet that is short is refused
there, with both numbers, having signed nothing.
An upload that is interrupted after the storage was bought is finished by the next put() of the
same file to the same place, without spending again: what was bought is written down on this
machine before the money moves (in the same config directory the command-line tool uses).
put(file, { tier: "heavy" }) keeps each part of the file whole on Filecoin, with separate storage
companies each holding a copy, instead of spreading it across Walrus storage nodes. It is sealed
exactly as a Standard upload is, and get(), list() and deletion work the same for both tiers.
await nmts.put("./archive.tar", { tier: "heavy" }); // credits
await nmts.put("./archive.tar", { tier: "heavy", pay: "evm", copies: 3 }); // your own Filecoin depositpay says who pays:
- Credits (the default): each part costs half the Standard credits, rounded up and at least one, and the file is kept 28 days.
"wallet": WAL from one of this key's Sui wallets, fordays(1 to 365, default 28), paid once every part is stored."evm": the EVM wallet this NMTS key derives pays Filecoin directly from its Filecoin Pay deposit, as the Synapse SDK would;evmWalletpicks another of its wallets.copies(1 to 12, default 2) andproviderschoose where.{ signer }: the same as"evm", from an EVM account you hold.
Credits and "wallet" go through NMTS's Filecoin treasury, and a server that has not switched Heavy
on refuses them with heavy_unavailable before anything is charged. nmts.me takes credits; its
"wallet" payment is off there and answers heavy_wallet_pay_off. "evm" and { signer } do not use the treasury. dryRun: true works out the cost and stops.
An option that belongs to the other tier or another payer is refused, not ignored.
The NMTS key derives a wallet at every number from 0 upwards, and each is a real wallet with an
address of its own. One of them pays: setActiveWallet(n) says which, and that number rides
inside the account's sealed file list, so the browser, the command-line tool and this package all
pay from the same address afterwards.
wallets() asks the chain which of them have been used — the wallets the account has made, plus any
further out holding coins or with a transaction behind them — and stops after twenty unused ones in a
row, where every other wallet's scan stops. A wallet funded past that is not lost: numbers come from
the key, so walletAddress({ index }) and put(…, { wallet }) reach any of them directly. Nothing
creates or deletes a wallet, because every wallet a key can derive already exists.
Nmts.device({ accountCode, apiKey, ...options }) // the NMTS key is in this process
Nmts.managed({ openCode, apiKey, ...options }) // the NMTS key is in your store
Nmts.fromEnv(options) // device, from the environment — Node only
new Nmts(root, options) // a root you built yourself
// options: { server?, network?, aggregators?, relay?, suiRpc?, fetch?, onProgress?, wasmUrl? }
// — where each one points and what `fetch` is for: "Where it connects" below
await nmts.account() // { accountId, server, network } — offline
await nmts.walletAddress() // the Sui address of the wallet this account pays from
await nmts.walletAddress({ index: 2 }) // the address of a wallet you name — offline
await nmts.wallets() // WalletInfo[]: { index, address, active } — asks the chain
await nmts.setActiveWallet(2) // which of this key's wallets pays, from now on, on this account
await nmts.publicCodes.list({ activity: true }) // every public code: live, revoked, what went through it
await nmts.publicCodes.create({ replace: 0 }) // publish the next code; `replace` revokes one in the same request
await nmts.publicCodes.revoke(1) // for good: nobody can send to it again
await nmts.publicCodes.identityFor(2) // { index, code, address } — offline
await nmts.makeLink("photos/a.jpg", { expiresDays: 30 }) // { link, id, createdAt, expiresAt } — anyone with it opens the file
await nmts.listLinks("photos/a.jpg") // every link to that file, cut ones included, with download counts
await nmts.revokeLink(id) // cut one; copies already downloaded stay with whoever has them
await nmts.allLinks() // every live link across the account's files, each with its file's path
await nmts.revokeAllLinks() // cut every live link in one request; answers how many
await Nmts.openLink(link) // { name, bytes } — no account; checked against the owner's hash
await nmts.list() // Entry[]: { id, path, kind, size, createdAt, updatedAt }, trash left out
await nmts.list({ trash: true }) // the same, with what is in the trash; those entries carry trashedAt
await nmts.list({ media: "video" }) // one kind only: "image" | "video" | "audio"
await nmts.mkdir("photos/2026") // { path, created } — makes missing folders above it too
await nmts.move(["a.pdf", "b.pdf"], "archive") // { moved: [{ from, to }] } — "/" is the top
await nmts.rename("archive/a.pdf", "first.pdf") // { from, to }
await nmts.remove("archive/b.pdf") // { removed } — to the trash, restorable for 30 days
await nmts.restore("archive/b.pdf") // { restored }
await nmts.erase("old.pdf", { confirm: ERASE_CONFIRM }) // { erased, storage } — for good; nothing undoes it
await nmts.put(file, { name?, to?, partSize?, pay?, wallet?, epochs?, storage?, thumbnail?, dryRun?, onStep?, onProgress? })
await nmts.get(path, { maxBytes?, thumbnail? }) // Uint8Array; 256 MiB ceiling unless raised
await nmts.getTo(path, destination, { force? }) // streams to disk, no ceiling — Node only
await nmts.storage({ pay? }) // [{ id, sizeBytes, startEpoch, endEpoch, status }] — asks the chain
await nmts.extend(path, { epochs?, dryRun?, force?, pay? }) // more storage time for one file — signs and spends WAL
await nmts.splitStorage(id, { sizeBytes, dryRun?, pay? }) // one storage resource into two
await nmts.mergeStorage(idA, idB, { dryRun?, pay? }) // two into one
await nmts.transferStorage(id, toAddress, { dryRun?, pay? }) // to another wallet — cannot be undone
blobSource(blob, name) // a Blob as an upload's bytes, for a file picker, a drag or a fetch-
Storage control:
storage,extend,splitStorage,mergeStorage,transferStorage. Storage on Walrus is an object a wallet holds, with a size and a period.storage()lists the ones the account's own wallet holds that are not bound inside a file, and the other four sign with that wallet —pay: { signer }points all five at a wallet you hold instead, which is the wallet that can act on what it paid for. WithdryRun: trueeach returns a review and signs nothing:{ wal, sui, wallet: { address, wal, sui }, shortfall }in the unitsput()uses, plus what the act would produce.suiisnullwhen the fee could not be measured, never 0. WithoutdryRunthe call is the agreement, as withput(), and it returns the transaction'sdigest.extend()spends WAL; splitting, merging and transferring pay the chain fee only, so their reviews leave the balances andshortfallnull.extend()refuses a file that is nowhere near its end unlessforce: true. If the purchase succeeds and the server then fails to record the new end, it throwsEXTEND_RECORDED_LATE: do not call it again, the storage is already bought. With a delegation tokenextend()needsstorage_spend, and the client reads the token's scope before anything is signed.transferStorage()cannot be undone and moves no file: what goes is size and remaining time. -
mkdir,move,rename,removeandrestorespend nothing. With a delegation token they need thefiles_writescope.move,removeandrestoretake one path or an array. -
A name already in use refuses a move, a rename or a restore with
code: "NAME_TAKEN"; nothing is numbered and nothing is replaced. The other codes areNOT_FOUND(nothing at that path, or the path names two things),BAD_NAME(empty, or a/in a name),NOT_IN_TRASHandINTO_ITSELF(a folder moved inside itself). -
remove()is the trash, not erasure. A folder takes everything under it, and a removed file keeps its storage. -
erase(paths, { confirm: ERASE_CONFIRM })erases files for good, and nothing undoes it: the server's record of each file, this account's key to it, and its entry in the file list. A folder erases every file under it.confirmhas to be the exported sentenceERASE_CONFIRM("I UNDERSTAND THIS IS PERMANENT") word for word — there is notruethat stands for it, so the code that erases somebody's files says so where it is written. Without it nothing is sent and the call refuses withERASE_NOT_CONFIRMED. WithreleaseStorage: truethe storage under files paid for with credits is destroyed first, andstoragereports each file; a file whose release failed is left whole. Storage bought from the wallet stays on Walrus until its term ends. It returns{ erased, storage }. -
put()takes a path ("./report.pdf", Node only), bytes ({ name, bytes }), or aBlob({ name, blob }). A bareUint8Arrayworks too, withnamein the options. -
In a browser, import
@needmoretruth/nmts-sdk/browser. It exports the same names and the same class; what it leaves out is the three things a page has no files for — a path input(),getTo()andNmts.fromEnv()— and each of those refuses by name rather than failing deeper. The account's key stays in the page, the sealed file list is kept in IndexedDB, andwasmUrlsays where the engine's WebAssembly is when a bundler has moved it. -
Paths are as
list()prints them:photos/2026/cat.jpg.to: "photos/2026"puts a file in that folder, which must already exist (mkdir()makes it). -
A name already in use is numbered —
report (2).pdf— rather than replacing what is there.onCollision: "overwrite"stores the new file under the name and sends the old one to the trash, restorable for 30 days;onCollision: "rename"numbers it. Either applies to that one call. Without it, the command-line tool'snmts on-collisionsetting on this machine decides, and it renames unless someone set it to overwrite. -
What
put()answers sayspaid: "credits" | "wallet". A credit-paid upload reportscredits; a wallet-paid one reportscredits: 0withwalandsui— the chains' smallest units, FROST and MIST, as decimal strings — and theendEpochits storage runs to.dryRun: trueanswers the same shape withdryRun: trueand noid. -
A video's preview picture.
put(video, { thumbnail })takes the picture as bytes or aBloband uploads it beside the video as an ordinary small file linked to it, priced and paid like any other; it is refused for a file that is not a video, before the account is read.get(video, { thumbnail: true })returns the picture instead of the video.list()shows a picture on its own only when its video is gone, and moving a video to the trash, restoring it or erasing it takes its picture with it. -
get()refuses rather than returns a half-right file. A wrong key, a part that will not open, a whole-file hash that does not match — none of them produce bytes.getTo()writes under a temporary name and renames only after the whole file is checked. -
networkmust be stated for any server but the public one. The wrong network does not error; it finds nothing.mainnetandtestnetare different places.
Every failure is an NmtsError with a nextStep sentence and an exitCode that means the same
as the command-line tool's; a refusal from the server is a ServerError carrying the server's own
code. A refusal is not a transient error and must not be retried in a loop.
An upload that fails is an UploadError, and paid says whether money already moved. When the
server refused it, code, status and retryAfter (seconds, or null) are the server's; a wallet
known to be short before anything was signed is code: "WALLET_SHORT". Branch on code, not on
the sentence.
const nmts = await Nmts.fromWallet({ sign, address, apiKey }); // or `delegation` in place of apiKeyOpens an account with a Sui wallet instead of a typed NMTS key. The wallet signs one fixed message; that signature finds and opens a copy of the account's NMTS key that the NMTS server keeps locked. NMTS cannot open the copy and does not learn which wallet it belongs to. What comes back is an ordinary device client: every method works.
sign(messageBytes) answers what the wallet standard answers — { signature, bytes }, or the
serialized signature alone. In a page that is dapp-kit's useSignPersonalMessage(). account
(default 1) picks which of that wallet's accounts, and app makes a key for your product only; both
are inside the signed message. Without app, one wallet opens the same account in every product
that asks — say which you chose to your users.
With a delegation token, delegation may also be a function. A token is signed for one account id,
and a device that has never seen the account cannot know that id until the wallet has opened it; the
function receives the opened account's public id and answers the token:
const nmts = await Nmts.fromWallet({
sign, address, app: "your-product",
delegation: (user) => fetch(`/nmts-token?user=${user}`).then((r) => r.text()),
});| Call | What it does |
|---|---|
nmts.openers.list() |
{ locator, kind, createdAt }[] — the wallets that open this account |
nmts.openers.addWallet({ sign, address, account?, app? }) |
attaches a wallet. It is asked to sign twice, and the call refuses with WALLET_NOT_REPEATABLE unless the two signatures are the same bytes |
nmts.openers.remove(locator) |
that wallet stops opening the account from then on. A wallet that opened it before has held the NMTS key |
nmts.openers.exportSlot(locator) |
the 62 locked bytes, as a Uint8Array — the wallet recovery file the NMTS recovery program opens with a signature, with no server |
A new account is made as before (Nmts.newAccountCode(), then registerWithDelegation), and the
wallet is attached with addWallet. A wallet that signs the same message differently each time is
refused by name: WALLET_ZKLOGIN, WALLET_MULTISIG, WALLET_PASSKEY, WALLET_UNKNOWN_SCHEME. A
signature over other bytes than the ones this library built is refused with
WALLET_SIGNED_OTHER_BYTES. On a managed client openers refuses with NOT_FOR_MANAGED_ROOT: your
own store is what opens that account. An account can have up to eight openers (OPENER_CAP), and a
wallet that already opens this account or another one at the same account number is refused with
WALLET_ALREADY_ATTACHED or WALLET_OPENS_ANOTHER_ACCOUNT.
The signature opens the account. This library hands it to the encryption engine and keeps no copy;
do the same in your sign.
| Step | Who | Where | How often |
|---|---|---|---|
| Make the account | a person | nmts.me | once |
| Make an API key | a person | the account screen at nmts.me | once, and again if it is revoked |
| Pass the check that says a person is here | a person | nmts.me, one short code | every twelve weeks, and only for making further accounts, credits and sharing |
| Get credits into the account | a person | nmts.me — the free trial | once, then as they run out |
A business registers once, in a browser: Settings › Developer › Platform at nmts.me, with the
public half of a key pair made by nmts platform keygen. After that its server opens NMTS accounts
for the users of its own product — they never visit nmts.me — up to a daily limit that info()
reports. Terms 3.7 applies: before you open an account for a person you
show them the NMTS Privacy Policy and obtain the consent its section 5.6 describes, and your
product is not directed to children.
import { Nmts } from "@needmoretruth/nmts-sdk";
// On your server. The private key signs every request and never leaves this process.
const business = Nmts.business({ accountId, privateKey });
// Managed — you hold the user's NMTS key. It comes back once; seal it in your own store.
const { accountId: user, accountCode } = await business.registerUser();
// Embedded — the user's device holds the key. The device makes it and tells you only its id:
// const accountCode = await Nmts.newAccountCode();
// const user = await Nmts.accountIdOf(accountCode);
const token = await business.delegate({
user,
scope: ["register", "files_read", "files_write"],
ttlSecs: 3600,
});
// ...and the device opens its own account and works with the token in place of an API key:
// await Nmts.registerWithDelegation({ accountCode, delegation: token });
// const nmts = Nmts.device({ accountCode, delegation: token });| Call | Where it runs | What it does |
|---|---|---|
Nmts.business({ accountId, privateKey }) |
your server — it refuses in a page (BUSINESS_IN_A_PAGE) |
the client below; nothing is sent until a method is called |
business.info() |
your server | the registered public key, the name, accounts opened today, the daily limit, and keyChangedAt — when the key was last replaced, or null |
business.registerUser() |
your server | opens an account and returns its new NMTS key, once |
business.registerUser({ accountCode }) |
your server | opens the account of a code you already hold |
business.users({ after?, limit? }) |
your server | one page of the accounts registered under your business — by your server or by a device holding its own key — { users: [{ accountId, createdAt, status }], next }; pass next as after until it is null. limit is 1 to 1000 |
business.usage() |
your server | { members, usersToday, usersDayCap, files, storedBytes, asOf } across the accounts registered under your business, whoever holds their keys |
business.delegate({ user, scope, ttlSecs }) |
your server | signs a delegation token for one of your users; nothing is sent. ttlSecs is at most 30 days |
business.rotateKey(newPrivateKey) |
your server | replaces the registered key; every token the old key signed stops working at once |
business.setPublicCodes([{ root, next? }]) |
wherever the users' roots are | replaces each user's one public code in a signed request per 100 users; each item carries that user's identity and sign-in secret, derived from their root |
Nmts.newAccountCode() · Nmts.accountIdOf(code) |
the device | a new NMTS key, and its public id; nothing is sent |
Nmts.registerWithDelegation({ accountCode, delegation }) |
the device | opens the device's own account with a token that carries register |
Scopes are files_read, files_write, storage_spend, register and files_erase. erase() needs
files_erase; files_write alone is refused. The server also asks for the NMTS key's own proof on
that request, which the client makes from the key it holds — so erasing through a token takes the
business's permission and the key holder's act together. A delegation token takes the
place of apiKey in Nmts.device() and Nmts.managed(), and every method works with it. It
cannot delete the account, make API keys, or reach the key that opens the files; the server refuses
those with DELEGATION_SCOPE.
In Nmts.device() and Nmts.managed(), delegation may also be a function that returns the
current token, directly or as a promise — delegation: () => tokens.currentFor(user). It is called
before every request to the NMTS server, retries included, so a client you keep for one user keeps
working after a token runs out, also in the middle of an upload whose storage is already paid for.
The client keeps no copy of what it returns; if minting a token costs a round trip, return the one
you hold until it is near its end.
@needmoretruth/nmts-sdk/gateway is the S3 server behind nmts s3, as a function your own server
calls. A storage adapter in Django, Rails or Laravel, a backup tool or an image pipeline that speaks
S3 then reads and writes NMTS accounts with its usual endpoint setting. Node only.
import { createS3Gateway, GATEWAY_SERVER_OPTIONS } from "@needmoretruth/nmts-sdk/gateway";
const gateway = createS3Gateway({
credentials: [{ accessKeyId, secretAccessKey, buckets: ["acme-user-17"] }],
bucket: async (name) => clientFor(name), // an Nmts client on any root, or null
write: true,
});
await gateway.listen(9000); // 127.0.0.1 unless you pass a host
// or in a server of your own: https.createServer({ ...GATEWAY_SERVER_OPTIONS, ...tls }, gateway.handler)- A bucket is an account.
bucket(name)answers theNmtsclient for that name, ornullforNoSuchBucket. The answer is remembered for a minute (anullfor five seconds), for at most 256 names. Callgateway.forget(name)when you give a bucket to another user or take it away: the next request asks again. An upload in pieces stays with the account it began under and is refused once the bucket names another. bucketson a key pair is the wall between your users. A pair held to named buckets getsAccessDeniedfor any other name, with the same answer whether or not that bucket exists. There are 1 to 16 pairs;accessKeyIdis 16 to 128 characters andsecretAccessKeyat least 32, and a weaker pair throwsGATEWAY_CREDENTIALSwhen the gateway is made.writeis off unless you turn it on, and then an upload spends whatput()spends. A delete isremove(): the trash, restorable for 30 days. A key that already holds a different file is refused with409, unlessoverwrite: "replace": then the new file is stored and the old one goes to the trash, which is what S3 clients expect a PUT to do. The same file again is a200that sends and spends nothing, either way.If-None-Match: *andIf-Matchare honoured. A key ending in/with no bytes makes that folder; an empty object is stored like any other file.putOptions(bucket, meta)decides who pays for each upload. Without it every upload spends the account's credits. It answers{ pay?, epochs?, storage? }, meaning what they mean toput();metais the client'sstorageClass(fromx-amz-storage-class, upper-cased) andcontentType, so a storage class can choose a term. Anything else it answers is ignored.bucketNames()is whatListBucketsanswers, narrowed to what the signing pair may touch. Without it, a pair held tobucketsis told those and an unrestricted pair is told none.virtualHostBase: "s3.example.com"serves virtual-hosted requests:acme.s3.example.com/a/b.txtis bucketacme, keya/b.txt. Other hosts stay path style,/acme/a/b.txt.- Between the S3 client and the gateway the files are not encrypted. That is what S3 clients
send. Keep the gateway on loopback or a private network, or mount
handlerbehind your own TLS. - Limits that protect your machine:
maxObjectBytes(none unless you set it;EntityTooLargeabove it, counted on the bytes that arrive),maxConcurrentWrites(16) andmaxUploadsPerBucket(1,000), each answeredSlowDownbeyond it. - A server of your own needs
GATEWAY_SERVER_OPTIONS, or Node ends every upload that takes more than five minutes to arrive; answer itscheckContinueevent withgateway.refusalBeforeBody(req)so a refused upload never sends its body.listendoes both. - An upload in pieces is kept in
stagingDiruntil it completes: by default a folder of the gateway's own under the system's temporary directory, readable by your user alone, whichclose()removes. In a folder you name, what this gateway left there and nothing touched for a day is removed when it starts and every hour.loggets one line per answered request — method, bucket, status and why it failed, never a file name. - ETags are not MD5s, including an upload in pieces (
"<32 hex>-1"). rclone withprovider = AWSchecks a multipart ETag against its parts and reports "Etag differ" on a file that stored correctly; useprovider = Other, oruse_multipart_etag = false.
| It talks to | For | Without an option | Option |
|---|---|---|---|
| The NMTS server | the account's records: the file list, credits, keys that wrap keys | https://nmts.me |
server |
| Walrus aggregators | reading stored bytes | the network's public aggregators | aggregators — a list |
| The Walrus upload relay | writing stored bytes | the network's relay | relay — one host |
| Sui nodes (JSON-RPC) | the chain: paying, extending, storage resources | public nodes, tried in order | suiRpc — one host or a list |
A list you give replaces the network's own: nothing you did not name is tried. relay and suiRpc
take effect on Node and in a page alike (before 0.9.0 they were read only in a page).
fetch is the function every one of those requests goes through, including the ones the Walrus and
Sui client libraries make. It is where a proxy goes. This package carries no proxy code of its own:
// npm install undici — an HTTP proxy, or Tor's own HTTP tunnel (`HTTPTunnelPort 9080` in torrc)
import { fetch as undiciFetch, ProxyAgent } from "undici";
const dispatcher = new ProxyAgent("http://127.0.0.1:9080");
const nmts = Nmts.device({
accountCode, apiKey,
fetch: (url, init) => undiciFetch(url, { ...init, dispatcher }),
});Checked on 2026-09-20 with Tor 0.4.9 and undici 8 on Node 24: with the runtime's own fetch
replaced by a function that throws, a wallet sign-in reached nmts.me through the tunnel and nothing
else was called. Whether the public Walrus relay and the public Sui nodes accept a Tor exit is theirs
to decide — name your own with relay and suiRpc if they refuse.
Two things to know before relying on it:
- These choices are one per process, not one per client. The last client that names an option sets it for every client in the process, so two clients cannot use two different proxies.
- In a page, two one-time downloads do not go through
fetch: the engine's WebAssembly file (wasmUrl) and the zstd encoder's. They are static files and carry nothing about an account. On Node neither is downloaded.
- A business may build its own product on NMTS. Terms 3.7 covers it: the business registers its account, opens accounts for the people who use its product, and answers to NMTS for what its product does in them. Read that section before you ship.
- Credits are not for resale. They cannot be bought, sold, transferred or exchanged for anything.
A product built on NMTS pays for its own storage with
pay: "wallet"— your coins, on the Sui chain, with NMTS never in the money path. - Nothing here puts coins into the wallet.
walletAddress()says where they go; sending them, and exchanging SUI for WAL, are the browser's and the command-line tool's. - A gift to the developer is never automated. Sending one is a person's act, every time.
- One account, one list. Everything in an account is one sealed list, and every edit rewrites it. The ceiling is 16 MiB sealed — about 60,000 files. Past that, use more accounts.
- Rate and spend ceilings exist on the server: one account may spend 4,096 credits (4 GiB) a day, and a person must pass the human check every twelve weeks for the things it gates.
- Sharing a file and the recovery list are in the
command-line tool as commands (
nmts share,nmts recovery-list), not in this package, and that tool does not export them as library calls yet. Both are planned for this package. The account's public codes are here:publicCodes.
git clone https://github.com/needmoretruth/nmts-sdk && cd nmts-sdk
npm install
npm test
npm run compile # dist/, with type declarationsInside the NMTS source tree the dependency on the command-line package is the sibling cli/
checkout; the published package pins a released version instead.
If you built something on this — a service, an app, a port — you owe us nothing: Apache-2.0 asks for the notices and nothing more. We would still like to know. Write to nmts@nmts.me.
Apache-2.0 — the full text is in LICENSE. If you need different terms, write to nmts@nmts.me and say why — see LICENSING.md.
Copyright © 2026 needmoretruth.