OGuardAI
Integrations

Vercel AI SDK

Protect Vercel AI SDK generations with OGuardAI so PII is tokenized before the model and restored in the output

The OGuardAI TypeScript SDK ships an optional Vercel AI SDK integration that wraps a single text generation so PII in the prompt is tokenized before the model runs and restored in the model output afterward. The generate call and the model only ever see the tokenized safe_text; the sealed session never enters the AI SDK. The integration is a thin seam over the SDK guardedCall primitive.

Install

The integration has no dependency on the ai package: you pass your own generate function, so the adapter works against any ai version and any provider. Install the SDK and the AI SDK, plus the provider package that supplies your model (for example @ai-sdk/openai).

npm install @oguardai/sdk ai

One boundary-safe entry point

guardedGenerateText is the single entry point, and it is boundary-safe by design. The transform and the rehydrate run outside the AI SDK: your generate function is the only code that touches the model, and it only ever receives the tokenized prompt. No experimental_telemetry span inside the generate call, and no provider request, observes the raw prompt or the restored output.

Unlike the LangChain integration, there is no composable in-framework variant here, so there is no tracer-exposed path to caveat. The single seam keeps the raw input and the restored output on the host side of the boundary in every case.

Signature

function guardedGenerateText(
  generate: (safePrompt: string) => Promise<GenerateTextLike | string> | GenerateTextLike | string,
  client: OGuardAIClient,
  prompt: string,
  options?: GuardedGenerateOptions,
): Promise<string>;

type GenerateTextLike = { text: string };

interface GuardedGenerateOptions {
  policy?: string;
  language?: string;
  outputChannel?: OutputChannel;
  restoreMode?: RestoreMode;
}

generate receives the tokenized prompt and returns either the AI SDK result (any object with a string text, which is what generateText returns) or a plain string. guardedGenerateText returns the restored text as a plain string, not a result object.

OptionDefaultDescription
policyserver defaultOGuardAI policy name applied to the transform.
languageserver defaultLanguage hint for detection (for example de).
outputChanneluser_outputOutput channel whose restore rules apply on the way back.
restoreModefullRestore mode for the rehydrate step (full, partial, masked, formatted, abstract, none).

Usage

import { generateText } from "ai";
import { openai } from "@ai-sdk/openai";
import { OGuardAIClient } from "@oguardai/sdk";
import { guardedGenerateText } from "@oguardai/sdk/integrations/vercel";

const client = new OGuardAIClient({ baseUrl: "http://localhost:3000" });
const model = openai("gpt-4o-mini");

const reply = await guardedGenerateText(
  (safePrompt) => generateText({ model, prompt: safePrompt }),
  client,
  "Antworte Herrn Max Mustermann unter max.mustermann@firma.example",
  { policy: "german-support", language: "de", outputChannel: "customer_email" },
);

The generate closure runs on Antworte Herrn {{person:p_001:...}} unter {{email:e_001:...}} only. The returned reply has the real values restored per the customer_email channel.

What the model and tracer see

The prompt reaches the model as tokenized safe_text, never a raw value:

Antworte Herrn {{person:p_001:...}} unter {{email:e_001:...}}

Because the transform and rehydrate happen outside the AI SDK, an experimental_telemetry span opened inside your generateText call records only the tokenized prompt and the tokenized model output. The restoration happens after guardedGenerateText receives the model result, on the host side of the boundary, so no tracer, log, or provider request ever observes a raw or a restored name, email, or IBAN.

Scope

The adapter protects one prompt string. For a multi-message chat, use guardedChat and rehydrateResponse on the client directly, which share one sealed session across the conversation. Tokens embedded in tool-call arguments or intermediate agent steps are out of scope for this adapter and are not restored.

Fail-closed behavior

  • If the transform returns no sealed session, guardedGenerateText throws before the model is called, so a prompt that cannot later be restored or proven protected never reaches the provider.
  • If generate resolves to neither a string nor an object with a string text, the adapter throws a TypeError rather than returning an unverified value.

How it works

  1. The integration calls /v1/transform on the prompt, replacing PII with semantic tokens.
  2. Your generate function runs on the tokenized safe_text and returns its output.
  3. The integration calls /v1/rehydrate on that output using the sealed session, restoring the real values per the output channel.

If the transform fails or yields no session, the generate function is never invoked (fail closed).

Runnable example: German CRM follow-up copilot

A company-grade example of a Vercel AI SDK text generation that never sees customer PII. Musterfirma GmbH (fictional) is a Munich maker of insulated windows. Its CRM copilot "Vera" reads a rep's call note and drafts a formal German follow-up email with an external LLM. German data-protection rules forbid sending customer data to that provider, so OGuardAI protects every note at the trust boundary: the generateText call and the model reason on semantic tokens only, and real values are restored per output channel afterward. The policy is german-support (German formal restore, gender-aware salutations, level-1 data masked or redacted per channel).

The full source is in the repository at examples/vercel-ts/. It has three files: src/scenario.ts (company config, channel map, the CRM note, the mock order table), src/model.ts (mode selection, the token contract system prompt, the live generateText call, and the deterministic offline drafter), and src/index.ts (the end-to-end trace: the adapter path with assertions, the per-channel primitive path, and the fail-closed demo). The OGuardAI round trip runs with or without a provider key: with OPENAI_API_KEY set the tokenized draft comes from a live generateText call, without it from a deterministic offline drafter. All PII in the example is fictional.

Prerequisites

  • Node.js 20 or newer.

  • An OGuardAI server on localhost:3000. This example needs-NER: the person name and gender detection need the NER detector, and the assertion that the person name never reaches the model depends on it. Email, IBAN, order number, and phone are caught by the built-in regex detectors; the person name needs the NER sidecar. Without it, the person assertion fails loudly rather than passing on an unprotected value. The full Docker stack provides the sidecar:

    # 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_safe
    docker compose -f deploy/docker/docker-compose.yml up -d
  • The default sealed session backend, so the transform returns a session the adapter can rehydrate. With the memory backend there is no session blob and the adapter fails closed.

  • Optional: OPENAI_API_KEY. When set, a live Vercel AI SDK generateText call drafts the reply. When unset, a deterministic offline drafter stands in, so the full OGuardAI round trip still runs without a provider key.

Setup

cd examples/vercel-ts
pnpm install

Run

export GUARDAI_URL="http://localhost:3000"
export GUARDAI_API_KEY="$GUARDAI_API_KEY_1"   # the bundled stack runs api_key auth

# Offline drafter (no LLM key needed): exercises transform + generate + rehydrate + per-channel + fail-closed
pnpm dev

# Live model: add a provider key first
export OPENAI_API_KEY="sk-..."
pnpm dev

pnpm build && pnpm start runs the compiled output.

Environment variables

VariableDefaultDescription
GUARDAI_URLhttp://localhost:3000OGuardAI server URL (GUARDAI_BASE_URL is accepted too).
GUARDAI_API_KEY(none)Sent as X-API-Key when OGuardAI auth is enabled.
OPENAI_API_KEY(none)When set, the live generateText call drafts the reply; otherwise offline mode.
OPENAI_MODELgpt-4o-miniModel for the OpenAI path.
CRM_POLICYgerman-supportOGuardAI policy name.

The scenario, channels, and CRM note

The company config, the four output channels the one draft is restored into, the fictional CRM note that carries the PII, and the trusted-zone mock order table. This is the fixture the trace runs on. From examples/vercel-ts/src/scenario.ts:

import type { OutputChannel } from "@oguardai/sdk";

export const COMPANY = {
  name: "Musterfirma GmbH",
  assistant: "Vera",
  /** German formal policy: gender-aware salutations, level-1 data masked/redacted. */
  policy: process.env.CRM_POLICY ?? "german-support",
  /** German notes; the NER detector tags person gender and formality from this. */
  language: "de",
} as const;

/**
 * Output channels the copilot restores the one tokenized draft into. Each consumer sees
 * only what the policy allows for its channel (channel_rules only tighten, never widen),
 * so the same draft yields four different visibilities.
 */
export const CHANNELS = {
  /** The follow-up sent to the customer: full name (gender-aware), order, email; IBAN masked. */
  customer: "customer_email" satisfies OutputChannel,
  /** The rep's CRM timeline: name and order visible, contact details and IBAN masked. */
  crm: "internal_summary" satisfies OutputChannel,
  /** A downstream fulfilment system: minimum data, every entity masked. */
  tool: "tool_payload" satisfies OutputChannel,
  /** The audit log line: fully redacted, safe to persist in any log store. */
  audit: "log_safe" satisfies OutputChannel,
} as const;

/**
 * The inbound CRM note a rep captured after a call. It carries several real-looking PII
 * entities (person, email, IBAN, order number, phone) that must never reach the model.
 */
export const CRM_NOTE = [
  "Kunde Herr Max Mustermann (max.mustermann@example.de, Tel. +49 89 5512094)",
  "fragt nach dem Stand seiner Bestellung ORD-2026-58120 und der zugesagten Erstattung.",
  "Erstattung soll auf IBAN DE97 9900 0000 0123 4567 89 gehen. Bitte formelle Rueckmeldung.",
].join(" ");

/**
 * Mock CRM/order system, keyed by the REAL order number. It lives in the trusted zone:
 * the copilot only ever needs it host-side, and the status strings carry no PII, so they
 * are safe to weave into a token-domain draft.
 */
export const ORDER_DB: Record<string, { status: string; item: string; refundEur: number }> = {
  "ORD-2026-58120": {
    status: "Reklamation offen, Ersatzteil versandbereit",
    item: "Aluminium-Fensterrahmen",
    refundEur: 340,
  },
};

The channel map corresponds to these visibilities of the same tokenized draft, which the policy only ever tightens, never widens:

ChannelpersonorderemailIBAN
customer_email (to the customer)full, "Herr ..."fullfullmasked
internal_summary (CRM timeline)fullfullmaskedmasked
tool_payload (fulfilment system)maskedmaskedmaskedmasked
log_safe (audit line)redactedredactedredactedredacted

The model wiring

Mode selection, the load-bearing token contract system prompt (the model must echo every placeholder verbatim and must not write a salutation before a person token, because OGuardAI injects the correct gendered "Herr" or "Frau" on restore), the deterministic offline drafter, and the live generateText call. In both modes the generate function receives tokenized safePrompt only. From examples/vercel-ts/src/model.ts:

import { generateText } from "ai";
import { openai } from "@ai-sdk/openai";
import { ORDER_DB, COMPANY } from "./scenario.js";

const OPENAI_MODEL = process.env.OPENAI_MODEL ?? "gpt-4o-mini";

/** Every OGuardAI token: {{type:id:hash}}. Used to read tokens back out of safe_text. */
const TOKEN = /\{\{([a-z_]+):[^{}]+\}\}/g;

export function llmMode(): "openai" | "offline" {
  return process.env.OPENAI_API_KEY ? "openai" : "offline";
}

/**
 * The token contract handed to the model. It is load-bearing: the model must echo every
 * placeholder verbatim and must NOT write a salutation before a person token, because
 * OGuardAI injects the correct gendered "Herr"/"Frau" on restore.
 */
export const SYSTEM_PROMPT = [
  "Du bist ein CRM-Assistent und schreibst formelle deutsche Kunden-E-Mails.",
  "Die Eingabe 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",
  "   oder formatiere niemals einen Platzhalter und schreibe niemals selbst einen echten Namen,",
  "   eine Nummer, eine E-Mail oder eine 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:...}},'.",
  "3. Erwaehne nur Platzhalter, die tatsaechlich vorkommen; erfinde keine Daten.",
].join("\n");

/** First token of each type found in a tokenized text, e.g. tokensByType(safe).person. */
function tokensByType(safeText: string): Record<string, string> {
  const found: Record<string, string> = {};
  for (const m of safeText.matchAll(TOKEN)) {
    const type = m[1];
    if (!(type in found)) found[type] = m[0];
  }
  return found;
}

/**
 * Deterministic stand-in for the model. It receives the tokenized prompt, reuses the exact
 * tokens it finds, and composes a formal German reply. The internal order status is looked
 * up host-side against an order NUMBER we resolve elsewhere; here we only key on presence,
 * so no PII is introduced by the draft.
 */
export function draftOffline(safePrompt: string): string {
  const t = tokensByType(safePrompt);
  const hasOrderRecord = Object.keys(ORDER_DB).length > 0;

  const lines: string[] = [];
  lines.push(t.person ? `Guten Tag ${t.person},` : "Guten Tag,");
  lines.push("");
  lines.push(
    t.order
      ? `vielen Dank fuer Ihre Nachricht zu Bestellung ${t.order}. Wir haben den Vorgang geprueft.`
      : "vielen Dank fuer Ihre Nachricht. Wir haben den Vorgang geprueft.",
  );
  if (hasOrderRecord && t.iban) {
    lines.push(
      `Die zugesagte Erstattung veranlassen wir auf Ihr Konto ${t.iban} innerhalb von 5 Werktagen.`,
    );
  }
  if (t.email) {
    lines.push(`Die Versandbestaetigung senden wir an ${t.email}.`);
  }
  lines.push("");
  lines.push("Mit freundlichen Gruessen");
  lines.push(`${COMPANY.assistant}, Kundenservice ${COMPANY.name}`);
  return lines.join("\n");
}

/** The live Vercel AI SDK generate call. Runs on tokenized `safePrompt` only. */
export async function draftWithModel(safePrompt: string): Promise<{ text: string }> {
  return generateText({
    model: openai(OPENAI_MODEL),
    system: SYSTEM_PROMPT,
    prompt: safePrompt,
    temperature: 0,
  });
}

The end-to-end trace

The trace in examples/vercel-ts/src/index.ts is the proof. It sets up the client, defines the sentinels the model must never see, splits them by how the runtime protects each (structured identifiers are caught by built-in detectors on every lane, so a structured leak is a hard failure; the person name needs NER, and german-support declares person detection as required, so a runtime without the NER sidecar fails the transform closed rather than let an un-tokenized name reach the model), and holds the boundary helpers. From examples/vercel-ts/src/index.ts:

import { strict as assert } from "node:assert";
import { OGuardAIClient, ValidationError } from "@oguardai/sdk";
import type { OutputChannel } from "@oguardai/sdk";
import { guardedGenerateText } from "@oguardai/sdk/integrations/vercel";
import { CHANNELS, COMPANY, CRM_NOTE } from "./scenario.js";
import { draftOffline, draftWithModel, llmMode } from "./model.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 });

/** Values the model must never see. The name needs NER, so a raw name in an offline run is a
 *  display-only note; the structured identifiers are caught by the built-in detectors, so a
 *  structured leak is a hard failure in every mode. */
const NER_PII = ["Max Mustermann"];
const STRUCTURED_PII = [
  "max.mustermann@example.de",
  "DE97 9900 0000 0123 4567 89",
  "ORD-2026-58120",
  "+49 89 5512094",
];
const RAW_PII = [...NER_PII, ...STRUCTURED_PII];

/** Any residual `{{...}}` on a customer-facing reply means an unresolved token; withhold it. */
const RESIDUAL_TOKEN = /\{\{[^{}]+\}\}/;

/** Run the model on tokenized text, in whichever mode is active. */
async function runModel(safePrompt: string): Promise<{ text: string } | string> {
  return llmMode() === "openai" ? draftWithModel(safePrompt) : draftOffline(safePrompt);
}

/** The structured identifiers are builtin-detectable, so any of them in a string bound for the
 *  model or a channel is a hard failure on every lane. Person names need NER, so a raw name is
 *  gated separately by the caller. Returns the raw names that crossed. */
function checkBoundary(text: string): string[] {
  for (const raw of STRUCTURED_PII) {
    assert.ok(!text.includes(raw), `structured PII leaked across the boundary: ${raw}`);
  }
  return NER_PII.filter((n) => text.includes(n));
}

The adapter path is the core of main(). The generate closure is the only code that touches the model; it captures exactly what the model received and returned, so the trace can assert the model input was tokenized and no raw PII leaked, and that the restored reply is token-free. From examples/vercel-ts/src/index.ts:

// The generate closure is the only code that touches the model. Capture exactly what it
// received (the tokenized prompt) and what it returned (the tokenized draft) so we can prove
// the boundary held after guardedGenerateText finishes.
const capture: { seenByModel?: string; tokenizedDraft?: string } = {};
const generate = async (safePrompt: string): Promise<{ text: string } | string> => {
  capture.seenByModel = safePrompt;
  const out = await runModel(safePrompt);
  capture.tokenizedDraft = typeof out === "string" ? out : out.text;
  return out;
};

// The adapter: transform + generate + rehydrate, all with rehydration OUTSIDE the AI SDK.
const reply = await guardedGenerateText(generate, client, CRM_NOTE, {
  policy: COMPANY.policy,
  language: COMPANY.language,
  outputChannel: CHANNELS.customer,
});

const seenByModel = capture.seenByModel;
assert.ok(seenByModel, "the model was never called");
assert.ok(seenByModel.includes("{{"), "the model input carried no tokens");
const modelNameLeaks = checkBoundary(seenByModel);
const tokenizedDraft = capture.tokenizedDraft;
assert.ok(tokenizedDraft !== undefined, "the model returned no draft");
const draftNameLeaks = checkBoundary(tokenizedDraft);
assert.ok(!RESIDUAL_TOKEN.test(reply), "the restored reply still carries a token");

The adapter restores to one channel. To render the same tokenized draft for several consumers, the per-channel section drops to the primitives the adapter is built on: transform once, generate on the safe_text, then rehydrate per channel. Each channel sees only what the policy allows. From examples/vercel-ts/src/index.ts:

async function perChannel(nerAvailable: boolean): Promise<string[]> {
  const t = await client.transform({
    input: CRM_NOTE,
    policy: COMPANY.policy,
    language: COMPANY.language,
  });
  if (!t.session_state) {
    throw new ValidationError("transform returned no session_state; refusing per-channel demo (fail closed)");
  }

  const channelNameLeaks = checkBoundary(t.safe_text);
  gateNames(nerAvailable, channelNameLeaks);

  const out = await runModel(t.safe_text);
  const draft = typeof out === "string" ? out : out.text;

  const draftNameLeaks = checkBoundary(draft);
  gateNames(nerAvailable, draftNameLeaks);

  const restore = async (channel: OutputChannel): Promise<string> => {
    const r = await client.rehydrate({
      output: draft,
      session_state: t.session_state as string,
      output_channel: channel,
    });
    return r.restored_text;
  };

  const [customer, crm, tool, audit] = await Promise.all([
    restore(CHANNELS.customer),
    restore(CHANNELS.crm),
    restore(CHANNELS.tool),
    restore(CHANNELS.audit),
  ]);

  console.log(`\n[customer_email]   -> Kunde:\n${indent(customer, 4)}`);
  console.log(`\n[internal_summary] -> CRM-Verlauf:\n${indent(crm, 4)}`);
  console.log(`\n[tool_payload]     -> Fulfilment-System:\n${indent(tool, 4)}`);
  console.log(`\n[log_safe]         -> Audit-Log:\n${indent(audit, 4)}`);

  return [...channelNameLeaks, ...draftNameLeaks];
}

The fail-closed section proves three safety properties: the adapter rejects a generate() that returns neither a string nor an object with a string text, a tampered session blob is rejected rather than silently restored, and a token the model invents is reported unresolved and never guessed. From examples/vercel-ts/src/index.ts:

async function failClosed(): Promise<void> {
  // 1) A generate() that returns neither a string nor an object with a string `text` is rejected
  //    by the adapter, never returned as an unverified value.
  let noTextAccepted = false;
  try {
    await guardedGenerateText(
      () => ({ foo: 1 }) as unknown as { text: string },
      client,
      "Guten Tag, ich brauche Hilfe.",
      { policy: COMPANY.policy, language: COMPANY.language },
    );
    noTextAccepted = true;
  } catch (err) {
    const kind =
      err instanceof TypeError
        ? "TypeError"
        : err instanceof ValidationError
          ? `ValidationError (${err.code})`
          : (err as Error).constructor.name;
    console.log(`1) generate() liefert kein {text} -> abgewiesen: ${kind} (fail closed)`);
  }
  if (noTextAccepted) {
    throw new Error("fail-closed check failed: a generate() result without text was accepted");
  }

  const probe = await client.transform({
    input: "Kunde Herr Max Mustermann meldet sich zu seiner Bestellung.",
    policy: COMPANY.policy,
    language: COMPANY.language,
  });
  const personToken = probe.entities.find((e) => e.type === "person")?.token ?? "{{person:p_001:0000}}";
  const state = probe.session_state ?? "";

  // 2) A tampered session blob must be rejected, never silently restored.
  const tampered = `${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(`2) Manipuliertes session_state -> abgelehnt: ${label}`);
  }
  if (tamperAccepted) {
    throw new Error("fail-closed check failed: a tampered session was accepted");
  }

  // 3) 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: state,
    output_channel: CHANNELS.customer,
  });
  console.log(
    `3) Erfundener Token -> aufgeloest=${r.tokens_resolved} offen=[${(r.tokens_unresolved ?? []).join(", ")}]`,
  );
  if (!r.restored_text.includes(invented)) {
    throw new Error("fail-closed check failed: a forged token was resolved instead of left unresolved");
  }
}

Expected output (offline mode, abridged)

Token ids and the trailing hash change per run; the structure does not.

Policy:    german-support   Sprache: de   LLM: offline

WAS DAS MODELL SIEHT (tokenisierter Prompt, kein Rohwert)
  Kunde Herr {{person:p_001:...}} ({{email:e_001:...}}, Tel. {{phone:ph_001:...}}) fragt nach
  dem Stand seiner Bestellung {{order:o_001:...}} ... IBAN {{iban:ib_001:...}} ...

ENTWURF DES MODELLS (noch tokenisiert)
  Guten Tag {{person:p_001:...}},
  vielen Dank fuer Ihre Nachricht zu Bestellung {{order:o_001:...}} ...

WIEDERHERGESTELLTE ANTWORT (Kanal customer_email)
  Guten Tag Herr Max Mustermann, ... Konto D*** **** **** **** **** *0 ...

PRO KANAL (dieselbe Antwort, verschiedene Sichtbarkeit)
[customer_email]   -> Guten Tag Herr Max Mustermann, ...
[internal_summary] -> ... an j*******************************e.
[tool_payload]     -> Guten Tag M** *********n, ... Bestellung O************0 ...
[log_safe]         -> Guten Tag [REDACTED], ... Bestellung [REDACTED] ...

FAIL-CLOSED
1) generate() liefert kein {text} -> abgewiesen: TypeError (fail closed)
2) Manipuliertes session_state -> abgelehnt: ValidationError (GUARDAI_INVALID_INPUT)
3) Erfundener Token -> aufgeloest=1 offen=[{{person:p_999:deadbeef0000}}]