Under a production auth mode entity.value is an empty string by default: raw values are returned only when you pass include_values: true AND your API key has the detect_values scope. Types, spans, and confidence are always present. This keeps raw PII out of a detect response unless a caller is explicitly authorized to receive it.
Note:entity.span.start and entity.span.end are UTF-8 byte offsets, not JavaScript
string indices. JavaScript indexes by UTF-16 code unit, so String.prototype.slice with them
mis-slices any non-ASCII text. Slice the encoded bytes instead:
const bytes = new TextEncoder().encode(input);const raw = new TextDecoder().decode(bytes.slice(entity.span.start, entity.span.end));
outputGuard scans text an LLM already produced for newly generated PII and returns the guarded output plus whether the guard triggered. It is a stateless call: it mints no session and consumes none, so use it as a second pass on model output that did not come from a tokenized transform. Pass an optional policy and output_channel to select which guard rules apply.
const guarded = await client.outputGuard({ output: "You can reach the customer at ops@example.com", policy: "default", output_channel: "customer_email",});console.log(guarded.output_guard_triggered); // true when the scan found PII in the outputconsole.log(guarded.safe_output); // guarded text, unchanged when nothing triggered
When the guard is configured to block rather than mask, a triggered scan returns HTTP 422, which the SDK surfaces as OutputBlockedError.
For multi-turn conversations, use OGuardAISession to automatically carry session state between calls:
import { OGuardAIClient, OGuardAISession } from "@oguardai/sdk";const client = new OGuardAIClient({ baseUrl: "http://localhost:3000" });const session = new OGuardAISession(client, "german-support");// Turn 1const turn1 = await session.transform("Ich bin Erika Mustermann, Kundennummer 948221");console.log(turn1.safe_text);// "Ich bin `{{person:p_001:4c19d2a08b5e}}`, Kundennummer `{{customer_id:cid_001:9f27c31d64ab}}`"const llmReply1 = "Guten Tag `{{person:p_001:4c19d2a08b5e}}`, wie kann ich helfen?";const restored1 = await session.rehydrate(llmReply1, "customer_email");// Turn 2: same session, entities are deduplicatedconst turn2 = await session.transform("Meine E-Mail ist erika.mustermann@example.com");// Erika Mustermann is still `{{person:p_001:4c19d2a08b5e}}`
guardedChat tokenizes a full chat array in one call. The server scans every message field by field, tokenizing each string-bearing field (content, multimodal text parts, tool-call arguments) and returns safeMessages with the same shape. Token ids are shared across messages and one sealed session covers the whole conversation. Rehydrate the model's reply with rehydrateResponse:
const { safeMessages, sessionState } = await client.guardedChat( [ { role: "system", content: "You are a support agent." }, { role: "user", content: "I am Erika Mustermann, erika.mustermann@example.com" }, ], { policy: "german-support" },);const reply = await callYourLLM(safeMessages); // sees tokens onlyconst restored = await client.rehydrateResponse(reply, sessionState, "customer_email");
guardedCall wraps any string -> string step (a LangChain chain, a raw provider call) so it transforms the input, runs your function on the tokenized text, and rehydrates the output. It fails closed if transform returns no session state:
const answer = await client.guardedCall( (safeText) => callYourLLM(safeText), "Draft a reply to Erika Mustermann", { policy: "default", outputChannel: "customer_email" },);
Transform or detect many inputs in one call. Each item takes an optional per-item policy, language, and custom_patterns (ad-hoc regex detectors scoped to that item only, mirroring a single transform), so a single batch can mix languages and per-item rules. session_mode is independent (default) or shared.
The RAG methods carry a corpus_id for cross-document and cross-session token identity: passing the same corpus_id on ingest, query, and context makes the same raw value tokenize to the same token id deterministically across documents and sessions, which is what lets a query entity align with an ingested document entity. The scope is that one corpus, not a global fingerprint.
// 1. Ingest: chunk + tokenize a document, store safe chunks in your vector DBconst ingest = await client.ragIngest({ text: documentText, chunking_strategy: "paragraph", corpus_id: "support-kb",});// Keep each chunk's chunk_session_state for step 3const docSessions = ingest.chunks.map((c) => c.chunk_session_state);// 2. Query: tokenize the user question for vector searchconst query = await client.ragQuery({ query: userQuestion, corpus_id: "support-kb" });const hits = await vectorSearch(query.safe_query);// 3. Context: merge retrieved chunks into a safe LLM contextconst ctx = await client.ragContext({ chunks: hits.map((h) => h.text), session_state: query.session_state, document_sessions: docSessions, corpus_id: "support-kb",});const llmAnswer = await callYourLLM(ctx.safe_chunks.join("\n"));// 4. Answer: validate tokens resolve; restore host-sideconst answer = await client.ragAnswer({ answer: llmAnswer, session_state: ctx.session_state, output_channel: "user_output",});// GDPR erasure: forget every value in an ingest sessionawait client.ragDelete({ session_state: ingest.chunks[0].chunk_session_state });
transformFile ingests a document (PDF, DOCX, TXT), extracts text, and tokenizes detected PII. transformImage runs OCR (Tesseract in the server image) and returns tokenized text plus bounding_boxes for client-side overlay. redactImage returns the redacted image bytes and deliberately over-redacts (it applies no policy) for visual safety; it accepts an optional language hint that is forwarded to OCR and detection.
The image route resolves the tenant/global default policy, so it accepts only language, not a policy field. Person and company names in a scan depend on the NER sidecar; without it those names are not detected and are not redacted, so treat image redaction as a data-minimization control, not an absolute guarantee.
The examples/chatbot-node example is a complete support desk built on this SDK. It exercises
OGuardAIClient and OGuardAISession end to end: one session per conversation, transform before
the model, rehydrate per output channel after. Musterfirma GmbH is a fictional German
outdoor-gear mail-order retailer whose assistant "Nora" drafts formal German replies with an external
LLM, while German data-protection rules forbid sending customer PII to that provider. This example is
the runtime seam that lets the model work on semantic tokens only, then restores real values per
output channel on the way back.
Every turn runs the full OGuardAI cycle:
customer message ─▶ [transform] ─▶ safe_text (tokens only) ─▶ [LLM draft] ─▶ [rehydrate per channel] raw PII, inside the model never sees a real the one draft, restored into four the trust boundary name, email, IBAN or phone channels at four visibility levels
Trust boundary in code. transform runs before the LLM call. If it throws, the raw message never
reaches the model. The LLM only ever receives safe_text plus non-identifying hints (token, type,
gender, formality).
Gender and formality aware restoration. The German NER detector tags the customer as female and
formal, and the german-support policy restores the person token as Frau Erika Mustermann on
customer-facing channels. The model is told never to write a salutation itself.
One draft, four channels, four visibility levels. The same tokenized reply is rehydrated on
customer_email, internal_summary, tool_payload, and log_safe. Each downstream consumer sees
only what its channel allows:
Channel
person
order
email / phone
IBAN
customer_email
Frau Erika Mustermann
full
full
masked
internal_summary
Frau Erika Mustermann
full
partially masked
masked
tool_payload
masked
masked
masked
masked
log_safe
[REDACTED]
[REDACTED]
[REDACTED]
[REDACTED]
Stable token identity across turns. One session per conversation, so the same customer, order, and
email keep the same token across every turn. A later turn that never repeats the name still restores
it, because the session resolves every token from every earlier turn.
Fail closed, never guess. A tampered session blob is rejected, not silently restored. A token the
model invents is left verbatim and reported, never fabricated. The bot treats any unresolved or
residual {{...}} token on the customer channel as unsafe and sends a generic fallback instead of a
dangling placeholder.
PII-free logging. The server logs the log_safe view only, so a turn is auditable without
persisting any customer data.
An OGuardAI server on http://localhost:3000 with the Python NER detector active, so person and
address are detected. Check with curl localhost:3000/v1/health: the detector component should
report builtin_and_ner. The german-support policy declares person detection as required, so a
runtime without the NER sidecar rejects the transform (fail closed, HTTP 503) rather than let an
un-tokenized name reach the model. The built-in regex still covers the structured PII (email, phone,
IBAN, order, customer id, date of birth), but this example needs the full stack with NER.
The german-support policy, shipped in policies/german-support/.
Start the full stack from the repo root:
# The bundled stack requires these four secrets, and its client key ships least-privilege (channel# user_output only), so widen it to the channels this example restores on.export GUARDAI_SESSION_SECRET=$(openssl rand -base64 32)export GUARDAI_API_KEY_1=$(openssl rand -hex 32)export GUARDAI_DETECTOR_API_KEY=$(openssl rand -hex 32)export GUARDAI_REDIS_PASSWORD=$(openssl rand -hex 32)export GUARDAI_API_KEY_1_ALLOWED_OUTPUT_CHANNELS=user_output,customer_email,internal_summary,tool_payload,log_safedocker compose -f deploy/docker/docker-compose.yml up -d
pnpm demo drives the fixture conversation in fixtures/nordwind-support-thread.de.md through the
bot and prints, per turn: the raw message, the tokenized text the LLM sees, the detected entities, the
tokenized draft, and the same draft restored into all four channels. It ends with the two fail-closed
properties.
export GUARDAI_API_KEY="$GUARDAI_API_KEY_1" # the bundled stack runs api_key authpnpm demo
Expected output for the first turn (values differ per run because token ids are random):
Was das LLM sieht (safe_text, nur Tokens): Guten Tag, mein Name ist Frau {{person:p_001:...}}. Meine Kundennummer lautet {{customer_id:cid_001:...}} und meine Bestellung {{order:o_001:...}} ... Geliefert wurde an {{address:addr_001:...}}, {{address:addr_002:...}}. Sie erreichen mich per E-Mail unter {{email:e_001:...}} oder telefonisch unter {{phone:ph_001:...}} ...Erkannte Entitaeten: address=2, customer_id=1, email=1, order=1, person=1, phone=1 [customer_email] -> Kunde: Guten Tag Frau Erika Mustermann, vielen Dank fuer Ihre Nachricht zu Bestellung ORD-2026-5510. ... Eine schriftliche Bestaetigung senden wir an erika.mustermann@beispiel.de. [internal_summary] -> Agent: ... Eine schriftliche Bestaetigung senden wir an k************************e. [tool_payload] -> Refund-Tool: Guten Tag E**** *********n, ... Bestellung O***********0. ... [log_safe] -> Audit-Log: Guten Tag [REDACTED], ... Bestellung [REDACTED]. ...
And the fail-closed section:
1) Tampered session_state -> rejected: ValidationError (GUARDAI_INVALID_INPUT)2) Invented token -> resolved=1 unresolved=[{{person:p_999:...}}] The bot's residual-token guard marks this reply unsafe and withholds it.
The same core is exposed as an Express server with /chat, /reset, and /health.
pnpm dev # tsx, no build step# or: pnpm build && pnpm start
The chatbot listens on http://localhost:4000.
# First turn: the reply is the customer_email view; audit_line is the log_safe view.curl -s -X POST http://localhost:4000/chat -H "Content-Type: application/json" \ -d '{"message":"Guten Tag, ich bin Frau Erika Mustermann, Bestellung ORD-2026-5510. Erreichbar unter erika.mustermann@beispiel.de."}'# Second turn: pass the conversation_id back so the same tokens resolve. The reply still# addresses "Frau Erika Mustermann" although this turn never repeats the name.curl -s -X POST http://localhost:4000/chat -H "Content-Type: application/json" \ -d '{"message":"Bitte erstatten Sie auf IBAN DE97 9900 0000 0123 4567 89.","conversation_id":"<id-from-turn-1>"}'# Health: reports the OGuardAI status and whether the LLM runs in openai or offline mode.curl -s http://localhost:4000/health
/chat returns { reply, conversation_id, safe, entities_detected, audit_line }. reply is safe to
send to the customer, audit_line is safe to persist anywhere.
The model only ever receives tokenized text. When OPENAI_API_KEY is set the adapter in src/llm.ts
calls OpenAI Chat Completions over fetch, reading the key from the environment exactly as a real
integration would. When it is unset the adapter falls back to a deterministic offline draft, so the
example runs end to end against OGuardAI alone with no provider account. The system prompt hands the
model the token contract: echo every placeholder verbatim, and never prepend a salutation to a person
token because the formatted restore injects the correct gendered Herr or Frau itself.
export OPENAI_API_KEY=sk-... # optional; enables the real provider pathexport OPENAI_MODEL=gpt-4o-mini # optional; default gpt-4o-minipnpm demo
SupportBot.handleTurn is the load-bearing file. It keeps one OGuardAISession per conversation,
transforms the incoming message before the LLM ever runs, drafts on tokens only, then rehydrates the
one draft into the four channels in parallel. It marks the reply unsafe if any token stays unresolved
or a residual {{...}} token survives on the customer channel, and returns a generic fallback in that
case.
/** * SupportBot: the reusable Musterfirma support core. * * One protection cycle per turn: * transform (PII -> tokens) -> LLM draft (tokens only) -> rehydrate per channel. * * The same OGuardAISession is kept per conversation, so token identity is stable * across turns (the same customer, order, or e-mail keeps the same token) and the * latest sealed session_state resolves every token from every earlier turn. */import { OGuardAIClient, OGuardAISession } from "@oguardai/sdk";import type { EntityContext, EntityInfo } from "@oguardai/sdk";import { CHANNELS, COMPANY } from "./scenario.js";import { ConversationStore } from "./conversation-store.js";import { draftReply, type EntityHint, type SafeMessage } from "./llm.js";/** Restoration of one LLM draft into each downstream channel. */export interface ChannelViews { /** customer_email: full name/order/email, IBAN masked. Safe to send to the customer. */ customer: string; /** internal_summary: name/order for the agent, contact + IBAN masked. */ agent: string; /** tool_payload: minimum data for a downstream refund/ticket system. */ tool: string; /** log_safe: fully redacted line, safe to persist in any log store. */ audit: string;}export interface TurnResult { conversationId: string; /** Exactly what the LLM received: tokens only, never raw PII. */ safeText: string; entities: EntityInfo[]; entitySummary: Record<string, number>; /** The LLM draft, still tokenized (pre-rehydration). */ llmDraft: string; views: ChannelViews; /** Tokens the runtime refused to resolve (e.g. a value the LLM invented). */ unresolvedTokens: string[]; /** false when a token stayed unresolved or a residual `{{...}}` survived. */ safe: boolean; /** The reply that is actually safe to send: the customer view, or a fallback. */ customerReply: string;}interface Conversation { session: OGuardAISession; history: SafeMessage[]; hints: Map<string, EntityHint>;}/** A generic, PII-free reply used when a draft cannot be safely rehydrated. */const SAFE_FALLBACK = "Entschuldigung, wir konnten Ihre Anfrage nicht sicher verarbeiten. " + "Ein Mitarbeiter meldet sich persoenlich bei Ihnen.";const RESIDUAL_TOKEN = /\{\{[^{}]+\}\}/;export class SupportBot { private readonly conversations = new ConversationStore<Conversation>(); constructor( private readonly client: OGuardAIClient, private readonly policy: string = COMPANY.policy, ) {} reset(conversationId: string): void { this.conversations.delete(conversationId); } private conversation(conversationId: string): Conversation { // conversationId comes straight from the request body. In production the conversation key must // derive from the authenticated principal, never from caller input; this demo trusts the caller // and only bounds the store so invented ids cannot grow the process without limit. let conv = this.conversations.get(conversationId); if (!conv) { conv = { session: new OGuardAISession(this.client, this.policy), history: [], hints: new Map() }; this.conversations.set(conversationId, conv); } return conv; } async handleTurn(conversationId: string, message: string): Promise<TurnResult> { const conv = this.conversation(conversationId); // Transform first. If this throws, the raw message never reaches the LLM (fail closed). const transformed = await conv.session.transform(message, { language: COMPANY.language, context: { destination: "external_llm", provider: "openai", caller_purpose: "support" }, }); mergeHints(conv.hints, transformed.entity_context ?? []); conv.history.push({ role: "user", content: transformed.safe_text }); // The model works on tokens only, guided by the accumulated non-identifying hints. const llmDraft = await draftReply(conv.history, [...conv.hints.values()]); conv.history.push({ role: "assistant", content: llmDraft }); // Restore the one draft into every channel; each sees only what its policy allows. const [customer, agent, tool, audit] = await Promise.all([ conv.session.rehydrate(llmDraft, CHANNELS.customer), conv.session.rehydrate(llmDraft, CHANNELS.agent), conv.session.rehydrate(llmDraft, CHANNELS.tool), conv.session.rehydrate(llmDraft, CHANNELS.audit), ]); // Unresolved tokens are never guessed. Treat any leftover token on the customer // channel as unsafe and withhold the reply rather than ship a dangling placeholder. const unresolvedTokens = customer.tokens_unresolved ?? []; const safe = unresolvedTokens.length === 0 && !RESIDUAL_TOKEN.test(customer.restored_text); return { conversationId, safeText: transformed.safe_text, entities: transformed.entities, entitySummary: summarize(transformed.entities, transformed.entity_summary), llmDraft, views: { customer: customer.restored_text, agent: agent.restored_text, tool: tool.restored_text, audit: audit.restored_text, }, unresolvedTokens, safe, customerReply: safe ? customer.restored_text : SAFE_FALLBACK, }; }}function mergeHints(target: Map<string, EntityHint>, context: EntityContext[]): void { for (const c of context) { target.set(c.token, { token: c.token, type: c.type, gender: c.gender, formality: c.formality, language: c.language, }); }}function summarize( entities: EntityInfo[], provided?: Record<string, number>,): Record<string, number> { if (provided && Object.keys(provided).length > 0) { return provided; } const counts: Record<string, number> = {}; for (const e of entities) { counts[e.type] = (counts[e.type] ?? 0) + 1; } return counts;}
Company config, the four output channels mapped to OutputChannel values, and the fixture loader that
splits the thread into customer turns on the <<<KUNDE>>> marker.
/** * Company and scenario configuration for the Musterfirma support bot. * * Musterfirma GmbH (fictional) is a German outdoor-gear mail-order retailer. * Its support assistant "Nora" drafts formal German replies with an external LLM, * but German data-protection rules forbid sending customer PII to that provider. * OGuardAI is the runtime that lets the LLM work on semantic tokens only, then * restores real values per output channel on the way back. */import { readFileSync } from "node:fs";import { dirname, resolve } from "node:path";import { fileURLToPath } from "node:url";import type { OutputChannel } from "@oguardai/sdk";export const COMPANY = { name: "Musterfirma GmbH", assistant: "Nora", /** German formal support policy: gender-aware salutations, level-1 data masked/redacted. */ policy: process.env.SUPPORT_POLICY ?? "german-support", /** Customers write in German; the NER detector tags person gender and formality from this. */ language: "de",} as const;/** * Output channels the bot restores into. Each downstream consumer sees only what * the german-support policy allows for its channel (see the policy's channel_rules). */export const CHANNELS = { /** The reply sent back to the customer: full name/order/email, IBAN masked. */ customer: "customer_email" satisfies OutputChannel, /** The human agent's console: name/order visible, contact + IBAN masked. */ agent: "internal_summary" satisfies OutputChannel, /** A downstream refund/ticket system: minimum data, everything masked. */ tool: "tool_payload" satisfies OutputChannel, /** The audit log line: fully redacted, safe to persist anywhere. */ audit: "log_safe" satisfies OutputChannel,} as const;const fixturesDir = resolve(dirname(fileURLToPath(import.meta.url)), "..", "fixtures");const CUSTOMER_MARKER = "<<<KUNDE>>>";/** * The invented PII planted in the fixture thread, split by detector. STRUCTURED * values are builtin-regex-detectable and must never cross to the model on any lane; * a NER name may cross only when the runtime runs without NER, and then it is * reported, never silent. Values mirror nordwind-support-thread.de.md exactly. */export const NER_PII = ["Erika Mustermann"] as const;export const STRUCTURED_PII = [ "KD-77410", "erika.mustermann@beispiel.de", "ORD-2026-5510", "+49 151 98765432", "DE97 9900 0000 0123 4567 89",] as const;/** * Load the customer turns from the fixture thread. Everything before the first * marker is narrative prose and is dropped; each marked block is one turn. * * A turn boundary is only a line whose entire content is the marker. Matching the * bare string would also split on the marker where the narrative prose mentions it * (e.g. inside backticks), producing a bogus first turn. */export function loadCustomerTurns(): string[] { const raw = readFileSync(resolve(fixturesDir, "nordwind-support-thread.de.md"), "utf8"); const turns: string[] = []; let current: string[] | null = null; for (const line of raw.split("\n")) { if (line.trim() === CUSTOMER_MARKER) { if (current) turns.push(current.join(" ").replace(/\s+/g, " ").trim()); current = []; continue; } if (current) current.push(line.trim()); } if (current) turns.push(current.join(" ").replace(/\s+/g, " ").trim()); return turns.filter((block) => block.length > 0);}
The model only ever receives tokenized text plus non-identifying hints. When OPENAI_API_KEY is set
it calls OpenAI Chat Completions over fetch; otherwise it composes a deterministic offline draft that
reuses the exact tokens from the latest turn.
/** * LLM adapter for the support bot. * * The model only ever receives tokenized text (safe_text) plus non-identifying * hints (token, type, gender, formality). It never sees a real name, e-mail, or * IBAN. When OPENAI_API_KEY is set the adapter calls OpenAI's Chat Completions * API over fetch; otherwise it falls back to a deterministic offline draft so the * example runs end-to-end against OGuardAI alone. */import { COMPANY } from "./scenario.js";/** One safe (already tokenized) conversation message handed to the model. */export interface SafeMessage { role: "user" | "assistant"; content: string;}/** Non-identifying metadata the model uses to place tokens and pick salutations. */export interface EntityHint { token: string; type: string; gender?: string; formality?: string; language?: string;}const OPENAI_BASE_URL = process.env.OPENAI_BASE_URL ?? "https://api.openai.com/v1";const OPENAI_MODEL = process.env.OPENAI_MODEL ?? "gpt-4o-mini";export function llmMode(): "openai" | "offline" { return process.env.OPENAI_API_KEY ? "openai" : "offline";}/** * The token contract is the load-bearing instruction: the model must echo every * placeholder verbatim and must NOT prepend a salutation to a person token, because * OGuardAI's `formatted` restore injects the correct gendered "Herr"/"Frau" itself. */function systemPrompt(hints: EntityHint[]): string { const table = hints .map((h) => ` ${h.token} -> ${h.type}${h.gender && h.gender !== "unknown" ? `, ${h.gender}` : ""}`) .join("\n"); return [ `Du bist ${COMPANY.assistant}, Kundenservice-Assistent von ${COMPANY.name}.`, "Antworte auf Deutsch in der formellen Sie-Form, knapp und hilfsbereit.", "", "Die Kundennachricht enthaelt Platzhalter der Form {{typ:id:hash}}. Sie stehen", "fuer echte Daten, die du nicht sehen darfst. Regeln:", "1. Uebernimm jeden Platzhalter Zeichen fuer Zeichen unveraendert. Erfinde,", " vervollstaendige, uebersetze oder formatiere niemals einen Platzhalter und", " schreibe niemals selbst einen echten Namen, eine Nummer, E-Mail oder IBAN.", "2. Schreibe KEIN 'Herr', 'Frau' oder 'Sehr geehrte' vor einen person-Platzhalter.", " Die Laufzeit setzt die korrekte Anrede selbst ein. Eroeffne mit", " 'Guten Tag {{person:...}},' und platziere den Platzhalter dort, wo der Name steht.", "3. Erwaehne nur Platzhalter, die tatsaechlich vorkommen; erfinde keine Daten.", "", "Bekannte Platzhalter dieser Konversation:", table || " (keine)", ].join("\n");}export async function draftReply(history: SafeMessage[], hints: EntityHint[]): Promise<string> { return llmMode() === "openai" ? draftWithOpenAI(history, hints) : draftOffline(history, hints);}async function draftWithOpenAI(history: SafeMessage[], hints: EntityHint[]): Promise<string> { const res = await fetch(`${OPENAI_BASE_URL}/chat/completions`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${process.env.OPENAI_API_KEY}`, }, body: JSON.stringify({ model: OPENAI_MODEL, temperature: 0.2, messages: [{ role: "system", content: systemPrompt(hints) }, ...history], }), }); if (!res.ok) { throw new Error(`OpenAI request failed: ${res.status} ${res.statusText}`); } const data = (await res.json()) as { choices?: Array<{ message?: { content?: string } }> }; const content = data.choices?.[0]?.message?.content; if (!content) { throw new Error("OpenAI returned an empty completion"); } return content.trim();}function tokenOf(type: string, hints: EntityHint[]): string | undefined { return hints.find((h) => h.type === type)?.token;}/** * Deterministic stand-in for the model. Composes a formal German reply that reuses * the exact tokens present in the latest customer turn, so the example produces the * same protection trace with or without a provider key. */function draftOffline(history: SafeMessage[], hints: EntityHint[]): string { const latest = [...history].reverse().find((m) => m.role === "user")?.content ?? ""; const mentions = (type: string): string | undefined => { const token = tokenOf(type, hints); return token && latest.includes(token) ? token : undefined; }; const person = tokenOf("person", hints); const order = mentions("order"); const iban = mentions("iban"); const email = mentions("email"); const dob = mentions("date_of_birth"); const lines: string[] = []; lines.push(person ? `Guten Tag ${person},` : "Guten Tag,"); lines.push(""); lines.push( order ? `vielen Dank fuer Ihre Nachricht zu Bestellung ${order}. Wir haben Ihr Anliegen aufgenommen und pruefen den Fall umgehend.` : "vielen Dank fuer Ihre Nachricht. Wir haben Ihr Anliegen aufgenommen und pruefen den Fall umgehend.", ); if (iban) { lines.push( `Die Rueckerstattung veranlassen wir auf das angegebene Konto ${iban} innerhalb von fuenf Werktagen.`, ); } if (dob) { lines.push(`Ihre Identitaet konnten wir anhand des hinterlegten Geburtsdatums ${dob} bestaetigen.`); } if (email) { lines.push(`Eine schriftliche Bestaetigung senden wir an ${email}.`); } lines.push(""); lines.push("Mit freundlichen Gruessen"); lines.push(`${COMPANY.assistant}, Kundenservice ${COMPANY.name}`); return lines.join("\n");}
A deployable Express surface over SupportBot. Each /chat turn runs the full cycle, returns the
customer-safe reply plus the redacted audit_line, and logs only the log_safe view. It fails closed
with HTTP 502 on any transform, LLM, or rehydrate error, never echoing the raw message.
/** * Musterfirma support chatbot server. * * A deployable Express surface over SupportBot. Every /chat turn runs the full * OGuardAI cycle: the customer message is tokenized before the LLM ever sees it, * the LLM drafts a reply on tokens only, and the reply is rehydrated on the * customer_email channel. The response also returns the fully redacted log_safe * line, so the server can log a turn without persisting any PII. * * Env: * PORT chatbot port (default 4000) * GUARDAI_URL OGuardAI base URL (default http://localhost:3000) * GUARDAI_BASE_URL accepted as an alias for GUARDAI_URL * GUARDAI_API_KEY sent as X-API-Key when OGuardAI auth is enabled * OPENAI_API_KEY optional; when unset the bot uses a deterministic offline draft * SUPPORT_POLICY OGuardAI policy name (default german-support) */import express from "express";import { randomUUID } from "node:crypto";import { OGuardAIClient } from "@oguardai/sdk";import { SupportBot } from "./support-bot.js";import { COMPANY } from "./scenario.js";import { llmMode } from "./llm.js";const guardaiUrl = process.env.GUARDAI_URL ?? process.env.GUARDAI_BASE_URL ?? "http://localhost:3000";const guardai = new OGuardAIClient({ baseUrl: guardaiUrl, apiKey: process.env.GUARDAI_API_KEY });const bot = new SupportBot(guardai);const app = express();app.use(express.json());/** * POST /chat * Body: { message: string, conversation_id?: string } * Response: { reply, conversation_id, safe, entities_detected, audit_line } */app.post("/chat", async (req, res) => { const { message, conversation_id } = req.body as { message?: string; conversation_id?: string }; if (!message || typeof message !== "string") { res.status(400).json({ error: "message is required" }); return; } const conversationId = conversation_id ?? randomUUID(); try { const turn = await bot.handleTurn(conversationId, message); // Log the fully redacted line, never the raw turn. console.log(`[audit] conversation=${conversationId} ${turn.views.audit}`); res.json({ reply: turn.customerReply, conversation_id: conversationId, safe: turn.safe, entities_detected: turn.entitySummary, audit_line: turn.views.audit, }); } catch (err) { // Fail closed: on any transform/LLM/rehydrate failure, return a safe error and // never echo the raw message or a partially processed value. const code = err instanceof Error && "code" in err ? String((err as { code: unknown }).code) : undefined; console.error(`[error] conversation=${conversationId} code=${code ?? "unknown"}`); res.status(502).json({ error: "Could not process the message safely", code, conversation_id: conversationId }); }});/** POST /reset { conversation_id }: drop a conversation's session. */app.post("/reset", (req, res) => { const { conversation_id } = req.body as { conversation_id?: string }; if (!conversation_id) { res.status(400).json({ error: "conversation_id is required" }); return; } bot.reset(conversation_id); res.json({ ok: true });});app.get("/health", async (_req, res) => { try { const health = await guardai.health(); res.json({ chatbot: "ok", guardai: health.status, llm: llmMode() }); } catch { res.json({ chatbot: "ok", guardai: "unreachable", llm: llmMode() }); }});const PORT = parseInt(process.env.PORT ?? "4000", 10);app.listen(PORT, () => { console.log(`${COMPANY.name} support chatbot on http://localhost:${PORT}`); console.log(`OGuardAI: ${guardaiUrl} policy: ${COMPANY.policy} LLM: ${llmMode()}`);});
Drives the fixture through SupportBot against a live server, asserts no structured PII crosses the
boundary on either the tokenized input or the tokenized draft, and then proves the two fail-closed
properties directly against the SDK: a tampered session_state is rejected, and an invented token is
left verbatim and reported.
/** * End-to-end trace of the Musterfirma support bot. * * Drives the fixture conversation through SupportBot against a live OGuardAI * server and prints, per turn: the tokenized text the LLM sees, the detected * entities, the tokenized draft, and the same draft restored into four channels. * Then it shows two fail-closed behaviors: a tampered session blob and a token * the model invented. * * Run: GUARDAI_API_KEY=... pnpm demo */import { OGuardAIClient, ValidationError } from "@oguardai/sdk";import { SupportBot } from "./support-bot.js";import { CHANNELS, COMPANY, NER_PII, STRUCTURED_PII, loadCustomerTurns } from "./scenario.js";import { llmMode } from "./llm.js";const guardaiUrl = process.env.GUARDAI_URL ?? process.env.GUARDAI_BASE_URL ?? "http://localhost:3000";const client = new OGuardAIClient({ baseUrl: guardaiUrl, apiKey: process.env.GUARDAI_API_KEY });function rule(label: string): void { console.log(`\n${"=".repeat(72)}\n${label}\n${"=".repeat(72)}`);}/** * Check one string that crosses to the model. A STRUCTURED value crossing is a hard * failure on every lane. A name crossing fails only when NER is active; without NER * the runtime has no name detector, so the name is a reported degradation, never a * silent leak. The one signal is ner_active from capabilities, not a transform probe. */function assertBoundary(text: string, nerAvailable: boolean, nameNotes: string[]): void { const structured = STRUCTURED_PII.filter((value) => text.includes(value)); if (structured.length > 0) { throw new Error(`boundary check failed: structured PII crossed to the model: ${structured.join(", ")}`); } for (const name of NER_PII.filter((value) => text.includes(value))) { if (nerAvailable) { throw new Error(`boundary check failed: the name ${name} crossed to the model while NER is active`); } console.log(`\n ! HINWEIS: Der Name ${name} erreichte das Modell, weil dieser Runtime ohne NER laeuft.`); if (!nameNotes.includes(name)) nameNotes.push(name); }}async function main(): Promise<void> { try { const health = await client.health(); if (health.status === "unhealthy") { throw new Error("server reports unhealthy"); } } catch { console.error(`OGuardAI is not reachable at ${guardaiUrl}.`); console.error("Start it first: docker compose -f deploy/docker/docker-compose.yml up -d (from the repo root; see this example's README)"); process.exit(1); } let nerAvailable = true; try { nerAvailable = (await client.capabilities()).ner_active; } catch { nerAvailable = true; } rule(`${COMPANY.name} - Support-Assistent "${COMPANY.assistant}"`); console.log(`OGuardAI: ${guardaiUrl}`); console.log( `Policy: ${COMPANY.policy} Sprache: ${COMPANY.language} LLM: ${llmMode()} NER: ${nerAvailable ? "aktiv" : "aus"}`, ); console.log( "Ziel: formelle deutsche Antworten per LLM, ohne dass Kundendaten die Vertrauensgrenze verlassen.", ); const bot = new SupportBot(client); const conversationId = "nordwind-demo"; const turns = loadCustomerTurns(); const nameNotes: string[] = []; for (const [i, message] of turns.entries()) { rule(`RUNDE ${i + 1}`); const turn = await bot.handleTurn(conversationId, message); console.log(`Was das LLM sieht (safe_text, nur Tokens):\n ${turn.safeText}`); console.log(`\nErkannte Entitaeten: ${formatSummary(turn.entitySummary)}`); console.log(`\nLLM-Entwurf (noch tokenisiert):\n${indent(turn.llmDraft)}`); console.log("\nWiederhergestellt pro Kanal (dieselbe Antwort, verschiedene Sichtbarkeit):"); console.log(`\n [customer_email] -> Kunde:\n${indent(turn.views.customer, 4)}`); console.log(`\n [internal_summary] -> Agent:\n${indent(turn.views.agent, 4)}`); console.log(`\n [tool_payload] -> Refund-Tool:\n${indent(turn.views.tool, 4)}`); console.log(`\n [log_safe] -> Audit-Log:\n${indent(turn.views.audit, 4)}`); if (!turn.safe) { console.log(`\n ! Antwort zurueckgehalten (unaufgeloeste Tokens: ${turn.unresolvedTokens.join(", ")})`); } assertBoundary(turn.safeText, nerAvailable, nameNotes); assertBoundary(turn.llmDraft, nerAvailable, nameNotes); } await failClosedDemo(); rule("Fertig"); if (nameNotes.length > 0) { console.log( `Strukturierte Daten blieben in der Vertrauensgrenze. Der Name ${[...new Set(nameNotes)].join(", ")} ` + "erreichte das Modell, weil dieser Runtime ohne NER laeuft.", ); } else { console.log("Das LLM hat zu keinem Zeitpunkt einen echten Namen, eine E-Mail oder eine IBAN gesehen."); }}/** Two safety properties shown against the raw OGuardAI contract. */async function failClosedDemo(): Promise<void> { rule("FAIL-CLOSED"); const t = await client.transform({ input: "Guten Tag, mein Name ist Frau Erika Mustermann und ich brauche Hilfe.", language: COMPANY.language, policy: COMPANY.policy, }); const personToken = t.entities.find((e) => e.type === "person")?.token ?? "{{person:p_001:0000}}"; // 1) A tampered session blob must be rejected, never silently restored. const tampered = `${t.session_state?.slice(0, -40)}${"A".repeat(40)}`; let tamperAccepted = false; try { await client.rehydrate({ output: `Guten Tag ${personToken}.`, session_state: tampered, output_channel: CHANNELS.customer, }); tamperAccepted = true; } catch (err) { const label = err instanceof ValidationError ? `ValidationError (${err.code})` : String(err); console.log(`1) Tampered session_state -> rejected: ${label}`); } if (tamperAccepted) { throw new Error("fail-closed check failed: a tampered session was accepted"); } // 2) A token the model invented is left verbatim and reported, never guessed. const invented = "{{person:p_999:deadbeef0000}}"; const r = await client.rehydrate({ output: `${personToken} und ${invented} melden sich.`, session_state: t.session_state ?? "", output_channel: CHANNELS.customer, }); console.log( `2) Invented token -> resolved=${r.tokens_resolved} unresolved=[${(r.tokens_unresolved ?? []).join(", ")}]`, ); console.log(` restored: ${r.restored_text}`); console.log(" The bot's residual-token guard marks this reply unsafe and withholds it."); if (!r.restored_text.includes(invented)) { throw new Error("fail-closed check failed: a forged token was resolved instead of left unresolved"); }}function formatSummary(summary: Record<string, number>): string { const parts = Object.entries(summary).map(([type, n]) => `${type}=${n}`); return parts.length ? parts.join(", ") : "(keine)";}function indent(text: string, spaces = 2): string { const pad = " ".repeat(spaces); return text .split("\n") .map((line) => pad + line) .join("\n");}main().catch((err) => { console.error("Demo failed:", err); process.exit(1);});
A bounded, self-expiring per-conversation store. Entries expire after a TTL and the least recently used
entry is evicted once the cap is reached, so a caller inventing distinct ids cannot grow the process
without limit.
/** * A bounded, self-expiring store of per-conversation state. * * The conversation key in this demo is supplied by the caller (the request body), so a caller can * invent an unlimited number of distinct ids. In production the key must derive from the * authenticated principal, never from the request body. This demo trusts the caller, so the only * real defect to close is unbounded growth: entries expire after a TTL, and once the entry cap is * reached the least recently used entry is evicted. Memory therefore stays bounded no matter how * many distinct ids a caller invents. */export interface ConversationStoreOptions { /** Maximum number of live conversations kept in memory. Default 1000. */ maxEntries?: number; /** Milliseconds of inactivity after which a conversation is dropped. Default 30 minutes. */ ttlMs?: number; /** Clock source, injectable so tests can drive expiry deterministically. Default Date.now. */ now?: () => number;}const DEFAULT_MAX_ENTRIES = 1000;const DEFAULT_TTL_MS = 30 * 60 * 1000;interface Entry<T> { value: T; lastSeen: number;}export class ConversationStore<T> { private readonly entries = new Map<string, Entry<T>>(); private readonly maxEntries: number; private readonly ttlMs: number; private readonly now: () => number; constructor(options: ConversationStoreOptions = {}) { this.maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES; this.ttlMs = options.ttlMs ?? DEFAULT_TTL_MS; this.now = options.now ?? Date.now; // A non-finite or non-positive cap or TTL silently disables eviction, leaving the store // unbounded, so reject it at construction instead of failing open at runtime. if (!Number.isInteger(this.maxEntries) || this.maxEntries <= 0) { throw new Error("maxEntries must be a positive integer"); } if (!Number.isFinite(this.ttlMs) || this.ttlMs <= 0) { throw new Error("ttlMs must be a positive number of milliseconds"); } } /** Return the value for id if it is present and unexpired, refreshing its recency. */ get(id: string): T | undefined { this.evictExpired(); const entry = this.entries.get(id); if (!entry) return undefined; entry.lastSeen = this.now(); // Re-insert so the most recently used id moves to the end and overflow evicts the coldest. this.entries.delete(id); this.entries.set(id, entry); return entry.value; } /** Store value under id, evicting the coldest entry if the cap would be exceeded. */ set(id: string, value: T): void { this.evictExpired(); // Map.set on an existing key updates in place without moving it, so an overwrite must delete // first to refresh recency the way get() does, or a hot key could still evict as the coldest. if (this.entries.has(id)) { this.entries.delete(id); } else if (this.entries.size >= this.maxEntries) { const coldest = this.entries.keys().next().value; if (coldest !== undefined) this.entries.delete(coldest); } this.entries.set(id, { value, lastSeen: this.now() }); } delete(id: string): void { this.entries.delete(id); } get size(): number { this.evictExpired(); return this.entries.size; } private evictExpired(): void { if (this.ttlMs <= 0) return; const cutoff = this.now() - this.ttlMs; for (const [id, entry] of this.entries) { if (entry.lastSeen <= cutoff) this.entries.delete(id); } }}
The three-turn German support thread. Every value is invented. The demo splits it into customer turns
on each line that is exactly <<<KUNDE>>>.
# Musterfirma GmbH, Support-Konversation (fiktiv)Musterfirma GmbH ist ein fiktiver deutscher Outdoor-Versandhaendler. DerSupport-Assistent "Nora" beantwortet Kundenanfragen auf Deutsch. Jede Nachrichtunten enthaelt frei erfundene personenbezogene Daten (Name, Kundennummer,Bestellnummer, E-Mail, Telefon, Lieferadresse, IBAN, Geburtsdatum). Es sind keineechten Personendaten enthalten.Der Demo-Runner behandelt jede Zeile, die nur aus dem Kunden-Marker besteht, alsBeginn einer neuen Gespraechsrunde und schickt den folgenden Block durch denOGuardAI-Schutzzyklus. Die gesamte Konversation laeuft in einer einzigen Session,damit dieselbe Person, dieselbe Bestellung und dieselbe E-Mail ueber alle Rundenhinweg denselben Token behalten.<<<KUNDE>>>Guten Tag, mein Name ist Frau Erika Mustermann. Meine Kundennummer lautet KD-77410und meine Bestellung ORD-2026-5510 (eine Trekkingjacke) ist beschaedigt bei mirangekommen. Geliefert wurde an Musterstrasse 14, 80331 Muenchen. Sie erreichen michper E-Mail unter erika.mustermann@beispiel.de oder telefonisch unter +49 151 98765432erreichbar. Bitte teilen Sie mir mit, wie ich vorgehen soll.<<<KUNDE>>>Vielen Dank fuer die schnelle Rueckmeldung. Ich moechte die beschaedigte Jacke ausBestellung ORD-2026-5510 zuruecksenden und den Kaufbetrag erstattet bekommen. Bitteueberweisen Sie die Rueckerstattung auf mein Konto mit der IBANDE97 9900 0000 0123 4567 89.<<<KUNDE>>>Koennten Sie mir die Rueckerstattung fuer Bestellung ORD-2026-5510 bitte noch einmalschriftlich an erika.mustermann@beispiel.de bestaetigen? Zur Verifizierung meinerIdentitaet nenne ich Ihnen mein Geburtsdatum: der 12.04.1987.