Tawa docs
Integrations

Vercel AI SDK

Wrap a model once — every generateText/streamText call is scanned before it reaches the provider.

@tawaguard/sdk/ai-sdk wraps a Vercel AI SDK language model via wrapLanguageModel, so every call using it is scanned by Tawa before it reaches the provider. It's a separate subpath export — ai and @ai-sdk/provider stay optional peer dependencies, so nothing pulls them in unless you actually use this integration.

Install

npm install @tawaguard/sdk ai @ai-sdk/openai

Requires ai@^7 and @ai-sdk/provider@^4 as peer dependencies. If your app already uses an older Vercel AI SDK major version elsewhere, upgrade it first — npm install will not silently downgrade it for you.

Wrap a model once

import { openai } from "@ai-sdk/openai";
import { generateText } from "ai";
import { TawaGuard } from "@tawaguard/sdk";
import { TawaBlockedError, tawa } from "@tawaguard/sdk/ai-sdk";

const tawaClient = await TawaGuard.create(process.env.TAWA_API_KEY!);
const model = tawa(openai("gpt-5"), tawaClient);

export async function POST(req: Request) {
  const { messages } = await req.json();

  try {
    const { text } = await generateText({ model, messages });
    return Response.json({ text });
  } catch (error) {
    if (error instanceof TawaBlockedError) {
      return new Response("Message blocked by content policy.", { status: 403 });
    }
    throw error;
  }
}

Use model anywhere you'd normally pass the raw provider model — every generateText/streamText call using it is scanned automatically. No manual analyze() call needed at each call site.

What gets scanned, and when

By default, the whole assembled prompt (system message + conversation history + the latest turn) is scanned via scanContext in a transformParams hook, before the call reaches the model — not just the newest user message, because injected content can sit anywhere in prior history.

This does not include tool calls, tool results, or anything fetched for RAG. extractPromptText's default only pulls type: "text" parts out of the assembled prompt — a tool result is a different part type entirely, so it is invisible to this scan under any configuration, not partially covered. If your chatbot fetches documents or calls tools (most agentic/RAG chatbots do), wrapping the model with tawa() gives you zero protection on that content by itself. Call scanChunk (RAG) or scanToolOutput (agent tools) yourself, inside wherever that content is produced — typically a tool's execute function — before returning it to the model.

A blocked or leaning-toward-blocked result throws TawaBlockedError instead of letting the call reach the model:

export class TawaBlockedError extends Error {
  readonly result: ScanResult; // the result that tripped it
  readonly stage: "prompt" | "response";
}

The default blocking policy: always block on block/hard_block/ soft_block, never on allow/ood, and defer to the server's own suggestion for the genuinely undecided actions (ambiguous/review/ sandbox). Override it with shouldBlock (see below).

The gateway already collapses review to allow or block per your app's setting, so against a current gateway the result's action is allow or block and the default policy follows it directly. The undecided branch only applies to a gateway that predates the review flag. To act on uncertain results, use onReview.

Blocked messages persist in conversation history

Once a message in a conversation is blocked, every later message in that same conversation is blocked too — even ones with no malicious content of their own.

This is expected, not a detection bug, and follows from two other documented behaviors:

  1. The Vercel AI SDK's useChat resends the entire message history on every turn, not just the newest message.
  2. tawa() scans the whole assembled prompt on every call (see What gets scanned, and when), so an injection planted earlier in the conversation is still caught even when the current turn looks innocent.

A blocked message's text stays in messages history. Every later call resends it, tawa() rescans it, and it re-trips the block — so the conversation stays blocked until you intervene. Handle it one of two ways:

Option 1: lock the conversation

Once any message in a thread has been blocked, stop accepting new input and require a fresh conversation (new sessionId, empty history).

app/api/chat/route.ts
// Server: tag the response so the client can detect a blocked turn.
export async function POST(req: Request) {
  const { messages } = await req.json();

  try {
    const { text } = await generateText({ model, messages });
    return Response.json({ text });
  } catch (error) {
    if (error instanceof TawaBlockedError) {
      return Response.json({ blocked: true, reason: error.result.rationale }, { status: 403 });
    }
    throw error;
  }
}
// Client: gate the composer once any message in the thread was blocked.
const locked = messages.some((m) => m.metadata?.blocked === true);

<ChatInput disabled={locked} />
{locked && <Button onClick={startNewConversation}>Start new chat</Button>}

Option 2: strip the blocked exchange and continue

Drop the blocked user message and its "blocked" reply from what gets sent to tawa()/the model on the next turn, while still showing them in the UI transcript.

// Drop blocked exchanges before they're resent to tawa()/the model.
function stripBlocked(messages: UIMessage[]) {
  return messages.filter((m) => !m.metadata?.blocked);
}

const { text } = await generateText({ model, messages: stripBlocked(messages) });

This isn't a security regression: a message that was already blocked never reached the model, and rescanning the same rejected text on every later turn adds no protection — only false positives on unrelated later messages.

Lock the conversationStrip and continue
A false positiveUser must start a new chatConversation continues unaffected
An attacker retryingMust start a new session — gives you a clean per-session signal to rate-limit or alert onFree, unlimited attempts within one session
Security impactNoneNone

Neither is more "correct" — pick based on your own risk tolerance.

tawa() has no built-in concept of "this conversation is now poisoned" for either option — check for a blocked turn yourself (keyed off scanOptions.sessionId, which the SDK also uses for the live event stream).

Scanning the model's reply too

Off by default — it doubles latency/cost per call, and the primary threat model (prompt injection) lives on the input side. Turn it on with scanResponses: true, for both generateText and streamText:

const model = tawa(openai("gpt-5"), tawaClient, { scanResponses: true });

On streamText, the reply is scanned in windows rather than all at once — a chunk of text is only forwarded to the caller once the window it belongs to has cleared a scan (see Streaming caveat for how a block surfaces). Window size defaults to 200 characters; tune it with streamScanWindowChars:

const model = tawa(openai("gpt-5"), tawaClient, {
  scanResponses: true,
  streamScanWindowChars: 500, // scan less often, at the cost of more unscanned text in flight
});

A smaller window bounds how much of a blocked reply can reach the caller before the stream is cut, at the cost of more scanResponse calls per reply. A larger window scans less often but lets more text through before each check.

Options

Pass an options object as the third argument to tawa() (or to tawaMiddleware() if you're composing with other AI SDK middleware via wrapLanguageModel({ middleware: [...] })):

const model = tawa(openai("gpt-5"), tawaClient, {
  shouldBlock: (result) => result.action !== "allow", // stricter than the default
  scanResponses: true,
  extractPromptText: (prompt) => /* custom extraction */,
  scanOptions: { cuid: currentUser.id, sessionId },
});
OptionTypeDefaultDescription
shouldBlock(result: ScanResult) => booleansee aboveDecide whether a scan result should block the call
scanResponsesbooleanfalseAlso scan the model's reply before it's returned, on both generateText and streamText
streamScanWindowCharsnumber200Streaming only — size (in characters) of each text window scanned before it's forwarded to the caller
extractPromptText(prompt) => stringscans the whole promptBuild the text handed to Tawa from the AI SDK's internal prompt array
scanOptionsScanOptions—Forwarded as-is to every scan call (cuid, tenantId, policyOverrides, language, sessionId)
onReview(result: ScanResult, stage: "prompt" | "response", text: string) => void | Promise<void>undefinedCalled once per scan whenever result.needsReview is true, whether or not the scan blocks, before the block decision. Errors thrown inside it go to onError, or are dropped when onError is not set. See The review flag
onError(error: unknown) => voidundefinedReceives an error thrown by onReview
policy"org-active" | BuiltInPolicyTemplateIdundefined (off)Make blocking policy-aware — see below

Policy Studio integration (policy)

The default blocking policy above only looks at the scan's raw action/ suggestion — it never reads ScanResult.policy, the verdict from your org's Policy Studio rules. That verdict is already computed server-side on every scan regardless of this option; by default it's simply discarded by shouldBlock.

Set policy to turn this on — off by default, so existing integrations are unaffected:

// Block using your org's already-active Policy Studio policy, on top of
// the existing hard-block safety floor. No extra network call — that
// policy is already evaluated server-side on every scan.
const model = tawa(openai("gpt-5"), tawaClient, { policy: "org-active" });

// Additionally layer a built-in template's rules on top of your org's
// policy, for this integration point specifically.
const model = tawa(openai("gpt-5"), tawaClient, { policy: "strict-zero-trust" });

A built-in template ("baseline-protection" | "strict-zero-trust" | "tool-heavy-agent") is additive, not a replacement — its rules are unioned with your org's active policy server-side, so it can only make blocking stricter, never loosen your org's real policy.

policy and shouldBlock are mutually exclusive — tawa()/ tawaMiddleware() throw synchronously if both are set. If you need custom logic combined with the policy verdict, write your own shouldBlock that calls the exported policyShouldBlock (or reads result.policy directly) instead of setting policy.

Streaming caveat

streamText() itself never throws — it returns synchronously and runs the pipeline in the background, so a block (on the prompt, or on the response when scanResponses is on) surfaces as an error part inside the stream (visible via result.fullStream, or in toUIMessageStreamResponse()'s output), not as a catchable exception around the streamText() call or a top-level 403. Handle it via the stream's error channel rather than try/catch if you need to react to it.

This is easy to get wrong in a way that hides the block entirely, not just handles it differently. toUIMessageStreamResponse()'s onError option defaults to () => "An error occurred." — every error, including TawaBlockedError, is masked to that generic string unless you supply onError yourself. Without it, a blocked message reaches the browser as "An error occurred.", indistinguishable from any other crash: no action, no confidence, no rationale, no signal it was Tawa at all. Write the onError below yourself — there is no default that surfaces the block.

app/api/chat/route.ts
import { openai } from "@ai-sdk/openai";
import { TawaGuard } from "@tawaguard/sdk";
import { TawaBlockedError, tawa } from "@tawaguard/sdk/ai-sdk";
import { streamText } from "ai";

const tawaClient = await TawaGuard.create(process.env.TAWA_API_KEY!);
const model = tawa(openai("gpt-5"), tawaClient);

export async function POST(req: Request) {
  const { messages } = await req.json();

  const result = streamText({ model, messages });

  return result.toUIMessageStreamResponse({
    onError: (error) =>
      error instanceof TawaBlockedError
        ? `blocked:${error.result.action}:${error.result.rationale}`
        : "An unexpected error occurred.",
  });
}

On the client, useChat()'s error.message will be that string — parse the blocked: prefix to render it distinctly from a real failure. This is not optional boilerplate you can skip for a first pass: skip it and every block your users hit looks like your app broke, not like it protected them.

A block on streamText cannot short-circuit to an HTTP-level rejection before any bytes are sent — it always surfaces through the stream's error channel, as shown above.

On this page