npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@anzenna/prompt-dlp

v0.2.1

Published

Gate AI prompt submissions through the Anzenna browser extension's DLP policy.

Readme

@anzenna/prompt-dlp

This package lets your web app run a prompt past the Anzenna browser extension before you send that prompt to a model provider. The extension answers allow, warn or block, sometimes with a masked rewrite of the prompt. It draws whatever the employee needs to see inside your page, and it records what happened. Your app asks before it submits, and honors the answer.

The extension, your app, and this package

Your employees' browsers already run the Anzenna extension, and it already gates prompt submissions on ChatGPT, Claude and Gemini. It does that by recognizing those sites: it knows where their composers are, watches them itself, and applies your DLP policy with nobody integrating anything. Your app is not one of those sites and cannot be recognized from the outside, so the direction reverses. Your app asks the extension.

That split decides what each side is responsible for.

  • The extension classifies the prompt on the device, evaluates your organization's DLP policy against the result, draws every message the employee sees in your page, and writes the audit record. It owns the debounce interval, the minimum prompt length worth classifying, and every deadline.
  • This package is transport. It holds no policy, no thresholds, no copy, no styling and no timer you would want to tune. It calls the extension and hands you the answer.
  • Your app supplies the composer, the submit action, the call to your model provider, and any UI of your own. Two jobs: ask before submitting, and honor the answer.

Everything the extension cannot answer resolves to allow. This package has no error channel at all, so read Failing open before you design anything around a failure case.

Requirements

Three things must be true before any call in this package does anything.

  1. Anzenna has onboarded your app's origin. The extension only accepts messages from origins listed in its own published manifest, so onboarding is an extension release, not a setting someone flips. Tell your Anzenna contact the exact origin you will call from (https://chat.example.com) and wait for the release that carries it. Until then every check() resolves to allow and nothing is recorded.
  2. The employee has the extension installed. It is normally force-installed by your IT organization. Someone without it, or on a browser without extension support, gets allow for everything.
  3. You have the extension ID. Anzenna gives you this string; it identifies the extension build your fleet runs. A wrong or empty ID produces no error, just allow for everything.

The extension is reached over Chromium's external-messaging API, so this works in Chrome and Edge. Where that API is absent, every check resolves to allow and the client reports the no-browser-extension-api diagnostic.

Install

npm install @anzenna/prompt-dlp

Integration

Everything reached through app below is yours, and this package neither supplies nor calls any of it. app.composer stands in for your prompt input and app.sendToModel for whatever posts the prompt to your provider.

import { AnzennaPromptClient } from "@anzenna/prompt-dlp";

declare const app: {
  composer: HTMLTextAreaElement;
  sendToModel(text: string): Promise<void>;
};

// Anzenna gives you this. A wrong one allows everything.
declare const ANZENNA_EXTENSION_ID: string;

const anzenna = new AnzennaPromptClient({
  extensionId: ANZENNA_EXTENSION_ID,
  // Runs when the employee answers the extension's banner, which is always
  // after check() has already returned `warn`.
  onWarnResolved: ({ choice, text }) => {
    if (choice === "dismiss") {
      return;
    }
    void app.sendToModel(text);
  },
});

// Every keystroke. The debounce and the length floor are the SDK's, and this is
// what lets the later check() answer from an already-computed verdict.
app.composer.addEventListener("input", () => {
  anzenna.onTextEntry(app.composer.value);
});

// Your submit handler: the Send button, the Enter key, whatever you use.
async function onSend(): Promise<void> {
  const prompt = app.composer.value;
  const decision = await anzenna.check(prompt);

  switch (decision.action) {
    case "block":
      // Nothing to draw: the extension already showed the employee why.
      return;
    case "warn":
      // Nothing to draw and nothing to remember. The extension is asking the
      // employee, and the answer arrives on onWarnResolved with the prompt
      // attached.
      return;
    default:
      // Only allow lands here: an action the SDK does not recognize becomes one.
      await app.sendToModel(decision.redacted ? decision.text : prompt);
  }
}

Construct one client and keep it for the life of the composer, then dispose() it when that composer goes away. Each new client pings the extension again, and a warning answer is delivered to the client whose check() raised it.

One submit, end to end

  1. The employee types. Every keystroke goes to onTextEntry. The SDK debounces on the extension's interval, and once the text clears the extension's minimum length it sends the text off to be classified. The verdict lands in the extension's cache.
  2. The employee submits. Your handler awaits check(prompt) before it calls your provider. Most of the time the verdict from step 1 is already computed, so check() returns without waiting on a classification pass.
  3. The verdict comes back in one of four shapes, and the extension has usually already spoken to the employee by the time you see it.

| Decision | What the extension already did | What your app does | | ----------------------------------- | --------------------------------------------- | ----------------------------------------------------------------------- | | action: "allow" | Nothing the employee saw | Submit what the employee typed | | action: "allow", redacted: true | Told the employee the prompt was masked | Submit decision.text, the rewrite, instead of what they typed | | action: "warn" | Showed a banner asking whether to send anyway | Do not submit. Do nothing else. onWarnResolved fires when they answer | | action: "block" | Showed the employee the reason | Do not submit. Do nothing else |

Nothing in that column is a call back into this package. Anzenna writes the audit record itself, at the moment the extension answers check(): it already knows the verdict it just gave. A warning is the one outcome still open at that point. It is recorded when the employee answers, and the extension takes that answer from its own banner rather than from your page.

If you have integrated another DLP SDK you are probably looking for the report() or track() call that closes the loop. There is none here, on purpose. An audit trail that depends on every customer app calling a reporting method correctly is an audit trail with holes in it, so the side that already knows the answer writes it down.

Warnings and the second event

check() resolves on the verdict and never waits on a person. A warn comes back from check() straight away, the extension draws its own banner in your page, and the employee's answer arrives later, on onWarnResolved. So a warn does not block your submit call: it splits one submit into two events, and the second one is where the prompt actually goes to your provider.

It works that way because a verdict and a person answering a question are different events, and only the first is worth holding a submit on. Holding the promise until somebody clicked a button would pin your submit handler to human reaction time, and a page that stops responding while it waits on a person is worse than one that resumes when the answer arrives.

onWarnResolved receives one object, with three fields and nothing else.

  • choice is "send" or "dismiss". On "send", submit. On "dismiss", do nothing at all: the employee has withdrawn the prompt, and Anzenna has already recorded that they did.
  • prompt is the string you passed to check(), so you do not have to keep track of which prompt is waiting on an answer.
  • text is what to submit: the masked rewrite where policy redacted it, otherwise the same string as prompt.

A "send" goes down your normal path as an ordinary submit, not as a late return from the old check(). Do not call check() again for that prompt. The employee has already answered for it, and the extension has already recorded their answer.

Two consequences worth knowing:

  • One warning is answerable at a time. The extension shows one banner per tab and replaces it, so an answer that arrives for a prompt the client is no longer holding is dropped rather than delivered late.
  • A warning requires a banner the employee can answer. Where the extension cannot reach your page to draw one it allows instead of warning, because nobody can answer a question that was never asked. Where you pass no onWarnResolved, the answer has nowhere to go and a warned prompt is never submitted.

Your app needs no dialog, no copy and no styling for a warning.

Redaction

redacted is a second discriminant on a decision, independent of action.

const toSubmit = decision.redacted ? decision.text : prompt;

When redacted is true, decision.text holds a masked rewrite of the prompt and that rewrite is what you send to your provider, not what the employee typed. When it is false, decision.text is undefined. TypeScript enforces the pairing, so there is no case where you read text from a decision that has none.

Failing open

check() always resolves and never rejects. Every failure resolves to { action: "allow" }:

  • no Anzenna extension installed,
  • a browser with no extension support at all,
  • an extension older than the SDK version you shipped,
  • an origin Anzenna has not onboarded,
  • a wrong or empty extensionId,
  • a service worker that crashed or never woke up,
  • a reply that is malformed, or an action this SDK does not recognize,
  • the extension's own classification or policy lookup running out of time.

onTextEntry() never throws either. There is no error channel on the calls themselves, so a decision cannot tell you "allowed by policy" apart from "nothing answered". Treat allow as the ordinary case and never show an Anzenna failure to an employee. A DLP control that breaks the product it protects gets uninstalled.

Where the SDK can say why a check did not reach Anzenna, it says so out of band, through Diagnostics. The last item above is the exception: a policy lookup that ran out of time is not a diagnostic, because the extension did answer and allow was the answer.

Diagnostics

The client writes to console.warn the first time it hits each kind of problem, prefixed with [anzenna/prompt-dlp]. That is how you tell a healthy install from a broken one, and it is the whole of the mechanism: there is deliberately no callback to register, because every one of these still allows the prompt through and none of them is your app's to handle.

[anzenna/prompt-dlp] extension-unreachable: The browser would not deliver the message...

| reason | What it means | | -------------------------- | -------------------------------------------------------------------------------------------------------------------------- | | no-extension-id | The client was constructed without an extensionId. Anzenna supplies that value. | | no-browser-extension-api | This browser exposes no extension messaging API. Chrome and Edge do. | | extension-unreachable | The browser would not deliver the message: the extension is missing or disabled, or Anzenna has not onboarded this origin. | | no-answer | The extension accepted a request and never answered it. | | send-threw | The call into the browser's messaging API threw. | | unrecognized-reply | The extension answered in a shape this version of the package does not know. Upgrade @anzenna/prompt-dlp. | | no-window | The client was constructed where there is no window, so it can never hear a warning answer. Construct it in the browser. |

Three things to rely on:

  • One line per reason per client. check() runs on every submit and onTextEntry on every keystroke, so a broken install would otherwise repeat itself thousands of times.
  • No prompt text, ever. A diagnostic carries only the reason and a message, so the console line is safe wherever your console output goes.
  • Nothing about behavior changes. Every diagnostic accompanies a prompt that was allowed through. A diagnostic is never a reason to stop a submit.

Timing the extension owns

Do not put your own timeout around check(), and do not debounce onTextEntry yourself.

  • The extension owns the deadline on a verdict and returns allow when it decides to stop waiting. A slow check() is therefore already the extension's decision rather than a hang, and a timeout of yours would only race an answer that is coming.
  • The extension supplies the debounce interval and the minimum length over its ping, and onTextEntry applies both. They can change without you shipping a new version of your app. Calling it on every keystroke is correct, and it is what makes the later check() fast. Debouncing it yourself delays the classification check() is waiting for.
  • The SDK's only timer is a 90-second backstop for an extension that disappears mid-call, set clear of every deadline the extension itself can spend. It bounds the reply to a check(), and it reports the no-answer diagnostic when it fires. It does not bound the wait on a warning banner, which check() has already returned before.

API

new AnzennaPromptClient(options)

| Option | Type | Notes | | ---------------- | ---------------------------------- | ------------------------------------------------------------------------------------------ | | extensionId | string, required | Supplied by Anzenna. | | onWarnResolved | (resolved: WarnResolved) => void | Optional. Called when the employee answers a warning banner. Without it, answers are lost. |

The constructor pings the extension once, to pick up the scoring hints before the first keystroke needs them, and starts listening for warning answers.

onTextEntry(text: string): void

Tell the client the composer's text changed. Call it on every keystroke. Fire and forget: it returns immediately and there is no result to wait on. The debounce and the minimum length are applied inside, and this is what warms the verdict that check() later reads.

check(text: string): Promise<PromptDecision>

Ask what to do with a prompt the employee just submitted. Await it before you send anything to your provider, then follow the answer: submit on allow, stop on block, and stop and wait for onWarnResolved on warn. It always resolves, and a warn resolves immediately (see Warnings and the second event).

probe()

probe(): Promise<{
  version: number;
  extensionVersion: string;
  scoring?: { debounceMs: number; minLength: number };
} | null>

Resolves to the extension's protocol version and release version, or null when no extension answered. Use it for a health check or a diagnostic screen. You do not need to call it before check(), which handles an absent extension on its own, and you should not make your submit path conditional on it.

It retries a missing answer on a fixed internal schedule, six attempts over roughly eight seconds, because a browser that is still starting the extension up can miss the first ping. A negative result therefore takes about that long to arrive, which matters if you render something while waiting. A success is cached, so later calls return it immediately.

dispose(): void

Release everything the client holds: a pending classification, an unanswered warning, and its page event listener. Call it when you tear down the composer. A disposed client no longer delivers warning answers, so create a new one for a new composer rather than reusing it.

Exported types

type PromptDecision = (
  { redacted: false; text?: undefined } | { redacted: true; text: string }
) &
  (
    | { action: "allow" }
    | { action: "warn"; message: string }
    | { action: "block"; message: string }
  );

type WarnResolved = { choice: WarnChoice; prompt: string; text: string };
type WarnChoice = "send" | "dismiss";

The package also exports AnzennaPromptOptions, the constructor's options type.