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 aiOne 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.
| Option | Default | Description |
|---|---|---|
policy | server default | OGuardAI policy name applied to the transform. |
language | server default | Language hint for detection (for example de). |
outputChannel | user_output | Output channel whose restore rules apply on the way back. |
restoreMode | full | Restore 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,
guardedGenerateTextthrows before the model is called, so a prompt that cannot later be restored or proven protected never reaches the provider. - If
generateresolves to neither a string nor an object with a stringtext, the adapter throws aTypeErrorrather than returning an unverified value.
How it works
- The integration calls
/v1/transformon the prompt, replacing PII with semantic tokens. - Your
generatefunction runs on the tokenizedsafe_textand returns its output. - The integration calls
/v1/rehydrateon 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 SDKgenerateTextcall 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 installRun
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 devpnpm build && pnpm start runs the compiled output.
Environment variables
| Variable | Default | Description |
|---|---|---|
GUARDAI_URL | http://localhost:3000 | OGuardAI 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_MODEL | gpt-4o-mini | Model for the OpenAI path. |
CRM_POLICY | german-support | OGuardAI 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:
| Channel | person | order | IBAN | |
|---|---|---|---|---|
customer_email (to the customer) | full, "Herr ..." | full | full | masked |
internal_summary (CRM timeline) | full | full | masked | masked |
tool_payload (fulfilment system) | masked | masked | masked | masked |
log_safe (audit line) | redacted | redacted | redacted | redacted |
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}}]