Browser SDK
@nopii/browser tokenizes message content before it leaves the page and reveals it when the reply comes back, so your servers only ever handle tokens. It also routes LLM calls made from the browser through NoPII.
Preview
Install
npm install @nopii/browserUsing React? @nopii/react wraps everything on this page in a provider and hooks.
Initialize
Give the SDK a client session from your own backend. Sessions are bound to your user, which is what lets that user reveal their earlier messages later. Your server mints them with @nopii/node or nopii-sdk.
import { NoPII } from "@nopii/browser";
let session: { token: string; expiresAt: number } | undefined;
const nopii = NoPII.init({
baseUrl: "https://api.nopii.co",
// Called before each NoPII request. Cache the token and refresh it shortly before it expires.
sessionToken: async () => {
if (!session || session.expiresAt - Date.now() < 60_000) {
const body = await fetch("/api/nopii-session", { method: "POST" }).then((r) => r.json());
session = { token: body.token, expiresAt: Date.parse(body.expiresAt) };
}
return session.token;
},
});For development, or an app with no signed-in users, a publishable key works instead: NoPII.init({ baseUrl, publishableKey: "npk_live_..." }). It only works from the origins listed on the key, and reveals are scoped by session id alone. See Keys and reveal policy.
| Option | Meaning |
|---|---|
baseUrl | Your NoPII API URL. |
sessionToken | A client session token, or a function returning one. Recommended. |
publishableKey | An origin-restricted key, when there is no session. |
sessionId | A NoPII session id to continue. Generated when omitted. |
placeholder | Rendered in place of a token the user may not reveal, such as "[redacted]". Tokens stay as they are by default. |
failOpen | Provider interception only: send the call unprotected when NoPII is unreachable. Leave it off. |
Protect your own routes (Tier 2)
Name the routes that carry messages and where the text is in their bodies. The SDK tokenizes those fields in one batch before the request is sent, then reveals the response as it streams back.
nopii.protect({
routes: [
{ match: "/api/chat", request: "ai-sdk" },
{ match: "/api/tickets", request: ["subject", "messages[*].body"] },
],
});
// Unchanged app code. /api/chat now receives tokens.
await fetch("/api/chat", { method: "POST", body: JSON.stringify({ messages }) });protect patches the global fetch. To leave it alone, pass patchGlobalFetch: false and hand nopii.fetch to the code that makes the request.
Request shapes
| request | Tokenizes |
|---|---|
"ai-sdk" | Vercel AI SDK UI messages: text, reasoning, tool inputs and outputs, data parts |
"openai" | Chat Completions messages, including tool call arguments |
"responses" | OpenAI Responses API input, instructions, function calls and outputs, prompt variables |
"anthropic" | Messages API system prompt and content, including tool use and tool results |
string[] | Paths to string fields: message, input.text, items[*].body |
false | Nothing. The response is still revealed. |
match takes a path starting with / for an exact path, any other string as a URL prefix, a RegExp, or a function of the URL.
Responses
By default the SDK recognizes the response format: Vercel AI SDK UI message streams, OpenAI Chat Completions and Responses API streams, Anthropic streams, JSON, and plain text. Set response on the route to one of "ai-sdk", "openai", "responses", "anthropic", "json", or "text" to be explicit, or to false to leave it alone.
- A token split across stream chunks is held until it is complete, so a partial token never renders.
- Streamed tool call arguments are held until the call ends and released as one revealed piece, so plaintext containing quotes cannot break the JSON your client is parsing.
- Tokens this page does not hold, such as ones from an earlier visit, are fetched in one batch under the reveal policy.
Conversation history
Chat clients resend the whole conversation every turn. When they send back text the SDK revealed, the SDK restores the exact tokens it came from, rather than detecting the PII again. Your backend sees the same token for the same value on every turn.
Forwarding to the LLM
The SDK sends the NoPII session id to your route in X-NoPII-Session-Id. Forward it to the proxy with X-NoPII-Mode: tokens-only, and the model is told what the tokens mean while the reply stays tokenized for the browser to reveal. The server SDKs do both for you.
WebSockets
Chat over a socket is protected the same way. Outgoing frames are tokenized in order, and incoming frames are revealed before your listeners see them.
nopii.protect({
sockets: [{ match: "wss://app.example.com/chat", send: ["message"] }],
});
const socket = new WebSocket("wss://app.example.com/chat"); // protected| Option | Meaning |
|---|---|
framing | "json" (default), "text", or "socket.io" |
send | For JSON, a request shape. For text, true. For Socket.IO, paths into { event, args }, such as args[*].text. |
receive | "auto" reveals every string (default), a list of paths reveals those, false leaves frames alone. |
events | Socket.IO event names to transform. Defaults to all. |
binary | Binary frames cannot be inspected, so they are blocked unless this is "pass". |
A frame that cannot be tokenized is not sent, and the socket fires error. Libraries that load WebSocket before the SDK runs, such as Socket.IO, need nopii.WebSocket passed to them:
import { io, WebSocket } from "socket.io-client";
nopii.protect({
sockets: [{ match: (url) => url.pathname.startsWith("/socket.io/"), framing: "socket.io", send: ["args[*].text"] }],
patchGlobalWebSocket: false,
});
class NoPIITransport extends WebSocket {
createSocket(uri: string, protocols?: string | string[]) {
return new nopii.WebSocket(uri, protocols);
}
}
// WebSocket transport only: long polling is not covered.
const socket = io("https://app.example.com", { transports: [NoPIITransport] });Revealing tokens
For tokenized content that did not arrive through a protected route, such as history loaded separately:
const text = await nopii.reveal("Invoice for [NAME: 8f2kQ1xZ]");
// Every string inside a JSON value
const history = await nopii.revealValue(await fetch("/api/history").then((r) => r.json()));
// Tokens this page already holds, with no network call
const quick = nopii.revealLocal(text);Rendered HTML
For HTML rendered by something you do not control, reveal the DOM instead. The SDK watches the element and reveals tokens in its text as they appear.
const observer = nopii.reveal(document.querySelector("#history")!, {
attributes: ["title"], // optional: attributes to reveal too
});
observer.disconnect(); // or nopii.destroy()It never writes into editable fields, scripts, or styles, and skips anything inside an element marked data-nopii-raw. A token split across two elements is not revealed.
Tokens the user may not see
A token created by another user stays a token, or becomes the placeholder you configured. For support tools, your backend can create a reveal grant and pass its id: nopii.reveal(text, { grantId }).
Route browser LLM calls through NoPII (Tier 1)
If your page already calls an LLM provider directly, hand nopii.fetch to the client. Requests to known provider hosts go to NoPII instead, which tokenizes them and reveals the reply.
import OpenAI from "openai";
const openai = new OpenAI({ apiKey, dangerouslyAllowBrowser: true, fetch: nopii.fetch });Or call nopii.intercept() to patch the global fetch for every provider call on the page. The provider key is authenticated the same way as with the proxy, so register it in the admin console. Supported hosts are OpenAI, Anthropic, xAI, DeepSeek, Mistral, Groq, Together, Fireworks, and Gemini; add others with intercept({ additionalProviders: [{ id, host, basePath }] }).
A provider key in the browser is still exposed
Tokenizing by hand
const result = await nopii.tokenize("Call John Smith at john@example.com");
result.texts[0]; // "Call [NAME: ...] at [EMAIL: ...]"
result.entities; // what was found, with offsets into your text
const found = await nopii.detect(draft); // highlight PII before sending; nothing is tokenizedErrors
| Error | When | What to do |
|---|---|---|
NoPIIUnavailableError | NoPII could not be reached, or CORS refused the call. The request was not sent. | Show a retry. Check the browser console for CORS. |
NoPIIAuthError | 401 or 403. code is "session_expired" when the session lapsed. | Refresh the session token, or check the key's origins and scopes. |
NoPIIRequestError | The request could not be protected, for example a body that is not JSON. | Fix the route configuration. |
Cleaning up
nopii.destroy() restores fetch and WebSocket, forgets protected routes, stops DOM observers, and drops the plaintext the instance was holding. Sockets already open stay protected.
More than one instance can patch the globals at once, and they can be destroyed in any order: the others keep protecting. If another library wrapped fetch after NoPII did, its wrapper is left in place.
Related
- React SDK: the provider, hooks, and useChat
- Node.js SDK: the server half of Tier 2
- Mock server: develop and test without an account