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/openaiRequires 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:
- The Vercel AI SDK's
useChatresends the entire message history on every turn, not just the newest message. 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).
// 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 conversation | Strip and continue | |
|---|---|---|
| A false positive | User must start a new chat | Conversation continues unaffected |
| An attacker retrying | Must start a new session — gives you a clean per-session signal to rate-limit or alert on | Free, unlimited attempts within one session |
| Security impact | None | None |
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 },
});| Option | Type | Default | Description |
|---|---|---|---|
shouldBlock | (result: ScanResult) => boolean | see above | Decide whether a scan result should block the call |
scanResponses | boolean | false | Also scan the model's reply before it's returned, on both generateText and streamText |
streamScanWindowChars | number | 200 | Streaming only — size (in characters) of each text window scanned before it's forwarded to the caller |
extractPromptText | (prompt) => string | scans the whole prompt | Build the text handed to Tawa from the AI SDK's internal prompt array |
scanOptions | ScanOptions | — | Forwarded as-is to every scan call (cuid, tenantId, policyOverrides, language, sessionId) |
onReview | (result: ScanResult, stage: "prompt" | "response", text: string) => void | Promise<void> | undefined | Called 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) => void | undefined | Receives an error thrown by onReview |
policy | "org-active" | BuiltInPolicyTemplateId | undefined (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.
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.