Node.js SDK
A drop-in wrapper for the OpenAI and Anthropic clients, adding guardrails and audit logging.
Installation
npm install @signalvaultio/node openai # or, for Anthropic: npm install @signalvaultio/node @anthropic-ai/sdk
Requires Node 18 or newer. The provider packages are optional peers, so install only the one you use:
openai 4 to 7, or
@anthropic-ai/sdk 0.20 or newer. Note that
openai 7 itself requires Node 22.
import { SignalVaultClient } from '@signalvaultio/node';
const client = new SignalVaultClient({
apiKey: 'sk_live_...',
openaiApiKey: process.env.OPENAI_API_KEY,
baseUrl: 'https://api.signalvault.io',
});
const response = await client.chat.completions.create({
model: 'gpt-4o-mini',
messages: [{ role: 'user', content: 'Hello!' }],
});
The named and default imports both work, under native ESM, CommonJS and TypeScript. The SDK is server-side only and throws if constructed in a browser, because bundling it would ship your API keys to end users.
Configuration
| Option | Type | Default | Description |
|---|---|---|---|
| apiKey | string | — | Your SignalVault API key |
| openaiApiKey | string | — | Your OpenAI key. Required for client.chat. |
| anthropicApiKey | string | — | Your Anthropic key. Required for client.messages. At least one provider key must be given. |
| baseUrl | string | https://api.signalvault.io | SignalVault API URL. Plain http is accepted only for localhost. |
| environment | string | production | Sent with events but does not select rules — the key you authenticate with decides that. Use a key issued for the environment you want. |
| debug | boolean | false | Extra console logging |
| mirrorMode | boolean | false | Observe only, never block. See Mirror mode. |
| failMode | 'open' | 'closed' | 'open' | What happens when SignalVault cannot be reached. See below. |
| preflightTimeout | number (ms) | 2000 | Budget for the blocking guardrail check |
| timeout | number (ms) | 10000 | Budget for background audit calls |
| metadata | object | {} | Default metadata merged into every event, beneath per-call metadata |
Anthropic
The same client exposes messages when you pass
anthropicApiKey. A top-level
system prompt is included in the guardrail check.
const message = await client.messages.create({
model: 'claude-sonnet-5',
max_tokens: 1024,
messages: [{ role: 'user', content: 'Hello!' }],
});
Handling a blocked request
The SDK does not hand you the decision. When a rule blocks a prompt it throws
SignalVaultBlockedError and the provider is never called, so
that is your rejection path:
import { SignalVaultBlockedError } from '@signalvaultio/node';
async function ask(client, userInput) {
try {
return await client.chat.completions.create({
model: 'gpt-4o-mini',
messages: [{ role: 'user', content: userInput }],
});
} catch (err) {
if (err instanceof SignalVaultBlockedError) {
// Never reached the provider. err.violations says which rules fired.
throw new HttpError(422, 'That request was blocked by policy.', err.violations);
}
throw err;
}
}
The error carries requestId,
violations and, when available,
dashboardUrl. Its message names the rule types that fired and
never repeats the matched text. A redact or
warn decision does not throw: the call proceeds, and the
redaction applies to what SignalVault stores, not to what your provider receives.
Streaming
Streaming is supported for both providers. Await the call, then iterate. The audit event is sent once the stream finishes, and a stream you abandon early still records what was received.
const stream = await client.chat.completions.create({
model: 'gpt-4o-mini',
messages: [{ role: 'user', content: 'Write a poem' }],
stream: true,
});
for await (const chunk of stream) {
process.stdout.write(chunk.choices[0]?.delta?.content || '');
}
For OpenAI the SDK asks for usage so tokens and cost are recorded, then consumes that final usage-only
chunk itself — so chunk.choices[0] is safe on every chunk you
receive.
When SignalVault is unreachable
failMode decides what happens when the guardrail check times
out or fails. The default is 'open':
'open'— the request proceeds unchecked and a warning is logged. Traffic keeps flowing; your guardrails do not apply to it.'closed'— the SDK throwsSignalVaultUnavailableErrorand the provider is never called.
Choose deliberately. 'open' favours availability and is the
default; 'closed' favours enforcement, which is usually what
a compliance requirement means. When SignalVault refuses the check outright — a bad key, an inactive
account, or the trial limit reached — nothing is logged for that call at all.
Over the rule-check limit
Rule checks are limited to 120 requests a minute per app. Over it, SignalVault still records the request,
marked unchecked, but runs no rules on it and stores no prompt, so there is no
decision and failMode applies: with 'open' the request goes to the provider
with a warning, and its response and tool calls are recorded as usual; with
'closed' the SDK throws SignalVaultUnavailableError and the provider is never
called. The SDK sends its failMode with the check, so the record says whether the request
was sent on. Requires version 0.6.0 or later.
Mirror Mode
In mirror mode the provider is called first and the audit events follow, off the request path. Nothing is ever blocked:
const client = new SignalVaultClient({
apiKey: 'sk_live_...',
openaiApiKey: process.env.OPENAI_API_KEY,
mirrorMode: true,
});
Auditing tool calls
client.tool(name, fn) wraps a function so every call is
recorded as an agent.tool_call event, and
client.tools.record(...) records one by hand. Pass
requestId explicitly to
tools.record to tie it to a request: unlike the wrapper, it does
not pick one up from the surrounding context.
Before your process exits
Audit events are sent in the background. In a short-lived process — a Lambda, a script, a one-off job —
call await client.flush() so queued events are not lost; in a serverless handler, at the end of
every invocation. close() flushes too, for up to 10 seconds, and drops nothing: events
still queued at its deadline are named in a warning and keep being retried. When the process runs out of
work, queued events get up to exitFlushTimeout (10 seconds by default) before it
exits; that does not happen on process.exit() or a signal, so call
close() in your shutdown handler.
From version 0.6.0, a failed send is retried, up to 6 attempts over about two minutes, with the same
event_id so nothing is stored twice. An event is dropped only when 1,000 are already
waiting, after its last attempt, or at the exit deadline. Each drop is logged with its
event_id, and client.droppedEvents counts them by cause.