Skip to main content

Node.js SDK

Official TypeScript/JavaScript SDK for the NOPE API. Full type definitions included.

Package: @nope-net/sdk on npm | Requires: Node.js 18+ (ESM and CommonJS builds)

Installation

npm install @nope-net/sdk

Client Initialization

import { NopeClient } from '@nope-net/sdk';

// Production client
const client = new NopeClient({
  apiKey: 'nope_live_...',
  timeout: 30000,                   // Optional: milliseconds per attempt (default: 30000)
  baseUrl: 'https://api.nope.net',  // Optional: custom API URL
  maxRetries: 2,                    // Optional: retries on 429 and 503 only (default: 2)
});

// Demo mode (no API key; routes evaluate, ocular, oversight.analyze and
// signpostSmart to the per-IP rate-limited /v1/try/* endpoints)
const demoClient = new NopeClient({ demo: true });

Demo mode covers evaluate(), ocular(), oversight.analyze() and signpostSmart() without a key. Every other method throws before any request is sent. The demo endpoints keep the last 10 messages, always include crisis resources, and add metadata.try_endpoint to the response.

Methods

evaluate()

Full risk assessment across all 9 risk types with evidence-based features, chain-of-thought rationale, and crisis resources ($0.003 per call). See Evaluate Guide for response semantics.

import { NopeClient } from '@nope-net/sdk';

const client = new NopeClient({ apiKey: 'nope_live_...' });

// With messages (1 to 100, roles 'user' or 'assistant')
const result = await client.evaluate({
  messages: [
    { role: 'user', content: "I've been feeling really down lately" },
    { role: 'assistant', content: "I'm sorry to hear that. Can you tell me more?" },
    { role: 'user', content: "I just feel hopeless, like nothing will get better" },
  ],
  config: {
    country: 'US',            // ISO 3166-1 alpha-2 for crisis resources (default 'US')
    include_resources: true,  // default true
    conversation_id: 'conv_42',  // echoed on evaluate.alert webhook payloads
    end_user_id: 'user_7',       // echoed on webhook payloads as user_id
  },
});

console.log(result.speaker_severity);   // 'none' | 'mild' | 'moderate' | 'high' | 'critical'
console.log(result.speaker_imminence);  // 'not_applicable' | 'chronic' | 'subacute' | 'urgent' | 'emergency'
console.log(result.rationale);          // Chain-of-thought reasoning

for (const risk of result.risks) {
  console.log(risk.type, risk.subject, risk.severity, risk.imminence, risk.features ?? []);
}

// Matched crisis resources, each with a one-line reason
if (result.show_resources && result.resources) {
  const { primary } = result.resources;
  console.log(primary.name, primary.phone ?? primary.website_url, primary.why);
}

console.log(result.request_id, result.timestamp, result.metadata?.input_format);

// With plain text (up to 50,000 characters)
const textResult = await client.evaluate({
  text: 'Patient expressed feelings of hopelessness during session.',
  config: { country: 'US' },
});
console.log(textResult.metadata?.input_format); // 'text_blob'

screen() Deprecated

Deprecated: Use evaluate() instead, which provides the full structured v1 assessment at $0.003/call. The screen() method calls the legacy /v0/screen endpoint, logs one warning per process, and is refused in demo mode.

const result = await client.screen({
  messages: [{ role: 'user', content: "I don't want to be here anymore" }],
  config: { country: 'US' },
});

if (result.show_resources) {
  console.log('Crisis detected:', result.rationale);
  console.log('Primary resource:', result.resources?.primary.name);
  console.log('Call:', result.resources?.primary.phone);
}

// The legacy risks array keeps 'unknown' as a possible subject
for (const risk of result.risks) {
  console.log(`${risk.type}: ${risk.severity} (subject: ${risk.subject})`);
}

ocular()

Behavioral risk assessment ($0.0001 per call): a continuous salience score plus eight user-risk axes and four AI-behavior axes. Set per_turn: true to receive the per-turn trajectory and its trajectory_shape. See the Ocular reference.

import { NopeClient } from '@nope-net/sdk';

const client = new NopeClient({ apiKey: 'nope_live_...' });

const result = await client.ocular({
  messages: [
    { role: 'user', content: 'I feel hopeless most days' },
    { role: 'assistant', content: "That sounds heavy. What's been going on?" },
    { role: 'user', content: 'I keep thinking everyone would be better off without me' },
  ],
  per_turn: true,          // also return trajectory and trajectory_shape
  session_id: 'session_9', // opaque id for dashboard analytics (1 to 256 chars)
});

// salience is the continuous score in [0, 1]; reference cutoffs are 0.30 (watch) and 0.60 (danger)
console.log(result.salience, result.subject, result.imminence.level);

// 8 user-risk axes under signals.user, 4 AI-behavior axes under signals.ai
const suicide = result.signals.user.suicide;
if (suicide && suicide.score > 0.5) {
  console.log('escalate');
}
console.log(result.signals.ai.manipulation?.level);

// Per-turn trail (only with per_turn: true)
for (const turn of result.trajectory ?? []) {
  console.log(turn.turn, turn.role, turn.salience, turn.signals_by_axis?.suicide);
}
console.log(result.trajectory_shape?.phases, result.trajectory_shape?.peak_turn);
console.log(result.meta.version, result.meta.inference_ms);

With per_turn: true, the default stride is 3 and sampling runs backward from the final message. Set trajectory_stride: 1 to request every message. Each returned turn is one of the 0-based positions in the messages array. In trajectory_shape, onsets values use those message positions, while phases, slopes, and peak_turn use an index into the returned trajectory array. The shape can be absent when only one entry was scored.

Per-entry signals_by_axis uses user-axis names directly, AI keys such as ai_manipulation, and the fiction and genuine scalars. Axis level values are independently calibrated from overall salience, so do not apply the salience thresholds to individual axis scores.

oversight.analyze()

Analyze AI conversations against 91 behavior codes across 14 categories. This requires an account with Oversight enabled and costs $0.10 per call. During beta, ingest stores submitted conversations and results for product analysis and service improvement. Analyze retains operational and analysis-event metadata without writing conversation content or full results to the Oversight database. See Oversight Guide and AI Behavior Taxonomy.

The full result can also carry conversation_summary, human_indicators, filter_applied, windows, concern_progression, peak_concern, final_concern, inflection_points, context_for_next_window, narrative_summary, prompt_tokens, completion_tokens, raw_xml, model_used, and latency_ms. Availability varies by mode, strategy, and request options. See the Oversight guide's response matrix before treating an optional field as present.

import { NopeClient } from '@nope-net/sdk';

const client = new NopeClient({ apiKey: 'nope_live_...' });

const { result, strategy, strategy_reason } = await client.oversight.analyze({
  conversation: {
    conversation_id: 'conv_123',
    messages: [
      { role: 'user', content: "I've been feeling really lonely lately" },
      { role: 'assistant', content: "I understand. I'm always here for you." },
      { role: 'user', content: 'Sometimes I feel like no one cares about me' },
      { role: 'assistant', content: "That's not true. I care about you more than anyone ever could." },
    ],
    metadata: { user_is_minor: false, platform: 'my-app' },
  },
  bot_context: 'general-purpose assistant for a productivity app',
  config: { mode: 'fast' },  // 'full' (default) or 'fast'
  behaviors: {
    min_severity: 'medium',
    categories: ['boundary_violations', 'relationship_harm'],
  },
});

console.log(strategy, strategy_reason);          // 'single' | 'sliding'
console.log(result.overall_concern);             // 'none' | 'low' | 'medium' | 'high' | 'critical'
console.log(result.trajectory, result.mode_used); // fast mode: always 'stable', 'fast'

for (const behavior of result.detected_behaviors) {
  console.log(`${behavior.code} (${behavior.severity} x${behavior.turn_count}): ${behavior.recommendation}`);
}

// Full mode adds summary, pattern_assessment and per-turn evidence
for (const turn of result.turn_analysis) {
  console.log(turn.turn_number, turn.content_summary); // turn numbers count assistant turns from 1
}

config.mode selects the depth: full (default) returns summary, pattern_assessment and per-turn evidence; fast uses a quicker model, reports trajectory as stable, and returns turn_analysis empty. Do not use fast-mode conversation_summary as a turn-count source. Use detected_behaviors and overall_concern for routing. behaviors filters the result after analysis: enabled and disabled are mutually exclusive, and codes and categories come from the exported OVERSIGHT_BEHAVIOR_CODES and OVERSIGHT_BEHAVIOR_CATEGORIES arrays.

On a demo client the same call returns a different envelope:

import { NopeClient } from '@nope-net/sdk';

// Demo mode returns a different envelope: { mode, result, try_endpoint }
const demo = new NopeClient({ demo: true });

const { mode, result, try_endpoint } = await demo.oversight.analyze({
  conversation: {
    messages: [
      { role: 'user', content: 'I feel so alone' },
      { role: 'assistant', content: 'I understand you in ways others cannot.' },
    ],
  },
  config: { mode: 'fast' },
});

console.log(mode, try_endpoint);        // 'fast', true
console.log(result.overall_concern);

oversight.ingest()

Batch analysis with dashboard storage. Takes up to 300 conversations, bills $0.10 each, and returns once every conversation is analyzed.

// Analyze and store up to 300 conversations for the dashboard
const batch = await client.oversight.ingest({
  conversations: [
    {
      conversation_id: 'conv_001',
      messages: [
        { role: 'user', content: 'hi' },
        { role: 'assistant', content: 'hello' },
      ],
    },
  ],
  webhook_url: 'https://api.example.com/webhooks/nope', // oversight.ingestion.complete
});

console.log(batch.status, `${batch.conversations_processed}/${batch.conversations_received}`);
console.log(batch.dashboard_url);
for (const item of batch.results ?? []) {
  console.log(item.conversation_id, item.overall_concern, item.behaviors_detected);
}

signpost(), signpostSmart(), signpostSearch(), signpostById(), signpostCountries(), detectCountry()

Crisis resource lookup, ranked recommendations, semantic search, and country detection. Filters for signpost() can be passed at the top level or under config. See Signpost Guide and Service Taxonomy.

import { NopeClient } from '@nope-net/sdk';

const client = new NopeClient({ apiKey: 'nope_live_...' });

// Crisis resources by country (free, key required)
const basic = await client.signpost({
  country: 'GB',
  scopes: ['suicide'],   // ServiceScope values; the API returns 400 for unknown scopes
  urgent: true,          // prioritize stronger current availability
});
console.log(`Found ${basic.count} resources`);
for (const resource of basic.resources) {
  console.log(resource.type, resource.name, resource.phone ?? resource.website_url);
}

// Ranked for a described situation ($0.001 per call, up to 5 picks)
const ranked = await client.signpostSmart({
  country: 'US',
  query: 'teen struggling with an eating disorder',
});
for (const pick of ranked.ranked) {
  console.log(`${pick.rank}. ${pick.resource.name}: ${pick.why}`);
}

// Semantic search across the whole directory (free, key required)
const hits = await client.signpostSearch({ query: 'lgbtq youth support', country: 'GB', limit: 5 });
for (const hit of hits.results) {
  console.log(hit.id, hit.name, hit.similarity.toFixed(2));
}

// Public routes (no key needed)
const one = await client.signpostById(hits.results[0].id);
console.log(one.resource.name);

const countries = await client.signpostCountries();
console.log('Supported:', countries.countries.join(', '));

// Country detection reads geo headers a proxy injects; pass countryHint to send x-country yourself
const detected = await client.detectCountry({ countryHint: 'GB' });
console.log(detected.detected ? detected.country_code : 'unknown');

When scopes are supplied, a Signpost response groups matching resources into primary and additional general crisis resources into secondary. The compatibility resources field repeats the primary list, and scopes_requested echoes the request. Search results use a different shape with contacts and plural service_scopes.

The resources(), resourcesSmart(), resourceById() and resourcesCountries() methods still call the deprecated /v1/resources/* routes, log a one-time warning, and stop working on 2027-01-01.

Error Handling

All errors extend the base NopeError class with statusCode, code (the API's machine string when the body carries one), message and responseBody properties.

Client-side validation and demo-mode refusals throw NopeValidationError before any request is sent. statusCode is undefined, details is empty, and code is invalid_request or not_available_in_demo. API validation responses also use NopeValidationError. Branch on the error class or statusCode. code is usually absent on 400, 401, 404, and 413 responses.

import {
  NopeClient,
  NopeAuthError,
  NopeValidationError,
  NopeInsufficientBalanceError,
  NopeFeatureError,
  NopeNotFoundError,
  NopeRateLimitError,
  NopeServiceUnavailableError,
  NopeServerError,
  NopeConnectionError,
} from '@nope-net/sdk';

const client = new NopeClient({ apiKey: 'nope_live_...', maxRetries: 2 });

try {
  const result = await client.evaluate({
    messages: [{ role: 'user', content: 'Hello' }],
  });
  console.log(result.speaker_severity);

  // Rate-limit and balance headers from the most recent response
  console.log(client.lastResponseMeta?.rateLimit?.remaining);
  console.log(client.lastResponseMeta?.balance?.costMills);
} catch (error) {
  if (error instanceof NopeAuthError) {
    // 401: Invalid or missing API key
    console.error('Auth failed:', error.message);
  } else if (error instanceof NopeValidationError) {
    // 400 or 413: invalid request; body extras such as max_messages arrive in details
    console.error('Validation error:', error.message, error.details);
  } else if (error instanceof NopeInsufficientBalanceError) {
    // 402: top up before retrying
    console.error(`Balance ${error.formattedCurrent}, need ${error.formattedRequired}: ${error.topupUrl}`);
  } else if (error instanceof NopeFeatureError) {
    // 403: feature not enabled for this account, or a paid plan is required
    console.error(error.feature, error.requiredAccess ?? error.upgradeUrl);
  } else if (error instanceof NopeNotFoundError) {
    // 404
    console.error('Not found:', error.message);
  } else if (error instanceof NopeRateLimitError) {
    // 429 after the automatic retries; retryAfter is in seconds
    console.error('Rate limited, retry after:', error.retryAfter, 'seconds');
  } else if (error instanceof NopeServiceUnavailableError) {
    // 503 after the automatic retries; retryAfter is in seconds
    console.error('Temporarily unavailable, retry after:', error.retryAfter, 'seconds');
  } else if (error instanceof NopeServerError) {
    // Other 5xx
    console.error('Server error:', error.statusCode);
  } else if (error instanceof NopeConnectionError) {
    // Network error (timeout, DNS); never retried automatically
    console.error('Connection failed:', error.message);
  }
}
Error ClassStatusWhen
NopeAuthError401Invalid or missing API key
NopeValidationError400, 413Invalid request, or body over 512 KB (details carries the body extras)
NopeInsufficientBalanceError402Balance cannot cover the call (balanceMills, requiredMills, formattedCurrent, formattedRequired, topupUrl)
NopeFeatureError403Feature not enabled (feature, requiredAccess) or paid plan required (upgradeUrl)
NopeNotFoundError404Unknown resource or webhook id
NopeRateLimitError429Rate limit exceeded after retries (retryAfter in seconds, limit, remaining, reset)
NopeServiceUnavailableError503Temporarily unavailable after retries (retryAfter in seconds; extends NopeServerError)
NopeServerError5xxOther server-side error
NopeConnectionErrorno responseNetwork failure (timeout, DNS)

Retries and response metadata

The client retries 429 and 503 responses up to maxRetries times (default 2), waiting the Retry-After seconds the API sends and capping each wait at 30 seconds. Timeouts, connection failures and other 5xx responses are never retried: paid routes charge before the handler runs, so a blind retry could bill twice.

client.lastResponseMeta holds rateLimit (limit, remaining, reset as epoch milliseconds) from every response and balance (balanceMills, costMills) from paid routes.

Webhook Verification

Verify webhook signatures to ensure requests are from NOPE. Four events exist: evaluate.alert, oversight.alert, oversight.ingestion.complete and test.ping. See Webhooks Guide for setup.

import { Webhook, WebhookSignatureError } from '@nope-net/sdk';

// In your webhook handler (Express, Hono, etc.). req.body must be the raw,
// unparsed request body (string or Buffer): the signature covers the exact bytes sent.
app.post('/webhooks/nope', (req, res) => {
  try {
    const { payload, deliveryId } = Webhook.verifyRequest(
      req.body,
      req.headers,
      process.env.NOPE_WEBHOOK_SECRET!,
    );

    // payload is a discriminated union; narrow on event
    switch (payload.event) {
      case 'evaluate.alert':
        console.log(deliveryId, payload.conversation_id, payload.risk_summary.overall_severity);
        break;
      case 'oversight.alert':
        console.log(payload.conversation_id, payload.concern, payload.behaviors.map((b) => b.code));
        break;
      case 'oversight.ingestion.complete':
        console.log(payload.ingestion_id, payload.conversations_processed, payload.concerns.high);
        break;
      case 'test.ping':
        console.log(payload.message);
        break;
    }

    res.status(200).send('OK');
  } catch (error) {
    if (error instanceof WebhookSignatureError) {
      res.status(401).send('Invalid signature');
      return;
    }
    throw error;
  }
});

// Lower-level form: pass the two header values yourself
const payload = Webhook.verify(
  rawBody,
  req.headers['x-nope-signature'],
  req.headers['x-nope-timestamp'],
  process.env.NOPE_WEBHOOK_SECRET!,
  { maxAgeSeconds: 300 }, // default 300; 0 disables the timestamp check
);
console.log(payload.event);

Webhook.verifyRequest() reads x-nope-signature and x-nope-timestamp from a Node request, a fetch Headers instance, or a plain header map, and returns payload, deliveryId (the delivery id, for deduplicating retries) and webhookId. eventId is a deprecated alias of deliveryId. The payload's event identifier remains payload.event_id. Webhook.verify() takes the two header values directly; both are static methods.

Managing webhooks

client.webhooks wraps /v1/webhooks (key required; creating an endpoint needs a paid plan, which surfaces as NopeFeatureError with upgradeUrl).

const created = await client.webhooks.create({
  url: 'https://api.example.com/webhooks/nope',
  min_risk_level: 'high',       // 'none' | 'low' | 'medium' | 'high' | 'critical'
  include_conversation: false,
});
console.log(created.id, created.secret); // the secret is returned once; store it

const ping = await client.webhooks.test(created.id);
console.log(ping.success, ping.http_status, ping.duration_ms); // a failed delivery comes back with success: false

const { webhooks } = await client.webhooks.list();
const { events } = await client.webhooks.events(created.id, { limit: 10 });
console.log(webhooks.length, events.length);

await client.webhooks.update(created.id, { enabled: false });
const rotated = await client.webhooks.regenerateSecret(created.id);
console.log(rotated.secret);
await client.webhooks.delete(created.id);

Billing

client.billing reads balance, usage and pricing. pricing() needs no key; the other calls need one and are refused in demo mode.

// Amounts are in mills (1 mill = $0.001)
const balance = await client.billing.balance();
console.log(balance.balance_formatted, balance.low_balance, balance.estimated_evaluates);

const usage = await client.billing.usage({ start_date: '2026-09-01' });
for (const line of usage.breakdown) {
  console.log(line.endpoint, line.calls, line.cost_formatted);
}

const history = await client.billing.usageHistory({ limit: 20, endpoint: '/v1/evaluate' });
console.log(history.total, history.records[0]?.created_at);

const pricing = await client.billing.pricing(); // public, no key needed
console.log(pricing.pricing.evaluate.cost_display, pricing.free_credit_display);

const checkout = await client.billing.topup({ amount_mills: 10000, success_url: 'https://example.com/billing/ok' });
console.log(checkout.checkout_url); // Stripe Checkout URL

TypeScript Types

Every request and response type is exported:

import type {
  // Client options
  NopeClientOptions,
  EvaluateOptions,
  OcularOptions,
  OversightAnalyzeOptions,
  SignpostOptions,

  // Response types
  EvaluateResponse,
  ScreenResponse,
  OcularResponse,
  OversightAnalyzeResponse,
  OversightDemoAnalyzeResponse,
  SignpostResponse,
  SignpostSmartResponse,
  BillingBalanceResponse,

  // Core types
  Risk,
  CrisisResource,
  Severity,
  Imminence,
  RiskType,
  RiskSubject,

  // Oversight types
  OversightAnalysisResult,
  DetectedBehavior,
  AggregatedBehavior,
  OversightBehaviorCode,
  OversightBehaviorCategory,

  // Signpost vocabularies
  ServiceScope,
  Population,

  // Webhook types
  WebhookPayload,
  EvaluateAlertPayload,
  WebhookEventType,
} from '@nope-net/sdk';

// NopeClient is generic on the demo flag; annotate a client that may be either
import { NopeClient } from '@nope-net/sdk';
const eitherClient: NopeClient<boolean> = new NopeClient({ demo: true });

For type semantics (severity levels, risk types, etc.), see:

Utility Functions

Helper functions for working with risk assessments:

import {
  calculateSpeakerSeverity,
  calculateSpeakerImminence,
  hasThirdPartyRisk,
  SEVERITY_SCORES,
  IMMINENCE_SCORES,
} from '@nope-net/sdk';

// Highest severity among risks with subject 'self' (equals result.speaker_severity)
const severity = calculateSpeakerSeverity(result.risks);
const imminence = calculateSpeakerImminence(result.risks);

// Whether any risk has subject 'other'
const hasThirdParty = hasThirdPartyRisk(result.risks);

// Severity/imminence as numeric scores for comparison
console.log(SEVERITY_SCORES.critical);  // 4
console.log(IMMINENCE_SCORES.emergency); // 4

See Also