Skip to content

SDK reference

The engine, export by export.

Everything the engine exports in version 0.1.0, with examples that were run against the engine build. All names and numbers below are fictional.

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.

ES module
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

Engine exports
ExportWhat 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_INSTRUCTIONSystem prompt text asking the model to copy placeholders exactly.
ProtectErrorThe error protect() and detect() throw. Treat it as do not send.
VaultThe 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.

Signatures (TypeScript)
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.

protect and detect options
OptionDefaultMeaning
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.
typesall typesOnly protect these entity types. Custom terms are always protected.
minConfidence0.5Drop detections that score below this.
vaulta new vaultPass the same vault through a conversation so a value keeps its placeholder.
customTerms and allowTerms
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;
Output (Fictional example)
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.
types, detect and minConfidence
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;
Output (Fictional example)
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

Detection profiles
ProfileWhat changes
generalThe default. Every built-in detector runs.
legalSame detection as general in version 0.1.0.
financeSame detection as general in version 0.1.0.
developerSame detection as general in version 0.1.0.
healthcareAlso 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.
Same text, each profile
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);
}
Output (Fictional example)
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.

  • PERSON
  • ORG
  • EMAIL
  • PHONE
  • SSN
  • ITIN
  • EIN
  • CREDIT_CARD
  • IBAN
  • SWIFT
  • ROUTING_NUMBER
  • BANK_ACCOUNT
  • ADDRESS
  • ZIP
  • DOB
  • DATE
  • MRN
  • NPI
  • DEA
  • MBI
  • HEALTH_PLAN_ID
  • CASE_NUMBER
  • PASSPORT
  • DRIVER_LICENSE
  • IP_ADDRESS
  • URL
  • SECRET
  • VIN
  • AGE
  • DEVICE_ID
  • NATIONAL_ID
  • CUSTOM

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.

One vault across a conversation, saved and loaded
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));
Output (Fictional example)
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.

Mangled placeholders
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);
Output (Fictional example)
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.

Chunks split inside placeholders
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());
Output (Fictional example)
Dear Rosalind Achterberg, the hearing for 2:25-cv-01482 is set. Regards, Achterberg team
What each push returned
Chunk pushedReturned
Dear [PERDear
SON_1], the hearing for [CASE_NUMRosalind Achterberg, the hearing for
BER_1] is set. Regards, [PERSON_1_LA2:25-cv-01482 is set. Regards,
ST] teamAchterberg team

A placeholder still open when the stream ends comes out of flush() unchanged.

Stream ends mid-placeholder
const r = createStreamRestorer(vault);
r.push('See you soon, [PERSON_1_LA'); // the stream ends here
r.flush();
push() then flush() returned
'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.

Results
estimateCredits('');                                       // 0
estimateCredits('Our client Rosalind Achterberg called.'); // 1
estimateCredits('x'.repeat(32000));                        // 1
estimateCredits('x'.repeat(32001));                        // 2
estimateCredits('x'.repeat(100000));                       // 4

Errors

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.

A custom detector that throws
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);
Output (Fictional example)
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.

Known limits