OGuardAI
Integrations

Express middleware

Mount one OGuardAI Express middleware on every AI route so PII is tokenized before the model and restored per output channel, shown across three policies

The examples/express-middleware example is an AI gateway for Musterfirma GmbH, a fictional Munich consumer-loan servicer. Its customer-operations team runs an internal assistant "Karla" that drafts agent briefs and customer replies, but German data-protection rules forbid sending customer PII to the external model. The gateway mounts ONE OGuardAI Express middleware on every AI route: the request is tokenized before any model call, and the response is restored per output channel on the way back, so the route handlers stay completely PII-agnostic.

client request (message with PII)
    -> guardai(config)                POST /v1/transform  -> req.guardai.safeText (tokens only)
    -> your route handler             sees safe_text only, calls the model, returns a draft
    -> guardai(config) (res.json)     POST /v1/rehydrate  -> restores per output channel
    -> client response                real values restored only where the channel allows

The middleware owns the trust boundary so the handler never does. It overwrites the raw request field with safe_text (so even a careless handler cannot read real PII) and wraps res.json to rehydrate the configured response fields before anything leaves the process.

Three routes, three profiles

The same guardai() factory is mounted three times with a different policy and channel each, which is the point of the example: one seam, configured per route.

RoutePolicyChannelWhat it proves
POST /assist/agent-brieffinancialinternal_summaryIBAN redacted at the ingestion boundary, restored brief for the human agent
POST /assist/customer-replygerman-supportcustomer_emailgender and formality aware salutation, per-request restore_mode
POST /assist/intake-screenstrict-pii(block gate)a level-1 identifier rejects the whole request; the model never runs

Prerequisites

  • Node.js 22+ (the local transport uses the global fetch, no SDK build step).
  • A running OGuardAI server on http://localhost:3000.
  • Needs-NER: the Python NER detector sidecar must be active so person and address are detected. Check with curl localhost:3000/v1/health: the detector component should report builtin_and_ner. The financial, german-support, and strict-pii policies all declare 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 financial, german-support, and strict-pii policies, all shipped in policies/.

Start the full stack (server plus the NER detector sidecar) from the repo root:

# The bundled stack requires these four secrets, and its client key ships least-privilege, so widen it
# to the policies and channels this example uses.
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_POLICIES=default,default-ner-required,german-support,financial,strict-pii
export GUARDAI_API_KEY_1_ALLOWED_OUTPUT_CHANNELS=user_output,internal_summary,customer_email,log_safe
docker compose -f deploy/docker/docker-compose.yml up -d

Setup

cd examples/express-middleware
pnpm install     # or: npm install

The example depends only on express; the OGuardAI transport is a thin typed fetch client defined in the example itself, so there is no SDK build step.

{
  "name": "guardai-express-middleware",
  "version": "1.0.0",
  "description": "Musterfirma AI gateway: one OGuardAI Express middleware, three policies",
  "type": "module",
  "scripts": {
    "typecheck": "tsc --noEmit",
    "dev": "tsx src/server.ts",
    "start": "tsx src/server.ts",
    "demo": "tsx src/demo.ts"
  },
  "dependencies": {
    "express": "^4.21.0"
  },
  "devDependencies": {
    "@types/express": "^4.17.21",
    "@types/node": "^20",
    "tsx": "^4.0",
    "typescript": "^5.5"
  }
}

Run the guided trace

Scenario: drive all three guarded routes against a live server and print the full protection trace per route.

pnpm demo starts the real Express app in-process against a live OGuardAI server, drives all three routes with the fixture documents in fixtures/, and prints the full protection trace per route: the raw text, what the model saw (tokens only), the detected entities, and the restored output per channel. It ends with the restore-mode spectrum and the three fail-closed proofs.

export GUARDAI_API_KEY="$GUARDAI_API_KEY_1"   # the bundled stack runs api_key auth
pnpm demo                      # or: npx tsx src/demo.ts

Expected output (values differ per run because token ids are random):

1) /assist/agent-brief   policy=financial  channel=internal_summary
Was das LLM sah (safe_text, nur Tokens):
  ... ich bin Frau Dr. {{person:p_001:...}}, geboren am {{date_of_birth:dob_001:...}},
  Kundennummer {{customer_id:cid_001:...}} ... Vorgang {{order:o_001:...}} ...
  Konto IBAN [REDACTED:iban].
IBAN am Boundary redigiert (nie ein Token): ja
Interne Notiz fuer den Sachbearbeiter (internal_summary):
  Kundenanliegen von Frau Dr. Erika Mustermann betrifft Vorgang ORD-2026-5510 ...
Audit-Zeile (log_safe, PII-frei, gefahrlos speicherbar):
  Kundenanliegen von [REDACTED] betrifft Vorgang [REDACTED] ...

2) /assist/customer-reply   policy=german-support  channel=customer_email
Antwort an die Kundin (customer_email, geschlechts- und formkorrekte Anrede):
  Guten Tag Frau Dr. Erika Mustermann,
  vielen Dank fuer Ihre Nachricht zum Vorgang ORD-2026-5510 ...
  Die Rueckerstattung veranlassen wir auf das angegebene Konto D*** **** ... *0 ...

3) restore_mode-Spektrum auf demselben Entwurf (Person-Zeile)
  formatted  -> Guten Tag Frau Dr. Erika Mustermann,
  full       -> Guten Tag Frau Dr. Erika Mustermann,
  partial    -> Guten Tag E. Mustermann,
  masked     -> Guten Tag E*************n,

4) /assist/intake-screen   policy=strict-pii  (block gate)
Sauberes Dokument -> HTTP 200, accepted=true
Dokument mit Reisepass/SSN/IBAN -> HTTP 422, code=GUARDAI_POLICY_DENIED
  Das Modell wurde nie aufgerufen; der Vorgang geht in die manuelle Pruefung.

5) Fail-closed Nachweise
a) Runtime nicht erreichbar -> HTTP 502, code=GUARDAI_UNREACHABLE
   Rohdaten in der Antwort geleakt: nein
b) Manipulierte Session -> abgelehnt: HTTP 400, code=GUARDAI_INVALID_INPUT
c) Erfundenes Token -> unresolved=["{{person:p_999:...}}"] (verbatim belassen, nie fabriziert)

Run the HTTP gateway

Scenario: run the same app as a deployable Express server and hit each route with curl.

pnpm dev          # tsx, no build step  (or: npx tsx src/server.ts)

The gateway listens on http://localhost:4000 and logs a PII-free audit line per request.

# Agent brief. financial policy -> IBAN redacted at the boundary; brief restored for the agent.
curl -s -X POST http://localhost:4000/assist/agent-brief -H "Content-Type: application/json" \
  -d '{"message":"Ich bin Frau Dr. Erika Mustermann, Vorgang ORD-2026-5510, doppelte Abbuchung. IBAN DE97 9900 0000 0123 4567 89."}'

# Customer reply. german-support -> gendered salutation on customer_email; IBAN stays masked.
curl -s -X POST http://localhost:4000/assist/customer-reply -H "Content-Type: application/json" \
  -d '{"message":"Ich bin Frau Dr. Erika Mustermann, Vorgang ORD-2026-5510. Erreichbar unter erika.mustermann@beispiel.de."}'

# Same reply, visibility tightened for the agent screen with a per-request restore_mode.
curl -s -X POST http://localhost:4000/assist/customer-reply -H "Content-Type: application/json" \
  -d '{"message":"Ich bin Frau Dr. Erika Mustermann, Vorgang ORD-2026-5510.","restore_mode":"partial"}'

# Intake screen. strict-pii accepts a clean doc, rejects one with a level-1 identifier (HTTP 422).
curl -s -X POST http://localhost:4000/assist/intake-screen -H "Content-Type: application/json" \
  -d '{"message":"Reisepass C01X00T47, IBAN DE97 9900 0000 0123 4567 89."}'

# Health: reports the gateway status and whether the LLM runs in openai or offline mode.
curl -s http://localhost:4000/health

Every business response also carries a _guardai block with { safe, entities_detected, safe_text, audit_line? } and an X-Guardai-Safe header, so a client can observe the protection without the PII ever crossing the boundary. safe_text is tokens only, so it is safe to expose; a real client just ignores it.

The middleware: src/guardai.ts

This is the load-bearing file. guardai(config) returns an Express RequestHandler: it transforms the request field before the handler, overwrites the raw field with safe_text, and wraps res.json to rehydrate the configured response fields per output channel. The GuardaiTransport class is a thin typed fetch client for /v1/transform and /v1/rehydrate. All the fail-closed paths (422 policy block, 502 unreachable, 422 residual or unresolved token, 503 scanner outage) live here.

/**
 * OGuardAI Express middleware.
 *
 * Mount `guardai(config)` on any route and PII protection becomes transparent:
 *
 *   request  ─▶ [transform]  ─▶ req.guardai.safeText (tokens only) ─▶ handler
 *   handler  ─▶ res.json({...}) ─▶ [rehydrate per channel] ─▶ client
 *
 * The middleware owns the trust boundary so the route handler never does. It
 * overwrites the raw request field with `safe_text`, so even a careless handler
 * cannot read real PII, and it wraps `res.json` to restore tokens on the
 * configured output channel before anything leaves the process.
 *
 * Fail-closed is the whole point:
 *   - transform blocked by policy (level-1 identifier) -> 422, model never runs.
 *   - transform unreachable or 5xx                     -> 502, raw PII never forwarded.
 *   - output guard refuses the restored content        -> 422, the draft is withheld.
 *   - output guard cannot run                          -> 503, retriable, nothing emitted.
 *   - a residual or unresolved token survives restore  -> 422, the draft is withheld.
 */

import type { NextFunction, Request, RequestHandler, Response } from "express";

export type OutputChannel =
  | "customer_email"
  | "internal_summary"
  | "tool_payload"
  | "log_safe"
  | (string & {});

export type RestoreMode = "full" | "partial" | "masked" | "formatted" | "abstract" | "none";

/** Non-identifying metadata the LLM adapter uses to place tokens and pick salutations. */
export interface EntityHint {
  token: string;
  type: string;
  gender?: string;
  formality?: string;
  language?: string;
}

/** State the middleware attaches to the request for the handler to consume. */
export interface GuardState {
  /** Exactly what the handler and the LLM may see: tokens only, never raw PII. */
  safeText: string;
  sessionState: string;
  hints: EntityHint[];
  entitySummary: Record<string, number>;
  /** Per-request restore_mode override captured before the handler mutates the body. */
  restoreModeOverride?: RestoreMode;
}

export interface GuardedRequest extends Request {
  guardai?: GuardState;
}

/** Response augmented with a handle to await the middleware's async restore step. */
export interface GuardedResponse extends Response {
  guardaiFinalized?: Promise<void>;
}

/** Config for one guarded route. Static per mount; the request may override restore_mode. */
export interface GuardRouteConfig {
  path?: string;
  policy: string;
  channel: OutputChannel;
  language?: string;
  callerRole?: string;
  /** Body field carrying the raw text. Default "message". */
  requestField?: string;
  /** Response fields to rehydrate. Empty means the route is a pure intake gate. */
  responseFields?: string[];
  restoreMode?: RestoreMode;
  restoreOverrides?: Record<string, RestoreMode>;
  /** When true, honor `restore_mode` from the request body (tightens visibility only). */
  allowRestoreModeFromBody?: boolean;
  /** Second channel used to emit a PII-free audit line. Omit to disable. */
  auditChannel?: OutputChannel;
  /** Adapter-specific hint (e.g. "brief" vs "reply") passed through on req.guardai. */
  task?: string;
}

const RESIDUAL_TOKEN = /\{\{[^{}]+\}\}/;

// ---------------------------------------------------------------------------
// Transport: a thin typed fetch client for the two endpoints the middleware uses.
// Kept local so the example runs with no SDK build step; the SDK wraps the same
// contract if you prefer typed sessions.
// ---------------------------------------------------------------------------

export class GuardaiHttpError extends Error {
  constructor(
    message: string,
    readonly status: number,
    readonly code?: string,
    readonly requestId?: string,
  ) {
    super(message);
    this.name = "GuardaiHttpError";
  }
}

interface TransformResult {
  safe_text: string;
  session_state: string;
  /** Non-identifying hints. Some policies (e.g. strict-pii) leave this empty. */
  entity_context?: EntityHint[];
  /** Always populated; used as the fallback source for hints and counts. */
  entities?: Array<{ token: string; type: string }>;
}

interface RehydrateResult {
  restored_text: string;
  tokens_unresolved?: string[];
}

export class GuardaiTransport {
  private readonly baseUrl: string;
  private readonly apiKey?: string;

  constructor(opts: { baseUrl?: string; apiKey?: string } = {}) {
    this.baseUrl = (opts.baseUrl ?? "http://localhost:3000").replace(/\/$/, "");
    this.apiKey = opts.apiKey;
  }

  private headers(): Record<string, string> {
    const h: Record<string, string> = { "Content-Type": "application/json" };
    if (this.apiKey) h["X-API-Key"] = this.apiKey;
    return h;
  }

  private async post<T>(path: string, body: unknown): Promise<T> {
    let res: globalThis.Response;
    try {
      res = await fetch(`${this.baseUrl}${path}`, {
        method: "POST",
        headers: this.headers(),
        body: JSON.stringify(body),
      });
    } catch (err) {
      // Network-level failure: the runtime is unreachable. Surface as 503 so the
      // middleware fails closed rather than forwarding raw PII.
      throw new GuardaiHttpError(`OGuardAI unreachable: ${(err as Error).message}`, 503, "GUARDAI_UNREACHABLE");
    }
    if (!res.ok) {
      const detail = (await res.json().catch(() => ({}))) as {
        error?: string;
        code?: string;
        request_id?: string;
      };
      throw new GuardaiHttpError(detail.error ?? `HTTP ${res.status}`, res.status, detail.code, detail.request_id);
    }
    return (await res.json()) as T;
  }

  transform(input: string, cfg: GuardRouteConfig): Promise<TransformResult> {
    return this.post<TransformResult>("/v1/transform", {
      input,
      input_type: "text",
      policy: cfg.policy,
      language: cfg.language,
      context: cfg.callerRole
        ? { caller_role: cfg.callerRole, destination: "external_llm", provider: "openai" }
        : undefined,
    });
  }

  rehydrate(opts: {
    output: string;
    sessionState: string;
    channel: OutputChannel;
    restoreMode?: RestoreMode;
    restoreOverrides?: Record<string, RestoreMode>;
  }): Promise<RehydrateResult> {
    return this.post<RehydrateResult>("/v1/rehydrate", {
      output: opts.output,
      session_state: opts.sessionState,
      output_channel: opts.channel,
      restore_mode: opts.restoreMode,
      restore_overrides: opts.restoreOverrides,
    });
  }
}

// ---------------------------------------------------------------------------
// The middleware factory.
// ---------------------------------------------------------------------------

export interface GuardOptions extends GuardRouteConfig {
  transport: GuardaiTransport;
  logger?: (line: string) => void;
}

export function guardai(opts: GuardOptions): RequestHandler {
  const requestField = opts.requestField ?? "message";
  const responseFields = opts.responseFields ?? ["reply"];
  const log = opts.logger ?? ((line: string) => console.log(line));

  return async (req: GuardedRequest, res: Response, next: NextFunction): Promise<void> => {
    const raw = (req.body as Record<string, unknown> | undefined)?.[requestField];
    if (typeof raw !== "string" || raw.trim() === "") {
      next();
      return;
    }

    // Capture the per-request restore_mode before the handler can touch the body.
    const bodyMode = (req.body as Record<string, unknown>)?.restore_mode;
    const restoreModeOverride =
      opts.allowRestoreModeFromBody && typeof bodyMode === "string" ? (bodyMode as RestoreMode) : undefined;

    // --- Request phase: transform before the handler runs. ---
    let transformed: TransformResult;
    try {
      transformed = await opts.transport.transform(raw, opts);
    } catch (err) {
      if (err instanceof GuardaiHttpError && err.status === 403) {
        // Blocked by policy: a level-1 identifier was present. The model must
        // never run on this input; hand the caller a clean rejection.
        log(`[guardai] blocked ${req.method} ${req.path} code=${err.code ?? "?"}`);
        res.status(422).json({ error: "Rejected by data policy", code: err.code, request_id: err.requestId });
        return;
      }
      // Any other failure: the boundary is unavailable. Fail closed, never forward raw PII.
      const code = err instanceof GuardaiHttpError ? err.code : "GUARDAI_TRANSFORM_FAILED";
      log(`[guardai] transform failed ${req.method} ${req.path} code=${code ?? "?"}`);
      res.status(502).json({ error: "Data protection unavailable", code });
      return;
    }

    const hints = deriveHints(transformed);
    const state: GuardState = {
      safeText: transformed.safe_text,
      sessionState: transformed.session_state,
      hints,
      entitySummary: summarize(hints),
      restoreModeOverride,
    };
    req.guardai = state;
    // Defense in depth: overwrite the raw field so the handler physically cannot
    // read real PII, only the tokenized text.
    // requestField is a fixed config option, not request input, so this is not remote injection.
    // nosemgrep: javascript.express.security.audit.remote-property-injection
    (req.body as Record<string, unknown>)[requestField] = transformed.safe_text;

    // --- Response phase: rehydrate the configured fields on the way out. ---
    const originalJson = res.json.bind(res);
    res.json = ((body: Record<string, unknown>) => {
      // Retain the restore promise instead of orphaning it: the send is awaitable via
      // res.guardaiFinalized, and an unsafe restore withholds the body inside finalize.
      (res as GuardedResponse).guardaiFinalized = finalize(body);
      return res;
    }) as Response["json"];

    async function finalize(body: Record<string, unknown>): Promise<void> {
      try {
        const restored = new Map<string, string>();
        let auditLine = "";

        for (const field of responseFields) {
          const draft = body?.[field];
          if (typeof draft !== "string") continue;

          const primary = await opts.transport.rehydrate({
            output: draft,
            sessionState: state.sessionState,
            channel: opts.channel,
            restoreMode: state.restoreModeOverride ?? opts.restoreMode,
            restoreOverrides: opts.restoreOverrides,
          });

          const unresolved = primary.tokens_unresolved ?? [];
          if (unresolved.length > 0 || RESIDUAL_TOKEN.test(primary.restored_text)) {
            // A token the model invented, or a residual placeholder, is never guessed.
            // The content was judged unsafe, so withhold the whole body and answer a
            // typed verdict rather than shipping half-restored text under a safe flag.
            const detail = unresolved.length > 0 ? `unresolved=${unresolved.length}` : "residual";
            log(`[guardai] withheld ${req.method} ${req.path} field=${field} ${detail}`);
            if (!res.headersSent) {
              res.status(422);
              originalJson({
                error: "Output withheld: unrestored tokens",
                code: "GUARDAI_OUTPUT_BLOCKED",
              });
            }
            return;
          }
          restored.set(field, primary.restored_text);

          if (opts.auditChannel) {
            const audit = await opts.transport.rehydrate({
              output: draft,
              sessionState: state.sessionState,
              channel: opts.auditChannel,
            });
            auditLine = audit.restored_text;
          }
        }

        for (const [field, text] of restored) body[field] = text;

        // Observability metadata, kept off the business payload. safe_text is tokens
        // only, so it is safe to expose; a real client just ignores it. Only a fully
        // restored body reaches this point, so the reported state is always safe.
        body._guardai = {
          safe: true,
          entities_detected: state.entitySummary,
          safe_text: state.safeText,
          ...(opts.auditChannel ? { audit_line: auditLine } : {}),
        };

        res.setHeader("X-Guardai-Safe", "true");
        const auditOneLine = auditLine.replace(/\s+/g, " ").trim();
        log(`[audit] ${req.method} ${req.path} safe=true${auditOneLine ? ` ${auditOneLine}` : ""}`);
        originalJson(body);
      } catch (err) {
        // Rehydrate failed. Keep the output-guard content verdict (422) and the
        // scanner-outage (503) apart; never emit the un-restored draft either way.
        const { status, code } = outputErrorResponse(err);
        log(`[guardai] rehydrate failed ${req.method} ${req.path} code=${code}`);
        if (!res.headersSent) {
          res.status(status);
          originalJson({ error: "Could not restore output safely", code });
        }
      }
    }

    next();
  };
}

/**
 * Prefer the rich `entity_context` (carries gender/formality for the LLM). Fall
 * back to `entities` when a policy omits it, so hints and counts are never empty
 * for a policy that still tokenized values (e.g. strict-pii).
 */
function deriveHints(t: TransformResult): EntityHint[] {
  if (t.entity_context && t.entity_context.length > 0) return t.entity_context;
  const seen = new Map<string, EntityHint>();
  for (const e of t.entities ?? []) {
    if (!seen.has(e.token)) seen.set(e.token, { token: e.token, type: e.type });
  }
  return [...seen.values()];
}

function summarize(hints: EntityHint[]): Record<string, number> {
  const counts: Record<string, number> = {};
  for (const h of hints) counts[h.type] = (counts[h.type] ?? 0) + 1;
  return counts;
}

/**
 * Map a rehydrate failure to the caller-facing status. The output guard keeps two
 * outcomes apart and the middleware must not collapse them: a content verdict is a 422
 * the caller cannot fix by resubmitting the same draft, while an unreachable scanner is a
 * retriable 503. Anything else stays a generic 502 gateway failure.
 */
function outputErrorResponse(err: unknown): { status: number; code: string } {
  if (err instanceof GuardaiHttpError) {
    if (err.code === "GUARDAI_OUTPUT_BLOCKED" || err.status === 422) {
      return { status: 422, code: err.code ?? "GUARDAI_OUTPUT_BLOCKED" };
    }
    if (err.code === "GUARDAI_OUTPUT_GUARD_UNAVAILABLE" || err.status === 503) {
      return { status: 503, code: err.code ?? "GUARDAI_OUTPUT_GUARD_UNAVAILABLE" };
    }
    return { status: 502, code: err.code ?? "GUARDAI_REHYDRATE_FAILED" };
  }
  return { status: 502, code: "GUARDAI_REHYDRATE_FAILED" };
}

/** Build a transport from the standard OGuardAI environment variables. */
export function transportFromEnv(): GuardaiTransport {
  const baseUrl =
    process.env.GUARDAI_URL ??
    process.env.GUARDAI_BASE_URL ??
    process.env.GUARDAI_BASE ??
    "http://localhost:3000";
  return new GuardaiTransport({ baseUrl, apiKey: process.env.GUARDAI_API_KEY });
}

Route profiles: src/scenario.ts

The company config and the three per-route protection profiles. channel sets the restore floor for the response; callerRole is passed into the transform context because the german-support policy only unmasks a person's full gendered salutation for a recognised support role. loadFixture reads a fixture file and collapses it to a single request line.

/**
 * Company and route configuration for the Musterfirma support gateway.
 *
 * Musterfirma GmbH (fictional) is a Munich consumer-loan servicer. Its
 * customer-operations team runs an internal "Karla" AI that drafts agent briefs
 * and customer replies, but German data-protection rules forbid sending customer
 * PII to the external model. This gateway mounts ONE OGuardAI middleware on every
 * AI route: the request is tokenized before any model call and the response is
 * restored per output channel on the way back, so the route handlers stay
 * completely PII-agnostic.
 *
 * The three routes each carry a different policy and channel to show the
 * middleware's per-route configurability, the product's signature strength on
 * this surface:
 *
 *   /assist/agent-brief    financial     internal_summary   IBAN redacted at the boundary
 *   /assist/customer-reply german-support customer_email     gender/formality-aware restore
 *   /assist/intake-screen  strict-pii    (block gate)        level-1 identifiers rejected
 */

import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import type { GuardRouteConfig } from "./guardai.js";

export const COMPANY = {
  name: "Musterfirma GmbH",
  assistant: "Karla",
  language: "de",
} as const;

/**
 * Per-route protection profiles. `channel` sets the restore floor for the
 * response; `callerRole` is passed into the transform context because the
 * german-support policy only unmasks a person's full gendered salutation for a
 * recognised support role.
 */
export const ROUTES = {
  /**
   * Agent brief. The `financial` policy redacts IBAN at the ingestion boundary,
   * so the brief is structurally incapable of carrying an account number. The
   * `internal_summary` channel is what the human agent sees.
   */
  agentBrief: {
    path: "/assist/agent-brief",
    policy: "financial",
    channel: "internal_summary",
    language: COMPANY.language,
    callerRole: "support_agent",
    requestField: "message",
    responseFields: ["brief"],
    task: "brief",
    auditChannel: "log_safe",
  },

  /**
   * Customer reply. `german-support` restores the person token with a gendered
   * "Frau"/"Herr" salutation on the `customer_email` channel while keeping IBAN
   * masked. The route also honours a per-request `restore_mode` so a caller can
   * dial the visibility spectrum (formatted/full/partial/masked) on one draft.
   */
  customerReply: {
    path: "/assist/customer-reply",
    policy: "german-support",
    channel: "customer_email",
    language: COMPANY.language,
    callerRole: "support_agent",
    requestField: "message",
    responseFields: ["reply"],
    task: "reply",
    allowRestoreModeFromBody: true,
    auditChannel: "log_safe",
  },

  /**
   * Intake screen. `strict-pii` blocks every level-1 identifier (IBAN, SSN,
   * passport, card). A document extract that contains one is rejected at the
   * boundary and routed to manual handling; no model is ever invoked. A clean,
   * level-2-only message is tokenized and accepted.
   */
  intakeScreen: {
    path: "/assist/intake-screen",
    policy: "strict-pii",
    channel: "internal_summary",
    language: COMPANY.language,
    callerRole: "support_agent",
    requestField: "message",
    responseFields: [],
  },
} satisfies Record<string, GuardRouteConfig>;

const fixturesDir = resolve(dirname(fileURLToPath(import.meta.url)), "..", "fixtures");

/**
 * Load a fixture message as a single line. The `Betreff:` subject header and any
 * blank lines are collapsed so the text is a clean request body for `transform`
 * and copy-pasteable into the curl examples in the README.
 */
export function loadFixture(name: string): string {
  const raw = readFileSync(resolve(fixturesDir, name), "utf8");
  return raw
    .split("\n")
    .filter((line) => !line.startsWith("Betreff:"))
    .join(" ")
    .replace(/\s+/g, " ")
    .trim();
}

The Express app: src/server.ts

The deployable gateway. One guardai() middleware is mounted on each of the three routes; every handler reads req.guardai.safeText (tokens only), calls the model, and returns a draft. The intake handler is only reached when strict-pii has already accepted the document.

/**
 * Musterfirma AI gateway.
 *
 * One OGuardAI middleware, mounted on three routes with three different policies.
 * Every handler is PII-agnostic: it reads req.guardai.safeText (tokens only),
 * calls the model, and returns a draft. The middleware tokenizes the request
 * before the handler and restores the response per output channel after it.
 *
 * Env:
 *   PORT               gateway port (default 4000)
 *   GUARDAI_URL        OGuardAI base URL (default http://localhost:3000);
 *                      GUARDAI_BASE_URL / GUARDAI_BASE accepted as aliases
 *   GUARDAI_API_KEY    sent as X-API-Key when OGuardAI auth is enabled
 *   OPENAI_API_KEY     optional; unset -> deterministic offline draft
 *   OPENAI_MODEL       optional; default gpt-4o-mini
 */

import express from "express";
import { guardai, transportFromEnv, type GuardaiTransport, type GuardedRequest } from "./guardai.js";
import { COMPANY, ROUTES } from "./scenario.js";
import { draft, llmMode } from "./llm.js";

export interface AppDeps {
  transport?: GuardaiTransport;
  /** Per-request audit sink. Defaults to console.log; the demo passes a quiet one. */
  logger?: (line: string) => void;
}

export function createApp(deps: AppDeps = {}): express.Express {
  const transport = deps.transport ?? transportFromEnv();
  const logger = deps.logger;
  const app = express();
  app.use(express.json());

  // Agent brief: financial policy redacts IBAN at the boundary; restored on the
  // internal_summary channel for the human agent.
  app.post(ROUTES.agentBrief.path, guardai({ ...ROUTES.agentBrief, transport, logger }), async (req: GuardedRequest, res) => {
    const g = req.guardai!;
    const brief = await draft("brief", g.safeText, g.hints);
    res.json({ brief });
  });

  // Customer reply: german-support restores a gendered salutation on the
  // customer_email channel; the caller may tighten visibility via restore_mode.
  app.post(ROUTES.customerReply.path, guardai({ ...ROUTES.customerReply, transport, logger }), async (req: GuardedRequest, res) => {
    const g = req.guardai!;
    const reply = await draft("reply", g.safeText, g.hints);
    res.json({ reply });
  });

  // Intake screen: strict-pii blocks level-1 identifiers before the handler runs.
  // Reaching this handler means the document is clean and already tokenized.
  app.post(ROUTES.intakeScreen.path, guardai({ ...ROUTES.intakeScreen, transport, logger }), (req: GuardedRequest, res) => {
    res.json({ accepted: true });
  });

  app.get("/health", async (_req, res) => {
    res.json({ gateway: "ok", llm: llmMode(), company: COMPANY.name });
  });

  return app;
}

const isMain = process.argv[1] ? import.meta.url === `file://${process.argv[1]}` : false;
if (isMain) {
  const app = createApp();
  const port = parseInt(process.env.PORT ?? "4000", 10);
  app.listen(port, () => {
    console.log(`${COMPANY.name} AI gateway on http://localhost:${port}  (LLM: ${llmMode()})`);
    console.log("Routes:");
    console.log(`  POST ${ROUTES.agentBrief.path}     policy=${ROUTES.agentBrief.policy} channel=${ROUTES.agentBrief.channel}`);
    console.log(`  POST ${ROUTES.customerReply.path}  policy=${ROUTES.customerReply.policy} channel=${ROUTES.customerReply.channel}`);
    console.log(`  POST ${ROUTES.intakeScreen.path}   policy=${ROUTES.intakeScreen.policy} (block gate)`);
  });
}

The LLM adapter: src/llm.ts

The model only ever receives the tokenized safe_text plus non-identifying hints. With OPENAI_API_KEY set the adapter calls OpenAI Chat Completions over fetch; unset, it falls back to a deterministic offline draft so the example runs end to end against OGuardAI alone. The system prompt carries 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 path
export OPENAI_MODEL=gpt-4o-mini     # optional; default gpt-4o-mini
pnpm demo
/**
 * LLM adapter for the Musterfirma gateway.
 *
 * The model only ever receives tokenized text (safe_text) plus non-identifying
 * hints (token, type, gender, formality). It never sees a real name, email, or
 * IBAN. With OPENAI_API_KEY set the adapter calls OpenAI Chat Completions over
 * fetch, reading the key from the environment exactly as a real integration
 * would; unset, it falls back to a deterministic offline draft so the example
 * runs end to end against OGuardAI alone.
 */

import type { EntityHint } from "./guardai.js";
import { COMPANY } from "./scenario.js";

export type DraftTask = "brief" | "reply";

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";
}

export async function draft(task: DraftTask, safeText: string, hints: EntityHint[]): Promise<string> {
  return llmMode() === "openai" ? draftWithOpenAI(task, safeText, hints) : draftOffline(task, safeText);
}

/**
 * The token contract is the load-bearing instruction: echo every placeholder
 * verbatim and never prepend a salutation to a person token, because OGuardAI's
 * formatted restore injects the correct gendered "Herr"/"Frau" itself.
 */
function systemPrompt(task: DraftTask, hints: EntityHint[]): string {
  const table = hints
    .map((h) => `  ${h.token} -> ${h.type}${h.gender && h.gender !== "unknown" ? `, ${h.gender}` : ""}`)
    .join("\n");

  const shared = [
    `Du bist ${COMPANY.assistant}, Assistenz der Kundenbetreuung von ${COMPANY.name}.`,
    "",
    "Der Text 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 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.",
    "3. Erwaehne nur Platzhalter, die tatsaechlich im Text vorkommen.",
    "",
    "Bekannte Platzhalter:",
    table || "  (keine)",
  ];

  const perTask =
    task === "reply"
      ? [
          "",
          "Aufgabe: Formuliere eine knappe, formelle Antwort in der Sie-Form an die",
          "Kundin oder den Kunden. Eroeffne mit 'Guten Tag {{person:...}},'.",
        ]
      : [
          "",
          "Aufgabe: Schreibe eine interne Kurznotiz (2 bis 3 Saetze) fuer den",
          "zustaendigen Sachbearbeiter. Keine Anrede, kein Gruss.",
        ];

  return [...shared, ...perTask].join("\n");
}

async function draftWithOpenAI(task: DraftTask, safeText: string, 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(task, hints) },
        { role: "user", content: safeText },
      ],
    }),
  });
  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();
}

const TOKEN = /\{\{[a-z_]+:[^{}]+\}\}/gi;

/** First token of a given type present in the safe text, or undefined. */
function tokenOf(type: string, safeText: string): string | undefined {
  const matches = safeText.match(TOKEN) ?? [];
  return matches.find((t) => t.startsWith(`{{${type}:`));
}

/**
 * Deterministic stand-in for the model. Reuses the exact tokens present in the
 * tokenized input, so the example produces the same protection trace with or
 * without a provider key. It only ever references tokens that exist, so nothing
 * the rehydrator has to resolve was invented.
 */
function draftOffline(task: DraftTask, safeText: string): string {
  const person = tokenOf("person", safeText);
  const order = tokenOf("order", safeText);
  const email = tokenOf("email", safeText);
  const iban = tokenOf("iban", safeText);

  if (task === "brief") {
    const parts: string[] = [];
    parts.push(person ? `Kundenanliegen von ${person}` : "Kundenanliegen");
    parts.push(order ? `betrifft Vorgang ${order}: gemeldete doppelte Abbuchung.` : "gemeldete doppelte Abbuchung.");
    parts.push("Bitte den Kontostand pruefen und eine Rueckerstattung veranlassen.");
    return parts.join(" ");
  }

  const lines: string[] = [];
  lines.push(person ? `Guten Tag ${person},` : "Guten Tag,");
  lines.push("");
  lines.push(
    order
      ? `vielen Dank fuer Ihre Nachricht zum Vorgang ${order}. Wir haben die gemeldete doppelte Abbuchung aufgenommen und pruefen den Fall.`
      : "vielen Dank fuer Ihre Nachricht. Wir haben Ihr Anliegen aufgenommen und pruefen den Fall.",
  );
  if (iban) {
    lines.push(`Die Rueckerstattung veranlassen wir auf das angegebene Konto ${iban} innerhalb von fuenf Werktagen.`);
  }
  if (email) {
    lines.push(`Eine schriftliche Bestaetigung senden wir an ${email}.`);
  }
  lines.push("");
  lines.push("Mit freundlichen Gruessen");
  lines.push(`${COMPANY.assistant}, Kundenbetreuung ${COMPANY.name}`);
  return lines.join("\n");
}

The guided trace driver: src/demo.ts

The pnpm demo entry point. It starts the app in-process, drives the three routes with the fixtures, prints the protection trace, then proves the restore-mode spectrum and the three fail-closed properties (unreachable runtime, tampered session, invented token).

/**
 * Guided end-to-end trace of the Musterfirma gateway.
 *
 * Starts the real Express app in-process against a live OGuardAI server, drives
 * the three guarded routes with the fixture documents, and prints the full
 * protection trace per route: the raw text, what the model saw (tokens only),
 * the detected entities, and the restored output on each channel. It ends with
 * the restore-mode spectrum and the three fail-closed properties.
 *
 * Run:  pnpm demo   (or: npx tsx src/demo.ts)
 */

import type { AddressInfo } from "node:net";
import { createApp } from "./server.js";
import { GuardaiTransport, transportFromEnv } from "./guardai.js";
import { COMPANY, ROUTES, loadFixture } from "./scenario.js";
import { llmMode } from "./llm.js";

interface HttpResult {
  status: number;
  body: Record<string, unknown>;
}

async function post(baseUrl: string, path: string, payload: unknown): Promise<HttpResult> {
  const res = await fetch(`${baseUrl}${path}`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(payload),
  });
  const body = (await res.json().catch(() => ({}))) as Record<string, unknown>;
  return { status: res.status, body };
}

function guardaiMeta(body: Record<string, unknown>): Record<string, unknown> {
  return (body._guardai as Record<string, unknown>) ?? {};
}

function heading(title: string): void {
  console.log(`\n${"=".repeat(78)}\n${title}\n${"=".repeat(78)}`);
}

async function main(): Promise<void> {
  const transport = transportFromEnv();
  // Pass a quiet logger so the middleware's per-request audit lines do not
  // interleave with the guided trace this demo prints itself.
  const app = createApp({ transport, logger: () => {} });
  const server = app.listen(0);
  await new Promise<void>((r) => server.once("listening", () => r()));
  const { port } = server.address() as AddressInfo;
  const baseUrl = `http://127.0.0.1:${port}`;

  console.log(`${COMPANY.name} AI gateway  |  OGuardAI middleware demo  |  LLM: ${llmMode()}`);

  const kundenanliegen = loadFixture("kundenanliegen.de.md");
  const intakeClean = loadFixture("intake-clean.de.md");
  const complianceBlock = loadFixture("compliance-block.de.md");

  try {
    // ---------------------------------------------------------------------
    // 1. Agent brief. financial policy -> IBAN redacted at the boundary.
    // ---------------------------------------------------------------------
    heading(`1) ${ROUTES.agentBrief.path}   policy=${ROUTES.agentBrief.policy}  channel=${ROUTES.agentBrief.channel}`);
    console.log("Rohnachricht (in der Trust-Zone, verlaesst den Prozess nie):");
    console.log(`  ${kundenanliegen}\n`);

    const brief = await post(baseUrl, ROUTES.agentBrief.path, { message: kundenanliegen });
    const briefMeta = guardaiMeta(brief.body);
    console.log("Was das LLM sah (safe_text, nur Tokens):");
    console.log(`  ${briefMeta.safe_text}\n`);
    const ibanRedacted = String(briefMeta.safe_text).includes("[REDACTED:iban]");
    console.log(`IBAN am Boundary redigiert (nie ein Token): ${ibanRedacted ? "ja" : "nein"}`);
    console.log(`Erkannte Entitaeten: ${JSON.stringify(briefMeta.entities_detected)}\n`);
    console.log("Interne Notiz fuer den Sachbearbeiter (internal_summary):");
    console.log(`  ${brief.body.brief}\n`);
    console.log("Audit-Zeile (log_safe, PII-frei, gefahrlos speicherbar):");
    console.log(`  ${briefMeta.audit_line}`);

    // ---------------------------------------------------------------------
    // 2. Customer reply. german-support -> gendered salutation restored.
    // ---------------------------------------------------------------------
    heading(`2) ${ROUTES.customerReply.path}   policy=${ROUTES.customerReply.policy}  channel=${ROUTES.customerReply.channel}`);
    const reply = await post(baseUrl, ROUTES.customerReply.path, { message: kundenanliegen });
    const replyMeta = guardaiMeta(reply.body);
    console.log("Antwort an die Kundin (customer_email, geschlechts- und formkorrekte Anrede):");
    console.log(String(reply.body.reply).split("\n").map((l) => `  ${l}`).join("\n"));
    console.log(`\nsicher (keine offenen Tokens): ${replyMeta.safe}`);

    // ---------------------------------------------------------------------
    // 3. Restore-mode spectrum on the same draft. restore_mode only tightens;
    //    it never widens past the channel floor (IBAN stays masked throughout).
    // ---------------------------------------------------------------------
    heading("3) restore_mode-Spektrum auf demselben Entwurf (Person-Zeile)");
    for (const mode of ["formatted", "full", "partial", "masked"] as const) {
      const r = await post(baseUrl, ROUTES.customerReply.path, { message: kundenanliegen, restore_mode: mode });
      const firstLine = String(r.body.reply).split("\n")[0];
      console.log(`  ${mode.padEnd(10)} -> ${firstLine}`);
    }

    // ---------------------------------------------------------------------
    // 4. Intake screen. strict-pii accepts a clean doc, rejects a level-1 one.
    // ---------------------------------------------------------------------
    heading(`4) ${ROUTES.intakeScreen.path}   policy=${ROUTES.intakeScreen.policy}  (block gate)`);
    const cleanIntake = await post(baseUrl, ROUTES.intakeScreen.path, { message: intakeClean });
    console.log(`Sauberes Dokument -> HTTP ${cleanIntake.status}, accepted=${cleanIntake.body.accepted}`);
    console.log(`  Entitaeten: ${JSON.stringify(guardaiMeta(cleanIntake.body).entities_detected)}`);
    const blockedIntake = await post(baseUrl, ROUTES.intakeScreen.path, { message: complianceBlock });
    console.log(`Dokument mit Reisepass/SSN/IBAN -> HTTP ${blockedIntake.status}, code=${blockedIntake.body.code}`);
    console.log("  Das Modell wurde nie aufgerufen; der Vorgang geht in die manuelle Pruefung.");

    // ---------------------------------------------------------------------
    // 5. Fail closed: transform unreachable -> 502, raw PII never forwarded.
    // ---------------------------------------------------------------------
    heading("5) Fail-closed Nachweise");
    const deadTransport = new GuardaiTransport({ baseUrl: "http://127.0.0.1:9", apiKey: "x" });
    const deadApp = createApp({ transport: deadTransport, logger: () => {} });
    const deadServer = deadApp.listen(0);
    await new Promise<void>((r) => deadServer.once("listening", () => r()));
    const deadPort = (deadServer.address() as AddressInfo).port;
    const down = await post(`http://127.0.0.1:${deadPort}`, ROUTES.customerReply.path, { message: kundenanliegen });
    const leaked = JSON.stringify(down.body).includes("Mustermann");
    console.log(`a) Runtime nicht erreichbar -> HTTP ${down.status}, code=${down.body.code}`);
    console.log(`   Rohdaten in der Antwort geleakt: ${leaked ? "JA (Fehler!)" : "nein"}`);
    deadServer.close();

    // b) Tampered session -> the runtime rejects it; the middleware maps that to 502.
    try {
      await transport.rehydrate({
        output: "Guten Tag {{person:p_001:deadbeef}}.",
        sessionState: '{"v":4,"tampered":true}',
        channel: "customer_email",
      });
      console.log("b) Manipulierte Session -> unerwartet akzeptiert (Fehler!)");
    } catch (err) {
      const code = (err as { code?: string }).code ?? "?";
      const status = (err as { status?: number }).status ?? "?";
      console.log(`b) Manipulierte Session -> abgelehnt: HTTP ${status}, code=${code} (nicht erraten, nicht wiederhergestellt)`);
    }

    // c) Invented token -> left unresolved and reported; the reply is marked unsafe.
    const invented = await transport.rehydrate({
      output: "Guten Tag {{person:p_999:cafef00d}}.",
      sessionState: (await transport.transform(kundenanliegen, ROUTES.customerReply)).session_state,
      channel: "customer_email",
    });
    console.log(`c) Erfundenes Token -> unresolved=${JSON.stringify(invented.tokens_unresolved ?? [])} (verbatim belassen, nie fabriziert)`);

    heading("Fertig");
    console.log("Eine Middleware, drei Policies, vier Restore-Modi, ein Trust-Boundary.");
  } finally {
    server.close();
  }
}

main().catch((err) => {
  console.error(err);
  process.exit(1);
});

Fixtures

The three fixtures drive the demo and are copy-pasteable into the curl calls. Every value in them is fictional. loadFixture strips the Betreff: subject header and collapses the text to one line.

fixtures/kundenanliegen.de.md: a full customer complaint carrying name, date of birth, customer id, order, address, email, phone, and IBAN.

Betreff: Rueckerstattung Ratenkredit, doppelte Abbuchung Vorgang ORD-2026-5510

Sehr geehrte Damen und Herren,

ich bin Frau Dr. Erika Mustermann, geboren am 14.03.1979, Kundennummer KDN-4471.
Zu meinem Ratenkredit mit dem Vorgang ORD-2026-5510 wurde am 30.06.2026 eine Rate
doppelt abgebucht. Ich wohne in der Lindenstrasse 12, 80331 Muenchen und bin
erreichbar unter erika.mustermann@beispiel.de oder telefonisch unter
+49 89 55512345. Bitte erstatten Sie den zu viel gezahlten Betrag auf mein Konto
IBAN DE97 9900 0000 0123 4567 89.

Mit freundlichen Gruessen
Erika Mustermann

fixtures/intake-clean.de.md: a level-2-only document that strict-pii accepts.

Betreff: Adressaenderung zum Vorgang ORD-2026-5510

Guten Tag, hier ist Erika Mustermann zum Vorgang ORD-2026-5510. Bitte aktualisieren
Sie meine Kontaktdaten auf erika.mustermann@beispiel.de und +49 89 55512345.
Vielen Dank.

fixtures/compliance-block.de.md: an OCR extract with passport, SSN, card, and IBAN that strict-pii rejects.

Auszug aus einem hochgeladenen Ausweisformular (OCR), Vorgang ORD-2026-5510:

Antragstellerin: Erika Mustermann
Reisepass: C01X00T47
US-Sozialversicherungsnummer: 123-45-6789
Kreditkarte: 4111 1111 1111 1111
IBAN: DE97 9900 0000 0123 4567 89

Environment variables

VariableDefaultDescription
GUARDAI_URLhttp://localhost:3000OGuardAI server URL
GUARDAI_BASE_URL / GUARDAI_BASE(fallback)Aliases for GUARDAI_URL
GUARDAI_API_KEY(none)Sent as X-API-Key when OGuardAI auth is enabled
PORT4000Gateway server port
OPENAI_API_KEY(none)Enables the real OpenAI path; offline draft when unset
OPENAI_MODELgpt-4o-miniModel for the OpenAI path
OPENAI_BASE_URLhttps://api.openai.com/v1Override for an OpenAI-compatible endpoint

Files

FileRole
src/scenario.tsCompany config, the three route profiles, fixture loader
src/guardai.tsThe middleware: typed transport plus the guardai() factory and fail-closed logic
src/llm.tsLLM adapter: OpenAI over fetch, or a deterministic offline draft
src/server.tsThe Express app: three guarded routes plus /health
src/demo.tsGuided end-to-end trace against a live server
fixtures/kundenanliegen.de.mdA full customer complaint (name, DOB, order, address, email, phone, IBAN)
fixtures/intake-clean.de.mdA level-2-only document that strict-pii accepts
fixtures/compliance-block.de.mdAn OCR extract with passport, SSN, card, and IBAN that strict-pii rejects