@vanteguardlabs/warden-ai-sdk
v0.2.1
Published
Wrap your Anthropic / OpenAI client. Every tool call is inspected by Agent Warden before it runs.
Maintainers
Readme
@vanteguardlabs/warden-ai-sdk
TypeScript SDK for Agent Warden. Wraps your Anthropic or OpenAI client and inspects every tool call the model emits against your policies before your tool-execution loop runs it.
Quickstart
Three commands from zero to a verdict:
# 1. Boot warden-lite locally. (Container, fly.io, or the static
# binary asset — pick what's easy for you. See:
# https://github.com/vanteguardlabs/warden-lite#run-it-in-60-seconds )
docker run -p 8088:8088 \
-e WARDEN_LITE_UPSTREAM_URL=https://api.anthropic.com \
-e WARDEN_LITE_MODE=observe \
ghcr.io/vanteguardlabs/warden-lite:latest
# 2. Install the SDK in your agent project.
pnpm add @vanteguardlabs/warden-ai-sdk @anthropic-ai/sdk
# 3. Wrap your client. The snippet below catches a deny verdict;
# in observe mode every call passes through and you read the
# verdict off the `onVerdict` callback instead.Then the snippet that catches a deny:
import Anthropic from '@anthropic-ai/sdk';
import { wardenWrap, WardenDenied } from '@vanteguardlabs/warden-ai-sdk';
const client = wardenWrap(new Anthropic(), {
endpoint: 'http://localhost:8088', // warden-lite ingress
token: process.env.WARDEN_LITE_TOKEN, // optional bearer
});
try {
const msg = await client.messages.create({
model: 'claude-opus-4-7',
max_tokens: 1024,
tools: [/* your tool schemas */],
messages: [{ role: 'user', content: 'delete the alice user' }],
});
// If the model emits a tool_use that policy denies, this never
// resolves — the throw below fires instead. Your existing tool
// loop only ever sees policy-cleared tool_use blocks.
} catch (e) {
if (e instanceof WardenDenied) {
log.warn('blocked', { tool: e.toolName, reasons: e.reasons });
} else throw e;
}OpenAI
Same wrap, same options — the SDK auto-detects the client shape:
import OpenAI from 'openai';
import { wardenWrap, WardenDenied } from '@vanteguardlabs/warden-ai-sdk';
const client = wardenWrap(new OpenAI(), {
endpoint: 'http://localhost:8088',
});
const completion = await client.chat.completions.create({
model: 'gpt-4-turbo',
tools: [/* your tool schemas */],
messages: [{ role: 'user', content: 'delete the alice user' }],
});
// Every entry in choices[].message.tool_calls is inspected before
// this promise resolves; denied calls raise WardenDenied just like
// the Anthropic path.Streaming
Both providers' streaming surfaces are wrapped transparently. The
SDK detects an async-iterable return from create() and inspects
each tool call as it assembles from deltas. The closing event
(Anthropic content_block_stop, OpenAI finish_reason: 'tool_calls')
is held until warden returns a verdict — denied calls throw
mid-iteration before the partner sees the event that would let
their loop execute the tool.
const stream = await client.messages.create({
model: 'claude-opus-4-7', max_tokens: 1024, stream: true,
tools: [...], messages: [...],
});
try {
for await (const event of stream) {
/* process event */
}
} catch (e) {
if (e instanceof WardenDenied) {
// partner never saw content_block_stop for the denied tool_use
} else throw e;
}Same shape with OpenAI's chat.completions.create({ stream: true }).
For Anthropic's messages.stream() helper, use
messages.create({ stream: true }) instead until that helper's
wrap lands in a follow-up.
What it does
wardenWrap is a transparent Proxy around your model client.
Detection is structural: a client with messages.create is wrapped
as Anthropic, a client with chat.completions.create as OpenAI.
Every other property — client.beta, client.models, custom
subclasses — passes through unchanged.
On every response, every tool call (Anthropic tool_use content
block / OpenAI tool_calls entry) is sent to warden-lite's
POST /mcp for inspection. The verdict drives:
| mode | verdict | result |
|---|---|---|
| enforce (default) | allow | response passes through |
| enforce | deny | throw WardenDenied |
| enforce | pending | throw WardenPending — await e.resolve() blocks for human approval, then returns void or throws WardenDenied |
| observe | any | response passes through, onVerdict fires |
observe is the rollout knob: warden inspects + records every call,
your code keeps running. Flip to enforce once you trust the
verdicts.
Install
pnpm add @vanteguardlabs/warden-ai-sdk @anthropic-ai/sdk # Anthropic
pnpm add @vanteguardlabs/warden-ai-sdk openai # OpenAI@anthropic-ai/sdk and openai are peer dependencies — install
whichever ones you use. The SDK has no hard import on either.
Run a warden-lite instance somewhere reachable.
warden-lite is a
single Rust binary, self-hosted in your infra — container, Fly.io
button, or cargo install. See its Run it in 60 seconds
section.
Demo
pnpm install
pnpm demoSee examples/demo/ — runs two canned scenarios
end-to-end against a local warden-lite:
[1/2] agent: "fetch user 42" warden: [ALLOW] passes through
[2/2] agent: "delete user 42" warden: [DENY] throws WardenDeniedAPI
wardenWrap(client, opts) → client
| opts field | type | default | what |
|---|---|---|---|
| endpoint | string | required | warden-lite ingress URL |
| token | string | undefined | bearer for warden-lite |
| mode | 'enforce' \| 'observe' | 'enforce' | throw on deny vs. record only |
| timeoutMs | number | 10_000 | per-inspection HTTP timeout |
| onVerdict | (v, ctx) => void \| Promise<void> | undefined | fires per inspected tool_use (before any throw) |
| fetch | typeof fetch | globalThis.fetch | override for testing |
| retry | { maxAttempts, baseDelayMs } | { 3, 100 } | network errors + 5xx retry with jittered exponential backoff. maxAttempts: 1 disables. |
Exceptions
WardenDenied— verdict wasdeny. CarriestoolName,reasons,reviewReasons,intentCategory, andcorrelationId(when warden-lite emitsX-Warden-Correlation-Id) for direct ledger lookup.WardenPending— verdict waspending(HIL parked the call). CarriestoolName,correlationId, andreviewReasons. Also exposesresolve({pollIntervalMs?, timeoutMs?}): Promise<void>— polls warden-lite until the operator decides. Resolves onallow, throwsWardenDeniedondeny, throwsWardenTransportErroron timeout (default 10 min). Lift this into a tool-execution loop:try { const msg = await wrapped.messages.create({...}); } catch (e) { if (e instanceof WardenPending) { await e.resolve(); // blocks until decided return wrapped.messages.create({...}); // retry now that operator approved } throw e; }WardenConfigError— bad options passed towardenWrap.WardenTransportError— warden ingress unreachable or returned an unexpected status / body shape. Carriesstatuswhen known.
Wire format
POST {endpoint}/mcp with a JSON-RPC 2.0 envelope:
{
"jsonrpc": "2.0",
"method": "tools/call",
"params": { "name": "<tool name>", "arguments": <tool input> },
"id": "<provider tool-call id>"
}The tool-call id round-trips into warden's audit ledger so a single
ledger lookup correlates back to the model's exact call. Anthropic
emits toolu_*, OpenAI emits call_* — both pass through verbatim.
See warden-lite/src/proxy.rs
for the server side.
Develop
pnpm install
pnpm build # tsup → dist/{index.mjs, index.cjs, index.d.ts}
pnpm test # vitest, 65 unit tests
pnpm typecheck # tsc --noEmit
pnpm demo # full e2e against local warden-liteEnd-to-end tests (5 cases against live warden-lite) skip unless
WARDEN_E2E_ENDPOINT is set:
WARDEN_E2E_ENDPOINT=http://localhost:8088 WARDEN_E2E_TOKEN=... pnpm testLicense
Apache-2.0. See LICENSE.
