OGuardAI
Integrations

Mastra

Protect Mastra agents with OGuardAI so PII is tokenized before the model and restored in the output

The OGuardAI TypeScript SDK ships an optional Mastra integration that keeps customer PII out of a Mastra agent. The guarantee is bounded to the wrapped generate call: the model reasons on the tokenized safe_text on the way in and the output is rehydrated on the way out, and the sealed session never enters the agent. It does not tokenize raw PII a tool returns mid-run in an agentic (tool-calling) loop, so guard those tool boundaries yourself. There is no hard dependency on @mastra/core: the Mastra message and processor shapes are declared structurally, so @oguardai/sdk builds and imports without Mastra installed, and the returned processor is structurally compatible with Mastra's Processor.

Install

npm install @oguardai/sdk @mastra/core

Two entry points

Both keep the model in the token domain, but they place the trust boundary differently.

  • guardMastraGenerate is the recommended, boundary-safe path. Transform and rehydrate run OUTSIDE the agent, so no Mastra tracer, telemetry span, callback, or output processor ever observes the raw input or the restored answer. Prefer this whenever a PII-exporting tracer is attached to the agent.
  • guardMastraAgent returns a Processor you register on the agent itself. Tokenization still happens before the model runs, but restoration happens INSIDE the framework, in processOutputResult. A Mastra tracer or output processor attached ABOVE this guard's output boundary WILL see the restored answer. Use it only when the agent is not exported to a PII-sensitive destination. guardMastra is an alias of guardMastraAgent.

guardMastraGenerate(client, agent, input, options?) transforms input to tokens, runs agent.generate(safeText, generateOptions) on the SAFE text, and rehydrates the model's output outside the agent. It fails closed if the transform yields no session_state. It mirrors LangChain's guardedInvoke.

import { OGuardAIClient } from "@oguardai/sdk";
import { guardMastraGenerate } from "@oguardai/sdk/integrations/mastra";
import { Agent } from "@mastra/core/agent";

const client = new OGuardAIClient({ baseUrl: "http://localhost:3000" });

const agent = new Agent({
  id: "support-copilot",
  name: "Support Copilot",
  instructions:
    "Antworte formell auf Deutsch. Uebernimm jeden {{type:id:cap}}-Platzhalter Zeichen fuer Zeichen.",
  model: "openai/gpt-4o-mini",
});

const answer = await guardMastraGenerate(
  client,
  agent,
  "Email Erika Mustermann at erika.mustermann@firma.example",
  { policy: "german-support", language: "de", outputChannel: "customer_email" },
);

On this wrapped generate call the agent, its tracer, and the model see Email {{person:p_001:ad4f97591c16}} at {{email:e_001:1bcaef1a4aff}}. The returned answer has the real values restored per the customer_email channel. In an agentic loop, a tool that returns fresh raw PII mid-run is a boundary this wrapper does not tokenize; guard those tool results yourself.

agent is any object with a compatible generate(input, options?) => { text }; pass a real Mastra Agent, or your own object, or an outputExtractor for a different result shape. A streaming agent must be buffered to text by the caller before rehydration.

Options

GuardMastraGenerateOptions:

OptionDefaultEffect
policyserver defaultOGuardAI policy name.
languageserver defaultDetection language hint.
outputChanneluser_outputOutput channel whose restore rules apply.
restoreModefullfull, partial, masked, formatted, abstract, or none.
generateOptions(none)Forwarded verbatim as the second argument to agent.generate.
outputExtractorresult.textSelects the string to rehydrate from the agent result.

In-framework processor path

guardMastraAgent(client, options?) returns ONE processor object implementing both processInput (tokenize) and processOutputResult (rehydrate). Register the SAME object in BOTH the agent's inputProcessors and outputProcessors, so a single per-request session bridges tokenize and rehydrate.

import { guardMastraAgent } from "@oguardai/sdk/integrations/mastra";
import { Agent } from "@mastra/core/agent";

const guard = guardMastraAgent(client, {
  policy: "german-support",
  language: "de",
  outputChannel: "customer_email",
});

const agent = new Agent({
  id: "support-copilot",
  name: "Support Copilot",
  instructions: "Antworte formell auf Deutsch. Uebernimm jeden Platzhalter unveraendert.",
  model: "openai/gpt-4o-mini",
  inputProcessors: [guard],
  outputProcessors: [guard],
});

const result = await agent.generate("Email Erika Mustermann at erika.mustermann@firma.example");

processInput tokenizes the user text before the model sees it, and processOutputResult restores the reply. Because restoration is inside the framework, a tracer or output processor attached above this guard's output boundary sees the restored answer; when that matters, use guardMastraGenerate.

Options

GuardMastraOptions:

OptionDefaultEffect
policyserver defaultOGuardAI policy name.
languageserver defaultDetection language hint.
outputChanneluser_outputOutput channel whose restore rules apply.
restoreModefullRestore mode for processOutputResult.
idoguardai-guardProcessor id/name reported to Mastra.
inputRoles["user"]Message roles whose text is tokenized on input.
outputRoles["assistant"]Message roles whose text is rehydrated on output.
outputGuardoffGuard a no-session output turn with the stateless output guard (see below).

When a request reaches the output processor with no session (the turn had no protectable input, or a prior output pass already consumed it), outputGuard decides what happens. Off (the default), the reply passes through unguarded. On, each output-role message is scanned by POST /v1/output-guard, which masks model-generated PII per the deployment's output-protection posture; a block returns 422 and a scanner outage 503, both of which throw so the turn is never left silently unguarded. It mints no session and does not change the session-backed path, which continues to rehydrate as before.

String-only messages

The processor supports text only. A message whose content is a plain string, or whose content.parts are all type: "text", is protected; multiple text parts on one message are joined and protected as a single text. A string content mirror (the value Mastra keeps alongside parts) is scanned and rewritten too. A message carrying any non-text, multimodal, or file part, a non-string content mirror, or a populated content-bearing sibling (experimental_attachments, toolInvocations, reasoning) is REJECTED (fail closed) on input and on both output paths, since the adapter cannot scan that content. Strip or separately protect such content before the agent.

What the model and tracer see

In the boundary-safe path, the only string handed to agent.generate is the tokenized safe_text; transform and rehydrate run outside the agent, so no Mastra tracer, telemetry span, callback, or output processor ever observes a raw or restored value.

In the processor path, the model still sees only tokens (processInput tokenizes before the model runs), but restoration happens inside processOutputResult, so a tracer attached above the guard's output boundary sees the restored answer. That is the one difference between the two paths, and the reason guardMastraGenerate is the default recommendation.

How it works

  1. The integration calls /v1/transform on the input text, replacing PII with semantic tokens and returning a sealed session_state.
  2. The Mastra agent runs on the tokenized safe_text.
  3. The integration calls /v1/rehydrate on the agent output using that session, restoring the real values per the output channel and restore mode.

Fail-closed behavior is explicit:

  • If the transform returns no session_state, guardMastraGenerate never runs the agent, and the processor's processInput throws before the model runs.
  • In the processor path, each request is keyed on Mastra's per-request carrier (messageList, then state, then requestContext). If Mastra provides none of them, both methods throw rather than share a global session slot across concurrent requests.
  • The session is consumed once: processOutputResult deletes it after use, so a replayed or observed output cannot re-restore against a stale session. If nothing was tokenized for a request, the output is returned unchanged rather than fabricating a restoration.

Runnable example: German CRM copilot on semantic tokens

A Mastra agent (Linnea, the CRM copilot of the fictional Musterfirma GmbH, a Munich maker of solid-wood furniture with B2B sales) reads a sales rep's instruction and drafts a formal German follow-up email to a business prospect with an external LLM. German data-protection rules forbid sending customer data to that provider, so OGuardAI protects the instruction at the trust boundary: the agent and the model reason on semantic tokens only, and real values are restored per output channel on the way back. All PII in the fixture is fictional.

The example proves four things:

  • The boundary-safe path. guardMastraGenerate(client, agent, input, options) transforms the instruction to tokens once, runs the Mastra agent on the tokenized safe_text, and rehydrates after the agent returns. The run records exactly what the agent's generate received and asserts it is tokenized and free of every raw PII string in the fixture.
  • Per-policy restoration through the adapter. The same instruction is run for two output channels. In customer_email the prospect's email is restored in full; in log_safe it is redacted.
  • The in-framework processor path. guardMastraAgent(client, options) is driven through processInput (tokenize) and processOutputResult (rehydrate) with a stub model in between, so the real exported processor runs deterministically without a provider key.
  • Fail-closed behavior. A message carrying a non-text part is rejected before any transform, and a request with no per-request carrier is refused rather than sharing a global session slot.

The full source is in the repository under examples/mastra-ts/:

FileRole
fixtures/lindenhof-crm-prospect.de.mdThe sales-rep instruction (fictional multi-entity PII).
src/scenario.tsCompany config, channel map, fixture loader, and the raw-PII list used for the leak check.
src/agent.tsThe live Mastra agent, the deterministic offline stand-in, and the generate-instrumentation used to prove tokenization.
src/index.tsThe end-to-end trace: boundary-safe path, per-channel restore, processor path, fail-closed demos.

Prerequisites

Needs-NER for the full scenario. Person name and gender detection need the NER detector, so start the OGuardAI server with the detector sidecar. The full Docker stack provides it:

# 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 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_OUTPUT_CHANNELS=user_output,customer_email,log_safe
docker compose -f ../../deploy/docker/docker-compose.yml up -d
  • Node.js 20 or newer.
  • An OGuardAI server on localhost:3000 with the NER detector enabled.
  • The german-support policy loaded (it ships with the server).
  • Optional: OPENAI_API_KEY. When set, a live Mastra agent (model: openai/gpt-4o-mini via model routing) drafts the reply. When unset, a deterministic offline stand-in that satisfies the same generate contract stands in, so the full OGuardAI round trip runs without a provider key.

Structured PII (email, phone, IBAN, customer id) is masked by the built-in regex detectors, but the example's german-support policy declares person detection as required, so a runtime without the NER sidecar rejects the transform (fail closed, HTTP 503) rather than let an un-tokenized name reach the model; run the full stack with the detector.

Setup and run

cd examples/mastra-ts
pnpm install
export GUARDAI_URL="http://localhost:3000"
export GUARDAI_API_KEY="$GUARDAI_API_KEY_1"   # the bundled stack runs api_key auth

# Offline stand-in (no LLM key needed): exercises transform + rehydrate + processor + fail-closed
pnpm dev

# Live Mastra agent: 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 Mastra agent drafts the reply; otherwise offline mode.
MASTRA_MODELopenai/gpt-4o-miniModel routing string for the live agent.
SUPPORT_POLICYgerman-supportOGuardAI policy name.

The fixture

The single sales-rep instruction. The runner reads only the block between the <<<EINGANG>>> and <<<ENDE>>> markers and sends that through OGuardAI before the Mastra agent runs. From examples/mastra-ts/fixtures/lindenhof-crm-prospect.de.md:

# Musterfirma GmbH, CRM-Notiz (fiktiv)

Musterfirma GmbH ist ein frei erfundener Muenchner Hersteller von
Massivholzmoebeln mit B2B-Vertrieb. Der CRM-Copilot "Linnea" entwirft formelle
deutsche Follow-up-E-Mails an Geschaeftskunden mit einem externen LLM. Die deutsche
Datenschutzlage verbietet es, personenbezogene Kundendaten an diesen Anbieter zu
senden. OGuardAI ist die Laufzeit, die dem Agenten nur semantische Tokens zeigt und
die echten Werte auf dem Rueckweg pro Ausgabekanal wiederherstellt.

Die Aussendienst-Anweisung unten enthaelt ausschliesslich frei erfundene
personenbezogene Daten (Name, Kundennummer, E-Mail, Telefon, IBAN). Es sind keine
echten Personendaten enthalten. Der Runner liest genau den Block zwischen den Markern
und schickt ihn durch den OGuardAI-Schutzzyklus, bevor der Mastra-Agent laeuft.

<<<EINGANG>>>
Entwirf bitte eine formelle Follow-up-E-Mail an unsere Ansprechpartnerin Frau Dr.
Erika Mustermann bei der Musterfirma GmbH, Kundennummer KD-884120. Sie hat im
Termin Interesse an 40 Eichenholz-Konferenztischen bekundet. Bestaetige den zugesagten
Liefertermin in Kalenderwoche 34, verweise freundlich auf unser Angebot AN-2026-0517
und bitte um eine Rueckmeldung bis Monatsende. Fuer die spaetere Rechnungsstellung
haben wir die Bankverbindung IBAN DE97 9900 0000 0123 4567 89 hinterlegt. Du erreichst
Frau Mustermann per E-Mail unter erika.mustermann@example.de oder
telefonisch unter +49 89 5550172.
<<<ENDE>>>

The scenario config

Company config, the channel map, the raw-PII sentinels the leak check runs against (split by which detector class catches them), and the fixture loader. From examples/mastra-ts/src/scenario.ts:

import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import type { OutputChannel } from "@oguardai/sdk";

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

/**
 * Output channels the copilot restores the tokenized draft into. Each consumer sees
 * only what the german-support policy allows for its channel (channel_rules only
 * tighten, never widen), so the same instruction yields two different visibilities.
 */
export const CHANNELS = {
  /** The email sent to the prospect: full name (gender-aware) and contact details. */
  prospectEmail: "customer_email" satisfies OutputChannel,
  /** The audit log line: fully redacted, safe to persist in any log store. */
  auditLog: "log_safe" satisfies OutputChannel,
} as const;

/**
 * The real (fictional) PII strings embedded in the fixture, split by which detector
 * class catches them. A structured identifier is caught by the built-in detectors, so
 * one reaching the agent is a hard failure in every mode. A person name needs the NER
 * detector, so a name reaching the agent is a hard failure only when the runtime reports
 * ner_active; without NER it is a printed note, never a silent leak.
 */
export const NER_PII: readonly string[] = ["Erika Mustermann"] as const;
export const STRUCTURED_PII: readonly string[] = [
  "erika.mustermann@example.de",
  "+49 89 5550172",
  "DE97 9900 0000 0123 4567 89",
  "KD-884120",
] as const;

const fixturesDir = resolve(dirname(fileURLToPath(import.meta.url)), "..", "fixtures");
const START_MARKER = "<<<EINGANG>>>";
const END_MARKER = "<<<ENDE>>>";

/**
 * Load the single sales-rep instruction from the fixture. Only the block between the
 * markers is returned; the surrounding narrative prose (which also names the markers)
 * is dropped, so the protected text is exactly the rep's instruction.
 */
export function loadInstruction(): string {
  const raw = readFileSync(resolve(fixturesDir, "lindenhof-crm-prospect.de.md"), "utf8");
  const start = raw.indexOf(START_MARKER);
  const end = raw.indexOf(END_MARKER);
  if (start === -1 || end === -1 || end < start) {
    throw new Error("fixture is missing the <<<EINGANG>>> / <<<ENDE>>> markers");
  }
  return raw
    .slice(start + START_MARKER.length, end)
    .split("\n")
    .map((line) => line.trim())
    .join(" ")
    .replace(/\s+/g, " ")
    .trim();
}

The agent and the offline stand-in

The live Mastra agent, the deterministic offline drafter that reuses only the tokens present in its input (so it can never emit a raw value), and the instrument wrapper that records every string handed to generate for the leak assertion. From examples/mastra-ts/src/agent.ts:

import { Agent } from "@mastra/core/agent";
import { openai } from "@ai-sdk/openai";
import type { MastraGenerativeAgent } from "@oguardai/sdk/integrations/mastra";
import { COMPANY } from "./scenario.js";

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

/**
 * The token contract handed to the live agent. The model must echo every placeholder
 * verbatim and must NOT write a salutation before a person token, because the
 * german-support policy injects the correct gendered "Herr"/"Frau" on restore.
 */
function instructions(): string {
  return [
    `Du bist ${COMPANY.assistant}, CRM-Copilot von ${COMPANY.name}.`,
    "Entwirf eine formelle deutsche Follow-up-E-Mail in der Sie-Form, knapp und hilfsbereit.",
    "Die Anweisung enthaelt Platzhalter der Form {{typ:id:pruefsumme}}. Sie stehen fuer echte Daten,",
    "du nicht sehen darfst. Uebernimm jeden Platzhalter Zeichen fuer Zeichen unveraendert.",
    "Erfinde, vervollstaendige oder formatiere niemals einen Platzhalter und schreibe niemals",
    "selbst einen echten Namen, eine E-Mail, eine Telefonnummer oder eine IBAN.",
    "Schreibe KEIN 'Herr', 'Frau' oder 'Sehr geehrte' vor einen person-Platzhalter; die",
    "Laufzeit setzt die korrekte Anrede ein. Eroeffne mit 'Guten Tag {{person:...}},'.",
  ].join("\n");
}

/**
 * Build the live Mastra agent with an ai-sdk OpenAI model that reads OPENAI_API_KEY from the
 * environment. The returned Agent is structurally a {@link MastraGenerativeAgent}.
 */
export function buildMastraAgent(): MastraGenerativeAgent {
  return new Agent({
    name: `${COMPANY.name} CRM Copilot`,
    instructions: instructions(),
    model: openai(process.env.MASTRA_MODEL ?? "gpt-4o-mini"),
  });
}

function findToken(safeText: string, type: string): string | undefined {
  return safeText.match(new RegExp(`\\{\\{${type}:[^{}]+\\}\\}`, "i"))?.[0];
}

/**
 * Deterministic drafter shared by the offline agent and the processor-path stub model.
 * It composes a formal German follow-up that reuses ONLY the tokens present in its input,
 * so by construction it can never emit a raw value: it never saw one.
 */
export function draftFromTokens(safeText: string): string {
  const person = findToken(safeText, "person");
  const email = findToken(safeText, "email");
  const iban = findToken(safeText, "iban");

  const lines: string[] = [];
  lines.push(person ? `Guten Tag ${person},` : "Guten Tag,");
  lines.push("");
  lines.push(
    "vielen Dank fuer Ihr Interesse an unseren Eichenholz-Konferenztischen. Gerne bestaetigen " +
      "wir den zugesagten Liefertermin in Kalenderwoche 34 und verweisen auf unser Angebot " +
      "AN-2026-0517.",
  );
  if (iban) {
    lines.push(`Fuer die Rechnungsstellung haben wir die Bankverbindung ${iban} vermerkt.`);
  }
  if (email) {
    lines.push(`Rueckfragen beantworten wir jederzeit; eine kurze Nachricht an ${email} genuegt.`);
  }
  lines.push("Ueber eine Rueckmeldung bis Monatsende freuen wir uns.");
  lines.push("");
  lines.push("Mit freundlichen Gruessen");
  lines.push(`${COMPANY.assistant}, Vertrieb ${COMPANY.name}`);
  return lines.join("\n");
}

/** Offline stand-in for the Mastra agent. Same `generate` shape, no provider key needed. */
export const offlineAgent: MastraGenerativeAgent = {
  async generate(input: string): Promise<{ text: string }> {
    return { text: draftFromTokens(input) };
  },
};

/**
 * Wrap an agent so every string handed to `generate` is recorded. `observed` is exactly
 * what the model (and any tracer attached inside the generate call) sees; the example
 * asserts it is tokenized and free of raw PII.
 */
export function instrument(agent: MastraGenerativeAgent): {
  agent: MastraGenerativeAgent;
  observed: string[];
} {
  const observed: string[] = [];
  const wrapped: MastraGenerativeAgent = {
    async generate(input: string, options?: unknown) {
      observed.push(input);
      return agent.generate(input, options);
    },
  };
  return { agent: wrapped, observed };
}

The end-to-end trace

src/index.ts is the runner. The blocks below are the load-bearing parts of that file; token ids and the trailing hash change per run, the structure does not.

The imports, client, and the leak-check helpers. From examples/mastra-ts/src/index.ts:

import { OGuardAIClient } from "@oguardai/sdk";
import { guardMastraGenerate, guardMastraAgent } from "@oguardai/sdk/integrations/mastra";
import type { MastraMessage } from "@oguardai/sdk/integrations/mastra";
import { CHANNELS, COMPANY, NER_PII, STRUCTURED_PII, loadInstruction } from "./scenario.js";
import { buildMastraAgent, offlineAgent, instrument, draftFromTokens, llmMode } from "./agent.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 });

const PROSPECT_EMAIL = "erika.mustermann@example.de";
const TOKEN_RE = /\{\{[^{}]+\}\}/;

function structuredLeaks(text: string): string[] {
  return STRUCTURED_PII.filter((value) => text.includes(value));
}

function nameLeaks(text: string): string[] {
  return NER_PII.filter((value) => text.includes(value));
}

function short(err: unknown): string {
  return err instanceof Error ? err.message : String(err);
}

The boundary-safe path in main: health check, the NER-active signal, one transform run through guardMastraGenerate for the prospect-email channel with the leak assertion on what the agent saw, then the same instruction rehydrated for the log_safe channel to show the policy split. From examples/mastra-ts/src/index.ts:

async function main(): Promise<void> {
  try {
    const health = await client.health();
    if (health.status === "unhealthy") {
      throw new Error("server reports unhealthy");
    }
  } catch {
    console.error(`OGuardAI is not reachable at ${guardaiUrl}.`);
    console.error("Start it first:  docker compose -f deploy/docker/docker-compose.yml up -d   (from the repo root; see this example's README)");
    process.exit(1);
  }

  // The one leak-exemption signal: a name that crosses is a hard failure when the runtime
  // runs NER, otherwise a printed note. When the signal cannot be read, treat NER as active.
  let nerAvailable = true;
  try {
    nerAvailable = (await client.capabilities()).ner_active;
  } catch {
    nerAvailable = true;
  }

  const instruction = loadInstruction();

  // 1) Boundary-safe path: transform + rehydrate happen OUTSIDE the agent.
  const base = llmMode() === "openai" ? buildMastraAgent() : offlineAgent;
  const { agent, observed } = instrument(base);

  const prospectDraft = await guardMastraGenerate(client, agent, instruction, {
    policy: COMPANY.policy,
    language: COMPANY.language,
    outputChannel: CHANNELS.prospectEmail,
  });

  const structural = [...new Set(observed.flatMap(structuredLeaks))];
  const names = [...new Set(observed.flatMap(nameLeaks))];
  const tokenized = observed.length > 0 && observed.every((seen) => TOKEN_RE.test(seen));
  console.log(`\nPruefung: alles tokenisiert=${tokenized}  Struktur-Leaks=${structural.length}  Namen-Leaks=${names.length}`);
  if (structural.length > 0) {
    throw new Error(`structured PII reached the agent: ${structural.join(", ")}`);
  }
  if (names.length > 0) {
    if (nerAvailable) {
      throw new Error(`a person name reached the agent while NER is active: ${names.join(", ")}`);
    }
    console.log(`Hinweis: Name nicht tokenisiert [${names.join(", ")}]; fuer Namensschutz die volle Umgebung mit NER starten.`);
  } else if (tokenized) {
    console.log("OK: Der Agent hat ausschliesslich Tokens gesehen, keinen echten Wert.");
  }

  // Same boundary-safe flow, different output channel: the audit line is redacted per policy.
  const auditDraft = await guardMastraGenerate(client, offlineAgent, instruction, {
    policy: COMPANY.policy,
    language: COMPANY.language,
    outputChannel: CHANNELS.auditLog,
  });

  console.log(
    `\nProbe pro Policy: E-Mail im Kunden-Kanal sichtbar=${prospectDraft.includes(PROSPECT_EMAIL)}  ` +
      `im Audit-Log sichtbar=${auditDraft.includes(PROSPECT_EMAIL)}`,
  );

  await processorPathDemo(instruction, nerAvailable);
  await failClosedDemos(instruction);
}

The in-framework processor path, driven by hand with the stub model so the exported processInput and processOutputResult run deterministically without a provider key. The same processor object is keyed on one per-request carrier so a single session bridges tokenize and rehydrate. From examples/mastra-ts/src/index.ts:

async function processorPathDemo(instruction: string, nerAvailable: boolean): Promise<string[]> {
  const guard = guardMastraAgent(client, {
    policy: COMPANY.policy,
    language: COMPANY.language,
    outputChannel: CHANNELS.prospectEmail,
  });

  // A plain object stands in for Mastra's per-request carrier; the guard keys the session on it.
  const carrier = {};

  const userMessages: MastraMessage[] = [{ role: "user", content: instruction }];
  const safeMessages = await guard.processInput({ messages: userMessages, messageList: carrier });
  const safeText = typeof safeMessages[0].content === "string" ? safeMessages[0].content : "";

  const procStructural = structuredLeaks(safeText);
  const procNames = nameLeaks(safeText);
  if (procStructural.length > 0) {
    throw new Error(`structured PII reached the processor: ${procStructural.join(", ")}`);
  }

  // The stub model runs ENTIRELY on tokens and returns a tokenized assistant reply.
  const assistantReply = draftFromTokens(safeText);
  const outMessages: MastraMessage[] = [{ role: "assistant", content: assistantReply }];
  const restoredMessages = await guard.processOutputResult({
    messages: outMessages,
    messageList: carrier,
  });
  const restored = typeof restoredMessages[0].content === "string" ? restoredMessages[0].content : "";

  return procNames;
}

The two adapter-native fail-closed properties: a non-text (multimodal) message is rejected before any transform, and a request with no per-request carrier is refused rather than sharing one global session slot. From examples/mastra-ts/src/index.ts:

async function failClosedDemos(instruction: string): Promise<void> {
  const guard = guardMastraAgent(client, { policy: COMPANY.policy, language: COMPANY.language });

  // 1) A non-text/multimodal part is rejected before any transform (offline, no server call).
  let multimodalAccepted = false;
  try {
    await guard.processInput({
      messages: [{ role: "user", content: { parts: [{ type: "image", url: "https://example/x.png" }] } }],
      messageList: {},
    });
    multimodalAccepted = true;
  } catch (err) {
    console.log(`1) Multimodal-Nachricht -> abgelehnt: ${short(err)}`);
  }
  if (multimodalAccepted) {
    throw new Error("fail-closed check failed: a multimodal message was accepted");
  }

  // 2) Without a per-request carrier the session cannot be correlated, so the guard refuses
  //    rather than sharing one global slot across concurrent requests.
  let carrierlessAccepted = false;
  try {
    await guard.processInput({ messages: [{ role: "user", content: instruction }] });
    carrierlessAccepted = true;
  } catch (err) {
    console.log(`2) Ohne per-request Carrier -> abgelehnt: ${short(err)}`);
  }
  if (carrierlessAccepted) {
    throw new Error("fail-closed check failed: a request without a per-request carrier was accepted");
  }
}

main().catch((err) => {
  console.error("Trace failed:", err);
  process.exit(1);
});

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 DER MASTRA-AGENT SIEHT (safe_text, nur Tokens)
  Entwirf bitte eine formelle Follow-up-E-Mail an unsere Ansprechpartnerin
  {{person:p_001:...}} bei der Musterfirma GmbH, Kundennummer
  {{customer_id:cid_001:...}} ...

Pruefung: alles tokenisiert=true  Rohdaten-Leaks=0
OK: Der Agent hat ausschliesslich Tokens gesehen, keinen echten Wert.

WIEDERHERGESTELLT (Kanal customer_email, an die Kundin)
  Guten Tag Frau Dr. Erika Mustermann, ... Nachricht an
  erika.mustermann@example.de genuegt. ...

WIEDERHERGESTELLT (Kanal log_safe, fuers Audit-Log)
  Guten Tag [REDACTED], ... Nachricht an [REDACTED] genuegt. ...

Probe pro Policy: E-Mail im Kunden-Kanal sichtbar=true  im Audit-Log sichtbar=false

PROZESSOR-PFAD (guardMastraAgent, in-framework)
  processInput -> was der Agent sieht (nur Tokens): ...
  processOutputResult -> wiederhergestellte Antwort: ...

FAIL-CLOSED
1) Multimodal-Nachricht -> abgelehnt: guardMastraAgent: message contains a non-text part; ...
2) Ohne per-request Carrier -> abgelehnt: guardMastraAgent: Mastra provided no per-request carrier ...

With the full stack and OPENAI_API_KEY set, a live Mastra agent drafts the tokenized reply instead of the offline stand-in; the same boundary and policy assertions apply.