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

The SDKs are in preview and not yet published to npm or PyPI. Package names and APIs may change before 1.0.

Install

bash
npm install @nopii/browser

Using 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.

typescript
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.

OptionMeaning
baseUrlYour NoPII API URL.
sessionTokenA client session token, or a function returning one. Recommended.
publishableKeyAn origin-restricted key, when there is no session.
sessionIdA NoPII session id to continue. Generated when omitted.
placeholderRendered in place of a token the user may not reveal, such as "[redacted]". Tokens stay as they are by default.
failOpenProvider 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.

typescript
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

requestTokenizes
"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
falseNothing. 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.

typescript
nopii.protect({
  sockets: [{ match: "wss://app.example.com/chat", send: ["message"] }],
});

const socket = new WebSocket("wss://app.example.com/chat"); // protected
OptionMeaning
framing"json" (default), "text", or "socket.io"
sendFor 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.
eventsSocket.IO event names to transform. Defaults to all.
binaryBinary 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:

typescript
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:

typescript
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.

typescript
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.

typescript
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

Tier 1 protects the data, not the key. Anyone can read a key shipped in a page. Prefer Tier 2 with the key on your server.

Tokenizing by hand

typescript
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 tokenized

Errors

ErrorWhenWhat to do
NoPIIUnavailableErrorNoPII could not be reached, or CORS refused the call. The request was not sent.Show a retry. Check the browser console for CORS.
NoPIIAuthError401 or 403. code is "session_expired" when the session lapsed.Refresh the session token, or check the key's origins and scopes.
NoPIIRequestErrorThe 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