Import
The engine ships inside the tightlip package from the download page. See Installation. In our source tree the engine is called @tightlip/core. That is an internal compatibility name during the move from Tightlip to Hushbridge, and it is not a package you can install on its own.
import {
protect, detect, restore, createStreamRestorer,
registerDetector, estimateCredits, PLACEHOLDER_INSTRUCTION,
ProtectError, Vault,
} from 'tightlip';None of these functions make network requests. They run in Node 18 or later, browsers, web workers and Chrome extension service workers, with no runtime dependencies.
Exports
| Export | What it does |
|---|---|
protect(text, opts?) | Returns the placeholder text, the vault, the detections and counts. Throws ProtectError instead of returning half-protected text. |
detect(text, opts?) | Returns detections only, with positions in the original text. Throws ProtectError on failure. |
restore(text, vault) | Puts real values back. Unknown placeholders are left as they are. |
createStreamRestorer(vault) | Returns push(chunk) and flush() for streamed answers. |
registerDetector(detector) | Adds a detector for every later call in this process. The same id replaces the earlier one. A detector that throws makes protect() throw. |
estimateCredits(text) | One credit per started block of 8,000 tokens, at about 4 characters per token. |
PLACEHOLDER_INSTRUCTION | System prompt text asking the model to copy placeholders exactly. |
ProtectError | The error protect() and detect() throw. Treat it as do not send. |
Vault | The swap table: create, fromJSON, toJSON, size, lookup. getById and findId are public on the class but marked internal. |
Types: EntityType, Profile, Detection, Detector, ProtectOptions, ProtectResult and VaultJSON. The engine also exports DetectorContext, the second argument a detector receives. The tightlip package does not re-export that one type.
protect(text: string, opts?: ProtectOptions): ProtectResult
detect(text: string, opts?: ProtectOptions): Detection[]
restore(text: string, vault: Vault): string
createStreamRestorer(vault: Vault): { push(chunk: string): string; flush(): string }
registerDetector(detector: Detector): void
estimateCredits(text: string): number
PLACEHOLDER_INSTRUCTION: string
class ProtectError extends Error { name: 'ProtectError'; cause?: unknown }
class Vault {
static create(): Vault
static fromJSON(json: VaultJSON): Vault
toJSON(): VaultJSON
get size(): number
lookup(placeholder: string): string | undefined
getById(id: string): string | undefined // marked internal
findId(type: EntityType, value: string): string | undefined // marked internal
}
interface ProtectResult {
text: string; // placeholder text
vault: Vault; // keep on the device
detections: Detection[]; // positions in the original text
counts: Partial<Record<EntityType, number>>; // distinct values per type
}
interface Detection {
type: EntityType; start: number; end: number; text: string;
confidence: number; // 0 to 1
detector: string; // for example 'email' or 'name-dictionary'
}
interface Detector {
id: string;
types: EntityType[];
detect(text: string, ctx: { profile: Profile }): Detection[];
}Options
protect() and detect() take the same options.
| Option | Default | Meaning |
|---|---|---|
profile | 'general' | One of general, legal, healthcare, finance, developer. See Profiles. |
customTerms | [] | Always protect these, such as client and matter names. Matched case-insensitively on whole words. Each item is { term, type? }. The type defaults to CUSTOM. |
allowTerms | [] | Never protect a detection whose whole value matches one of these, case-insensitively. |
types | all types | Only protect these entity types. Custom terms are always protected. |
minConfidence | 0.5 | Drop detections that score below this. |
vault | a new vault | Pass the same vault through a conversation so a value keeps its placeholder. |
const input = 'Send the Harrow Lane file from Brightwater Clinic to Ines Okonkwo before Friday.';
protect(input).text;
protect(input, { customTerms: [{ term: 'Harrow Lane' }] }).text;
protect(input, {
customTerms: [{ term: 'Harrow Lane' }],
allowTerms: ['Brightwater Clinic'],
}).text;Send the Harrow Lane file from [ORG_1] to [PERSON_1] before Friday.
Send the [CUSTOM_1] file from [ORG_1] to [PERSON_1] before Friday.
Send the [CUSTOM_1] file from Brightwater Clinic to [PERSON_1] before Friday.const input = 'Email Tobias Lindqvist at tobias.l@example.com or call (415) 555-0199.';
protect(input, { types: ['EMAIL'] }).text;
detect(input).map(({ type, text, confidence }) => [type, text, confidence]);
protect(input, { minConfidence: 0.95 }).text;Email Tobias Lindqvist at [EMAIL_1] or call (415) 555-0199.
[ [ 'PERSON', 'Tobias Lindqvist', 0.75 ],
[ 'EMAIL', 'tobias.l@example.com', 0.99 ],
[ 'PHONE', '(415) 555-0199', 0.88 ] ]
Email Tobias Lindqvist at [EMAIL_1] or call (415) 555-0199.Profiles
| Profile | What changes |
|---|---|
general | The default. Every built-in detector runs. |
legal | Same detection as general in version 0.1.0. |
finance | Same detection as general in version 0.1.0. |
developer | Same detection as general in version 0.1.0. |
healthcare | Also protects every full date, and scores a bare NPI number high enough to pass the default threshold. Outside this profile, dates are protected only as dates of birth. |
const input = 'Patient Bartholomew Okafor was seen on 08/02/2026. Call him on (206) 555-0142.';
for (const profile of ['general', 'legal', 'healthcare', 'finance', 'developer']) {
console.log(profile.padEnd(11), protect(input, { profile }).text);
}general Patient [PERSON_1] was seen on 08/02/2026. Call him on [PHONE_1].
legal Patient [PERSON_1] was seen on 08/02/2026. Call him on [PHONE_1].
healthcare Patient [PERSON_1] was seen on [DATE_1]. Call him on [PHONE_1].
finance Patient [PERSON_1] was seen on 08/02/2026. Call him on [PHONE_1].
developer Patient [PERSON_1] was seen on 08/02/2026. Call him on [PHONE_1].Hushbridge reduces what reaches the AI. Its output is not de-identified under HIPAA or any other rule, and the healthcare profile does not change that.
Entity types
Each detection has one of these types. The type becomes the placeholder prefix.
PERSONORGEMAILPHONESSNITINEINCREDIT_CARDIBANSWIFTROUTING_NUMBERBANK_ACCOUNTADDRESSZIPDOBDATEMRNNPIDEAMBIHEALTH_PLAN_IDCASE_NUMBERPASSPORTDRIVER_LICENSEIP_ADDRESSURLSECRETVINAGEDEVICE_IDNATIONAL_IDCUSTOM
Detection uses rules, validators such as Luhn for cards, the ABA checksum for routing numbers and mod 97 for IBANs, and name lists. National ID formats cover Pakistan CNIC and NICOP, the UAE Emirates ID, India Aadhaar and PAN, UK National Insurance and NHS numbers, Canada SIN and Australia TFN. Other countries' numbers are protected when they follow an identity label.
Placeholders
A placeholder looks like [TYPE_N], numbered per type within one vault. The same value always gets the same placeholder. People, organisations and custom terms match without case. Numbers match on their digits, so 123-45-6789 and 123456789 share one.
Once a full name is [PERSON_1], a later surname or first name on its own becomes [PERSON_1_LAST] or [PERSON_1_FIRST]. Text in the input that already looks like a placeholder is never reused for a new value. restore(protect(x).text, vault) returns x exactly.
The vault
The vault lives in memory, in the process that made it. Nothing saves it for you. To keep a conversation across page loads or restarts, serialise it with toJSON() and load it with Vault.fromJSON(). The JSON holds the real values, so store it only where your app alone can read it. fromJSON throws a TypeError for JSON it does not recognise.
The vault also keeps a record of the last 50 texts it protected, so restore() can reverse those exactly. Older records drop off. Their placeholders still restore through the normal mapping, which has no such limit.
const vault = Vault.create();
const turn1 = protect('Our client Marisol Quintero disputes the charge.', { vault });
const turn2 = protect('Ms. Quintero sent a second email from marisol.q@example.com.', { vault });
console.log(turn1.text);
console.log(turn2.text);
console.log(vault.size);
console.log(vault.lookup('[PERSON_1]'));
console.log(vault.lookup('PERSON_1_LAST'));
// Save and load. Store the JSON where only your app can read it.
const saved = JSON.stringify(vault.toJSON());
const reloaded = Vault.fromJSON(JSON.parse(saved));
console.log(Object.keys(vault.toJSON()));
const reply = 'I have drafted a note to [PERSON_1] and will reply from [EMAIL_1]. [PERSON_1_LAST] will see it today.';
console.log(restore(reply, reloaded));Our client [PERSON_1] disputes the charge.
Ms. [PERSON_1_LAST] sent a second email from [EMAIL_1].
3
Marisol Quintero
Quintero
[ 'v', 'counters', 'entries', 'persons', 'reserved', 'records' ]
I have drafted a note to Marisol Quintero and will reply from marisol.q@example.com. Quintero will see it today.Restoration
Models do not always copy placeholders cleanly. restore() accepts [PERSON_1], PERSON_1, [Person_1], [PERSON 1], Markdown-escaped \[PERSON\_1\], bold or italic wrappers and possessives. A placeholder the vault does not know stays as it is.
const { vault } = protect('Our client Rosalind Achterberg called.');
restore("Thanks, PERSON_1. **[Person_1]** and [PERSON 1] and \\[PERSON\\_1\\] and [PERSON_1]'s file. [PERSON_9] is unknown.", vault);Thanks, Rosalind Achterberg. **Rosalind Achterberg** and Rosalind Achterberg and Rosalind Achterberg and Rosalind Achterberg's file. [PERSON_9] is unknown.Streaming
A placeholder can be split across chunks. push() returns the text that is safe to show now. It holds back a possible placeholder at the end of the buffer, such as [PERS, until it completes or grows past 40 characters. Call flush() when the stream ends.
const { vault } = protect(
'Our client Rosalind Achterberg, Case No. 2:25-cv-01482, called.',
{ profile: 'legal' },
);
const restorer = createStreamRestorer(vault);
for (const chunk of [
'Dear [PER',
'SON_1], the hearing for [CASE_NUM',
'BER_1] is set. Regards, [PERSON_1_LA',
'ST] team',
]) {
process.stdout.write(restorer.push(chunk));
}
process.stdout.write(restorer.flush());Dear Rosalind Achterberg, the hearing for 2:25-cv-01482 is set. Regards, Achterberg team| Chunk pushed | Returned |
|---|---|
Dear [PER | Dear |
SON_1], the hearing for [CASE_NUM | Rosalind Achterberg, the hearing for |
BER_1] is set. Regards, [PERSON_1_LA | 2:25-cv-01482 is set. Regards, |
ST] team | Achterberg team |
A placeholder still open when the stream ends comes out of flush() unchanged.
const r = createStreamRestorer(vault);
r.push('See you soon, [PERSON_1_LA'); // the stream ends here
r.flush();'See you soon, '
'[PERSON_1_LA'Estimating credits
estimateCredits() counts one credit per started block of 8,000 tokens, at about 4 characters per token. That is 32,000 characters per credit. Credits cost USD 0.05 each. The engine only counts. Charging happens in the client API described in the API reference.
estimateCredits(''); // 0
estimateCredits('Our client Rosalind Achterberg called.'); // 1
estimateCredits('x'.repeat(32000)); // 1
estimateCredits('x'.repeat(32001)); // 2
estimateCredits('x'.repeat(100000)); // 4Errors
protect() and detect() throw ProtectError when anything inside fails, including an unknown profile or a detector that throws. The original error is on cause. Never send the text after a ProtectError. registerDetector() throws a TypeError if the detector has no id or detect function.
registerDetector({
id: 'docs-example-detector',
types: ['CUSTOM'],
detect() {
throw new Error('model not loaded');
},
});
try {
protect('Our client Rosalind Achterberg called.');
} catch (e) {
console.log(e instanceof ProtectError, e.name);
console.log(e.message);
// Do not send anything.
}
// The same id replaces a detector. This one finds nothing.
registerDetector({ id: 'docs-example-detector', types: ['CUSTOM'], detect: () => [] });
console.log(protect('Our client Rosalind Achterberg called.').text);true ProtectError
Tightlip could not protect this text, so it must not be sent: model not loaded
Our client [PERSON_1] called.A registered detector stays active for the rest of the process. There is no public way to remove one, so replace it by id as shown.