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

OptionTypeDefaultDescription
apiKeystring—Your SignalVault API key
openaiApiKeystring—Your OpenAI key. Required for client.chat.
anthropicApiKeystring—Your Anthropic key. Required for client.messages. At least one provider key must be given.
baseUrlstringhttps://api.signalvault.ioSignalVault API URL. Plain http is accepted only for localhost.
environmentstringproductionSent with events but does not select rules — the key you authenticate with decides that. Use a key issued for the environment you want.
debugbooleanfalseExtra console logging
mirrorModebooleanfalseObserve only, never block. See Mirror mode.
failMode'open' | 'closed''open'What happens when SignalVault cannot be reached. See below.
preflightTimeoutnumber (ms)2000Budget for the blocking guardrail check
timeoutnumber (ms)10000Budget for background audit calls
metadataobject{}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 throws SignalVaultUnavailableError and 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.