Examples

Copy it. Run it. Watch the labels.

Every example here uses the public API. The outputs shown are the documented runs: the README quickstart, the API docs receipts example, and the repository checks. Nothing is a mock-up, and no output is invented.

These examples target @gainratio/privacy-core 0.3.2. Everything except the real-model section runs offline with no API key.

Quickstart: the four-step loop

Node 22.13 or newer. Create an empty project and install the package:

mkdir try-privacy-core && cd try-privacy-core
npm init -y
npm install @gainratio/privacy-core

Save this as example.mjs:

import {
  approve,
  guardedProvider,
  NoLLMProvider,
  redactForEgress,
  rehydrate,
  Vault,
} from "@gainratio/privacy-core";

const text = "Maria Lopez asked: refund $482.10 to card 4242 4242 4242 4242 and email maria@example.com.";
const vault = new Vault(); // keeps the real values, in memory, on this machine
const log = (entry) => console.log("[log]", entry.kind, entry.placeholders.join(" "));

const pending = await redactForEgress(text, vault, log); // 1. swap values for labels
console.log("will send: ", pending.redactedText);

const payload = approve(pending, log); // 2. a person has read the line above and said yes
const reply = await guardedProvider(new NoLLMProvider()).complete(payload); // 3. offline stand-in model
const answer = reply.redactedText.split("\n")[1];

console.log("AI saw:    ", answer);
console.log("you see:   ", rehydrate(answer, vault, payload.vaultRef)); // 4. real values put back locally

Run it:

node example.mjs

This is the real output, recorded in the README:

[log] redact [AMOUNT_1] [CARD_1] [EMAIL_1]
will send:  Maria Lopez asked: refund [AMOUNT_1] to card [CARD_1] and email [EMAIL_1].
[log] approve [AMOUNT_1] [CARD_1] [EMAIL_1]
AI saw:     I reviewed your statement. It referenced 3 redacted value(s): [AMOUNT_1], [CARD_1], [EMAIL_1].
you see:    I reviewed your statement. It referenced 3 redacted value(s): $482.10, 4242 4242 4242 4242, maria@example.com.
The amount, card and email were replaced. The name was not. “Maria Lopez” goes out as written. That is why step 2 exists: a person reads the outgoing text before anything is sent and removes what the library missed.

Detect only: what the ruleset sees

detect is the deterministic scanner underneath redaction. It returns spans — type, value, start and end offsets — and writes nothing to a vault.

import { detect } from "@gainratio/privacy-core";

const spans = detect(
  "Grace Hopper, card 4242 4242 4242 4242, was charged $482.10 at Whole Foods on 01/14/2026.",
);

for (const span of spans) {
  console.log(span.type, JSON.stringify(span.value), span.start, span.end);
}

Output:

NAME "Grace Hopper" 0 12
CARD "4242 4242 4242 4242" 19 38
AMOUNT "$482.10" 52 59
MERCHANT "Whole Foods" 63 74
DATE "01/14/2026" 78 88

That example uses two of the small dictionaries: one of three demo names and one of five demo merchants. General names and merchants are not detected. The offsets are positions in the original string, so you can render a highlighted preview without changing the text.

The compile-time guard

Provider adapters accept only a branded RedactedPayload. Passing raw text stops at the compiler, before anything can run:

import { NoLLMProvider } from "@gainratio/privacy-core";

const provider = new NoLLMProvider();
await provider.complete("my card is 4242 4242 4242 4242");

The compiler reports:

oops.ts(4,25): error TS2345: Argument of type 'string' is not assignable to parameter of type 'RedactedPayload'.
  Type 'string' is not assignable to type 'Branded<"RedactedPayload">'.

The line and column depend on your file; the rejection is the point. TypeScript’s brand disappears at runtime, so the guard is enforced a second way: every approved payload is registered by object identity, and a hand-built or spread-cloned look-alike is refused by assertApproved before any network call. That is the UnapprovedPayloadError in the receipts example.

Signed receipts: an allow and a deny

Receipts are opt-in. Install the signing package alongside privacy-core:

npm install @gainratio/avow
import { generateSeedHex, publicKeyHex, verifySignature } from "@gainratio/avow";
import {
  approve,
  guardedProvider,
  NoLLMProvider,
  redactForEgress,
  Vault,
} from "@gainratio/privacy-core";

const seedHex = generateSeedHex(); // your signing key, generated on this device
const receipts = [];

const provider = guardedProvider(new NoLLMProvider(), {
  provider: "offline-echo",
  seedHex,
  onReceipt: (r) => receipts.push(r),
});

const vault = new Vault();
const pending = await redactForEgress("Card on file: 4242 4242 4242 4242", vault);
await provider.complete(approve(pending, () => {}));

// A payload the guard never approved is refused — and the refusal is recorded too.
// (In TypeScript this call wouldn't compile; plain JS shows the runtime half.)
await provider
  .complete({ redactedText: "raw card 4242 4242 4242 4242", vaultRef: { id: "x" } })
  .catch((err) => console.log("refused:", err.constructor.name));

for (const r of receipts) console.log(r.payload);

// Anyone holding your public key can check the receipts were not edited later.
await verifySignature(receipts[0], await publicKeyHex(seedHex));
console.log("\nsignature check: passed");

The program prints refused: UnapprovedPayloadError and signature check: passed, plus two receipt payloads: one allow and one deny. Each receipt contains action, provider, args_digest, decision and detector_version. The digest is a SHA-256 hash of the redacted text — never the text itself — so the exact value depends on the input and the ruleset.

Receipts are tamper-evident records, not replay-prevention tokens: the signed envelope is deterministic, so two identical decisions produce identical receipts. A downstream audit store must track occurrences if it needs to count sends. And a signature proves a record has not changed since it was signed; it says nothing about whether the signing host was already compromised.

Omit the governance argument and you get exactly the same redaction and the same fail-closed guard — just no receipt.

Calling a real model

This is the only example that needs a key and a network. The library is environment-agnostic: your code passes the key in, and the library never reads it from the environment for you. Keep this on a server you control.

import {
  approve,
  makeProvider,
  redactForEgress,
  rehydrate,
  Vault,
} from "@gainratio/privacy-core";

const statement = "Card on file: 4242 4242 4242 4242 for ada@example.com.";

const { provider, label } = makeProvider({
  apiKey: process.env.OPENROUTER_API_KEY, // server-side only
  model: "openai/gpt-4o-mini",
});

const vault = new Vault();
const pending = await redactForEgress(statement, vault);
const payload = approve(pending, () => {});
const reply = await provider.complete(payload);
const answer = rehydrate(reply.redactedText, vault, payload.vaultRef);

console.log(label);
console.log(answer);

No output is shown because this path needs a real key and a live endpoint; the labels on the wire and the restored answer follow the same shape as the quickstart. makeProvider defaults to openai/gpt-4o-mini, a 30-second deadline and a 1 MiB response cap, and throws MissingApiKeyError rather than silently falling back when no key is present.

In a browser, do not put the key in the bundle. Point endpoint at a same-origin proxy that injects the key server-side. That is how the repository’s demo calls OpenRouter; the browser only ever holds a non-secret sentinel. See deployment guidance.

The browser demo, run locally

There is no embedded demo on this page. The real demo is a Vite app in the repository, and the Playwright wire proof drives it. Run it yourself:

git clone https://github.com/gainratio/privacy-core
cd privacy-core
corepack enable
pnpm install
pnpm exec playwright install chromium   # first time only
pnpm demo

Open http://localhost:5173. A synthetic bank statement is pre-loaded. You see the raw text, the exact text that will be sent, and — after you click Approve & send — the answer with real values restored locally. With no key set, the offline stand-in answers, so the whole loop runs with nothing on the network.

Open your browser’s network tab while you click: only labels go out. Stop the demo with Ctrl-C when you are done; the e2e suite reuses a server already on port 5173 and will adopt the wrong one if you leave it running.

Run the checks

The repository gate is one command and is what CI runs:

pnpm gate

It runs Biome lint and format checks, TypeScript checks, Vitest with 100% coverage thresholds, the Playwright browser test and the build. To run only the wire proof:

pnpm exec playwright install --with-deps chromium   # first time only
pnpm test:e2e

The e2e test drives the demo in real Chromium, intercepts the outbound request and fails if any real value appears in it. That is the claim this site is built on, and it is testable without trusting the copy.

Next