agentid-vercel-sdk
v0.1.35
Published
AgentID wrapper for Vercel AI SDK guardrails, masking, workflow telemetry, and audit logging.
Readme
agentid-vercel-sdk
agentid-vercel-sdk is the official AgentID wrapper for the Vercel AI SDK.
It wraps an existing model with:
- pre-flight AgentID
/guard - optional prompt rewriting from guard verdicts
- block-before-provider billing on denied requests
- post-flight
/ingestand/ingest/finalizetelemetry
The client callsite still uses normal generateText() and streamText().
Default behavior stays backend-first:
- AgentID
/guardis the primary decision point - local deterministic checks only run when
clientFastFailis explicitly enabled - in
strictMode/fail_close, local deterministic checks are also used as the last fallback if the backend guard is temporarily unreachable
Install
Pick the provider package you actually use.
npm install ai agentid-vercel-sdk @ai-sdk/openaior
npm install ai agentid-vercel-sdk @ai-sdk/anthropicagentid-vercel-sdk depends on agentid-sdk@^0.1.38.
Local beta install from this monorepo
Before the packages are published publicly, install both local packages explicitly:
npm install ../../agentid-sdk ../../packages/vercel-sdk ai @ai-sdk/openaiThere is also a runnable Next.js reference app in examples/vercel-ai-sdk-next.
Local tarball beta install
If you want to simulate a real consumer install before publish, pack both packages and install the tarballs:
cd agentid-sdk
npm pack
cd ../packages/vercel-sdk
npm pack
npm install ../../agentid-sdk/agentid-sdk-<version>.tgz ./agentid-vercel-sdk-<version>.tgz ai @ai-sdk/openaiInside this monorepo you can run the automated consumer install smoke with:
npm run qa:vercel-sdk-install-smokeRequired env
AGENTID_SYSTEM_ID=00000000-0000-0000-0000-000000000000
AGENTID_API_KEY=sk_live_...
OPENAI_API_KEY=sk-proj-...Swap OPENAI_API_KEY for your provider key when using Anthropic or another Vercel AI SDK provider.
generateText() example
import { openai } from "@ai-sdk/openai";
import { generateText } from "ai";
import { AgentIdSecurityError, withAgentId } from "agentid-vercel-sdk";
const secureModel = withAgentId(openai("gpt-4o"), {
systemId: process.env.AGENTID_SYSTEM_ID!,
apiKey: process.env.AGENTID_API_KEY!,
});
try {
const result = await generateText({
model: secureModel,
prompt: "Write a short refund confirmation.",
});
console.log(result.text);
} catch (error) {
if (error instanceof AgentIdSecurityError) {
console.error("Blocked by AgentID:", error.reason);
return;
}
throw error;
}streamText() example
import { openai } from "@ai-sdk/openai";
import { streamText } from "ai";
import { AgentIdSecurityError, withAgentId } from "agentid-vercel-sdk";
const secureModel = withAgentId(openai("gpt-4o"), {
systemId: process.env.AGENTID_SYSTEM_ID!,
apiKey: process.env.AGENTID_API_KEY!,
});
try {
const result = streamText({
model: secureModel,
prompt: "Stream a short answer.",
});
for await (const chunk of result.textStream) {
process.stdout.write(chunk);
}
} catch (error) {
if (error instanceof AgentIdSecurityError) {
console.error("Blocked by AgentID:", error.reason);
return;
}
throw error;
}Streaming telemetry is observed on a forked ReadableStream branch so the user-visible stream is not blocked by AgentID logging.
The wrapper is Edge-safe for normal Vercel AI SDK server usage. It does not require fs or native Node-only runtime APIs in the wrapper path.
Critical: masking and blocking before the LLM
AgentID only protects the LLM call that is actually routed through the AgentID wrapper. Logging a masked copy to AgentID is not enough.
For pre-LLM masking and blocking, the application must:
- create the provider model
- wrap that model with
withAgentId(...) - pass the wrapped model to
generateText()orstreamText() - send the complete prompt/message history through that wrapped call
- render the returned wrapped result/stream, not a separate raw provider response
withAgentId(...) protects text parts across the full Vercel AI SDK prompt
history before provider dispatch when SDK-side masking is enabled. It also marks
telemetry with full_history_protected=true, messages_count, and
protected_messages_count so the dashboard can distinguish a full wrapper
integration from audit-only logging.
Correct:
const secureModel = withAgentId(openai("gpt-4o"), {
systemId: process.env.AGENTID_SYSTEM_ID!,
apiKey: process.env.AGENTID_API_KEY!,
});
const result = await generateText({
model: secureModel,
messages: conversationMessages,
});Incorrect:
// This only creates a masked audit trail. It does not protect the LLM call below.
await agent.log({ input: maskedPrompt, output: maskedOutput, system_id: systemId });
// Raw prompt/history still goes directly to the provider.
const result = await generateText({
model: openai("gpt-4o"),
messages: rawConversationMessages,
});For chat and agent applications, do not protect only the latest text box value. The provider usually receives the full message history. If any previous user, assistant, tool, retrieval, memory, or system-supplied message contains raw PII, the model can still see it and repeat it later.
In other words, this must be protected as one LLM input:
await streamText({
model: secureModel,
messages: [
{ role: "system", content: systemPrompt },
...conversationHistory,
{ role: "user", content: latestUserMessage },
],
});Do not call an unwrapped provider model anywhere else in the same request path. Do not send raw chat history to the provider and then send only a masked copy to AgentID for logging. That pattern will make the dashboard look masked after refresh, but the LLM will already have seen the raw data.
Blocking and masking are separate controls:
- masking enabled: sensitive values are rewritten before provider dispatch
- blocking enabled: denied prompts throw
AgentIdSecurityErrorbefore provider dispatch - both enabled: the guard can block high-risk prompts; allowed prompts still use protected text
Recommended chat UX
For privacy-first chat apps, show the protected user transcript immediately when local masking applies. Do not wait for a dashboard/log refresh to replace raw values in the UI.
Recommended pattern:
- compute a local preview with
PIIManager.anonymize(...)before adding the optimistic user bubble - render the protected user bubble, for example
Moje jmeno je <PERSON_1> - call your server route with the user input/history and ensure the server route uses
withAgentId(...) - render the wrapped
generateText()result or wrappedstreamText()stream - do not render a raw provider stream in parallel with the wrapped stream
Example client-side optimistic bubble:
import { PIIManager } from "agentid-sdk";
const pii = new PIIManager();
const preview = pii.anonymize(input, { pii: true, secrets: true }).maskedText;
setMessages((messages) => [
...messages,
{ role: "user", content: preview, piiProtected: preview !== input },
]);If your product intentionally wants users to see their own raw text locally, make that an explicit product/security decision. It does not change the server rule: the provider call must still use the wrapped model and protected history.
Manual history protection helper
If you cannot use withAgentId(...) for a custom route yet, protect the complete
message array before calling the provider. Do not protect only the latest input.
import { protectMessageHistory } from "agentid-vercel-sdk";
const protectedHistory = protectMessageHistory(body.messages, {
pii: true,
secrets: true,
});
const result = streamText({
model: getCustomModel(),
messages: protectedHistory.messages,
});For manual integrations, include these telemetry fields when you call
agent.log(...) or /ingest:
metadata: {
full_history_protected: true,
messages_count: body.messages.length,
protected_messages_count: body.messages.length,
transformed_prompt_text_parts_count: protectedHistory.transformedTextPartsCount,
}Without that signal, AgentID treats the row as manual or legacy integration and the Activity detail can show an integration-risk warning.
Guidance for AI coding agents
When an AI coding agent integrates AgentID into an app, use this checklist:
- Find the exact server-side function that calls the LLM provider.
- Replace the model passed to
generateText()/streamText()with awithAgentId(...)wrapped model. - If the app uses OpenAI directly, replace the client with
agent.wrapOpenAI(...)and call the wrapped client. - Protect the full
messagesarray, not justpromptor the latest user input. - Remove or refactor any parallel direct provider call in the same request.
- Ensure the UI consumes the wrapped response or wrapped stream.
- Add a regression test where the first message contains a name and the second asks the model to repeat it. The answer must not contain the raw name.
Minimum regression expectation:
User: Moje jmeno je Jan Kroupa
Assistant: ... <PERSON_1> ...
User: jake jmeno jsem poslal v minule zprave?
Assistant must not contain: Jan, KroupaIf the model can answer Jan Kroupa, the raw history reached the LLM.
What the wrapper does
- calls AgentID
/guardbefore the provider request - throws
AgentIdSecurityErrorwhen the guard blocks the request - forwards allowed traffic to the wrapped provider model
- writes completion telemetry through AgentID
/ingest - finalizes streaming telemetry through AgentID
/ingest/finalize
Fail-open / fail-close handling, capability fetch, retries, and finalize semantics are delegated to agentid-sdk, so the wrapper stays aligned with the current AgentID runtime contract.
Practical behavior:
- default: backend-first, fail-open unless the effective system policy resolves to fail-close
clientFastFail: optional local preflight before/guardstrictModeorfailureMode: "fail_close": fail-close oriented behavior with local deterministic fallback if backend guard is temporarily unreachable
Per-request metadata
You can override request context per call via providerOptions.agentid.
const result = await generateText({
model: secureModel,
prompt: "Summarize this customer ticket.",
providerOptions: {
agentid: {
userId: "customer-123",
requestIdentity: {
tenantId: "acme",
sessionId: "sess-42",
},
expectedLanguages: ["en"],
clientEventId: "11111111-1111-4111-8111-111111111111",
},
},
});Supported request-level overrides:
userIdrequestIdentityexpectedLanguagesclientEventIdtelemetry
Workflow and tool-step telemetry
withAgentId() logs the guarded LLM call. For the rest of an agent run, use the re-exported AgentID operation logger with the same workflowRunId.
import { openai } from "@ai-sdk/openai";
import { generateText } from "ai";
import {
AgentID,
createAgentIdCorrelationId,
createAgentIdTelemetryContext,
withAgentId,
} from "agentid-vercel-sdk";
const systemId = process.env.AGENTID_SYSTEM_ID!;
const apiKey = process.env.AGENTID_API_KEY!;
const workflowRunId = createAgentIdCorrelationId();
const agent = new AgentID({ apiKey });
const secureModel = withAgentId(openai("gpt-4o"), {
systemId,
apiKey,
telemetry: createAgentIdTelemetryContext({
workflowRunId,
workflowName: "Invoice follow-up",
}),
});
await agent.logOperation({
system_id: systemId,
telemetry: createAgentIdTelemetryContext({
workflowRunId,
workflowStepName: "load_invoice",
toolName: "finance.invoice_lookup",
toolTargetType: "invoice",
}),
event_category: "tool",
event_status: "completed",
});
await generateText({
model: secureModel,
prompt: "Draft a short payment reminder.",
providerOptions: {
agentid: {
telemetry: createAgentIdTelemetryContext({
workflowRunId,
workflowStepName: "draft_email",
toolName: "email.draft",
toolTargetType: "email",
eventCategory: "ai",
eventSubtype: "payment_reminder_draft_generated",
}),
},
},
});
await agent.logOperation({
system_id: systemId,
telemetry: createAgentIdTelemetryContext({
workflowRunId,
workflowStepName: "send_email",
toolName: "email.send",
toolTargetType: "email",
}),
event_category: "delivery",
event_status: "completed",
});Each event remains its own audit row. The dashboard groups rows that share workflow_run_id, so tool calls, delivery events, inbox events, guard checks, and LLM calls appear as one workflow timeline without treating non-LLM work as model spend. Do not reuse clientEventId for the whole workflow; keep workflow correlation in workflowRunId and let each event keep its own idempotency key.
Dashboard behavior:
- the guarded prompt/LLM step remains inspectable as its own prompt/guard Activity row
- the workflow summary row opens the step-by-step timeline for the run
- non-LLM tool, delivery, inbox, and workflow lifecycle rows show
Model: Not applicable - only spend-bearing model rows should contribute to model cost totals
Token, cost, and ROI dashboards require the spend-bearing model row to carry
provider usage. The wrapper forwards Vercel AI SDK usage metadata when the
provider exposes it. If a custom path logs completion manually, include
usage.prompt_tokens, usage.completion_tokens, usage.total_tokens, the real
model id, and latency. Without those fields, Activity can look correct while
Total Spend, token charts, and ROI stay empty.
For tool execute() wrappers and multi-step agent runs, prefer the workflow
trail helper:
import {
AgentID,
createAgentIdCorrelationId,
createAgentIdTelemetryContext,
createAgentIdWorkflowTrail,
withAgentId,
} from "agentid-vercel-sdk";
const workflowRunId = createAgentIdCorrelationId();
const agent = new AgentID({ apiKey: process.env.AGENTID_API_KEY! });
const trail = createAgentIdWorkflowTrail({
agent,
system_id: process.env.AGENTID_SYSTEM_ID!,
telemetry: createAgentIdTelemetryContext({
workflowRunId,
workflowName: "Invoice follow-up",
}),
});
const searchInvoice = async () =>
trail.runStep(
{
telemetry: createAgentIdTelemetryContext({
workflowStepName: "lookup_invoice",
toolName: "finance.invoice_lookup",
toolTargetType: "invoice",
eventCategory: "tool",
}),
},
async () => lookupInvoice(),
{
complete: {
metadata: { result_count: 1 },
},
}
);Current V1 limits
- The wrapper expects a text user prompt.
- File-only prompt payloads currently raise
AgentIdPromptExtractionError. - If you use non-wrapped provider surfaces, call
guard()andlogOperation()/log()explicitly instead of assuming automatic telemetry. - The wrapper protects calls made through the wrapped model only. It cannot protect unrelated provider calls, custom fetches, browser-only LLM calls, or another deployed app that has not been updated to use the wrapper.
Masking and scanner coverage
Masking behavior is inherited from agentid-sdk and the AgentID runtime config.
When SDK-side masking is enabled, protected traffic is rewritten before provider
dispatch, before user-visible wrapper output, and before ingest.
Current scanner regression coverage includes:
- multiline PEM, certificate, and PGP private key blocks
- password disclosures such as
my Password is Passwordk123 - environment assignments such as
DB_PASSWORD=... - suffix-safe secret values such as passwords ending in
# - base64-like secret values with
=/==padding - security-question answers after
answer is,is, or localized equivalents
Provider coverage in this repo
The package has provider-level integration coverage for:
@ai-sdk/openainon-stream@ai-sdk/openaistream@ai-sdk/anthropicnon-stream@ai-sdk/anthropicstream
That is intentional. The wrapper should stay provider-agnostic at the Vercel AI SDK layer, so it is validated against more than one concrete provider contract.
Publishing Notes (NPM)
NPM renders this README.md from the package root during npm publish.
Before publishing from the monorepo, run:
npm run audit:all
npm run qa:production-gateThe production gate includes the package-local audit for
packages/vercel-sdk, provider integration tests, typecheck, build, and a
consumer install smoke path through the root qa:vercel-sdk-install-smoke
script.
