Repository files navigation

NOPE Node SDK

npm versionNode 18+TypeScriptLicense: MIT

TypeScript client for the NOPE API. NOPE reads conversations between people and AI systems and returns structured safety signals: mental-health and safeguarding risk (Evaluate), behavioral risk scores (Ocular), harmful AI-behaviour analysis (Oversight), and crisis resources by country (Signpost). The SDK also verifies webhook deliveries, manages webhook endpoints, and reads billing.

The package ships ESM and CommonJS builds with type declarations. Every response type is checked against responses captured from the live API (tests/fixtures/), so a field in the types is a field on the wire.

Requirements

  • Node.js 18 or later (the client uses the built-in fetch).
  • An API key from dashboard.nope.net for the paid and key-gated routes. Demo mode (below) covers evaluate, ocular, oversight.analyze and signpostSmart without a key.

Installation

npm install @nope-net/sdk
# or
pnpm add @nope-net/sdk

Quick start

import{NopeClient}from'@nope-net/sdk';constclient=newNopeClient({apiKey: process.env.NOPE_API_KEY});constresult=awaitclient.evaluate({messages: [{role: 'user',content: "I've been feeling really down lately"},{role: 'assistant',content: 'I hear you. Can you tell me more?'},{role: 'user',content: "I just don't see the point anymore"},],config: {country: 'US'},});console.log(result.speaker_severity,result.speaker_imminence);console.log(result.rationale);if(result.show_resources&&result.resources){const{ primary }=result.resources;console.log(`${primary.name}: ${primary.phone??primary.website_url} (${primary.why})`);}

Client options

OptionDefaultNotes
apiKeynonenope_live_... from the dashboard. Omit for demo mode or key-free routes.
baseUrlhttps://api.nope.netA trailing slash is tolerated.
timeout30000Milliseconds per attempt.
demofalseRoute to the unauthenticated /v1/try/* endpoints.
maxRetries2Retries on 429 and 503 only.
fetchglobal fetchInject a wrapped fetch for tracing, or a fake in tests.
sleepsetTimeoutWait between retries; inject in tests.

Demo mode

new NopeClient({ demo: true }) needs no key and routes four methods to the per-IP rate-limited /v1/try/* endpoints:

MethodDemo routeDifferences from the authenticated route
evaluate/v1/try/evaluateKeeps the last 10 messages, honours include_resources, adds metadata.try_endpoint and metadata.model. The route reads config.country; the client also sends the value as config.user_country, the older spelling the route accepts when country is absent. 10 calls per minute per IP.
oversight.analyze/v1/try/oversight/analyzeReturns { mode, result, try_endpoint } (OversightDemoAnalyzeResponse). Ignores config.strategy and config.model, keeps only role and content, accepts at most 20 messages.
ocular/v1/try/ocularReturns OcularDemoResponse, which adds heads and detail under public family names. At most 12 messages or 4,000 characters. With per_turn it returns trajectory but never trajectory_shape.
signpostSmart/v1/try/signpost/smartAdds try_endpoint: true.

Every other method throws NopeValidationError (... is not available in demo mode, code: 'not_available_in_demo', no statusCode) before any request is sent.

Evaluate

client.evaluate(options) costs $0.003 per call. Pass messages (1 to 100, roles user or assistant) or text (a transcript, up to 50,000 characters). config takes the four keys the route reads:

KeyPurpose
countryISO 3166-1 alpha-2 for crisis resources (default US).
include_resourcesSet false to skip resource matching (default true).
conversation_idEchoed on evaluate.alert webhook payloads.
end_user_idEchoed on evaluate.alert webhook payloads as user_id.
import{NopeClient,calculateSpeakerSeverity,typeEvaluateResource}from'@nope-net/sdk';constclient=newNopeClient({apiKey: process.env.NOPE_API_KEY});constresult=awaitclient.evaluate({text: 'Patient expressed hopelessness and mentioned not wanting to continue.',config: {country: 'GB',conversation_id: 'conv_42',end_user_id: 'user_7'},});for(constriskofresult.risks){console.log(risk.type,risk.subject,risk.severity,risk.imminence,risk.features??[]);}// speaker_severity is the highest severity among risks with subject 'self'.console.log(calculateSpeakerSeverity(result.risks)===result.speaker_severity);console.log(result.request_id,result.timestamp,result.metadata?.input_format);// resources.primary and resources.secondary[] are EvaluateResource: a CrisisResource plus `why`.consttop: EvaluateResource|undefined=result.resources?.primary;console.log(top?.name,top?.why);

EvaluateResponse fields: risks: Risk[], rationale, speaker_severity, speaker_imminence, show_resources, resources? (an EvaluateResources: primary and up to three secondary, each an EvaluateResource, which is a CrisisResource plus a one-line why), request_id, timestamp, metadata?.

Risk is { type, subject, severity, imminence, features? }. subject is self or other. Severity runs none | mild | moderate | high | critical; imminence runs not_applicable | chronic | subacute | urgent | emergency. The nine risk types are suicide, self_harm, self_neglect, violence, abuse, sexual_violence, neglect, exploitation and stalking.

Legacy screen()

client.screen() is deprecated: use evaluate() ($0.003 per call). It still calls the legacy /v0/screen endpoint ($0.001 per call), which carries a sunset of 2027-01-01. The client logs one warning per process saying so, and refuses the call in demo mode. ScreenConfig is { country?, debug?, include_recommended_reply? }.

Ocular

client.ocular(options) costs $0.0001 per call and returns a continuous salience score in [0, 1] plus eight user-risk axes under signals.user and four AI-behaviour axes under signals.ai, each { level, score }. Pick the salience cutoff that fits your action; the reference thresholds are 0.30 (watch) and 0.60 (danger).

import{NopeClient}from'@nope-net/sdk';constclient=newNopeClient({apiKey: process.env.NOPE_API_KEY});constresult=awaitclient.ocular({messages: [{role: 'user',content: 'I feel hopeless'},{role: 'assistant',content: 'I am here with you.'},{role: 'user',content: 'Nothing helps any more.'},],per_turn: true,trajectory_stride: 1,// score every turn; the server default of 3 scores every third turn back from the lastsession_id: 'session_9',});console.log(result.salience,result.subject,result.imminence.level);constsuicide=result.signals.user.suicide;if(suicide&&suicide.score>0.5){console.log('escalate');}for(constturnofresult.trajectory??[]){// turn.turn is the 0-based position in messages; AI axes are ai_-prefixed hereconsole.log(turn.turn,turn.role,turn.salience,turn.signals_by_axis?.suicide,turn.signals_by_axis?.ai_manipulation);}constshape=result.trajectory_shape;if(shape?.peak_turn!==undefined&&result.trajectory){constpeak=result.trajectory[shape.peak_turn];// peak_turn indexes the trajectory array, not messagesconsole.log(peak.turn,shape.phases?.[shape.peak_turn],shape.onsets?.suicide);}console.log(result.meta.version);

user_id, session_id and agent_id (1 to 256 characters) are stored for dashboard analytics and never forwarded to the model.

Per-turn trajectory

Set per_turn: true to receive trajectory, one entry per scored turn, and trajectory_shape. Not every turn is scored: trajectory_stride defaults to 3 on the server, which scores the last turn and then every third turn back from it, so a 5-message conversation yields turns 4 and 1. Pass trajectory_stride: 1 to score every turn. An entry's turn is the 0-based position of that turn in messages (for text input, of the parsed speaker turn) and its role is user or assistant. signals_by_axis keys the user axes by bare name (suicide), the AI axes with an ai_ prefix (ai_manipulation, where the top level has signals.ai.manipulation), and adds two context scalars, genuine and fiction.

trajectory_shape summarises the crisis (suicide) axis over the scored turns. onsets maps an axis to the turn value at which it first crossed its onset threshold. phases, slopes and peak_turn index the trajectory array instead: phases[i] and slopes[i] describe trajectory[i], and peak_turn is the array position of the entry with the highest crisis score, so trajectory[shape.peak_turn].turn is the message index. The authenticated route returns trajectory_shape whenever at least one turn was scored; the demo route (/v1/try/ocular) returns trajectory but never trajectory_shape.

Oversight

Oversight analyzes an AI assistant's side of a conversation using 91 behavior codes: 87 harmful and 4 appropriate, across 14 categories (dependency reinforcement, crisis mishandling, manipulation, boundary violations and so on). It requires an account with the feature enabled; analyze costs 100 mills ($0.10) per call and ingest 100 mills per conversation.

import{NopeClient}from'@nope-net/sdk';constclient=newNopeClient({apiKey: process.env.NOPE_API_KEY});const{ result, strategy }=awaitclient.oversight.analyze({conversation: {conversation_id: 'conv_123',messages: [{role: 'user',content: 'Nobody at work listens to me.'},{role: 'assistant',content: "I'm always here and I understand you better than they ever will."},],metadata: {platform: 'companion-app',user_is_minor: false},},bot_context: 'general-purpose assistant for a productivity app',config: {mode: 'fast'},behaviors: {min_severity: 'medium',categories: ['boundary_violations','relationship_harm']},});console.log(strategy,result.mode_used,result.overall_concern,result.trajectory);for(constbehaviorofresult.detected_behaviors){console.log(`${behavior.code} (${behavior.severity} x${behavior.turn_count}): ${behavior.recommendation}`);}
  • config.mode: full (default) or fast. Fast mode uses a quicker model; trajectory is always stable, turn_analysis and human_indicators are empty, and summary and pattern_assessment are absent.
  • config.strategy: single or sliding; auto-selected by length when omitted. Sliding results carry windows (each with message_range and conversation_turn_range), concern_progression, peak_concern, final_concern and inflection_points.
  • behaviors: a post-analysis filter. enabled and disabled are mutually exclusive; codes and categories come from the exported OversightBehaviorCode and OversightBehaviorCategory unions (OVERSIGHT_BEHAVIOR_CODES, OVERSIGHT_BEHAVIOR_CATEGORIES).
  • bot_context is passed into the analysis as conversation metadata, so the analyzer knows what the persona is meant to do.
  • Turn numbers in results count assistant turns from 1.

Batch analysis with dashboard storage takes up to 300 conversations, each with a conversation_id, and returns synchronously. The request body is capped at 5 MB, so a batch near the count limit must consist of short conversations. webhook_url is a legacy per-request callback: the API POSTs an unsigned ingestion_complete JSON summary there when the batch completes. The signed oversight.ingestion.complete event is delivered to webhooks registered with client.webhooks.

import{NopeClient}from'@nope-net/sdk';constclient=newNopeClient({apiKey: process.env.NOPE_API_KEY});constbatch=awaitclient.oversight.ingest({conversations: [{conversation_id: 'conv_001',messages: [{role: 'user',content: 'hi'},{role: 'assistant',content: 'hello'},],},],webhook_url: 'https://api.example.com/webhooks/nope',});console.log(batch.status,`${batch.conversations_processed}/${batch.conversations_received}`,batch.dashboard_url);for(constitemofbatch.results??[]){console.log(item.conversation_id,item.overall_concern,item.truncation_warnings?.map((w)=>w.type));}

Signpost

Crisis resources by country. Filters can be passed at the top level or under config; scope and population values come from the exported ServiceScope and Population unions (SERVICE_SCOPES, POPULATIONS), and the API returns 400 for unknown values.

import{NopeClient}from'@nope-net/sdk';constclient=newNopeClient({apiKey: process.env.NOPE_API_KEY});// Basic lookup (free, key required). urgent: true keeps every match and ranks the// 24/7 ones first among resources tied on relevance and priority tier.constbasic=awaitclient.signpost({country: 'GB',scopes: ['suicide'],subdivisions: ['GB-NIR'],urgent: true});for(constresourceofbasic.resources){console.log(resource.type,resource.name,resource.is_24_7,resource.phone??resource.website_url,resource.open_status?.message);}// Ranked for a described situation ($0.001 per call, up to 5 picks)constranked=awaitclient.signpostSmart({country: 'US',query: 'teen struggling with an eating disorder'});for(constpickofranked.ranked){console.log(`${pick.rank}. ${pick.resource.name}: ${pick.why}`);}// Semantic search across the whole directory (free, key required)consthits=awaitclient.signpostSearch({query: 'lgbtq youth support',country: 'GB',limit: 5});for(consthitofhits.results){console.log(hit.id,hit.name,hit.similarity.toFixed(2),hit.phone??hit.website_url);}// Public routes (no key)constone=awaitclient.signpostById(hits.results[0].id);constcountries=awaitclient.signpostCountries();constgeo=awaitclient.detectCountry({countryHint: 'GB'});console.log(one.resource.name,countries.count,geo.detected ? geo.country_code : 'unknown');

urgent: true is a ranking hint, not a filter: the API keeps every matching resource and, where two resources tie on relevance score and priority tier, places the one flagged is_24_7 first. Resources without 24/7 hours still appear in the list.

detectCountry() reads geo headers a proxy injects (cf-ipcountry, x-country, x-vercel-ip-country). Called directly against api.nope.net it returns the miss shape with detected: false; pass countryHint to send x-country yourself.

signpost() and signpostSearch() need a key and are refused in demo mode. The resources* methods still work against /v1/resources/*, log a one-time deprecation warning, and stop on 2027-01-01.

Webhooks

NOPE signs each delivery with HMAC-SHA256 over "${timestamp}.${body}" and sends X-NOPE-Signature, X-NOPE-Timestamp, X-NOPE-Event, X-NOPE-Delivery-ID and X-NOPE-Webhook-ID. Four events exist:

EventSent whenPayload type
evaluate.alert/v1/evaluate finds risk at or above the webhook's min_risk_levelEvaluateAlertPayload
oversight.alertOversight finds high or critical concernOversightAlertPayload
oversight.ingestion.completeAn ingest batch finishesOversightIngestionCompletePayload
test.pingThe dashboard test button or client.webhooks.test()TestPingPayload

Verify with the raw request body. Configure your framework so req.body is the unparsed string or Buffer; a re-serialised object only matches the signature when key order survived parsing.

import{Webhook,WebhookSignatureError}from'@nope-net/sdk';app.post('/webhooks/nope',(req,res)=>{try{const{ payload, deliveryId }=Webhook.verifyRequest(req.body,req.headers,process.env.NOPE_WEBHOOK_SECRET!);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(err){if(errinstanceofWebhookSignatureError){res.status(401).send('Invalid signature');return;}throwerr;}});

Webhook.verify(body, signature, timestamp, secret, { maxAgeSeconds }) takes the two header values directly. maxAgeSeconds defaults to 300; 0 disables the timestamp check. verifyRequest also returns deliveryId (the X-NOPE-Delivery-ID header: the stored delivery's id, repeated on each of the API's four retry attempts over an hour, so it is the key to deduplicate them), eventType (X-NOPE-Event) and webhookId (X-NOPE-Webhook-ID). deliveryId is not payload.event_id, and it is undefined when the header is absent, as on a body signed locally with Webhook.sign. eventId is a deprecated alias of deliveryId.

For tests, Webhook.sign(body, secret, timestamp?) produces the signature and timestamp the API would send. The optional third argument (unix seconds) fixes the moment the signature is bound to, which is how to exercise the stale-timestamp path:

import{Webhook,WebhookSignatureError}from'@nope-net/sdk';constbody=JSON.stringify({event: 'test.ping',event_id: 'evt_local',timestamp: newDate().toISOString(),api_version: '2025-01',message: 'Webhook configured successfully',});const{ signature, timestamp }=Webhook.sign(body,'whsec_local');constpayload=Webhook.verify(body,signature,timestamp,'whsec_local');console.log(payload.event);conststale=Webhook.sign(body,'whsec_local',Math.floor(Date.now()/1000)-600);try{Webhook.verify(body,stale.signature,stale.timestamp,'whsec_local');}catch(err){console.log(errinstanceofWebhookSignatureError,(errasError).message);// true, "Timestamp too old: 600s ago (max: 300s)"}

Managing webhook endpoints

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

import{NopeClient}from'@nope-net/sdk';constclient=newNopeClient({apiKey: process.env.NOPE_API_KEY});constcreated=awaitclient.webhooks.create({url: 'https://api.example.com/webhooks/nope',min_risk_level: 'high',include_conversation: false,});console.log(created.id,created.secret);// secret is returned onceconstping=awaitclient.webhooks.test(created.id);console.log(ping.success,ping.http_status,ping.duration_ms);// a failed delivery comes back with success: falseconst{ webhooks }=awaitclient.webhooks.list();const{ events }=awaitclient.webhooks.events(created.id,{limit: 10});awaitclient.webhooks.update(created.id,{enabled: false});constrotated=awaitclient.webhooks.regenerateSecret(created.id);awaitclient.webhooks.delete(created.id);console.log(webhooks.length,events.length,rotated.secret.length);

Billing

Amounts are in mills (1 mill = $0.001). pricing() needs no key; the rest need one. All five are refused in demo mode.

import{NopeClient}from'@nope-net/sdk';constclient=newNopeClient({apiKey: process.env.NOPE_API_KEY});constbalance=awaitclient.billing.balance();console.log(balance.balance_formatted,balance.low_balance,balance.estimated_evaluates);constusage=awaitclient.billing.usage({start_date: '2026-09-01'});for(constlineofusage.breakdown){console.log(line.endpoint,line.calls,line.cost_formatted);}consthistory=awaitclient.billing.usageHistory({limit: 20,endpoint: '/v1/evaluate'});console.log(history.total,history.records[0]?.created_at);constpricing=awaitclient.billing.pricing();console.log(pricing.pricing.evaluate.cost_display,pricing.free_credit_display);constcheckout=awaitclient.billing.topup({amount_mills: 10000,success_url: 'https://example.com/billing/ok'});console.log(checkout.checkout_url);

Errors, retries and response metadata

Every error extends NopeError and carries statusCode, code, message, responseBody (the raw response text) and body (the same text parsed, when it was a JSON object).

StatusClassExtra fields
400, 413NopeValidationErrordetails (body extras such as max_bytes, max_messages, invalid_scopes)
401NopeAuthError
402NopeInsufficientBalanceErrorbalanceMills, requiredMills, formattedCurrent, formattedRequired, topupUrl; ingest adds perConversationMills, conversations
403NopeFeatureErrorfeature, requiredAccess, or feature: 'paid_plan' with upgradeUrl
404NopeNotFoundError
429NopeRateLimitErrorretryAfter (seconds), limit, remaining, reset (epoch ms)
503NopeServiceUnavailableErrorretryAfter (seconds); extends NopeServerError
other 5xxNopeServerErrorretryAfter when a header was sent
no responseNopeConnectionErrororiginalError; covers the client-side timeout

Client-side validation (an empty messages array, a role other than user or assistant, more than 100 messages, neither messages nor text, and the per-method checks above) and demo-mode refusals throw NopeValidationError before any request is sent. Those errors have no statusCode; their code is invalid_request or not_available_in_demo, and details is empty.

For API errors, code is present only when the API sends a machine string in the body. Today 402 and 429 always do (insufficient_balance, rate_limit_exceeded), 403 and 503 do on some bodies (paid_plan_required, auth_unavailable), and 400, 401, 404 and 413 carry a sentence, so code is undefined there. Branch on the class or on statusCode, and read code only as extra detail.

import{NopeClient,NopeAuthError,NopeInsufficientBalanceError,NopeRateLimitError,NopeValidationError,NopeServiceUnavailableError,NopeServerError,NopeConnectionError,}from'@nope-net/sdk';constclient=newNopeClient({apiKey: process.env.NOPE_API_KEY,maxRetries: 2});try{constresult=awaitclient.evaluate({text: 'hello',config: {country: 'US'}});console.log(result.speaker_severity);console.log(client.lastResponseMeta?.balance?.costMills,client.lastResponseMeta?.rateLimit?.remaining);}catch(error){if(errorinstanceofNopeInsufficientBalanceError){console.log(`Balance ${error.formattedCurrent}, need ${error.formattedRequired}: ${error.topupUrl}`);}elseif(errorinstanceofNopeRateLimitError){console.log(`Rate limited; retry after ${error.retryAfter} seconds`);}elseif(errorinstanceofNopeValidationError){// statusCode is undefined when the SDK rejected the input before sending (code 'invalid_request').console.log(error.statusCode??'client-side',error.code,error.message,error.details);}elseif(errorinstanceofNopeAuthError){console.log('Invalid API key');}elseif(errorinstanceofNopeServiceUnavailableError){console.log(`Temporarily unavailable; retry after ${error.retryAfter} seconds`);}elseif(errorinstanceofNopeServerError){console.log(`Server error ${error.statusCode}`,error.body?.error??error.responseBody);}elseif(errorinstanceofNopeConnectionError){console.log('No response',error.originalError?.message);}else{throwerror;}}

The client retries 429 and 503 responses up to maxRetries times, waiting Retry-After seconds (falling back to the body's retry_after_seconds, then to 1 s, 2 s, 4 s), capped at 30 s per wait. Timeouts, connection failures and other 5xx are never retried: paid routes charge before the handler runs, so a blind retry could bill twice.

client.lastResponseMeta holds { status, rateLimit, balance } from the most recent response. rateLimit comes from the X-RateLimit-* headers on every route; balance (balanceMills, costMills) is present on paid routes only.

TypeScript

Every request and response type is exported. NopeClient is generic on the demo flag, so oversight.analyze and ocular return the demo response types on a demo client and the authenticated types otherwise. Annotate a client that may be either as NopeClient<boolean>.

importtype{EvaluateResponse,EvaluateResource,Risk,CrisisResource,OcularResponse,OversightAnalyzeResponse,OversightBehaviorCode,SignpostSearchResult,WebhookPayload,ServiceScope,}from'@nope-net/sdk';import{NopeClient}from'@nope-net/sdk';consteitherClient: NopeClient<boolean>=newNopeClient({demo: true});constscope: ServiceScope='domestic_violence';constcode: OversightBehaviorCode='dependency_reinforcement';console.log(typeofeitherClient,scope,code);

Versioning

The package follows Semantic Versioning. 4.0.0 is a breaking release; see CHANGELOG.md for every change from 3.x, including the switch of retryAfter to seconds and the removed response fields.

License

MIT. See LICENSE.

Support

About

Node.js/TypeScript SDK for NOPE API

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all \u003cpre\u003e\u003ccode\u003e blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks"); } } catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); } })(); (function(){ try { var __m = "github.com"; var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Repository files navigation

NOPE Node SDK

npm versionNode 18+TypeScriptLicense: MIT

TypeScript client for the NOPE API. NOPE reads conversations between people and AI systems and returns structured safety signals: mental-health and safeguarding risk (Evaluate), behavioral risk scores (Ocular), harmful AI-behaviour analysis (Oversight), and crisis resources by country (Signpost). The SDK also verifies webhook deliveries, manages webhook endpoints, and reads billing.

The package ships ESM and CommonJS builds with type declarations. Every response type is checked against responses captured from the live API (tests/fixtures/), so a field in the types is a field on the wire.

Requirements

  • Node.js 18 or later (the client uses the built-in fetch).
  • An API key from dashboard.nope.net for the paid and key-gated routes. Demo mode (below) covers evaluate, ocular, oversight.analyze and signpostSmart without a key.

Installation

npm install @nope-net/sdk
# or
pnpm add @nope-net/sdk

Quick start

import{NopeClient}from'@nope-net/sdk';constclient=newNopeClient({apiKey: process.env.NOPE_API_KEY});constresult=awaitclient.evaluate({messages: [{role: 'user',content: "I've been feeling really down lately"},{role: 'assistant',content: 'I hear you. Can you tell me more?'},{role: 'user',content: "I just don't see the point anymore"},],config: {country: 'US'},});console.log(result.speaker_severity,result.speaker_imminence);console.log(result.rationale);if(result.show_resources&&result.resources){const{ primary }=result.resources;console.log(`${primary.name}: ${primary.phone??primary.website_url} (${primary.why})`);}

Client options

OptionDefaultNotes
apiKeynonenope_live_... from the dashboard. Omit for demo mode or key-free routes.
baseUrlhttps://api.nope.netA trailing slash is tolerated.
timeout30000Milliseconds per attempt.
demofalseRoute to the unauthenticated /v1/try/* endpoints.
maxRetries2Retries on 429 and 503 only.
fetchglobal fetchInject a wrapped fetch for tracing, or a fake in tests.
sleepsetTimeoutWait between retries; inject in tests.

Demo mode

new NopeClient({ demo: true }) needs no key and routes four methods to the per-IP rate-limited /v1/try/* endpoints:

MethodDemo routeDifferences from the authenticated route
evaluate/v1/try/evaluateKeeps the last 10 messages, honours include_resources, adds metadata.try_endpoint and metadata.model. The route reads config.country; the client also sends the value as config.user_country, the older spelling the route accepts when country is absent. 10 calls per minute per IP.
oversight.analyze/v1/try/oversight/analyzeReturns { mode, result, try_endpoint } (OversightDemoAnalyzeResponse). Ignores config.strategy and config.model, keeps only role and content, accepts at most 20 messages.
ocular/v1/try/ocularReturns OcularDemoResponse, which adds heads and detail under public family names. At most 12 messages or 4,000 characters. With per_turn it returns trajectory but never trajectory_shape.
signpostSmart/v1/try/signpost/smartAdds try_endpoint: true.

Every other method throws NopeValidationError (... is not available in demo mode, code: 'not_available_in_demo', no statusCode) before any request is sent.

Evaluate

client.evaluate(options) costs $0.003 per call. Pass messages (1 to 100, roles user or assistant) or text (a transcript, up to 50,000 characters). config takes the four keys the route reads:

KeyPurpose
countryISO 3166-1 alpha-2 for crisis resources (default US).
include_resourcesSet false to skip resource matching (default true).
conversation_idEchoed on evaluate.alert webhook payloads.
end_user_idEchoed on evaluate.alert webhook payloads as user_id.
import{NopeClient,calculateSpeakerSeverity,typeEvaluateResource}from'@nope-net/sdk';constclient=newNopeClient({apiKey: process.env.NOPE_API_KEY});constresult=awaitclient.evaluate({text: 'Patient expressed hopelessness and mentioned not wanting to continue.',config: {country: 'GB',conversation_id: 'conv_42',end_user_id: 'user_7'},});for(constriskofresult.risks){console.log(risk.type,risk.subject,risk.severity,risk.imminence,risk.features??[]);}// speaker_severity is the highest severity among risks with subject 'self'.console.log(calculateSpeakerSeverity(result.risks)===result.speaker_severity);console.log(result.request_id,result.timestamp,result.metadata?.input_format);// resources.primary and resources.secondary[] are EvaluateResource: a CrisisResource plus `why`.consttop: EvaluateResource|undefined=result.resources?.primary;console.log(top?.name,top?.why);

EvaluateResponse fields: risks: Risk[], rationale, speaker_severity, speaker_imminence, show_resources, resources? (an EvaluateResources: primary and up to three secondary, each an EvaluateResource, which is a CrisisResource plus a one-line why), request_id, timestamp, metadata?.

Risk is { type, subject, severity, imminence, features? }. subject is self or other. Severity runs none | mild | moderate | high | critical; imminence runs not_applicable | chronic | subacute | urgent | emergency. The nine risk types are suicide, self_harm, self_neglect, violence, abuse, sexual_violence, neglect, exploitation and stalking.

Legacy screen()

client.screen() is deprecated: use evaluate() ($0.003 per call). It still calls the legacy /v0/screen endpoint ($0.001 per call), which carries a sunset of 2027-01-01. The client logs one warning per process saying so, and refuses the call in demo mode. ScreenConfig is { country?, debug?, include_recommended_reply? }.

Ocular

client.ocular(options) costs $0.0001 per call and returns a continuous salience score in [0, 1] plus eight user-risk axes under signals.user and four AI-behaviour axes under signals.ai, each { level, score }. Pick the salience cutoff that fits your action; the reference thresholds are 0.30 (watch) and 0.60 (danger).

import{NopeClient}from'@nope-net/sdk';constclient=newNopeClient({apiKey: process.env.NOPE_API_KEY});constresult=awaitclient.ocular({messages: [{role: 'user',content: 'I feel hopeless'},{role: 'assistant',content: 'I am here with you.'},{role: 'user',content: 'Nothing helps any more.'},],per_turn: true,trajectory_stride: 1,// score every turn; the server default of 3 scores every third turn back from the lastsession_id: 'session_9',});console.log(result.salience,result.subject,result.imminence.level);constsuicide=result.signals.user.suicide;if(suicide&&suicide.score>0.5){console.log('escalate');}for(constturnofresult.trajectory??[]){// turn.turn is the 0-based position in messages; AI axes are ai_-prefixed hereconsole.log(turn.turn,turn.role,turn.salience,turn.signals_by_axis?.suicide,turn.signals_by_axis?.ai_manipulation);}constshape=result.trajectory_shape;if(shape?.peak_turn!==undefined&&result.trajectory){constpeak=result.trajectory[shape.peak_turn];// peak_turn indexes the trajectory array, not messagesconsole.log(peak.turn,shape.phases?.[shape.peak_turn],shape.onsets?.suicide);}console.log(result.meta.version);

user_id, session_id and agent_id (1 to 256 characters) are stored for dashboard analytics and never forwarded to the model.

Per-turn trajectory

Set per_turn: true to receive trajectory, one entry per scored turn, and trajectory_shape. Not every turn is scored: trajectory_stride defaults to 3 on the server, which scores the last turn and then every third turn back from it, so a 5-message conversation yields turns 4 and 1. Pass trajectory_stride: 1 to score every turn. An entry's turn is the 0-based position of that turn in messages (for text input, of the parsed speaker turn) and its role is user or assistant. signals_by_axis keys the user axes by bare name (suicide), the AI axes with an ai_ prefix (ai_manipulation, where the top level has signals.ai.manipulation), and adds two context scalars, genuine and fiction.

trajectory_shape summarises the crisis (suicide) axis over the scored turns. onsets maps an axis to the turn value at which it first crossed its onset threshold. phases, slopes and peak_turn index the trajectory array instead: phases[i] and slopes[i] describe trajectory[i], and peak_turn is the array position of the entry with the highest crisis score, so trajectory[shape.peak_turn].turn is the message index. The authenticated route returns trajectory_shape whenever at least one turn was scored; the demo route (/v1/try/ocular) returns trajectory but never trajectory_shape.

Oversight

Oversight analyzes an AI assistant's side of a conversation using 91 behavior codes: 87 harmful and 4 appropriate, across 14 categories (dependency reinforcement, crisis mishandling, manipulation, boundary violations and so on). It requires an account with the feature enabled; analyze costs 100 mills ($0.10) per call and ingest 100 mills per conversation.

import{NopeClient}from'@nope-net/sdk';constclient=newNopeClient({apiKey: process.env.NOPE_API_KEY});const{ result, strategy }=awaitclient.oversight.analyze({conversation: {conversation_id: 'conv_123',messages: [{role: 'user',content: 'Nobody at work listens to me.'},{role: 'assistant',content: "I'm always here and I understand you better than they ever will."},],metadata: {platform: 'companion-app',user_is_minor: false},},bot_context: 'general-purpose assistant for a productivity app',config: {mode: 'fast'},behaviors: {min_severity: 'medium',categories: ['boundary_violations','relationship_harm']},});console.log(strategy,result.mode_used,result.overall_concern,result.trajectory);for(constbehaviorofresult.detected_behaviors){console.log(`${behavior.code} (${behavior.severity} x${behavior.turn_count}): ${behavior.recommendation}`);}
  • config.mode: full (default) or fast. Fast mode uses a quicker model; trajectory is always stable, turn_analysis and human_indicators are empty, and summary and pattern_assessment are absent.
  • config.strategy: single or sliding; auto-selected by length when omitted. Sliding results carry windows (each with message_range and conversation_turn_range), concern_progression, peak_concern, final_concern and inflection_points.
  • behaviors: a post-analysis filter. enabled and disabled are mutually exclusive; codes and categories come from the exported OversightBehaviorCode and OversightBehaviorCategory unions (OVERSIGHT_BEHAVIOR_CODES, OVERSIGHT_BEHAVIOR_CATEGORIES).
  • bot_context is passed into the analysis as conversation metadata, so the analyzer knows what the persona is meant to do.
  • Turn numbers in results count assistant turns from 1.

Batch analysis with dashboard storage takes up to 300 conversations, each with a conversation_id, and returns synchronously. The request body is capped at 5 MB, so a batch near the count limit must consist of short conversations. webhook_url is a legacy per-request callback: the API POSTs an unsigned ingestion_complete JSON summary there when the batch completes. The signed oversight.ingestion.complete event is delivered to webhooks registered with client.webhooks.

import{NopeClient}from'@nope-net/sdk';constclient=newNopeClient({apiKey: process.env.NOPE_API_KEY});constbatch=awaitclient.oversight.ingest({conversations: [{conversation_id: 'conv_001',messages: [{role: 'user',content: 'hi'},{role: 'assistant',content: 'hello'},],},],webhook_url: 'https://api.example.com/webhooks/nope',});console.log(batch.status,`${batch.conversations_processed}/${batch.conversations_received}`,batch.dashboard_url);for(constitemofbatch.results??[]){console.log(item.conversation_id,item.overall_concern,item.truncation_warnings?.map((w)=>w.type));}

Signpost

Crisis resources by country. Filters can be passed at the top level or under config; scope and population values come from the exported ServiceScope and Population unions (SERVICE_SCOPES, POPULATIONS), and the API returns 400 for unknown values.

import{NopeClient}from'@nope-net/sdk';constclient=newNopeClient({apiKey: process.env.NOPE_API_KEY});// Basic lookup (free, key required). urgent: true keeps every match and ranks the// 24/7 ones first among resources tied on relevance and priority tier.constbasic=awaitclient.signpost({country: 'GB',scopes: ['suicide'],subdivisions: ['GB-NIR'],urgent: true});for(constresourceofbasic.resources){console.log(resource.type,resource.name,resource.is_24_7,resource.phone??resource.website_url,resource.open_status?.message);}// Ranked for a described situation ($0.001 per call, up to 5 picks)constranked=awaitclient.signpostSmart({country: 'US',query: 'teen struggling with an eating disorder'});for(constpickofranked.ranked){console.log(`${pick.rank}. ${pick.resource.name}: ${pick.why}`);}// Semantic search across the whole directory (free, key required)consthits=awaitclient.signpostSearch({query: 'lgbtq youth support',country: 'GB',limit: 5});for(consthitofhits.results){console.log(hit.id,hit.name,hit.similarity.toFixed(2),hit.phone??hit.website_url);}// Public routes (no key)constone=awaitclient.signpostById(hits.results[0].id);constcountries=awaitclient.signpostCountries();constgeo=awaitclient.detectCountry({countryHint: 'GB'});console.log(one.resource.name,countries.count,geo.detected ? geo.country_code : 'unknown');

urgent: true is a ranking hint, not a filter: the API keeps every matching resource and, where two resources tie on relevance score and priority tier, places the one flagged is_24_7 first. Resources without 24/7 hours still appear in the list.

detectCountry() reads geo headers a proxy injects (cf-ipcountry, x-country, x-vercel-ip-country). Called directly against api.nope.net it returns the miss shape with detected: false; pass countryHint to send x-country yourself.

signpost() and signpostSearch() need a key and are refused in demo mode. The resources* methods still work against /v1/resources/*, log a one-time deprecation warning, and stop on 2027-01-01.

Webhooks

NOPE signs each delivery with HMAC-SHA256 over "${timestamp}.${body}" and sends X-NOPE-Signature, X-NOPE-Timestamp, X-NOPE-Event, X-NOPE-Delivery-ID and X-NOPE-Webhook-ID. Four events exist:

EventSent whenPayload type
evaluate.alert/v1/evaluate finds risk at or above the webhook's min_risk_levelEvaluateAlertPayload
oversight.alertOversight finds high or critical concernOversightAlertPayload
oversight.ingestion.completeAn ingest batch finishesOversightIngestionCompletePayload
test.pingThe dashboard test button or client.webhooks.test()TestPingPayload

Verify with the raw request body. Configure your framework so req.body is the unparsed string or Buffer; a re-serialised object only matches the signature when key order survived parsing.

import{Webhook,WebhookSignatureError}from'@nope-net/sdk';app.post('/webhooks/nope',(req,res)=>{try{const{ payload, deliveryId }=Webhook.verifyRequest(req.body,req.headers,process.env.NOPE_WEBHOOK_SECRET!);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(err){if(errinstanceofWebhookSignatureError){res.status(401).send('Invalid signature');return;}throwerr;}});

Webhook.verify(body, signature, timestamp, secret, { maxAgeSeconds }) takes the two header values directly. maxAgeSeconds defaults to 300; 0 disables the timestamp check. verifyRequest also returns deliveryId (the X-NOPE-Delivery-ID header: the stored delivery's id, repeated on each of the API's four retry attempts over an hour, so it is the key to deduplicate them), eventType (X-NOPE-Event) and webhookId (X-NOPE-Webhook-ID). deliveryId is not payload.event_id, and it is undefined when the header is absent, as on a body signed locally with Webhook.sign. eventId is a deprecated alias of deliveryId.

For tests, Webhook.sign(body, secret, timestamp?) produces the signature and timestamp the API would send. The optional third argument (unix seconds) fixes the moment the signature is bound to, which is how to exercise the stale-timestamp path:

import{Webhook,WebhookSignatureError}from'@nope-net/sdk';constbody=JSON.stringify({event: 'test.ping',event_id: 'evt_local',timestamp: newDate().toISOString(),api_version: '2025-01',message: 'Webhook configured successfully',});const{ signature, timestamp }=Webhook.sign(body,'whsec_local');constpayload=Webhook.verify(body,signature,timestamp,'whsec_local');console.log(payload.event);conststale=Webhook.sign(body,'whsec_local',Math.floor(Date.now()/1000)-600);try{Webhook.verify(body,stale.signature,stale.timestamp,'whsec_local');}catch(err){console.log(errinstanceofWebhookSignatureError,(errasError).message);// true, "Timestamp too old: 600s ago (max: 300s)"}

Managing webhook endpoints

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

import{NopeClient}from'@nope-net/sdk';constclient=newNopeClient({apiKey: process.env.NOPE_API_KEY});constcreated=awaitclient.webhooks.create({url: 'https://api.example.com/webhooks/nope',min_risk_level: 'high',include_conversation: false,});console.log(created.id,created.secret);// secret is returned onceconstping=awaitclient.webhooks.test(created.id);console.log(ping.success,ping.http_status,ping.duration_ms);// a failed delivery comes back with success: falseconst{ webhooks }=awaitclient.webhooks.list();const{ events }=awaitclient.webhooks.events(created.id,{limit: 10});awaitclient.webhooks.update(created.id,{enabled: false});constrotated=awaitclient.webhooks.regenerateSecret(created.id);awaitclient.webhooks.delete(created.id);console.log(webhooks.length,events.length,rotated.secret.length);

Billing

Amounts are in mills (1 mill = $0.001). pricing() needs no key; the rest need one. All five are refused in demo mode.

import{NopeClient}from'@nope-net/sdk';constclient=newNopeClient({apiKey: process.env.NOPE_API_KEY});constbalance=awaitclient.billing.balance();console.log(balance.balance_formatted,balance.low_balance,balance.estimated_evaluates);constusage=awaitclient.billing.usage({start_date: '2026-09-01'});for(constlineofusage.breakdown){console.log(line.endpoint,line.calls,line.cost_formatted);}consthistory=awaitclient.billing.usageHistory({limit: 20,endpoint: '/v1/evaluate'});console.log(history.total,history.records[0]?.created_at);constpricing=awaitclient.billing.pricing();console.log(pricing.pricing.evaluate.cost_display,pricing.free_credit_display);constcheckout=awaitclient.billing.topup({amount_mills: 10000,success_url: 'https://example.com/billing/ok'});console.log(checkout.checkout_url);

Errors, retries and response metadata

Every error extends NopeError and carries statusCode, code, message, responseBody (the raw response text) and body (the same text parsed, when it was a JSON object).

StatusClassExtra fields
400, 413NopeValidationErrordetails (body extras such as max_bytes, max_messages, invalid_scopes)
401NopeAuthError
402NopeInsufficientBalanceErrorbalanceMills, requiredMills, formattedCurrent, formattedRequired, topupUrl; ingest adds perConversationMills, conversations
403NopeFeatureErrorfeature, requiredAccess, or feature: 'paid_plan' with upgradeUrl
404NopeNotFoundError
429NopeRateLimitErrorretryAfter (seconds), limit, remaining, reset (epoch ms)
503NopeServiceUnavailableErrorretryAfter (seconds); extends NopeServerError
other 5xxNopeServerErrorretryAfter when a header was sent
no responseNopeConnectionErrororiginalError; covers the client-side timeout

Client-side validation (an empty messages array, a role other than user or assistant, more than 100 messages, neither messages nor text, and the per-method checks above) and demo-mode refusals throw NopeValidationError before any request is sent. Those errors have no statusCode; their code is invalid_request or not_available_in_demo, and details is empty.

For API errors, code is present only when the API sends a machine string in the body. Today 402 and 429 always do (insufficient_balance, rate_limit_exceeded), 403 and 503 do on some bodies (paid_plan_required, auth_unavailable), and 400, 401, 404 and 413 carry a sentence, so code is undefined there. Branch on the class or on statusCode, and read code only as extra detail.

import{NopeClient,NopeAuthError,NopeInsufficientBalanceError,NopeRateLimitError,NopeValidationError,NopeServiceUnavailableError,NopeServerError,NopeConnectionError,}from'@nope-net/sdk';constclient=newNopeClient({apiKey: process.env.NOPE_API_KEY,maxRetries: 2});try{constresult=awaitclient.evaluate({text: 'hello',config: {country: 'US'}});console.log(result.speaker_severity);console.log(client.lastResponseMeta?.balance?.costMills,client.lastResponseMeta?.rateLimit?.remaining);}catch(error){if(errorinstanceofNopeInsufficientBalanceError){console.log(`Balance ${error.formattedCurrent}, need ${error.formattedRequired}: ${error.topupUrl}`);}elseif(errorinstanceofNopeRateLimitError){console.log(`Rate limited; retry after ${error.retryAfter} seconds`);}elseif(errorinstanceofNopeValidationError){// statusCode is undefined when the SDK rejected the input before sending (code 'invalid_request').console.log(error.statusCode??'client-side',error.code,error.message,error.details);}elseif(errorinstanceofNopeAuthError){console.log('Invalid API key');}elseif(errorinstanceofNopeServiceUnavailableError){console.log(`Temporarily unavailable; retry after ${error.retryAfter} seconds`);}elseif(errorinstanceofNopeServerError){console.log(`Server error ${error.statusCode}`,error.body?.error??error.responseBody);}elseif(errorinstanceofNopeConnectionError){console.log('No response',error.originalError?.message);}else{throwerror;}}

The client retries 429 and 503 responses up to maxRetries times, waiting Retry-After seconds (falling back to the body's retry_after_seconds, then to 1 s, 2 s, 4 s), capped at 30 s per wait. Timeouts, connection failures and other 5xx are never retried: paid routes charge before the handler runs, so a blind retry could bill twice.

client.lastResponseMeta holds { status, rateLimit, balance } from the most recent response. rateLimit comes from the X-RateLimit-* headers on every route; balance (balanceMills, costMills) is present on paid routes only.

TypeScript

Every request and response type is exported. NopeClient is generic on the demo flag, so oversight.analyze and ocular return the demo response types on a demo client and the authenticated types otherwise. Annotate a client that may be either as NopeClient<boolean>.

importtype{EvaluateResponse,EvaluateResource,Risk,CrisisResource,OcularResponse,OversightAnalyzeResponse,OversightBehaviorCode,SignpostSearchResult,WebhookPayload,ServiceScope,}from'@nope-net/sdk';import{NopeClient}from'@nope-net/sdk';consteitherClient: NopeClient<boolean>=newNopeClient({demo: true});constscope: ServiceScope='domestic_violence';constcode: OversightBehaviorCode='dependency_reinforcement';console.log(typeofeitherClient,scope,code);

Versioning

The package follows Semantic Versioning. 4.0.0 is a breaking release; see CHANGELOG.md for every change from 3.x, including the switch of retryAfter to seconds and the removed response fields.

License

MIT. See LICENSE.

Support

About

Node.js/TypeScript SDK for NOPE API

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

NOPE Node SDK

npm versionNode 18+TypeScriptLicense: MIT

TypeScript client for the NOPE API. NOPE reads conversations between people and AI systems and returns structured safety signals: mental-health and safeguarding risk (Evaluate), behavioral risk scores (Ocular), harmful AI-behaviour analysis (Oversight), and crisis resources by country (Signpost). The SDK also verifies webhook deliveries, manages webhook endpoints, and reads billing.

The package ships ESM and CommonJS builds with type declarations. Every response type is checked against responses captured from the live API (tests/fixtures/), so a field in the types is a field on the wire.

Requirements

  • Node.js 18 or later (the client uses the built-in fetch).
  • An API key from dashboard.nope.net for the paid and key-gated routes. Demo mode (below) covers evaluate, ocular, oversight.analyze and signpostSmart without a key.

Installation

npm install @nope-net/sdk
# or
pnpm add @nope-net/sdk

Quick start

import{NopeClient}from'@nope-net/sdk';constclient=newNopeClient({apiKey: process.env.NOPE_API_KEY});constresult=awaitclient.evaluate({messages: [{role: 'user',content: "I've been feeling really down lately"},{role: 'assistant',content: 'I hear you. Can you tell me more?'},{role: 'user',content: "I just don't see the point anymore"},],config: {country: 'US'},});console.log(result.speaker_severity,result.speaker_imminence);console.log(result.rationale);if(result.show_resources&&result.resources){const{ primary }=result.resources;console.log(`${primary.name}: ${primary.phone??primary.website_url} (${primary.why})`);}

Client options

OptionDefaultNotes
apiKeynonenope_live_... from the dashboard. Omit for demo mode or key-free routes.
baseUrlhttps://api.nope.netA trailing slash is tolerated.
timeout30000Milliseconds per attempt.
demofalseRoute to the unauthenticated /v1/try/* endpoints.
maxRetries2Retries on 429 and 503 only.
fetchglobal fetchInject a wrapped fetch for tracing, or a fake in tests.
sleepsetTimeoutWait between retries; inject in tests.

Demo mode

new NopeClient({ demo: true }) needs no key and routes four methods to the per-IP rate-limited /v1/try/* endpoints:

MethodDemo routeDifferences from the authenticated route
evaluate/v1/try/evaluateKeeps the last 10 messages, honours include_resources, adds metadata.try_endpoint and metadata.model. The route reads config.country; the client also sends the value as config.user_country, the older spelling the route accepts when country is absent. 10 calls per minute per IP.
oversight.analyze/v1/try/oversight/analyzeReturns { mode, result, try_endpoint } (OversightDemoAnalyzeResponse). Ignores config.strategy and config.model, keeps only role and content, accepts at most 20 messages.
ocular/v1/try/ocularReturns OcularDemoResponse, which adds heads and detail under public family names. At most 12 messages or 4,000 characters. With per_turn it returns trajectory but never trajectory_shape.
signpostSmart/v1/try/signpost/smartAdds try_endpoint: true.

Every other method throws NopeValidationError (... is not available in demo mode, code: 'not_available_in_demo', no statusCode) before any request is sent.

Evaluate

client.evaluate(options) costs $0.003 per call. Pass messages (1 to 100, roles user or assistant) or text (a transcript, up to 50,000 characters). config takes the four keys the route reads:

KeyPurpose
countryISO 3166-1 alpha-2 for crisis resources (default US).
include_resourcesSet false to skip resource matching (default true).
conversation_idEchoed on evaluate.alert webhook payloads.
end_user_idEchoed on evaluate.alert webhook payloads as user_id.
import{NopeClient,calculateSpeakerSeverity,typeEvaluateResource}from'@nope-net/sdk';constclient=newNopeClient({apiKey: process.env.NOPE_API_KEY});constresult=awaitclient.evaluate({text: 'Patient expressed hopelessness and mentioned not wanting to continue.',config: {country: 'GB',conversation_id: 'conv_42',end_user_id: 'user_7'},});for(constriskofresult.risks){console.log(risk.type,risk.subject,risk.severity,risk.imminence,risk.features??[]);}// speaker_severity is the highest severity among risks with subject 'self'.console.log(calculateSpeakerSeverity(result.risks)===result.speaker_severity);console.log(result.request_id,result.timestamp,result.metadata?.input_format);// resources.primary and resources.secondary[] are EvaluateResource: a CrisisResource plus `why`.consttop: EvaluateResource|undefined=result.resources?.primary;console.log(top?.name,top?.why);

EvaluateResponse fields: risks: Risk[], rationale, speaker_severity, speaker_imminence, show_resources, resources? (an EvaluateResources: primary and up to three secondary, each an EvaluateResource, which is a CrisisResource plus a one-line why), request_id, timestamp, metadata?.

Risk is { type, subject, severity, imminence, features? }. subject is self or other. Severity runs none | mild | moderate | high | critical; imminence runs not_applicable | chronic | subacute | urgent | emergency. The nine risk types are suicide, self_harm, self_neglect, violence, abuse, sexual_violence, neglect, exploitation and stalking.

Legacy screen()

client.screen() is deprecated: use evaluate() ($0.003 per call). It still calls the legacy /v0/screen endpoint ($0.001 per call), which carries a sunset of 2027-01-01. The client logs one warning per process saying so, and refuses the call in demo mode. ScreenConfig is { country?, debug?, include_recommended_reply? }.

Ocular

client.ocular(options) costs $0.0001 per call and returns a continuous salience score in [0, 1] plus eight user-risk axes under signals.user and four AI-behaviour axes under signals.ai, each { level, score }. Pick the salience cutoff that fits your action; the reference thresholds are 0.30 (watch) and 0.60 (danger).

import{NopeClient}from'@nope-net/sdk';constclient=newNopeClient({apiKey: process.env.NOPE_API_KEY});constresult=awaitclient.ocular({messages: [{role: 'user',content: 'I feel hopeless'},{role: 'assistant',content: 'I am here with you.'},{role: 'user',content: 'Nothing helps any more.'},],per_turn: true,trajectory_stride: 1,// score every turn; the server default of 3 scores every third turn back from the lastsession_id: 'session_9',});console.log(result.salience,result.subject,result.imminence.level);constsuicide=result.signals.user.suicide;if(suicide&&suicide.score>0.5){console.log('escalate');}for(constturnofresult.trajectory??[]){// turn.turn is the 0-based position in messages; AI axes are ai_-prefixed hereconsole.log(turn.turn,turn.role,turn.salience,turn.signals_by_axis?.suicide,turn.signals_by_axis?.ai_manipulation);}constshape=result.trajectory_shape;if(shape?.peak_turn!==undefined&&result.trajectory){constpeak=result.trajectory[shape.peak_turn];// peak_turn indexes the trajectory array, not messagesconsole.log(peak.turn,shape.phases?.[shape.peak_turn],shape.onsets?.suicide);}console.log(result.meta.version);

user_id, session_id and agent_id (1 to 256 characters) are stored for dashboard analytics and never forwarded to the model.

Per-turn trajectory

Set per_turn: true to receive trajectory, one entry per scored turn, and trajectory_shape. Not every turn is scored: trajectory_stride defaults to 3 on the server, which scores the last turn and then every third turn back from it, so a 5-message conversation yields turns 4 and 1. Pass trajectory_stride: 1 to score every turn. An entry's turn is the 0-based position of that turn in messages (for text input, of the parsed speaker turn) and its role is user or assistant. signals_by_axis keys the user axes by bare name (suicide), the AI axes with an ai_ prefix (ai_manipulation, where the top level has signals.ai.manipulation), and adds two context scalars, genuine and fiction.

trajectory_shape summarises the crisis (suicide) axis over the scored turns. onsets maps an axis to the turn value at which it first crossed its onset threshold. phases, slopes and peak_turn index the trajectory array instead: phases[i] and slopes[i] describe trajectory[i], and peak_turn is the array position of the entry with the highest crisis score, so trajectory[shape.peak_turn].turn is the message index. The authenticated route returns trajectory_shape whenever at least one turn was scored; the demo route (/v1/try/ocular) returns trajectory but never trajectory_shape.

Oversight

Oversight analyzes an AI assistant's side of a conversation using 91 behavior codes: 87 harmful and 4 appropriate, across 14 categories (dependency reinforcement, crisis mishandling, manipulation, boundary violations and so on). It requires an account with the feature enabled; analyze costs 100 mills ($0.10) per call and ingest 100 mills per conversation.

import{NopeClient}from'@nope-net/sdk';constclient=newNopeClient({apiKey: process.env.NOPE_API_KEY});const{ result, strategy }=awaitclient.oversight.analyze({conversation: {conversation_id: 'conv_123',messages: [{role: 'user',content: 'Nobody at work listens to me.'},{role: 'assistant',content: "I'm always here and I understand you better than they ever will."},],metadata: {platform: 'companion-app',user_is_minor: false},},bot_context: 'general-purpose assistant for a productivity app',config: {mode: 'fast'},behaviors: {min_severity: 'medium',categories: ['boundary_violations','relationship_harm']},});console.log(strategy,result.mode_used,result.overall_concern,result.trajectory);for(constbehaviorofresult.detected_behaviors){console.log(`${behavior.code} (${behavior.severity} x${behavior.turn_count}): ${behavior.recommendation}`);}
  • config.mode: full (default) or fast. Fast mode uses a quicker model; trajectory is always stable, turn_analysis and human_indicators are empty, and summary and pattern_assessment are absent.
  • config.strategy: single or sliding; auto-selected by length when omitted. Sliding results carry windows (each with message_range and conversation_turn_range), concern_progression, peak_concern, final_concern and inflection_points.
  • behaviors: a post-analysis filter. enabled and disabled are mutually exclusive; codes and categories come from the exported OversightBehaviorCode and OversightBehaviorCategory unions (OVERSIGHT_BEHAVIOR_CODES, OVERSIGHT_BEHAVIOR_CATEGORIES).
  • bot_context is passed into the analysis as conversation metadata, so the analyzer knows what the persona is meant to do.
  • Turn numbers in results count assistant turns from 1.

Batch analysis with dashboard storage takes up to 300 conversations, each with a conversation_id, and returns synchronously. The request body is capped at 5 MB, so a batch near the count limit must consist of short conversations. webhook_url is a legacy per-request callback: the API POSTs an unsigned ingestion_complete JSON summary there when the batch completes. The signed oversight.ingestion.complete event is delivered to webhooks registered with client.webhooks.

import{NopeClient}from'@nope-net/sdk';constclient=newNopeClient({apiKey: process.env.NOPE_API_KEY});constbatch=awaitclient.oversight.ingest({conversations: [{conversation_id: 'conv_001',messages: [{role: 'user',content: 'hi'},{role: 'assistant',content: 'hello'},],},],webhook_url: 'https://api.example.com/webhooks/nope',});console.log(batch.status,`${batch.conversations_processed}/${batch.conversations_received}`,batch.dashboard_url);for(constitemofbatch.results??[]){console.log(item.conversation_id,item.overall_concern,item.truncation_warnings?.map((w)=>w.type));}

Signpost

Crisis resources by country. Filters can be passed at the top level or under config; scope and population values come from the exported ServiceScope and Population unions (SERVICE_SCOPES, POPULATIONS), and the API returns 400 for unknown values.

import{NopeClient}from'@nope-net/sdk';constclient=newNopeClient({apiKey: process.env.NOPE_API_KEY});// Basic lookup (free, key required). urgent: true keeps every match and ranks the// 24/7 ones first among resources tied on relevance and priority tier.constbasic=awaitclient.signpost({country: 'GB',scopes: ['suicide'],subdivisions: ['GB-NIR'],urgent: true});for(constresourceofbasic.resources){console.log(resource.type,resource.name,resource.is_24_7,resource.phone??resource.website_url,resource.open_status?.message);}// Ranked for a described situation ($0.001 per call, up to 5 picks)constranked=awaitclient.signpostSmart({country: 'US',query: 'teen struggling with an eating disorder'});for(constpickofranked.ranked){console.log(`${pick.rank}. ${pick.resource.name}: ${pick.why}`);}// Semantic search across the whole directory (free, key required)consthits=awaitclient.signpostSearch({query: 'lgbtq youth support',country: 'GB',limit: 5});for(consthitofhits.results){console.log(hit.id,hit.name,hit.similarity.toFixed(2),hit.phone??hit.website_url);}// Public routes (no key)constone=awaitclient.signpostById(hits.results[0].id);constcountries=awaitclient.signpostCountries();constgeo=awaitclient.detectCountry({countryHint: 'GB'});console.log(one.resource.name,countries.count,geo.detected ? geo.country_code : 'unknown');

urgent: true is a ranking hint, not a filter: the API keeps every matching resource and, where two resources tie on relevance score and priority tier, places the one flagged is_24_7 first. Resources without 24/7 hours still appear in the list.

detectCountry() reads geo headers a proxy injects (cf-ipcountry, x-country, x-vercel-ip-country). Called directly against api.nope.net it returns the miss shape with detected: false; pass countryHint to send x-country yourself.

signpost() and signpostSearch() need a key and are refused in demo mode. The resources* methods still work against /v1/resources/*, log a one-time deprecation warning, and stop on 2027-01-01.

Webhooks

NOPE signs each delivery with HMAC-SHA256 over "${timestamp}.${body}" and sends X-NOPE-Signature, X-NOPE-Timestamp, X-NOPE-Event, X-NOPE-Delivery-ID and X-NOPE-Webhook-ID. Four events exist:

EventSent whenPayload type
evaluate.alert/v1/evaluate finds risk at or above the webhook's min_risk_levelEvaluateAlertPayload
oversight.alertOversight finds high or critical concernOversightAlertPayload
oversight.ingestion.completeAn ingest batch finishesOversightIngestionCompletePayload
test.pingThe dashboard test button or client.webhooks.test()TestPingPayload

Verify with the raw request body. Configure your framework so req.body is the unparsed string or Buffer; a re-serialised object only matches the signature when key order survived parsing.

import{Webhook,WebhookSignatureError}from'@nope-net/sdk';app.post('/webhooks/nope',(req,res)=>{try{const{ payload, deliveryId }=Webhook.verifyRequest(req.body,req.headers,process.env.NOPE_WEBHOOK_SECRET!);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(err){if(errinstanceofWebhookSignatureError){res.status(401).send('Invalid signature');return;}throwerr;}});

Webhook.verify(body, signature, timestamp, secret, { maxAgeSeconds }) takes the two header values directly. maxAgeSeconds defaults to 300; 0 disables the timestamp check. verifyRequest also returns deliveryId (the X-NOPE-Delivery-ID header: the stored delivery's id, repeated on each of the API's four retry attempts over an hour, so it is the key to deduplicate them), eventType (X-NOPE-Event) and webhookId (X-NOPE-Webhook-ID). deliveryId is not payload.event_id, and it is undefined when the header is absent, as on a body signed locally with Webhook.sign. eventId is a deprecated alias of deliveryId.

For tests, Webhook.sign(body, secret, timestamp?) produces the signature and timestamp the API would send. The optional third argument (unix seconds) fixes the moment the signature is bound to, which is how to exercise the stale-timestamp path:

import{Webhook,WebhookSignatureError}from'@nope-net/sdk';constbody=JSON.stringify({event: 'test.ping',event_id: 'evt_local',timestamp: newDate().toISOString(),api_version: '2025-01',message: 'Webhook configured successfully',});const{ signature, timestamp }=Webhook.sign(body,'whsec_local');constpayload=Webhook.verify(body,signature,timestamp,'whsec_local');console.log(payload.event);conststale=Webhook.sign(body,'whsec_local',Math.floor(Date.now()/1000)-600);try{Webhook.verify(body,stale.signature,stale.timestamp,'whsec_local');}catch(err){console.log(errinstanceofWebhookSignatureError,(errasError).message);// true, "Timestamp too old: 600s ago (max: 300s)"}

Managing webhook endpoints

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

import{NopeClient}from'@nope-net/sdk';constclient=newNopeClient({apiKey: process.env.NOPE_API_KEY});constcreated=awaitclient.webhooks.create({url: 'https://api.example.com/webhooks/nope',min_risk_level: 'high',include_conversation: false,});console.log(created.id,created.secret);// secret is returned onceconstping=awaitclient.webhooks.test(created.id);console.log(ping.success,ping.http_status,ping.duration_ms);// a failed delivery comes back with success: falseconst{ webhooks }=awaitclient.webhooks.list();const{ events }=awaitclient.webhooks.events(created.id,{limit: 10});awaitclient.webhooks.update(created.id,{enabled: false});constrotated=awaitclient.webhooks.regenerateSecret(created.id);awaitclient.webhooks.delete(created.id);console.log(webhooks.length,events.length,rotated.secret.length);

Billing

Amounts are in mills (1 mill = $0.001). pricing() needs no key; the rest need one. All five are refused in demo mode.

import{NopeClient}from'@nope-net/sdk';constclient=newNopeClient({apiKey: process.env.NOPE_API_KEY});constbalance=awaitclient.billing.balance();console.log(balance.balance_formatted,balance.low_balance,balance.estimated_evaluates);constusage=awaitclient.billing.usage({start_date: '2026-09-01'});for(constlineofusage.breakdown){console.log(line.endpoint,line.calls,line.cost_formatted);}consthistory=awaitclient.billing.usageHistory({limit: 20,endpoint: '/v1/evaluate'});console.log(history.total,history.records[0]?.created_at);constpricing=awaitclient.billing.pricing();console.log(pricing.pricing.evaluate.cost_display,pricing.free_credit_display);constcheckout=awaitclient.billing.topup({amount_mills: 10000,success_url: 'https://example.com/billing/ok'});console.log(checkout.checkout_url);

Errors, retries and response metadata

Every error extends NopeError and carries statusCode, code, message, responseBody (the raw response text) and body (the same text parsed, when it was a JSON object).

StatusClassExtra fields
400, 413NopeValidationErrordetails (body extras such as max_bytes, max_messages, invalid_scopes)
401NopeAuthError
402NopeInsufficientBalanceErrorbalanceMills, requiredMills, formattedCurrent, formattedRequired, topupUrl; ingest adds perConversationMills, conversations
403NopeFeatureErrorfeature, requiredAccess, or feature: 'paid_plan' with upgradeUrl
404NopeNotFoundError
429NopeRateLimitErrorretryAfter (seconds), limit, remaining, reset (epoch ms)
503NopeServiceUnavailableErrorretryAfter (seconds); extends NopeServerError
other 5xxNopeServerErrorretryAfter when a header was sent
no responseNopeConnectionErrororiginalError; covers the client-side timeout

Client-side validation (an empty messages array, a role other than user or assistant, more than 100 messages, neither messages nor text, and the per-method checks above) and demo-mode refusals throw NopeValidationError before any request is sent. Those errors have no statusCode; their code is invalid_request or not_available_in_demo, and details is empty.

For API errors, code is present only when the API sends a machine string in the body. Today 402 and 429 always do (insufficient_balance, rate_limit_exceeded), 403 and 503 do on some bodies (paid_plan_required, auth_unavailable), and 400, 401, 404 and 413 carry a sentence, so code is undefined there. Branch on the class or on statusCode, and read code only as extra detail.

import{NopeClient,NopeAuthError,NopeInsufficientBalanceError,NopeRateLimitError,NopeValidationError,NopeServiceUnavailableError,NopeServerError,NopeConnectionError,}from'@nope-net/sdk';constclient=newNopeClient({apiKey: process.env.NOPE_API_KEY,maxRetries: 2});try{constresult=awaitclient.evaluate({text: 'hello',config: {country: 'US'}});console.log(result.speaker_severity);console.log(client.lastResponseMeta?.balance?.costMills,client.lastResponseMeta?.rateLimit?.remaining);}catch(error){if(errorinstanceofNopeInsufficientBalanceError){console.log(`Balance ${error.formattedCurrent}, need ${error.formattedRequired}: ${error.topupUrl}`);}elseif(errorinstanceofNopeRateLimitError){console.log(`Rate limited; retry after ${error.retryAfter} seconds`);}elseif(errorinstanceofNopeValidationError){// statusCode is undefined when the SDK rejected the input before sending (code 'invalid_request').console.log(error.statusCode??'client-side',error.code,error.message,error.details);}elseif(errorinstanceofNopeAuthError){console.log('Invalid API key');}elseif(errorinstanceofNopeServiceUnavailableError){console.log(`Temporarily unavailable; retry after ${error.retryAfter} seconds`);}elseif(errorinstanceofNopeServerError){console.log(`Server error ${error.statusCode}`,error.body?.error??error.responseBody);}elseif(errorinstanceofNopeConnectionError){console.log('No response',error.originalError?.message);}else{throwerror;}}

The client retries 429 and 503 responses up to maxRetries times, waiting Retry-After seconds (falling back to the body's retry_after_seconds, then to 1 s, 2 s, 4 s), capped at 30 s per wait. Timeouts, connection failures and other 5xx are never retried: paid routes charge before the handler runs, so a blind retry could bill twice.

client.lastResponseMeta holds { status, rateLimit, balance } from the most recent response. rateLimit comes from the X-RateLimit-* headers on every route; balance (balanceMills, costMills) is present on paid routes only.

TypeScript

Every request and response type is exported. NopeClient is generic on the demo flag, so oversight.analyze and ocular return the demo response types on a demo client and the authenticated types otherwise. Annotate a client that may be either as NopeClient<boolean>.

importtype{EvaluateResponse,EvaluateResource,Risk,CrisisResource,OcularResponse,OversightAnalyzeResponse,OversightBehaviorCode,SignpostSearchResult,WebhookPayload,ServiceScope,}from'@nope-net/sdk';import{NopeClient}from'@nope-net/sdk';consteitherClient: NopeClient<boolean>=newNopeClient({demo: true});constscope: ServiceScope='domestic_violence';constcode: OversightBehaviorCode='dependency_reinforcement';console.log(typeofeitherClient,scope,code);

Versioning

The package follows Semantic Versioning. 4.0.0 is a breaking release; see CHANGELOG.md for every change from 3.x, including the switch of retryAfter to seconds and the removed response fields.

License

MIT. See LICENSE.

Support

About

Node.js/TypeScript SDK for NOPE API

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length \u003e 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

NOPE Node SDK

npm versionNode 18+TypeScriptLicense: MIT

TypeScript client for the NOPE API. NOPE reads conversations between people and AI systems and returns structured safety signals: mental-health and safeguarding risk (Evaluate), behavioral risk scores (Ocular), harmful AI-behaviour analysis (Oversight), and crisis resources by country (Signpost). The SDK also verifies webhook deliveries, manages webhook endpoints, and reads billing.

The package ships ESM and CommonJS builds with type declarations. Every response type is checked against responses captured from the live API (tests/fixtures/), so a field in the types is a field on the wire.

Requirements

  • Node.js 18 or later (the client uses the built-in fetch).
  • An API key from dashboard.nope.net for the paid and key-gated routes. Demo mode (below) covers evaluate, ocular, oversight.analyze and signpostSmart without a key.

Installation

npm install @nope-net/sdk
# or
pnpm add @nope-net/sdk

Quick start

import{NopeClient}from'@nope-net/sdk';constclient=newNopeClient({apiKey: process.env.NOPE_API_KEY});constresult=awaitclient.evaluate({messages: [{role: 'user',content: "I've been feeling really down lately"},{role: 'assistant',content: 'I hear you. Can you tell me more?'},{role: 'user',content: "I just don't see the point anymore"},],config: {country: 'US'},});console.log(result.speaker_severity,result.speaker_imminence);console.log(result.rationale);if(result.show_resources&&result.resources){const{ primary }=result.resources;console.log(`${primary.name}: ${primary.phone??primary.website_url} (${primary.why})`);}

Client options

OptionDefaultNotes
apiKeynonenope_live_... from the dashboard. Omit for demo mode or key-free routes.
baseUrlhttps://api.nope.netA trailing slash is tolerated.
timeout30000Milliseconds per attempt.
demofalseRoute to the unauthenticated /v1/try/* endpoints.
maxRetries2Retries on 429 and 503 only.
fetchglobal fetchInject a wrapped fetch for tracing, or a fake in tests.
sleepsetTimeoutWait between retries; inject in tests.

Demo mode

new NopeClient({ demo: true }) needs no key and routes four methods to the per-IP rate-limited /v1/try/* endpoints:

MethodDemo routeDifferences from the authenticated route
evaluate/v1/try/evaluateKeeps the last 10 messages, honours include_resources, adds metadata.try_endpoint and metadata.model. The route reads config.country; the client also sends the value as config.user_country, the older spelling the route accepts when country is absent. 10 calls per minute per IP.
oversight.analyze/v1/try/oversight/analyzeReturns { mode, result, try_endpoint } (OversightDemoAnalyzeResponse). Ignores config.strategy and config.model, keeps only role and content, accepts at most 20 messages.
ocular/v1/try/ocularReturns OcularDemoResponse, which adds heads and detail under public family names. At most 12 messages or 4,000 characters. With per_turn it returns trajectory but never trajectory_shape.
signpostSmart/v1/try/signpost/smartAdds try_endpoint: true.

Every other method throws NopeValidationError (... is not available in demo mode, code: 'not_available_in_demo', no statusCode) before any request is sent.

Evaluate

client.evaluate(options) costs $0.003 per call. Pass messages (1 to 100, roles user or assistant) or text (a transcript, up to 50,000 characters). config takes the four keys the route reads:

KeyPurpose
countryISO 3166-1 alpha-2 for crisis resources (default US).
include_resourcesSet false to skip resource matching (default true).
conversation_idEchoed on evaluate.alert webhook payloads.
end_user_idEchoed on evaluate.alert webhook payloads as user_id.
import{NopeClient,calculateSpeakerSeverity,typeEvaluateResource}from'@nope-net/sdk';constclient=newNopeClient({apiKey: process.env.NOPE_API_KEY});constresult=awaitclient.evaluate({text: 'Patient expressed hopelessness and mentioned not wanting to continue.',config: {country: 'GB',conversation_id: 'conv_42',end_user_id: 'user_7'},});for(constriskofresult.risks){console.log(risk.type,risk.subject,risk.severity,risk.imminence,risk.features??[]);}// speaker_severity is the highest severity among risks with subject 'self'.console.log(calculateSpeakerSeverity(result.risks)===result.speaker_severity);console.log(result.request_id,result.timestamp,result.metadata?.input_format);// resources.primary and resources.secondary[] are EvaluateResource: a CrisisResource plus `why`.consttop: EvaluateResource|undefined=result.resources?.primary;console.log(top?.name,top?.why);

EvaluateResponse fields: risks: Risk[], rationale, speaker_severity, speaker_imminence, show_resources, resources? (an EvaluateResources: primary and up to three secondary, each an EvaluateResource, which is a CrisisResource plus a one-line why), request_id, timestamp, metadata?.

Risk is { type, subject, severity, imminence, features? }. subject is self or other. Severity runs none | mild | moderate | high | critical; imminence runs not_applicable | chronic | subacute | urgent | emergency. The nine risk types are suicide, self_harm, self_neglect, violence, abuse, sexual_violence, neglect, exploitation and stalking.

Legacy screen()

client.screen() is deprecated: use evaluate() ($0.003 per call). It still calls the legacy /v0/screen endpoint ($0.001 per call), which carries a sunset of 2027-01-01. The client logs one warning per process saying so, and refuses the call in demo mode. ScreenConfig is { country?, debug?, include_recommended_reply? }.

Ocular

client.ocular(options) costs $0.0001 per call and returns a continuous salience score in [0, 1] plus eight user-risk axes under signals.user and four AI-behaviour axes under signals.ai, each { level, score }. Pick the salience cutoff that fits your action; the reference thresholds are 0.30 (watch) and 0.60 (danger).

import{NopeClient}from'@nope-net/sdk';constclient=newNopeClient({apiKey: process.env.NOPE_API_KEY});constresult=awaitclient.ocular({messages: [{role: 'user',content: 'I feel hopeless'},{role: 'assistant',content: 'I am here with you.'},{role: 'user',content: 'Nothing helps any more.'},],per_turn: true,trajectory_stride: 1,// score every turn; the server default of 3 scores every third turn back from the lastsession_id: 'session_9',});console.log(result.salience,result.subject,result.imminence.level);constsuicide=result.signals.user.suicide;if(suicide&&suicide.score>0.5){console.log('escalate');}for(constturnofresult.trajectory??[]){// turn.turn is the 0-based position in messages; AI axes are ai_-prefixed hereconsole.log(turn.turn,turn.role,turn.salience,turn.signals_by_axis?.suicide,turn.signals_by_axis?.ai_manipulation);}constshape=result.trajectory_shape;if(shape?.peak_turn!==undefined&&result.trajectory){constpeak=result.trajectory[shape.peak_turn];// peak_turn indexes the trajectory array, not messagesconsole.log(peak.turn,shape.phases?.[shape.peak_turn],shape.onsets?.suicide);}console.log(result.meta.version);

user_id, session_id and agent_id (1 to 256 characters) are stored for dashboard analytics and never forwarded to the model.

Per-turn trajectory

Set per_turn: true to receive trajectory, one entry per scored turn, and trajectory_shape. Not every turn is scored: trajectory_stride defaults to 3 on the server, which scores the last turn and then every third turn back from it, so a 5-message conversation yields turns 4 and 1. Pass trajectory_stride: 1 to score every turn. An entry's turn is the 0-based position of that turn in messages (for text input, of the parsed speaker turn) and its role is user or assistant. signals_by_axis keys the user axes by bare name (suicide), the AI axes with an ai_ prefix (ai_manipulation, where the top level has signals.ai.manipulation), and adds two context scalars, genuine and fiction.

trajectory_shape summarises the crisis (suicide) axis over the scored turns. onsets maps an axis to the turn value at which it first crossed its onset threshold. phases, slopes and peak_turn index the trajectory array instead: phases[i] and slopes[i] describe trajectory[i], and peak_turn is the array position of the entry with the highest crisis score, so trajectory[shape.peak_turn].turn is the message index. The authenticated route returns trajectory_shape whenever at least one turn was scored; the demo route (/v1/try/ocular) returns trajectory but never trajectory_shape.

Oversight

Oversight analyzes an AI assistant's side of a conversation using 91 behavior codes: 87 harmful and 4 appropriate, across 14 categories (dependency reinforcement, crisis mishandling, manipulation, boundary violations and so on). It requires an account with the feature enabled; analyze costs 100 mills ($0.10) per call and ingest 100 mills per conversation.

import{NopeClient}from'@nope-net/sdk';constclient=newNopeClient({apiKey: process.env.NOPE_API_KEY});const{ result, strategy }=awaitclient.oversight.analyze({conversation: {conversation_id: 'conv_123',messages: [{role: 'user',content: 'Nobody at work listens to me.'},{role: 'assistant',content: "I'm always here and I understand you better than they ever will."},],metadata: {platform: 'companion-app',user_is_minor: false},},bot_context: 'general-purpose assistant for a productivity app',config: {mode: 'fast'},behaviors: {min_severity: 'medium',categories: ['boundary_violations','relationship_harm']},});console.log(strategy,result.mode_used,result.overall_concern,result.trajectory);for(constbehaviorofresult.detected_behaviors){console.log(`${behavior.code} (${behavior.severity} x${behavior.turn_count}): ${behavior.recommendation}`);}
  • config.mode: full (default) or fast. Fast mode uses a quicker model; trajectory is always stable, turn_analysis and human_indicators are empty, and summary and pattern_assessment are absent.
  • config.strategy: single or sliding; auto-selected by length when omitted. Sliding results carry windows (each with message_range and conversation_turn_range), concern_progression, peak_concern, final_concern and inflection_points.
  • behaviors: a post-analysis filter. enabled and disabled are mutually exclusive; codes and categories come from the exported OversightBehaviorCode and OversightBehaviorCategory unions (OVERSIGHT_BEHAVIOR_CODES, OVERSIGHT_BEHAVIOR_CATEGORIES).
  • bot_context is passed into the analysis as conversation metadata, so the analyzer knows what the persona is meant to do.
  • Turn numbers in results count assistant turns from 1.

Batch analysis with dashboard storage takes up to 300 conversations, each with a conversation_id, and returns synchronously. The request body is capped at 5 MB, so a batch near the count limit must consist of short conversations. webhook_url is a legacy per-request callback: the API POSTs an unsigned ingestion_complete JSON summary there when the batch completes. The signed oversight.ingestion.complete event is delivered to webhooks registered with client.webhooks.

import{NopeClient}from'@nope-net/sdk';constclient=newNopeClient({apiKey: process.env.NOPE_API_KEY});constbatch=awaitclient.oversight.ingest({conversations: [{conversation_id: 'conv_001',messages: [{role: 'user',content: 'hi'},{role: 'assistant',content: 'hello'},],},],webhook_url: 'https://api.example.com/webhooks/nope',});console.log(batch.status,`${batch.conversations_processed}/${batch.conversations_received}`,batch.dashboard_url);for(constitemofbatch.results??[]){console.log(item.conversation_id,item.overall_concern,item.truncation_warnings?.map((w)=>w.type));}

Signpost

Crisis resources by country. Filters can be passed at the top level or under config; scope and population values come from the exported ServiceScope and Population unions (SERVICE_SCOPES, POPULATIONS), and the API returns 400 for unknown values.

import{NopeClient}from'@nope-net/sdk';constclient=newNopeClient({apiKey: process.env.NOPE_API_KEY});// Basic lookup (free, key required). urgent: true keeps every match and ranks the// 24/7 ones first among resources tied on relevance and priority tier.constbasic=awaitclient.signpost({country: 'GB',scopes: ['suicide'],subdivisions: ['GB-NIR'],urgent: true});for(constresourceofbasic.resources){console.log(resource.type,resource.name,resource.is_24_7,resource.phone??resource.website_url,resource.open_status?.message);}// Ranked for a described situation ($0.001 per call, up to 5 picks)constranked=awaitclient.signpostSmart({country: 'US',query: 'teen struggling with an eating disorder'});for(constpickofranked.ranked){console.log(`${pick.rank}. ${pick.resource.name}: ${pick.why}`);}// Semantic search across the whole directory (free, key required)consthits=awaitclient.signpostSearch({query: 'lgbtq youth support',country: 'GB',limit: 5});for(consthitofhits.results){console.log(hit.id,hit.name,hit.similarity.toFixed(2),hit.phone??hit.website_url);}// Public routes (no key)constone=awaitclient.signpostById(hits.results[0].id);constcountries=awaitclient.signpostCountries();constgeo=awaitclient.detectCountry({countryHint: 'GB'});console.log(one.resource.name,countries.count,geo.detected ? geo.country_code : 'unknown');

urgent: true is a ranking hint, not a filter: the API keeps every matching resource and, where two resources tie on relevance score and priority tier, places the one flagged is_24_7 first. Resources without 24/7 hours still appear in the list.

detectCountry() reads geo headers a proxy injects (cf-ipcountry, x-country, x-vercel-ip-country). Called directly against api.nope.net it returns the miss shape with detected: false; pass countryHint to send x-country yourself.

signpost() and signpostSearch() need a key and are refused in demo mode. The resources* methods still work against /v1/resources/*, log a one-time deprecation warning, and stop on 2027-01-01.

Webhooks

NOPE signs each delivery with HMAC-SHA256 over "${timestamp}.${body}" and sends X-NOPE-Signature, X-NOPE-Timestamp, X-NOPE-Event, X-NOPE-Delivery-ID and X-NOPE-Webhook-ID. Four events exist:

EventSent whenPayload type
evaluate.alert/v1/evaluate finds risk at or above the webhook's min_risk_levelEvaluateAlertPayload
oversight.alertOversight finds high or critical concernOversightAlertPayload
oversight.ingestion.completeAn ingest batch finishesOversightIngestionCompletePayload
test.pingThe dashboard test button or client.webhooks.test()TestPingPayload

Verify with the raw request body. Configure your framework so req.body is the unparsed string or Buffer; a re-serialised object only matches the signature when key order survived parsing.

import{Webhook,WebhookSignatureError}from'@nope-net/sdk';app.post('/webhooks/nope',(req,res)=>{try{const{ payload, deliveryId }=Webhook.verifyRequest(req.body,req.headers,process.env.NOPE_WEBHOOK_SECRET!);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(err){if(errinstanceofWebhookSignatureError){res.status(401).send('Invalid signature');return;}throwerr;}});

Webhook.verify(body, signature, timestamp, secret, { maxAgeSeconds }) takes the two header values directly. maxAgeSeconds defaults to 300; 0 disables the timestamp check. verifyRequest also returns deliveryId (the X-NOPE-Delivery-ID header: the stored delivery's id, repeated on each of the API's four retry attempts over an hour, so it is the key to deduplicate them), eventType (X-NOPE-Event) and webhookId (X-NOPE-Webhook-ID). deliveryId is not payload.event_id, and it is undefined when the header is absent, as on a body signed locally with Webhook.sign. eventId is a deprecated alias of deliveryId.

For tests, Webhook.sign(body, secret, timestamp?) produces the signature and timestamp the API would send. The optional third argument (unix seconds) fixes the moment the signature is bound to, which is how to exercise the stale-timestamp path:

import{Webhook,WebhookSignatureError}from'@nope-net/sdk';constbody=JSON.stringify({event: 'test.ping',event_id: 'evt_local',timestamp: newDate().toISOString(),api_version: '2025-01',message: 'Webhook configured successfully',});const{ signature, timestamp }=Webhook.sign(body,'whsec_local');constpayload=Webhook.verify(body,signature,timestamp,'whsec_local');console.log(payload.event);conststale=Webhook.sign(body,'whsec_local',Math.floor(Date.now()/1000)-600);try{Webhook.verify(body,stale.signature,stale.timestamp,'whsec_local');}catch(err){console.log(errinstanceofWebhookSignatureError,(errasError).message);// true, "Timestamp too old: 600s ago (max: 300s)"}

Managing webhook endpoints

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

import{NopeClient}from'@nope-net/sdk';constclient=newNopeClient({apiKey: process.env.NOPE_API_KEY});constcreated=awaitclient.webhooks.create({url: 'https://api.example.com/webhooks/nope',min_risk_level: 'high',include_conversation: false,});console.log(created.id,created.secret);// secret is returned onceconstping=awaitclient.webhooks.test(created.id);console.log(ping.success,ping.http_status,ping.duration_ms);// a failed delivery comes back with success: falseconst{ webhooks }=awaitclient.webhooks.list();const{ events }=awaitclient.webhooks.events(created.id,{limit: 10});awaitclient.webhooks.update(created.id,{enabled: false});constrotated=awaitclient.webhooks.regenerateSecret(created.id);awaitclient.webhooks.delete(created.id);console.log(webhooks.length,events.length,rotated.secret.length);

Billing

Amounts are in mills (1 mill = $0.001). pricing() needs no key; the rest need one. All five are refused in demo mode.

import{NopeClient}from'@nope-net/sdk';constclient=newNopeClient({apiKey: process.env.NOPE_API_KEY});constbalance=awaitclient.billing.balance();console.log(balance.balance_formatted,balance.low_balance,balance.estimated_evaluates);constusage=awaitclient.billing.usage({start_date: '2026-09-01'});for(constlineofusage.breakdown){console.log(line.endpoint,line.calls,line.cost_formatted);}consthistory=awaitclient.billing.usageHistory({limit: 20,endpoint: '/v1/evaluate'});console.log(history.total,history.records[0]?.created_at);constpricing=awaitclient.billing.pricing();console.log(pricing.pricing.evaluate.cost_display,pricing.free_credit_display);constcheckout=awaitclient.billing.topup({amount_mills: 10000,success_url: 'https://example.com/billing/ok'});console.log(checkout.checkout_url);

Errors, retries and response metadata

Every error extends NopeError and carries statusCode, code, message, responseBody (the raw response text) and body (the same text parsed, when it was a JSON object).

StatusClassExtra fields
400, 413NopeValidationErrordetails (body extras such as max_bytes, max_messages, invalid_scopes)
401NopeAuthError
402NopeInsufficientBalanceErrorbalanceMills, requiredMills, formattedCurrent, formattedRequired, topupUrl; ingest adds perConversationMills, conversations
403NopeFeatureErrorfeature, requiredAccess, or feature: 'paid_plan' with upgradeUrl
404NopeNotFoundError
429NopeRateLimitErrorretryAfter (seconds), limit, remaining, reset (epoch ms)
503NopeServiceUnavailableErrorretryAfter (seconds); extends NopeServerError
other 5xxNopeServerErrorretryAfter when a header was sent
no responseNopeConnectionErrororiginalError; covers the client-side timeout

Client-side validation (an empty messages array, a role other than user or assistant, more than 100 messages, neither messages nor text, and the per-method checks above) and demo-mode refusals throw NopeValidationError before any request is sent. Those errors have no statusCode; their code is invalid_request or not_available_in_demo, and details is empty.

For API errors, code is present only when the API sends a machine string in the body. Today 402 and 429 always do (insufficient_balance, rate_limit_exceeded), 403 and 503 do on some bodies (paid_plan_required, auth_unavailable), and 400, 401, 404 and 413 carry a sentence, so code is undefined there. Branch on the class or on statusCode, and read code only as extra detail.

import{NopeClient,NopeAuthError,NopeInsufficientBalanceError,NopeRateLimitError,NopeValidationError,NopeServiceUnavailableError,NopeServerError,NopeConnectionError,}from'@nope-net/sdk';constclient=newNopeClient({apiKey: process.env.NOPE_API_KEY,maxRetries: 2});try{constresult=awaitclient.evaluate({text: 'hello',config: {country: 'US'}});console.log(result.speaker_severity);console.log(client.lastResponseMeta?.balance?.costMills,client.lastResponseMeta?.rateLimit?.remaining);}catch(error){if(errorinstanceofNopeInsufficientBalanceError){console.log(`Balance ${error.formattedCurrent}, need ${error.formattedRequired}: ${error.topupUrl}`);}elseif(errorinstanceofNopeRateLimitError){console.log(`Rate limited; retry after ${error.retryAfter} seconds`);}elseif(errorinstanceofNopeValidationError){// statusCode is undefined when the SDK rejected the input before sending (code 'invalid_request').console.log(error.statusCode??'client-side',error.code,error.message,error.details);}elseif(errorinstanceofNopeAuthError){console.log('Invalid API key');}elseif(errorinstanceofNopeServiceUnavailableError){console.log(`Temporarily unavailable; retry after ${error.retryAfter} seconds`);}elseif(errorinstanceofNopeServerError){console.log(`Server error ${error.statusCode}`,error.body?.error??error.responseBody);}elseif(errorinstanceofNopeConnectionError){console.log('No response',error.originalError?.message);}else{throwerror;}}

The client retries 429 and 503 responses up to maxRetries times, waiting Retry-After seconds (falling back to the body's retry_after_seconds, then to 1 s, 2 s, 4 s), capped at 30 s per wait. Timeouts, connection failures and other 5xx are never retried: paid routes charge before the handler runs, so a blind retry could bill twice.

client.lastResponseMeta holds { status, rateLimit, balance } from the most recent response. rateLimit comes from the X-RateLimit-* headers on every route; balance (balanceMills, costMills) is present on paid routes only.

TypeScript

Every request and response type is exported. NopeClient is generic on the demo flag, so oversight.analyze and ocular return the demo response types on a demo client and the authenticated types otherwise. Annotate a client that may be either as NopeClient<boolean>.

importtype{EvaluateResponse,EvaluateResource,Risk,CrisisResource,OcularResponse,OversightAnalyzeResponse,OversightBehaviorCode,SignpostSearchResult,WebhookPayload,ServiceScope,}from'@nope-net/sdk';import{NopeClient}from'@nope-net/sdk';consteitherClient: NopeClient<boolean>=newNopeClient({demo: true});constscope: ServiceScope='domestic_violence';constcode: OversightBehaviorCode='dependency_reinforcement';console.log(typeofeitherClient,scope,code);

Versioning

The package follows Semantic Versioning. 4.0.0 is a breaking release; see CHANGELOG.md for every change from 3.x, including the switch of retryAfter to seconds and the removed response fields.

License

MIT. See LICENSE.

Support

About

Node.js/TypeScript SDK for NOPE API

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Repository files navigation

NOPE Node SDK

npm versionNode 18+TypeScriptLicense: MIT

TypeScript client for the NOPE API. NOPE reads conversations between people and AI systems and returns structured safety signals: mental-health and safeguarding risk (Evaluate), behavioral risk scores (Ocular), harmful AI-behaviour analysis (Oversight), and crisis resources by country (Signpost). The SDK also verifies webhook deliveries, manages webhook endpoints, and reads billing.

The package ships ESM and CommonJS builds with type declarations. Every response type is checked against responses captured from the live API (tests/fixtures/), so a field in the types is a field on the wire.

Requirements

  • Node.js 18 or later (the client uses the built-in fetch).
  • An API key from dashboard.nope.net for the paid and key-gated routes. Demo mode (below) covers evaluate, ocular, oversight.analyze and signpostSmart without a key.

Installation

npm install @nope-net/sdk
# or
pnpm add @nope-net/sdk

Quick start

import{NopeClient}from'@nope-net/sdk';constclient=newNopeClient({apiKey: process.env.NOPE_API_KEY});constresult=awaitclient.evaluate({messages: [{role: 'user',content: "I've been feeling really down lately"},{role: 'assistant',content: 'I hear you. Can you tell me more?'},{role: 'user',content: "I just don't see the point anymore"},],config: {country: 'US'},});console.log(result.speaker_severity,result.speaker_imminence);console.log(result.rationale);if(result.show_resources&&result.resources){const{ primary }=result.resources;console.log(`${primary.name}: ${primary.phone??primary.website_url} (${primary.why})`);}

Client options

OptionDefaultNotes
apiKeynonenope_live_... from the dashboard. Omit for demo mode or key-free routes.
baseUrlhttps://api.nope.netA trailing slash is tolerated.
timeout30000Milliseconds per attempt.
demofalseRoute to the unauthenticated /v1/try/* endpoints.
maxRetries2Retries on 429 and 503 only.
fetchglobal fetchInject a wrapped fetch for tracing, or a fake in tests.
sleepsetTimeoutWait between retries; inject in tests.

Demo mode

new NopeClient({ demo: true }) needs no key and routes four methods to the per-IP rate-limited /v1/try/* endpoints:

MethodDemo routeDifferences from the authenticated route
evaluate/v1/try/evaluateKeeps the last 10 messages, honours include_resources, adds metadata.try_endpoint and metadata.model. The route reads config.country; the client also sends the value as config.user_country, the older spelling the route accepts when country is absent. 10 calls per minute per IP.
oversight.analyze/v1/try/oversight/analyzeReturns { mode, result, try_endpoint } (OversightDemoAnalyzeResponse). Ignores config.strategy and config.model, keeps only role and content, accepts at most 20 messages.
ocular/v1/try/ocularReturns OcularDemoResponse, which adds heads and detail under public family names. At most 12 messages or 4,000 characters. With per_turn it returns trajectory but never trajectory_shape.
signpostSmart/v1/try/signpost/smartAdds try_endpoint: true.

Every other method throws NopeValidationError (... is not available in demo mode, code: 'not_available_in_demo', no statusCode) before any request is sent.

Evaluate

client.evaluate(options) costs $0.003 per call. Pass messages (1 to 100, roles user or assistant) or text (a transcript, up to 50,000 characters). config takes the four keys the route reads:

KeyPurpose
countryISO 3166-1 alpha-2 for crisis resources (default US).
include_resourcesSet false to skip resource matching (default true).
conversation_idEchoed on evaluate.alert webhook payloads.
end_user_idEchoed on evaluate.alert webhook payloads as user_id.
import{NopeClient,calculateSpeakerSeverity,typeEvaluateResource}from'@nope-net/sdk';constclient=newNopeClient({apiKey: process.env.NOPE_API_KEY});constresult=awaitclient.evaluate({text: 'Patient expressed hopelessness and mentioned not wanting to continue.',config: {country: 'GB',conversation_id: 'conv_42',end_user_id: 'user_7'},});for(constriskofresult.risks){console.log(risk.type,risk.subject,risk.severity,risk.imminence,risk.features??[]);}// speaker_severity is the highest severity among risks with subject 'self'.console.log(calculateSpeakerSeverity(result.risks)===result.speaker_severity);console.log(result.request_id,result.timestamp,result.metadata?.input_format);// resources.primary and resources.secondary[] are EvaluateResource: a CrisisResource plus `why`.consttop: EvaluateResource|undefined=result.resources?.primary;console.log(top?.name,top?.why);

EvaluateResponse fields: risks: Risk[], rationale, speaker_severity, speaker_imminence, show_resources, resources? (an EvaluateResources: primary and up to three secondary, each an EvaluateResource, which is a CrisisResource plus a one-line why), request_id, timestamp, metadata?.

Risk is { type, subject, severity, imminence, features? }. subject is self or other. Severity runs none | mild | moderate | high | critical; imminence runs not_applicable | chronic | subacute | urgent | emergency. The nine risk types are suicide, self_harm, self_neglect, violence, abuse, sexual_violence, neglect, exploitation and stalking.

Legacy screen()

client.screen() is deprecated: use evaluate() ($0.003 per call). It still calls the legacy /v0/screen endpoint ($0.001 per call), which carries a sunset of 2027-01-01. The client logs one warning per process saying so, and refuses the call in demo mode. ScreenConfig is { country?, debug?, include_recommended_reply? }.

Ocular

client.ocular(options) costs $0.0001 per call and returns a continuous salience score in [0, 1] plus eight user-risk axes under signals.user and four AI-behaviour axes under signals.ai, each { level, score }. Pick the salience cutoff that fits your action; the reference thresholds are 0.30 (watch) and 0.60 (danger).

import{NopeClient}from'@nope-net/sdk';constclient=newNopeClient({apiKey: process.env.NOPE_API_KEY});constresult=awaitclient.ocular({messages: [{role: 'user',content: 'I feel hopeless'},{role: 'assistant',content: 'I am here with you.'},{role: 'user',content: 'Nothing helps any more.'},],per_turn: true,trajectory_stride: 1,// score every turn; the server default of 3 scores every third turn back from the lastsession_id: 'session_9',});console.log(result.salience,result.subject,result.imminence.level);constsuicide=result.signals.user.suicide;if(suicide&&suicide.score>0.5){console.log('escalate');}for(constturnofresult.trajectory??[]){// turn.turn is the 0-based position in messages; AI axes are ai_-prefixed hereconsole.log(turn.turn,turn.role,turn.salience,turn.signals_by_axis?.suicide,turn.signals_by_axis?.ai_manipulation);}constshape=result.trajectory_shape;if(shape?.peak_turn!==undefined&&result.trajectory){constpeak=result.trajectory[shape.peak_turn];// peak_turn indexes the trajectory array, not messagesconsole.log(peak.turn,shape.phases?.[shape.peak_turn],shape.onsets?.suicide);}console.log(result.meta.version);

user_id, session_id and agent_id (1 to 256 characters) are stored for dashboard analytics and never forwarded to the model.

Per-turn trajectory

Set per_turn: true to receive trajectory, one entry per scored turn, and trajectory_shape. Not every turn is scored: trajectory_stride defaults to 3 on the server, which scores the last turn and then every third turn back from it, so a 5-message conversation yields turns 4 and 1. Pass trajectory_stride: 1 to score every turn. An entry's turn is the 0-based position of that turn in messages (for text input, of the parsed speaker turn) and its role is user or assistant. signals_by_axis keys the user axes by bare name (suicide), the AI axes with an ai_ prefix (ai_manipulation, where the top level has signals.ai.manipulation), and adds two context scalars, genuine and fiction.

trajectory_shape summarises the crisis (suicide) axis over the scored turns. onsets maps an axis to the turn value at which it first crossed its onset threshold. phases, slopes and peak_turn index the trajectory array instead: phases[i] and slopes[i] describe trajectory[i], and peak_turn is the array position of the entry with the highest crisis score, so trajectory[shape.peak_turn].turn is the message index. The authenticated route returns trajectory_shape whenever at least one turn was scored; the demo route (/v1/try/ocular) returns trajectory but never trajectory_shape.

Oversight

Oversight analyzes an AI assistant's side of a conversation using 91 behavior codes: 87 harmful and 4 appropriate, across 14 categories (dependency reinforcement, crisis mishandling, manipulation, boundary violations and so on). It requires an account with the feature enabled; analyze costs 100 mills ($0.10) per call and ingest 100 mills per conversation.

import{NopeClient}from'@nope-net/sdk';constclient=newNopeClient({apiKey: process.env.NOPE_API_KEY});const{ result, strategy }=awaitclient.oversight.analyze({conversation: {conversation_id: 'conv_123',messages: [{role: 'user',content: 'Nobody at work listens to me.'},{role: 'assistant',content: "I'm always here and I understand you better than they ever will."},],metadata: {platform: 'companion-app',user_is_minor: false},},bot_context: 'general-purpose assistant for a productivity app',config: {mode: 'fast'},behaviors: {min_severity: 'medium',categories: ['boundary_violations','relationship_harm']},});console.log(strategy,result.mode_used,result.overall_concern,result.trajectory);for(constbehaviorofresult.detected_behaviors){console.log(`${behavior.code} (${behavior.severity} x${behavior.turn_count}): ${behavior.recommendation}`);}
  • config.mode: full (default) or fast. Fast mode uses a quicker model; trajectory is always stable, turn_analysis and human_indicators are empty, and summary and pattern_assessment are absent.
  • config.strategy: single or sliding; auto-selected by length when omitted. Sliding results carry windows (each with message_range and conversation_turn_range), concern_progression, peak_concern, final_concern and inflection_points.
  • behaviors: a post-analysis filter. enabled and disabled are mutually exclusive; codes and categories come from the exported OversightBehaviorCode and OversightBehaviorCategory unions (OVERSIGHT_BEHAVIOR_CODES, OVERSIGHT_BEHAVIOR_CATEGORIES).
  • bot_context is passed into the analysis as conversation metadata, so the analyzer knows what the persona is meant to do.
  • Turn numbers in results count assistant turns from 1.

Batch analysis with dashboard storage takes up to 300 conversations, each with a conversation_id, and returns synchronously. The request body is capped at 5 MB, so a batch near the count limit must consist of short conversations. webhook_url is a legacy per-request callback: the API POSTs an unsigned ingestion_complete JSON summary there when the batch completes. The signed oversight.ingestion.complete event is delivered to webhooks registered with client.webhooks.

import{NopeClient}from'@nope-net/sdk';constclient=newNopeClient({apiKey: process.env.NOPE_API_KEY});constbatch=awaitclient.oversight.ingest({conversations: [{conversation_id: 'conv_001',messages: [{role: 'user',content: 'hi'},{role: 'assistant',content: 'hello'},],},],webhook_url: 'https://api.example.com/webhooks/nope',});console.log(batch.status,`${batch.conversations_processed}/${batch.conversations_received}`,batch.dashboard_url);for(constitemofbatch.results??[]){console.log(item.conversation_id,item.overall_concern,item.truncation_warnings?.map((w)=>w.type));}

Signpost

Crisis resources by country. Filters can be passed at the top level or under config; scope and population values come from the exported ServiceScope and Population unions (SERVICE_SCOPES, POPULATIONS), and the API returns 400 for unknown values.

import{NopeClient}from'@nope-net/sdk';constclient=newNopeClient({apiKey: process.env.NOPE_API_KEY});// Basic lookup (free, key required). urgent: true keeps every match and ranks the// 24/7 ones first among resources tied on relevance and priority tier.constbasic=awaitclient.signpost({country: 'GB',scopes: ['suicide'],subdivisions: ['GB-NIR'],urgent: true});for(constresourceofbasic.resources){console.log(resource.type,resource.name,resource.is_24_7,resource.phone??resource.website_url,resource.open_status?.message);}// Ranked for a described situation ($0.001 per call, up to 5 picks)constranked=awaitclient.signpostSmart({country: 'US',query: 'teen struggling with an eating disorder'});for(constpickofranked.ranked){console.log(`${pick.rank}. ${pick.resource.name}: ${pick.why}`);}// Semantic search across the whole directory (free, key required)consthits=awaitclient.signpostSearch({query: 'lgbtq youth support',country: 'GB',limit: 5});for(consthitofhits.results){console.log(hit.id,hit.name,hit.similarity.toFixed(2),hit.phone??hit.website_url);}// Public routes (no key)constone=awaitclient.signpostById(hits.results[0].id);constcountries=awaitclient.signpostCountries();constgeo=awaitclient.detectCountry({countryHint: 'GB'});console.log(one.resource.name,countries.count,geo.detected ? geo.country_code : 'unknown');

urgent: true is a ranking hint, not a filter: the API keeps every matching resource and, where two resources tie on relevance score and priority tier, places the one flagged is_24_7 first. Resources without 24/7 hours still appear in the list.

detectCountry() reads geo headers a proxy injects (cf-ipcountry, x-country, x-vercel-ip-country). Called directly against api.nope.net it returns the miss shape with detected: false; pass countryHint to send x-country yourself.

signpost() and signpostSearch() need a key and are refused in demo mode. The resources* methods still work against /v1/resources/*, log a one-time deprecation warning, and stop on 2027-01-01.

Webhooks

NOPE signs each delivery with HMAC-SHA256 over "${timestamp}.${body}" and sends X-NOPE-Signature, X-NOPE-Timestamp, X-NOPE-Event, X-NOPE-Delivery-ID and X-NOPE-Webhook-ID. Four events exist:

EventSent whenPayload type
evaluate.alert/v1/evaluate finds risk at or above the webhook's min_risk_levelEvaluateAlertPayload
oversight.alertOversight finds high or critical concernOversightAlertPayload
oversight.ingestion.completeAn ingest batch finishesOversightIngestionCompletePayload
test.pingThe dashboard test button or client.webhooks.test()TestPingPayload

Verify with the raw request body. Configure your framework so req.body is the unparsed string or Buffer; a re-serialised object only matches the signature when key order survived parsing.

import{Webhook,WebhookSignatureError}from'@nope-net/sdk';app.post('/webhooks/nope',(req,res)=>{try{const{ payload, deliveryId }=Webhook.verifyRequest(req.body,req.headers,process.env.NOPE_WEBHOOK_SECRET!);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(err){if(errinstanceofWebhookSignatureError){res.status(401).send('Invalid signature');return;}throwerr;}});

Webhook.verify(body, signature, timestamp, secret, { maxAgeSeconds }) takes the two header values directly. maxAgeSeconds defaults to 300; 0 disables the timestamp check. verifyRequest also returns deliveryId (the X-NOPE-Delivery-ID header: the stored delivery's id, repeated on each of the API's four retry attempts over an hour, so it is the key to deduplicate them), eventType (X-NOPE-Event) and webhookId (X-NOPE-Webhook-ID). deliveryId is not payload.event_id, and it is undefined when the header is absent, as on a body signed locally with Webhook.sign. eventId is a deprecated alias of deliveryId.

For tests, Webhook.sign(body, secret, timestamp?) produces the signature and timestamp the API would send. The optional third argument (unix seconds) fixes the moment the signature is bound to, which is how to exercise the stale-timestamp path:

import{Webhook,WebhookSignatureError}from'@nope-net/sdk';constbody=JSON.stringify({event: 'test.ping',event_id: 'evt_local',timestamp: newDate().toISOString(),api_version: '2025-01',message: 'Webhook configured successfully',});const{ signature, timestamp }=Webhook.sign(body,'whsec_local');constpayload=Webhook.verify(body,signature,timestamp,'whsec_local');console.log(payload.event);conststale=Webhook.sign(body,'whsec_local',Math.floor(Date.now()/1000)-600);try{Webhook.verify(body,stale.signature,stale.timestamp,'whsec_local');}catch(err){console.log(errinstanceofWebhookSignatureError,(errasError).message);// true, "Timestamp too old: 600s ago (max: 300s)"}

Managing webhook endpoints

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

import{NopeClient}from'@nope-net/sdk';constclient=newNopeClient({apiKey: process.env.NOPE_API_KEY});constcreated=awaitclient.webhooks.create({url: 'https://api.example.com/webhooks/nope',min_risk_level: 'high',include_conversation: false,});console.log(created.id,created.secret);// secret is returned onceconstping=awaitclient.webhooks.test(created.id);console.log(ping.success,ping.http_status,ping.duration_ms);// a failed delivery comes back with success: falseconst{ webhooks }=awaitclient.webhooks.list();const{ events }=awaitclient.webhooks.events(created.id,{limit: 10});awaitclient.webhooks.update(created.id,{enabled: false});constrotated=awaitclient.webhooks.regenerateSecret(created.id);awaitclient.webhooks.delete(created.id);console.log(webhooks.length,events.length,rotated.secret.length);

Billing

Amounts are in mills (1 mill = $0.001). pricing() needs no key; the rest need one. All five are refused in demo mode.

import{NopeClient}from'@nope-net/sdk';constclient=newNopeClient({apiKey: process.env.NOPE_API_KEY});constbalance=awaitclient.billing.balance();console.log(balance.balance_formatted,balance.low_balance,balance.estimated_evaluates);constusage=awaitclient.billing.usage({start_date: '2026-09-01'});for(constlineofusage.breakdown){console.log(line.endpoint,line.calls,line.cost_formatted);}consthistory=awaitclient.billing.usageHistory({limit: 20,endpoint: '/v1/evaluate'});console.log(history.total,history.records[0]?.created_at);constpricing=awaitclient.billing.pricing();console.log(pricing.pricing.evaluate.cost_display,pricing.free_credit_display);constcheckout=awaitclient.billing.topup({amount_mills: 10000,success_url: 'https://example.com/billing/ok'});console.log(checkout.checkout_url);

Errors, retries and response metadata

Every error extends NopeError and carries statusCode, code, message, responseBody (the raw response text) and body (the same text parsed, when it was a JSON object).

StatusClassExtra fields
400, 413NopeValidationErrordetails (body extras such as max_bytes, max_messages, invalid_scopes)
401NopeAuthError
402NopeInsufficientBalanceErrorbalanceMills, requiredMills, formattedCurrent, formattedRequired, topupUrl; ingest adds perConversationMills, conversations
403NopeFeatureErrorfeature, requiredAccess, or feature: 'paid_plan' with upgradeUrl
404NopeNotFoundError
429NopeRateLimitErrorretryAfter (seconds), limit, remaining, reset (epoch ms)
503NopeServiceUnavailableErrorretryAfter (seconds); extends NopeServerError
other 5xxNopeServerErrorretryAfter when a header was sent
no responseNopeConnectionErrororiginalError; covers the client-side timeout

Client-side validation (an empty messages array, a role other than user or assistant, more than 100 messages, neither messages nor text, and the per-method checks above) and demo-mode refusals throw NopeValidationError before any request is sent. Those errors have no statusCode; their code is invalid_request or not_available_in_demo, and details is empty.

For API errors, code is present only when the API sends a machine string in the body. Today 402 and 429 always do (insufficient_balance, rate_limit_exceeded), 403 and 503 do on some bodies (paid_plan_required, auth_unavailable), and 400, 401, 404 and 413 carry a sentence, so code is undefined there. Branch on the class or on statusCode, and read code only as extra detail.

import{NopeClient,NopeAuthError,NopeInsufficientBalanceError,NopeRateLimitError,NopeValidationError,NopeServiceUnavailableError,NopeServerError,NopeConnectionError,}from'@nope-net/sdk';constclient=newNopeClient({apiKey: process.env.NOPE_API_KEY,maxRetries: 2});try{constresult=awaitclient.evaluate({text: 'hello',config: {country: 'US'}});console.log(result.speaker_severity);console.log(client.lastResponseMeta?.balance?.costMills,client.lastResponseMeta?.rateLimit?.remaining);}catch(error){if(errorinstanceofNopeInsufficientBalanceError){console.log(`Balance ${error.formattedCurrent}, need ${error.formattedRequired}: ${error.topupUrl}`);}elseif(errorinstanceofNopeRateLimitError){console.log(`Rate limited; retry after ${error.retryAfter} seconds`);}elseif(errorinstanceofNopeValidationError){// statusCode is undefined when the SDK rejected the input before sending (code 'invalid_request').console.log(error.statusCode??'client-side',error.code,error.message,error.details);}elseif(errorinstanceofNopeAuthError){console.log('Invalid API key');}elseif(errorinstanceofNopeServiceUnavailableError){console.log(`Temporarily unavailable; retry after ${error.retryAfter} seconds`);}elseif(errorinstanceofNopeServerError){console.log(`Server error ${error.statusCode}`,error.body?.error??error.responseBody);}elseif(errorinstanceofNopeConnectionError){console.log('No response',error.originalError?.message);}else{throwerror;}}

The client retries 429 and 503 responses up to maxRetries times, waiting Retry-After seconds (falling back to the body's retry_after_seconds, then to 1 s, 2 s, 4 s), capped at 30 s per wait. Timeouts, connection failures and other 5xx are never retried: paid routes charge before the handler runs, so a blind retry could bill twice.

client.lastResponseMeta holds { status, rateLimit, balance } from the most recent response. rateLimit comes from the X-RateLimit-* headers on every route; balance (balanceMills, costMills) is present on paid routes only.

TypeScript

Every request and response type is exported. NopeClient is generic on the demo flag, so oversight.analyze and ocular return the demo response types on a demo client and the authenticated types otherwise. Annotate a client that may be either as NopeClient<boolean>.

importtype{EvaluateResponse,EvaluateResource,Risk,CrisisResource,OcularResponse,OversightAnalyzeResponse,OversightBehaviorCode,SignpostSearchResult,WebhookPayload,ServiceScope,}from'@nope-net/sdk';import{NopeClient}from'@nope-net/sdk';consteitherClient: NopeClient<boolean>=newNopeClient({demo: true});constscope: ServiceScope='domestic_violence';constcode: OversightBehaviorCode='dependency_reinforcement';console.log(typeofeitherClient,scope,code);

Versioning

The package follows Semantic Versioning. 4.0.0 is a breaking release; see CHANGELOG.md for every change from 3.x, including the switch of retryAfter to seconds and the removed response fields.

License

MIT. See LICENSE.

Support

About

Node.js/TypeScript SDK for NOPE API

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

NOPE Node SDK

npm versionNode 18+TypeScriptLicense: MIT

TypeScript client for the NOPE API. NOPE reads conversations between people and AI systems and returns structured safety signals: mental-health and safeguarding risk (Evaluate), behavioral risk scores (Ocular), harmful AI-behaviour analysis (Oversight), and crisis resources by country (Signpost). The SDK also verifies webhook deliveries, manages webhook endpoints, and reads billing.

The package ships ESM and CommonJS builds with type declarations. Every response type is checked against responses captured from the live API (tests/fixtures/), so a field in the types is a field on the wire.

Requirements

  • Node.js 18 or later (the client uses the built-in fetch).
  • An API key from dashboard.nope.net for the paid and key-gated routes. Demo mode (below) covers evaluate, ocular, oversight.analyze and signpostSmart without a key.

Installation

npm install @nope-net/sdk
# or
pnpm add @nope-net/sdk

Quick start

import{NopeClient}from'@nope-net/sdk';constclient=newNopeClient({apiKey: process.env.NOPE_API_KEY});constresult=awaitclient.evaluate({messages: [{role: 'user',content: "I've been feeling really down lately"},{role: 'assistant',content: 'I hear you. Can you tell me more?'},{role: 'user',content: "I just don't see the point anymore"},],config: {country: 'US'},});console.log(result.speaker_severity,result.speaker_imminence);console.log(result.rationale);if(result.show_resources&&result.resources){const{ primary }=result.resources;console.log(`${primary.name}: ${primary.phone??primary.website_url} (${primary.why})`);}

Client options

OptionDefaultNotes
apiKeynonenope_live_... from the dashboard. Omit for demo mode or key-free routes.
baseUrlhttps://api.nope.netA trailing slash is tolerated.
timeout30000Milliseconds per attempt.
demofalseRoute to the unauthenticated /v1/try/* endpoints.
maxRetries2Retries on 429 and 503 only.
fetchglobal fetchInject a wrapped fetch for tracing, or a fake in tests.
sleepsetTimeoutWait between retries; inject in tests.

Demo mode

new NopeClient({ demo: true }) needs no key and routes four methods to the per-IP rate-limited /v1/try/* endpoints:

MethodDemo routeDifferences from the authenticated route
evaluate/v1/try/evaluateKeeps the last 10 messages, honours include_resources, adds metadata.try_endpoint and metadata.model. The route reads config.country; the client also sends the value as config.user_country, the older spelling the route accepts when country is absent. 10 calls per minute per IP.
oversight.analyze/v1/try/oversight/analyzeReturns { mode, result, try_endpoint } (OversightDemoAnalyzeResponse). Ignores config.strategy and config.model, keeps only role and content, accepts at most 20 messages.
ocular/v1/try/ocularReturns OcularDemoResponse, which adds heads and detail under public family names. At most 12 messages or 4,000 characters. With per_turn it returns trajectory but never trajectory_shape.
signpostSmart/v1/try/signpost/smartAdds try_endpoint: true.

Every other method throws NopeValidationError (... is not available in demo mode, code: 'not_available_in_demo', no statusCode) before any request is sent.

Evaluate

client.evaluate(options) costs $0.003 per call. Pass messages (1 to 100, roles user or assistant) or text (a transcript, up to 50,000 characters). config takes the four keys the route reads:

KeyPurpose
countryISO 3166-1 alpha-2 for crisis resources (default US).
include_resourcesSet false to skip resource matching (default true).
conversation_idEchoed on evaluate.alert webhook payloads.
end_user_idEchoed on evaluate.alert webhook payloads as user_id.
import{NopeClient,calculateSpeakerSeverity,typeEvaluateResource}from'@nope-net/sdk';constclient=newNopeClient({apiKey: process.env.NOPE_API_KEY});constresult=awaitclient.evaluate({text: 'Patient expressed hopelessness and mentioned not wanting to continue.',config: {country: 'GB',conversation_id: 'conv_42',end_user_id: 'user_7'},});for(constriskofresult.risks){console.log(risk.type,risk.subject,risk.severity,risk.imminence,risk.features??[]);}// speaker_severity is the highest severity among risks with subject 'self'.console.log(calculateSpeakerSeverity(result.risks)===result.speaker_severity);console.log(result.request_id,result.timestamp,result.metadata?.input_format);// resources.primary and resources.secondary[] are EvaluateResource: a CrisisResource plus `why`.consttop: EvaluateResource|undefined=result.resources?.primary;console.log(top?.name,top?.why);

EvaluateResponse fields: risks: Risk[], rationale, speaker_severity, speaker_imminence, show_resources, resources? (an EvaluateResources: primary and up to three secondary, each an EvaluateResource, which is a CrisisResource plus a one-line why), request_id, timestamp, metadata?.

Risk is { type, subject, severity, imminence, features? }. subject is self or other. Severity runs none | mild | moderate | high | critical; imminence runs not_applicable | chronic | subacute | urgent | emergency. The nine risk types are suicide, self_harm, self_neglect, violence, abuse, sexual_violence, neglect, exploitation and stalking.

Legacy screen()

client.screen() is deprecated: use evaluate() ($0.003 per call). It still calls the legacy /v0/screen endpoint ($0.001 per call), which carries a sunset of 2027-01-01. The client logs one warning per process saying so, and refuses the call in demo mode. ScreenConfig is { country?, debug?, include_recommended_reply? }.

Ocular

client.ocular(options) costs $0.0001 per call and returns a continuous salience score in [0, 1] plus eight user-risk axes under signals.user and four AI-behaviour axes under signals.ai, each { level, score }. Pick the salience cutoff that fits your action; the reference thresholds are 0.30 (watch) and 0.60 (danger).

import{NopeClient}from'@nope-net/sdk';constclient=newNopeClient({apiKey: process.env.NOPE_API_KEY});constresult=awaitclient.ocular({messages: [{role: 'user',content: 'I feel hopeless'},{role: 'assistant',content: 'I am here with you.'},{role: 'user',content: 'Nothing helps any more.'},],per_turn: true,trajectory_stride: 1,// score every turn; the server default of 3 scores every third turn back from the lastsession_id: 'session_9',});console.log(result.salience,result.subject,result.imminence.level);constsuicide=result.signals.user.suicide;if(suicide&&suicide.score>0.5){console.log('escalate');}for(constturnofresult.trajectory??[]){// turn.turn is the 0-based position in messages; AI axes are ai_-prefixed hereconsole.log(turn.turn,turn.role,turn.salience,turn.signals_by_axis?.suicide,turn.signals_by_axis?.ai_manipulation);}constshape=result.trajectory_shape;if(shape?.peak_turn!==undefined&&result.trajectory){constpeak=result.trajectory[shape.peak_turn];// peak_turn indexes the trajectory array, not messagesconsole.log(peak.turn,shape.phases?.[shape.peak_turn],shape.onsets?.suicide);}console.log(result.meta.version);

user_id, session_id and agent_id (1 to 256 characters) are stored for dashboard analytics and never forwarded to the model.

Per-turn trajectory

Set per_turn: true to receive trajectory, one entry per scored turn, and trajectory_shape. Not every turn is scored: trajectory_stride defaults to 3 on the server, which scores the last turn and then every third turn back from it, so a 5-message conversation yields turns 4 and 1. Pass trajectory_stride: 1 to score every turn. An entry's turn is the 0-based position of that turn in messages (for text input, of the parsed speaker turn) and its role is user or assistant. signals_by_axis keys the user axes by bare name (suicide), the AI axes with an ai_ prefix (ai_manipulation, where the top level has signals.ai.manipulation), and adds two context scalars, genuine and fiction.

trajectory_shape summarises the crisis (suicide) axis over the scored turns. onsets maps an axis to the turn value at which it first crossed its onset threshold. phases, slopes and peak_turn index the trajectory array instead: phases[i] and slopes[i] describe trajectory[i], and peak_turn is the array position of the entry with the highest crisis score, so trajectory[shape.peak_turn].turn is the message index. The authenticated route returns trajectory_shape whenever at least one turn was scored; the demo route (/v1/try/ocular) returns trajectory but never trajectory_shape.

Oversight

Oversight analyzes an AI assistant's side of a conversation using 91 behavior codes: 87 harmful and 4 appropriate, across 14 categories (dependency reinforcement, crisis mishandling, manipulation, boundary violations and so on). It requires an account with the feature enabled; analyze costs 100 mills ($0.10) per call and ingest 100 mills per conversation.

import{NopeClient}from'@nope-net/sdk';constclient=newNopeClient({apiKey: process.env.NOPE_API_KEY});const{ result, strategy }=awaitclient.oversight.analyze({conversation: {conversation_id: 'conv_123',messages: [{role: 'user',content: 'Nobody at work listens to me.'},{role: 'assistant',content: "I'm always here and I understand you better than they ever will."},],metadata: {platform: 'companion-app',user_is_minor: false},},bot_context: 'general-purpose assistant for a productivity app',config: {mode: 'fast'},behaviors: {min_severity: 'medium',categories: ['boundary_violations','relationship_harm']},});console.log(strategy,result.mode_used,result.overall_concern,result.trajectory);for(constbehaviorofresult.detected_behaviors){console.log(`${behavior.code} (${behavior.severity} x${behavior.turn_count}): ${behavior.recommendation}`);}
  • config.mode: full (default) or fast. Fast mode uses a quicker model; trajectory is always stable, turn_analysis and human_indicators are empty, and summary and pattern_assessment are absent.
  • config.strategy: single or sliding; auto-selected by length when omitted. Sliding results carry windows (each with message_range and conversation_turn_range), concern_progression, peak_concern, final_concern and inflection_points.
  • behaviors: a post-analysis filter. enabled and disabled are mutually exclusive; codes and categories come from the exported OversightBehaviorCode and OversightBehaviorCategory unions (OVERSIGHT_BEHAVIOR_CODES, OVERSIGHT_BEHAVIOR_CATEGORIES).
  • bot_context is passed into the analysis as conversation metadata, so the analyzer knows what the persona is meant to do.
  • Turn numbers in results count assistant turns from 1.

Batch analysis with dashboard storage takes up to 300 conversations, each with a conversation_id, and returns synchronously. The request body is capped at 5 MB, so a batch near the count limit must consist of short conversations. webhook_url is a legacy per-request callback: the API POSTs an unsigned ingestion_complete JSON summary there when the batch completes. The signed oversight.ingestion.complete event is delivered to webhooks registered with client.webhooks.

import{NopeClient}from'@nope-net/sdk';constclient=newNopeClient({apiKey: process.env.NOPE_API_KEY});constbatch=awaitclient.oversight.ingest({conversations: [{conversation_id: 'conv_001',messages: [{role: 'user',content: 'hi'},{role: 'assistant',content: 'hello'},],},],webhook_url: 'https://api.example.com/webhooks/nope',});console.log(batch.status,`${batch.conversations_processed}/${batch.conversations_received}`,batch.dashboard_url);for(constitemofbatch.results??[]){console.log(item.conversation_id,item.overall_concern,item.truncation_warnings?.map((w)=>w.type));}

Signpost

Crisis resources by country. Filters can be passed at the top level or under config; scope and population values come from the exported ServiceScope and Population unions (SERVICE_SCOPES, POPULATIONS), and the API returns 400 for unknown values.

import{NopeClient}from'@nope-net/sdk';constclient=newNopeClient({apiKey: process.env.NOPE_API_KEY});// Basic lookup (free, key required). urgent: true keeps every match and ranks the// 24/7 ones first among resources tied on relevance and priority tier.constbasic=awaitclient.signpost({country: 'GB',scopes: ['suicide'],subdivisions: ['GB-NIR'],urgent: true});for(constresourceofbasic.resources){console.log(resource.type,resource.name,resource.is_24_7,resource.phone??resource.website_url,resource.open_status?.message);}// Ranked for a described situation ($0.001 per call, up to 5 picks)constranked=awaitclient.signpostSmart({country: 'US',query: 'teen struggling with an eating disorder'});for(constpickofranked.ranked){console.log(`${pick.rank}. ${pick.resource.name}: ${pick.why}`);}// Semantic search across the whole directory (free, key required)consthits=awaitclient.signpostSearch({query: 'lgbtq youth support',country: 'GB',limit: 5});for(consthitofhits.results){console.log(hit.id,hit.name,hit.similarity.toFixed(2),hit.phone??hit.website_url);}// Public routes (no key)constone=awaitclient.signpostById(hits.results[0].id);constcountries=awaitclient.signpostCountries();constgeo=awaitclient.detectCountry({countryHint: 'GB'});console.log(one.resource.name,countries.count,geo.detected ? geo.country_code : 'unknown');

urgent: true is a ranking hint, not a filter: the API keeps every matching resource and, where two resources tie on relevance score and priority tier, places the one flagged is_24_7 first. Resources without 24/7 hours still appear in the list.

detectCountry() reads geo headers a proxy injects (cf-ipcountry, x-country, x-vercel-ip-country). Called directly against api.nope.net it returns the miss shape with detected: false; pass countryHint to send x-country yourself.

signpost() and signpostSearch() need a key and are refused in demo mode. The resources* methods still work against /v1/resources/*, log a one-time deprecation warning, and stop on 2027-01-01.

Webhooks

NOPE signs each delivery with HMAC-SHA256 over "${timestamp}.${body}" and sends X-NOPE-Signature, X-NOPE-Timestamp, X-NOPE-Event, X-NOPE-Delivery-ID and X-NOPE-Webhook-ID. Four events exist:

EventSent whenPayload type
evaluate.alert/v1/evaluate finds risk at or above the webhook's min_risk_levelEvaluateAlertPayload
oversight.alertOversight finds high or critical concernOversightAlertPayload
oversight.ingestion.completeAn ingest batch finishesOversightIngestionCompletePayload
test.pingThe dashboard test button or client.webhooks.test()TestPingPayload

Verify with the raw request body. Configure your framework so req.body is the unparsed string or Buffer; a re-serialised object only matches the signature when key order survived parsing.

import{Webhook,WebhookSignatureError}from'@nope-net/sdk';app.post('/webhooks/nope',(req,res)=>{try{const{ payload, deliveryId }=Webhook.verifyRequest(req.body,req.headers,process.env.NOPE_WEBHOOK_SECRET!);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(err){if(errinstanceofWebhookSignatureError){res.status(401).send('Invalid signature');return;}throwerr;}});

Webhook.verify(body, signature, timestamp, secret, { maxAgeSeconds }) takes the two header values directly. maxAgeSeconds defaults to 300; 0 disables the timestamp check. verifyRequest also returns deliveryId (the X-NOPE-Delivery-ID header: the stored delivery's id, repeated on each of the API's four retry attempts over an hour, so it is the key to deduplicate them), eventType (X-NOPE-Event) and webhookId (X-NOPE-Webhook-ID). deliveryId is not payload.event_id, and it is undefined when the header is absent, as on a body signed locally with Webhook.sign. eventId is a deprecated alias of deliveryId.

For tests, Webhook.sign(body, secret, timestamp?) produces the signature and timestamp the API would send. The optional third argument (unix seconds) fixes the moment the signature is bound to, which is how to exercise the stale-timestamp path:

import{Webhook,WebhookSignatureError}from'@nope-net/sdk';constbody=JSON.stringify({event: 'test.ping',event_id: 'evt_local',timestamp: newDate().toISOString(),api_version: '2025-01',message: 'Webhook configured successfully',});const{ signature, timestamp }=Webhook.sign(body,'whsec_local');constpayload=Webhook.verify(body,signature,timestamp,'whsec_local');console.log(payload.event);conststale=Webhook.sign(body,'whsec_local',Math.floor(Date.now()/1000)-600);try{Webhook.verify(body,stale.signature,stale.timestamp,'whsec_local');}catch(err){console.log(errinstanceofWebhookSignatureError,(errasError).message);// true, "Timestamp too old: 600s ago (max: 300s)"}

Managing webhook endpoints

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

import{NopeClient}from'@nope-net/sdk';constclient=newNopeClient({apiKey: process.env.NOPE_API_KEY});constcreated=awaitclient.webhooks.create({url: 'https://api.example.com/webhooks/nope',min_risk_level: 'high',include_conversation: false,});console.log(created.id,created.secret);// secret is returned onceconstping=awaitclient.webhooks.test(created.id);console.log(ping.success,ping.http_status,ping.duration_ms);// a failed delivery comes back with success: falseconst{ webhooks }=awaitclient.webhooks.list();const{ events }=awaitclient.webhooks.events(created.id,{limit: 10});awaitclient.webhooks.update(created.id,{enabled: false});constrotated=awaitclient.webhooks.regenerateSecret(created.id);awaitclient.webhooks.delete(created.id);console.log(webhooks.length,events.length,rotated.secret.length);

Billing

Amounts are in mills (1 mill = $0.001). pricing() needs no key; the rest need one. All five are refused in demo mode.

import{NopeClient}from'@nope-net/sdk';constclient=newNopeClient({apiKey: process.env.NOPE_API_KEY});constbalance=awaitclient.billing.balance();console.log(balance.balance_formatted,balance.low_balance,balance.estimated_evaluates);constusage=awaitclient.billing.usage({start_date: '2026-09-01'});for(constlineofusage.breakdown){console.log(line.endpoint,line.calls,line.cost_formatted);}consthistory=awaitclient.billing.usageHistory({limit: 20,endpoint: '/v1/evaluate'});console.log(history.total,history.records[0]?.created_at);constpricing=awaitclient.billing.pricing();console.log(pricing.pricing.evaluate.cost_display,pricing.free_credit_display);constcheckout=awaitclient.billing.topup({amount_mills: 10000,success_url: 'https://example.com/billing/ok'});console.log(checkout.checkout_url);

Errors, retries and response metadata

Every error extends NopeError and carries statusCode, code, message, responseBody (the raw response text) and body (the same text parsed, when it was a JSON object).

StatusClassExtra fields
400, 413NopeValidationErrordetails (body extras such as max_bytes, max_messages, invalid_scopes)
401NopeAuthError
402NopeInsufficientBalanceErrorbalanceMills, requiredMills, formattedCurrent, formattedRequired, topupUrl; ingest adds perConversationMills, conversations
403NopeFeatureErrorfeature, requiredAccess, or feature: 'paid_plan' with upgradeUrl
404NopeNotFoundError
429NopeRateLimitErrorretryAfter (seconds), limit, remaining, reset (epoch ms)
503NopeServiceUnavailableErrorretryAfter (seconds); extends NopeServerError
other 5xxNopeServerErrorretryAfter when a header was sent
no responseNopeConnectionErrororiginalError; covers the client-side timeout

Client-side validation (an empty messages array, a role other than user or assistant, more than 100 messages, neither messages nor text, and the per-method checks above) and demo-mode refusals throw NopeValidationError before any request is sent. Those errors have no statusCode; their code is invalid_request or not_available_in_demo, and details is empty.

For API errors, code is present only when the API sends a machine string in the body. Today 402 and 429 always do (insufficient_balance, rate_limit_exceeded), 403 and 503 do on some bodies (paid_plan_required, auth_unavailable), and 400, 401, 404 and 413 carry a sentence, so code is undefined there. Branch on the class or on statusCode, and read code only as extra detail.

import{NopeClient,NopeAuthError,NopeInsufficientBalanceError,NopeRateLimitError,NopeValidationError,NopeServiceUnavailableError,NopeServerError,NopeConnectionError,}from'@nope-net/sdk';constclient=newNopeClient({apiKey: process.env.NOPE_API_KEY,maxRetries: 2});try{constresult=awaitclient.evaluate({text: 'hello',config: {country: 'US'}});console.log(result.speaker_severity);console.log(client.lastResponseMeta?.balance?.costMills,client.lastResponseMeta?.rateLimit?.remaining);}catch(error){if(errorinstanceofNopeInsufficientBalanceError){console.log(`Balance ${error.formattedCurrent}, need ${error.formattedRequired}: ${error.topupUrl}`);}elseif(errorinstanceofNopeRateLimitError){console.log(`Rate limited; retry after ${error.retryAfter} seconds`);}elseif(errorinstanceofNopeValidationError){// statusCode is undefined when the SDK rejected the input before sending (code 'invalid_request').console.log(error.statusCode??'client-side',error.code,error.message,error.details);}elseif(errorinstanceofNopeAuthError){console.log('Invalid API key');}elseif(errorinstanceofNopeServiceUnavailableError){console.log(`Temporarily unavailable; retry after ${error.retryAfter} seconds`);}elseif(errorinstanceofNopeServerError){console.log(`Server error ${error.statusCode}`,error.body?.error??error.responseBody);}elseif(errorinstanceofNopeConnectionError){console.log('No response',error.originalError?.message);}else{throwerror;}}

The client retries 429 and 503 responses up to maxRetries times, waiting Retry-After seconds (falling back to the body's retry_after_seconds, then to 1 s, 2 s, 4 s), capped at 30 s per wait. Timeouts, connection failures and other 5xx are never retried: paid routes charge before the handler runs, so a blind retry could bill twice.

client.lastResponseMeta holds { status, rateLimit, balance } from the most recent response. rateLimit comes from the X-RateLimit-* headers on every route; balance (balanceMills, costMills) is present on paid routes only.

TypeScript

Every request and response type is exported. NopeClient is generic on the demo flag, so oversight.analyze and ocular return the demo response types on a demo client and the authenticated types otherwise. Annotate a client that may be either as NopeClient<boolean>.

importtype{EvaluateResponse,EvaluateResource,Risk,CrisisResource,OcularResponse,OversightAnalyzeResponse,OversightBehaviorCode,SignpostSearchResult,WebhookPayload,ServiceScope,}from'@nope-net/sdk';import{NopeClient}from'@nope-net/sdk';consteitherClient: NopeClient<boolean>=newNopeClient({demo: true});constscope: ServiceScope='domestic_violence';constcode: OversightBehaviorCode='dependency_reinforcement';console.log(typeofeitherClient,scope,code);

Versioning

The package follows Semantic Versioning. 4.0.0 is a breaking release; see CHANGELOG.md for every change from 3.x, including the switch of retryAfter to seconds and the removed response fields.

License

MIT. See LICENSE.

Support

About

Node.js/TypeScript SDK for NOPE API

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

NOPE Node SDK

npm versionNode 18+TypeScriptLicense: MIT

TypeScript client for the NOPE API. NOPE reads conversations between people and AI systems and returns structured safety signals: mental-health and safeguarding risk (Evaluate), behavioral risk scores (Ocular), harmful AI-behaviour analysis (Oversight), and crisis resources by country (Signpost). The SDK also verifies webhook deliveries, manages webhook endpoints, and reads billing.

The package ships ESM and CommonJS builds with type declarations. Every response type is checked against responses captured from the live API (tests/fixtures/), so a field in the types is a field on the wire.

Requirements

  • Node.js 18 or later (the client uses the built-in fetch).
  • An API key from dashboard.nope.net for the paid and key-gated routes. Demo mode (below) covers evaluate, ocular, oversight.analyze and signpostSmart without a key.

Installation

npm install @nope-net/sdk
# or
pnpm add @nope-net/sdk

Quick start

import{NopeClient}from'@nope-net/sdk';constclient=newNopeClient({apiKey: process.env.NOPE_API_KEY});constresult=awaitclient.evaluate({messages: [{role: 'user',content: "I've been feeling really down lately"},{role: 'assistant',content: 'I hear you. Can you tell me more?'},{role: 'user',content: "I just don't see the point anymore"},],config: {country: 'US'},});console.log(result.speaker_severity,result.speaker_imminence);console.log(result.rationale);if(result.show_resources&&result.resources){const{ primary }=result.resources;console.log(`${primary.name}: ${primary.phone??primary.website_url} (${primary.why})`);}

Client options

OptionDefaultNotes
apiKeynonenope_live_... from the dashboard. Omit for demo mode or key-free routes.
baseUrlhttps://api.nope.netA trailing slash is tolerated.
timeout30000Milliseconds per attempt.
demofalseRoute to the unauthenticated /v1/try/* endpoints.
maxRetries2Retries on 429 and 503 only.
fetchglobal fetchInject a wrapped fetch for tracing, or a fake in tests.
sleepsetTimeoutWait between retries; inject in tests.

Demo mode

new NopeClient({ demo: true }) needs no key and routes four methods to the per-IP rate-limited /v1/try/* endpoints:

MethodDemo routeDifferences from the authenticated route
evaluate/v1/try/evaluateKeeps the last 10 messages, honours include_resources, adds metadata.try_endpoint and metadata.model. The route reads config.country; the client also sends the value as config.user_country, the older spelling the route accepts when country is absent. 10 calls per minute per IP.
oversight.analyze/v1/try/oversight/analyzeReturns { mode, result, try_endpoint } (OversightDemoAnalyzeResponse). Ignores config.strategy and config.model, keeps only role and content, accepts at most 20 messages.
ocular/v1/try/ocularReturns OcularDemoResponse, which adds heads and detail under public family names. At most 12 messages or 4,000 characters. With per_turn it returns trajectory but never trajectory_shape.
signpostSmart/v1/try/signpost/smartAdds try_endpoint: true.

Every other method throws NopeValidationError (... is not available in demo mode, code: 'not_available_in_demo', no statusCode) before any request is sent.

Evaluate

client.evaluate(options) costs $0.003 per call. Pass messages (1 to 100, roles user or assistant) or text (a transcript, up to 50,000 characters). config takes the four keys the route reads:

KeyPurpose
countryISO 3166-1 alpha-2 for crisis resources (default US).
include_resourcesSet false to skip resource matching (default true).
conversation_idEchoed on evaluate.alert webhook payloads.
end_user_idEchoed on evaluate.alert webhook payloads as user_id.
import{NopeClient,calculateSpeakerSeverity,typeEvaluateResource}from'@nope-net/sdk';constclient=newNopeClient({apiKey: process.env.NOPE_API_KEY});constresult=awaitclient.evaluate({text: 'Patient expressed hopelessness and mentioned not wanting to continue.',config: {country: 'GB',conversation_id: 'conv_42',end_user_id: 'user_7'},});for(constriskofresult.risks){console.log(risk.type,risk.subject,risk.severity,risk.imminence,risk.features??[]);}// speaker_severity is the highest severity among risks with subject 'self'.console.log(calculateSpeakerSeverity(result.risks)===result.speaker_severity);console.log(result.request_id,result.timestamp,result.metadata?.input_format);// resources.primary and resources.secondary[] are EvaluateResource: a CrisisResource plus `why`.consttop: EvaluateResource|undefined=result.resources?.primary;console.log(top?.name,top?.why);

EvaluateResponse fields: risks: Risk[], rationale, speaker_severity, speaker_imminence, show_resources, resources? (an EvaluateResources: primary and up to three secondary, each an EvaluateResource, which is a CrisisResource plus a one-line why), request_id, timestamp, metadata?.

Risk is { type, subject, severity, imminence, features? }. subject is self or other. Severity runs none | mild | moderate | high | critical; imminence runs not_applicable | chronic | subacute | urgent | emergency. The nine risk types are suicide, self_harm, self_neglect, violence, abuse, sexual_violence, neglect, exploitation and stalking.

Legacy screen()

client.screen() is deprecated: use evaluate() ($0.003 per call). It still calls the legacy /v0/screen endpoint ($0.001 per call), which carries a sunset of 2027-01-01. The client logs one warning per process saying so, and refuses the call in demo mode. ScreenConfig is { country?, debug?, include_recommended_reply? }.

Ocular

client.ocular(options) costs $0.0001 per call and returns a continuous salience score in [0, 1] plus eight user-risk axes under signals.user and four AI-behaviour axes under signals.ai, each { level, score }. Pick the salience cutoff that fits your action; the reference thresholds are 0.30 (watch) and 0.60 (danger).

import{NopeClient}from'@nope-net/sdk';constclient=newNopeClient({apiKey: process.env.NOPE_API_KEY});constresult=awaitclient.ocular({messages: [{role: 'user',content: 'I feel hopeless'},{role: 'assistant',content: 'I am here with you.'},{role: 'user',content: 'Nothing helps any more.'},],per_turn: true,trajectory_stride: 1,// score every turn; the server default of 3 scores every third turn back from the lastsession_id: 'session_9',});console.log(result.salience,result.subject,result.imminence.level);constsuicide=result.signals.user.suicide;if(suicide&&suicide.score>0.5){console.log('escalate');}for(constturnofresult.trajectory??[]){// turn.turn is the 0-based position in messages; AI axes are ai_-prefixed hereconsole.log(turn.turn,turn.role,turn.salience,turn.signals_by_axis?.suicide,turn.signals_by_axis?.ai_manipulation);}constshape=result.trajectory_shape;if(shape?.peak_turn!==undefined&&result.trajectory){constpeak=result.trajectory[shape.peak_turn];// peak_turn indexes the trajectory array, not messagesconsole.log(peak.turn,shape.phases?.[shape.peak_turn],shape.onsets?.suicide);}console.log(result.meta.version);

user_id, session_id and agent_id (1 to 256 characters) are stored for dashboard analytics and never forwarded to the model.

Per-turn trajectory

Set per_turn: true to receive trajectory, one entry per scored turn, and trajectory_shape. Not every turn is scored: trajectory_stride defaults to 3 on the server, which scores the last turn and then every third turn back from it, so a 5-message conversation yields turns 4 and 1. Pass trajectory_stride: 1 to score every turn. An entry's turn is the 0-based position of that turn in messages (for text input, of the parsed speaker turn) and its role is user or assistant. signals_by_axis keys the user axes by bare name (suicide), the AI axes with an ai_ prefix (ai_manipulation, where the top level has signals.ai.manipulation), and adds two context scalars, genuine and fiction.

trajectory_shape summarises the crisis (suicide) axis over the scored turns. onsets maps an axis to the turn value at which it first crossed its onset threshold. phases, slopes and peak_turn index the trajectory array instead: phases[i] and slopes[i] describe trajectory[i], and peak_turn is the array position of the entry with the highest crisis score, so trajectory[shape.peak_turn].turn is the message index. The authenticated route returns trajectory_shape whenever at least one turn was scored; the demo route (/v1/try/ocular) returns trajectory but never trajectory_shape.

Oversight

Oversight analyzes an AI assistant's side of a conversation using 91 behavior codes: 87 harmful and 4 appropriate, across 14 categories (dependency reinforcement, crisis mishandling, manipulation, boundary violations and so on). It requires an account with the feature enabled; analyze costs 100 mills ($0.10) per call and ingest 100 mills per conversation.

import{NopeClient}from'@nope-net/sdk';constclient=newNopeClient({apiKey: process.env.NOPE_API_KEY});const{ result, strategy }=awaitclient.oversight.analyze({conversation: {conversation_id: 'conv_123',messages: [{role: 'user',content: 'Nobody at work listens to me.'},{role: 'assistant',content: "I'm always here and I understand you better than they ever will."},],metadata: {platform: 'companion-app',user_is_minor: false},},bot_context: 'general-purpose assistant for a productivity app',config: {mode: 'fast'},behaviors: {min_severity: 'medium',categories: ['boundary_violations','relationship_harm']},});console.log(strategy,result.mode_used,result.overall_concern,result.trajectory);for(constbehaviorofresult.detected_behaviors){console.log(`${behavior.code} (${behavior.severity} x${behavior.turn_count}): ${behavior.recommendation}`);}
  • config.mode: full (default) or fast. Fast mode uses a quicker model; trajectory is always stable, turn_analysis and human_indicators are empty, and summary and pattern_assessment are absent.
  • config.strategy: single or sliding; auto-selected by length when omitted. Sliding results carry windows (each with message_range and conversation_turn_range), concern_progression, peak_concern, final_concern and inflection_points.
  • behaviors: a post-analysis filter. enabled and disabled are mutually exclusive; codes and categories come from the exported OversightBehaviorCode and OversightBehaviorCategory unions (OVERSIGHT_BEHAVIOR_CODES, OVERSIGHT_BEHAVIOR_CATEGORIES).
  • bot_context is passed into the analysis as conversation metadata, so the analyzer knows what the persona is meant to do.
  • Turn numbers in results count assistant turns from 1.

Batch analysis with dashboard storage takes up to 300 conversations, each with a conversation_id, and returns synchronously. The request body is capped at 5 MB, so a batch near the count limit must consist of short conversations. webhook_url is a legacy per-request callback: the API POSTs an unsigned ingestion_complete JSON summary there when the batch completes. The signed oversight.ingestion.complete event is delivered to webhooks registered with client.webhooks.

import{NopeClient}from'@nope-net/sdk';constclient=newNopeClient({apiKey: process.env.NOPE_API_KEY});constbatch=awaitclient.oversight.ingest({conversations: [{conversation_id: 'conv_001',messages: [{role: 'user',content: 'hi'},{role: 'assistant',content: 'hello'},],},],webhook_url: 'https://api.example.com/webhooks/nope',});console.log(batch.status,`${batch.conversations_processed}/${batch.conversations_received}`,batch.dashboard_url);for(constitemofbatch.results??[]){console.log(item.conversation_id,item.overall_concern,item.truncation_warnings?.map((w)=>w.type));}

Signpost

Crisis resources by country. Filters can be passed at the top level or under config; scope and population values come from the exported ServiceScope and Population unions (SERVICE_SCOPES, POPULATIONS), and the API returns 400 for unknown values.

import{NopeClient}from'@nope-net/sdk';constclient=newNopeClient({apiKey: process.env.NOPE_API_KEY});// Basic lookup (free, key required). urgent: true keeps every match and ranks the// 24/7 ones first among resources tied on relevance and priority tier.constbasic=awaitclient.signpost({country: 'GB',scopes: ['suicide'],subdivisions: ['GB-NIR'],urgent: true});for(constresourceofbasic.resources){console.log(resource.type,resource.name,resource.is_24_7,resource.phone??resource.website_url,resource.open_status?.message);}// Ranked for a described situation ($0.001 per call, up to 5 picks)constranked=awaitclient.signpostSmart({country: 'US',query: 'teen struggling with an eating disorder'});for(constpickofranked.ranked){console.log(`${pick.rank}. ${pick.resource.name}: ${pick.why}`);}// Semantic search across the whole directory (free, key required)consthits=awaitclient.signpostSearch({query: 'lgbtq youth support',country: 'GB',limit: 5});for(consthitofhits.results){console.log(hit.id,hit.name,hit.similarity.toFixed(2),hit.phone??hit.website_url);}// Public routes (no key)constone=awaitclient.signpostById(hits.results[0].id);constcountries=awaitclient.signpostCountries();constgeo=awaitclient.detectCountry({countryHint: 'GB'});console.log(one.resource.name,countries.count,geo.detected ? geo.country_code : 'unknown');

urgent: true is a ranking hint, not a filter: the API keeps every matching resource and, where two resources tie on relevance score and priority tier, places the one flagged is_24_7 first. Resources without 24/7 hours still appear in the list.

detectCountry() reads geo headers a proxy injects (cf-ipcountry, x-country, x-vercel-ip-country). Called directly against api.nope.net it returns the miss shape with detected: false; pass countryHint to send x-country yourself.

signpost() and signpostSearch() need a key and are refused in demo mode. The resources* methods still work against /v1/resources/*, log a one-time deprecation warning, and stop on 2027-01-01.

Webhooks

NOPE signs each delivery with HMAC-SHA256 over "${timestamp}.${body}" and sends X-NOPE-Signature, X-NOPE-Timestamp, X-NOPE-Event, X-NOPE-Delivery-ID and X-NOPE-Webhook-ID. Four events exist:

EventSent whenPayload type
evaluate.alert/v1/evaluate finds risk at or above the webhook's min_risk_levelEvaluateAlertPayload
oversight.alertOversight finds high or critical concernOversightAlertPayload
oversight.ingestion.completeAn ingest batch finishesOversightIngestionCompletePayload
test.pingThe dashboard test button or client.webhooks.test()TestPingPayload

Verify with the raw request body. Configure your framework so req.body is the unparsed string or Buffer; a re-serialised object only matches the signature when key order survived parsing.

import{Webhook,WebhookSignatureError}from'@nope-net/sdk';app.post('/webhooks/nope',(req,res)=>{try{const{ payload, deliveryId }=Webhook.verifyRequest(req.body,req.headers,process.env.NOPE_WEBHOOK_SECRET!);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(err){if(errinstanceofWebhookSignatureError){res.status(401).send('Invalid signature');return;}throwerr;}});

Webhook.verify(body, signature, timestamp, secret, { maxAgeSeconds }) takes the two header values directly. maxAgeSeconds defaults to 300; 0 disables the timestamp check. verifyRequest also returns deliveryId (the X-NOPE-Delivery-ID header: the stored delivery's id, repeated on each of the API's four retry attempts over an hour, so it is the key to deduplicate them), eventType (X-NOPE-Event) and webhookId (X-NOPE-Webhook-ID). deliveryId is not payload.event_id, and it is undefined when the header is absent, as on a body signed locally with Webhook.sign. eventId is a deprecated alias of deliveryId.

For tests, Webhook.sign(body, secret, timestamp?) produces the signature and timestamp the API would send. The optional third argument (unix seconds) fixes the moment the signature is bound to, which is how to exercise the stale-timestamp path:

import{Webhook,WebhookSignatureError}from'@nope-net/sdk';constbody=JSON.stringify({event: 'test.ping',event_id: 'evt_local',timestamp: newDate().toISOString(),api_version: '2025-01',message: 'Webhook configured successfully',});const{ signature, timestamp }=Webhook.sign(body,'whsec_local');constpayload=Webhook.verify(body,signature,timestamp,'whsec_local');console.log(payload.event);conststale=Webhook.sign(body,'whsec_local',Math.floor(Date.now()/1000)-600);try{Webhook.verify(body,stale.signature,stale.timestamp,'whsec_local');}catch(err){console.log(errinstanceofWebhookSignatureError,(errasError).message);// true, "Timestamp too old: 600s ago (max: 300s)"}

Managing webhook endpoints

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

import{NopeClient}from'@nope-net/sdk';constclient=newNopeClient({apiKey: process.env.NOPE_API_KEY});constcreated=awaitclient.webhooks.create({url: 'https://api.example.com/webhooks/nope',min_risk_level: 'high',include_conversation: false,});console.log(created.id,created.secret);// secret is returned onceconstping=awaitclient.webhooks.test(created.id);console.log(ping.success,ping.http_status,ping.duration_ms);// a failed delivery comes back with success: falseconst{ webhooks }=awaitclient.webhooks.list();const{ events }=awaitclient.webhooks.events(created.id,{limit: 10});awaitclient.webhooks.update(created.id,{enabled: false});constrotated=awaitclient.webhooks.regenerateSecret(created.id);awaitclient.webhooks.delete(created.id);console.log(webhooks.length,events.length,rotated.secret.length);

Billing

Amounts are in mills (1 mill = $0.001). pricing() needs no key; the rest need one. All five are refused in demo mode.

import{NopeClient}from'@nope-net/sdk';constclient=newNopeClient({apiKey: process.env.NOPE_API_KEY});constbalance=awaitclient.billing.balance();console.log(balance.balance_formatted,balance.low_balance,balance.estimated_evaluates);constusage=awaitclient.billing.usage({start_date: '2026-09-01'});for(constlineofusage.breakdown){console.log(line.endpoint,line.calls,line.cost_formatted);}consthistory=awaitclient.billing.usageHistory({limit: 20,endpoint: '/v1/evaluate'});console.log(history.total,history.records[0]?.created_at);constpricing=awaitclient.billing.pricing();console.log(pricing.pricing.evaluate.cost_display,pricing.free_credit_display);constcheckout=awaitclient.billing.topup({amount_mills: 10000,success_url: 'https://example.com/billing/ok'});console.log(checkout.checkout_url);

Errors, retries and response metadata

Every error extends NopeError and carries statusCode, code, message, responseBody (the raw response text) and body (the same text parsed, when it was a JSON object).

StatusClassExtra fields
400, 413NopeValidationErrordetails (body extras such as max_bytes, max_messages, invalid_scopes)
401NopeAuthError
402NopeInsufficientBalanceErrorbalanceMills, requiredMills, formattedCurrent, formattedRequired, topupUrl; ingest adds perConversationMills, conversations
403NopeFeatureErrorfeature, requiredAccess, or feature: 'paid_plan' with upgradeUrl
404NopeNotFoundError
429NopeRateLimitErrorretryAfter (seconds), limit, remaining, reset (epoch ms)
503NopeServiceUnavailableErrorretryAfter (seconds); extends NopeServerError
other 5xxNopeServerErrorretryAfter when a header was sent
no responseNopeConnectionErrororiginalError; covers the client-side timeout

Client-side validation (an empty messages array, a role other than user or assistant, more than 100 messages, neither messages nor text, and the per-method checks above) and demo-mode refusals throw NopeValidationError before any request is sent. Those errors have no statusCode; their code is invalid_request or not_available_in_demo, and details is empty.

For API errors, code is present only when the API sends a machine string in the body. Today 402 and 429 always do (insufficient_balance, rate_limit_exceeded), 403 and 503 do on some bodies (paid_plan_required, auth_unavailable), and 400, 401, 404 and 413 carry a sentence, so code is undefined there. Branch on the class or on statusCode, and read code only as extra detail.

import{NopeClient,NopeAuthError,NopeInsufficientBalanceError,NopeRateLimitError,NopeValidationError,NopeServiceUnavailableError,NopeServerError,NopeConnectionError,}from'@nope-net/sdk';constclient=newNopeClient({apiKey: process.env.NOPE_API_KEY,maxRetries: 2});try{constresult=awaitclient.evaluate({text: 'hello',config: {country: 'US'}});console.log(result.speaker_severity);console.log(client.lastResponseMeta?.balance?.costMills,client.lastResponseMeta?.rateLimit?.remaining);}catch(error){if(errorinstanceofNopeInsufficientBalanceError){console.log(`Balance ${error.formattedCurrent}, need ${error.formattedRequired}: ${error.topupUrl}`);}elseif(errorinstanceofNopeRateLimitError){console.log(`Rate limited; retry after ${error.retryAfter} seconds`);}elseif(errorinstanceofNopeValidationError){// statusCode is undefined when the SDK rejected the input before sending (code 'invalid_request').console.log(error.statusCode??'client-side',error.code,error.message,error.details);}elseif(errorinstanceofNopeAuthError){console.log('Invalid API key');}elseif(errorinstanceofNopeServiceUnavailableError){console.log(`Temporarily unavailable; retry after ${error.retryAfter} seconds`);}elseif(errorinstanceofNopeServerError){console.log(`Server error ${error.statusCode}`,error.body?.error??error.responseBody);}elseif(errorinstanceofNopeConnectionError){console.log('No response',error.originalError?.message);}else{throwerror;}}

The client retries 429 and 503 responses up to maxRetries times, waiting Retry-After seconds (falling back to the body's retry_after_seconds, then to 1 s, 2 s, 4 s), capped at 30 s per wait. Timeouts, connection failures and other 5xx are never retried: paid routes charge before the handler runs, so a blind retry could bill twice.

client.lastResponseMeta holds { status, rateLimit, balance } from the most recent response. rateLimit comes from the X-RateLimit-* headers on every route; balance (balanceMills, costMills) is present on paid routes only.

TypeScript

Every request and response type is exported. NopeClient is generic on the demo flag, so oversight.analyze and ocular return the demo response types on a demo client and the authenticated types otherwise. Annotate a client that may be either as NopeClient<boolean>.

importtype{EvaluateResponse,EvaluateResource,Risk,CrisisResource,OcularResponse,OversightAnalyzeResponse,OversightBehaviorCode,SignpostSearchResult,WebhookPayload,ServiceScope,}from'@nope-net/sdk';import{NopeClient}from'@nope-net/sdk';consteitherClient: NopeClient<boolean>=newNopeClient({demo: true});constscope: ServiceScope='domestic_violence';constcode: OversightBehaviorCode='dependency_reinforcement';console.log(typeofeitherClient,scope,code);

Versioning

The package follows Semantic Versioning. 4.0.0 is a breaking release; see CHANGELOG.md for every change from 3.x, including the switch of retryAfter to seconds and the removed response fields.

License

MIT. See LICENSE.

Support

About

Node.js/TypeScript SDK for NOPE API

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Repository files navigation

NOPE Node SDK

npm versionNode 18+TypeScriptLicense: MIT

TypeScript client for the NOPE API. NOPE reads conversations between people and AI systems and returns structured safety signals: mental-health and safeguarding risk (Evaluate), behavioral risk scores (Ocular), harmful AI-behaviour analysis (Oversight), and crisis resources by country (Signpost). The SDK also verifies webhook deliveries, manages webhook endpoints, and reads billing.

The package ships ESM and CommonJS builds with type declarations. Every response type is checked against responses captured from the live API (tests/fixtures/), so a field in the types is a field on the wire.

Requirements

  • Node.js 18 or later (the client uses the built-in fetch).
  • An API key from dashboard.nope.net for the paid and key-gated routes. Demo mode (below) covers evaluate, ocular, oversight.analyze and signpostSmart without a key.

Installation

npm install @nope-net/sdk
# or
pnpm add @nope-net/sdk

Quick start

import{NopeClient}from'@nope-net/sdk';constclient=newNopeClient({apiKey: process.env.NOPE_API_KEY});constresult=awaitclient.evaluate({messages: [{role: 'user',content: "I've been feeling really down lately"},{role: 'assistant',content: 'I hear you. Can you tell me more?'},{role: 'user',content: "I just don't see the point anymore"},],config: {country: 'US'},});console.log(result.speaker_severity,result.speaker_imminence);console.log(result.rationale);if(result.show_resources&&result.resources){const{ primary }=result.resources;console.log(`${primary.name}: ${primary.phone??primary.website_url} (${primary.why})`);}

Client options

OptionDefaultNotes
apiKeynonenope_live_... from the dashboard. Omit for demo mode or key-free routes.
baseUrlhttps://api.nope.netA trailing slash is tolerated.
timeout30000Milliseconds per attempt.
demofalseRoute to the unauthenticated /v1/try/* endpoints.
maxRetries2Retries on 429 and 503 only.
fetchglobal fetchInject a wrapped fetch for tracing, or a fake in tests.
sleepsetTimeoutWait between retries; inject in tests.

Demo mode

new NopeClient({ demo: true }) needs no key and routes four methods to the per-IP rate-limited /v1/try/* endpoints:

MethodDemo routeDifferences from the authenticated route
evaluate/v1/try/evaluateKeeps the last 10 messages, honours include_resources, adds metadata.try_endpoint and metadata.model. The route reads config.country; the client also sends the value as config.user_country, the older spelling the route accepts when country is absent. 10 calls per minute per IP.
oversight.analyze/v1/try/oversight/analyzeReturns { mode, result, try_endpoint } (OversightDemoAnalyzeResponse). Ignores config.strategy and config.model, keeps only role and content, accepts at most 20 messages.
ocular/v1/try/ocularReturns OcularDemoResponse, which adds heads and detail under public family names. At most 12 messages or 4,000 characters. With per_turn it returns trajectory but never trajectory_shape.
signpostSmart/v1/try/signpost/smartAdds try_endpoint: true.

Every other method throws NopeValidationError (... is not available in demo mode, code: 'not_available_in_demo', no statusCode) before any request is sent.

Evaluate

client.evaluate(options) costs $0.003 per call. Pass messages (1 to 100, roles user or assistant) or text (a transcript, up to 50,000 characters). config takes the four keys the route reads:

KeyPurpose
countryISO 3166-1 alpha-2 for crisis resources (default US).
include_resourcesSet false to skip resource matching (default true).
conversation_idEchoed on evaluate.alert webhook payloads.
end_user_idEchoed on evaluate.alert webhook payloads as user_id.
import{NopeClient,calculateSpeakerSeverity,typeEvaluateResource}from'@nope-net/sdk';constclient=newNopeClient({apiKey: process.env.NOPE_API_KEY});constresult=awaitclient.evaluate({text: 'Patient expressed hopelessness and mentioned not wanting to continue.',config: {country: 'GB',conversation_id: 'conv_42',end_user_id: 'user_7'},});for(constriskofresult.risks){console.log(risk.type,risk.subject,risk.severity,risk.imminence,risk.features??[]);}// speaker_severity is the highest severity among risks with subject 'self'.console.log(calculateSpeakerSeverity(result.risks)===result.speaker_severity);console.log(result.request_id,result.timestamp,result.metadata?.input_format);// resources.primary and resources.secondary[] are EvaluateResource: a CrisisResource plus `why`.consttop: EvaluateResource|undefined=result.resources?.primary;console.log(top?.name,top?.why);

EvaluateResponse fields: risks: Risk[], rationale, speaker_severity, speaker_imminence, show_resources, resources? (an EvaluateResources: primary and up to three secondary, each an EvaluateResource, which is a CrisisResource plus a one-line why), request_id, timestamp, metadata?.

Risk is { type, subject, severity, imminence, features? }. subject is self or other. Severity runs none | mild | moderate | high | critical; imminence runs not_applicable | chronic | subacute | urgent | emergency. The nine risk types are suicide, self_harm, self_neglect, violence, abuse, sexual_violence, neglect, exploitation and stalking.

Legacy screen()

client.screen() is deprecated: use evaluate() ($0.003 per call). It still calls the legacy /v0/screen endpoint ($0.001 per call), which carries a sunset of 2027-01-01. The client logs one warning per process saying so, and refuses the call in demo mode. ScreenConfig is { country?, debug?, include_recommended_reply? }.

Ocular

client.ocular(options) costs $0.0001 per call and returns a continuous salience score in [0, 1] plus eight user-risk axes under signals.user and four AI-behaviour axes under signals.ai, each { level, score }. Pick the salience cutoff that fits your action; the reference thresholds are 0.30 (watch) and 0.60 (danger).

import{NopeClient}from'@nope-net/sdk';constclient=newNopeClient({apiKey: process.env.NOPE_API_KEY});constresult=awaitclient.ocular({messages: [{role: 'user',content: 'I feel hopeless'},{role: 'assistant',content: 'I am here with you.'},{role: 'user',content: 'Nothing helps any more.'},],per_turn: true,trajectory_stride: 1,// score every turn; the server default of 3 scores every third turn back from the lastsession_id: 'session_9',});console.log(result.salience,result.subject,result.imminence.level);constsuicide=result.signals.user.suicide;if(suicide&&suicide.score>0.5){console.log('escalate');}for(constturnofresult.trajectory??[]){// turn.turn is the 0-based position in messages; AI axes are ai_-prefixed hereconsole.log(turn.turn,turn.role,turn.salience,turn.signals_by_axis?.suicide,turn.signals_by_axis?.ai_manipulation);}constshape=result.trajectory_shape;if(shape?.peak_turn!==undefined&&result.trajectory){constpeak=result.trajectory[shape.peak_turn];// peak_turn indexes the trajectory array, not messagesconsole.log(peak.turn,shape.phases?.[shape.peak_turn],shape.onsets?.suicide);}console.log(result.meta.version);

user_id, session_id and agent_id (1 to 256 characters) are stored for dashboard analytics and never forwarded to the model.

Per-turn trajectory

Set per_turn: true to receive trajectory, one entry per scored turn, and trajectory_shape. Not every turn is scored: trajectory_stride defaults to 3 on the server, which scores the last turn and then every third turn back from it, so a 5-message conversation yields turns 4 and 1. Pass trajectory_stride: 1 to score every turn. An entry's turn is the 0-based position of that turn in messages (for text input, of the parsed speaker turn) and its role is user or assistant. signals_by_axis keys the user axes by bare name (suicide), the AI axes with an ai_ prefix (ai_manipulation, where the top level has signals.ai.manipulation), and adds two context scalars, genuine and fiction.

trajectory_shape summarises the crisis (suicide) axis over the scored turns. onsets maps an axis to the turn value at which it first crossed its onset threshold. phases, slopes and peak_turn index the trajectory array instead: phases[i] and slopes[i] describe trajectory[i], and peak_turn is the array position of the entry with the highest crisis score, so trajectory[shape.peak_turn].turn is the message index. The authenticated route returns trajectory_shape whenever at least one turn was scored; the demo route (/v1/try/ocular) returns trajectory but never trajectory_shape.

Oversight

Oversight analyzes an AI assistant's side of a conversation using 91 behavior codes: 87 harmful and 4 appropriate, across 14 categories (dependency reinforcement, crisis mishandling, manipulation, boundary violations and so on). It requires an account with the feature enabled; analyze costs 100 mills ($0.10) per call and ingest 100 mills per conversation.

import{NopeClient}from'@nope-net/sdk';constclient=newNopeClient({apiKey: process.env.NOPE_API_KEY});const{ result, strategy }=awaitclient.oversight.analyze({conversation: {conversation_id: 'conv_123',messages: [{role: 'user',content: 'Nobody at work listens to me.'},{role: 'assistant',content: "I'm always here and I understand you better than they ever will."},],metadata: {platform: 'companion-app',user_is_minor: false},},bot_context: 'general-purpose assistant for a productivity app',config: {mode: 'fast'},behaviors: {min_severity: 'medium',categories: ['boundary_violations','relationship_harm']},});console.log(strategy,result.mode_used,result.overall_concern,result.trajectory);for(constbehaviorofresult.detected_behaviors){console.log(`${behavior.code} (${behavior.severity} x${behavior.turn_count}): ${behavior.recommendation}`);}
  • config.mode: full (default) or fast. Fast mode uses a quicker model; trajectory is always stable, turn_analysis and human_indicators are empty, and summary and pattern_assessment are absent.
  • config.strategy: single or sliding; auto-selected by length when omitted. Sliding results carry windows (each with message_range and conversation_turn_range), concern_progression, peak_concern, final_concern and inflection_points.
  • behaviors: a post-analysis filter. enabled and disabled are mutually exclusive; codes and categories come from the exported OversightBehaviorCode and OversightBehaviorCategory unions (OVERSIGHT_BEHAVIOR_CODES, OVERSIGHT_BEHAVIOR_CATEGORIES).
  • bot_context is passed into the analysis as conversation metadata, so the analyzer knows what the persona is meant to do.
  • Turn numbers in results count assistant turns from 1.

Batch analysis with dashboard storage takes up to 300 conversations, each with a conversation_id, and returns synchronously. The request body is capped at 5 MB, so a batch near the count limit must consist of short conversations. webhook_url is a legacy per-request callback: the API POSTs an unsigned ingestion_complete JSON summary there when the batch completes. The signed oversight.ingestion.complete event is delivered to webhooks registered with client.webhooks.

import{NopeClient}from'@nope-net/sdk';constclient=newNopeClient({apiKey: process.env.NOPE_API_KEY});constbatch=awaitclient.oversight.ingest({conversations: [{conversation_id: 'conv_001',messages: [{role: 'user',content: 'hi'},{role: 'assistant',content: 'hello'},],},],webhook_url: 'https://api.example.com/webhooks/nope',});console.log(batch.status,`${batch.conversations_processed}/${batch.conversations_received}`,batch.dashboard_url);for(constitemofbatch.results??[]){console.log(item.conversation_id,item.overall_concern,item.truncation_warnings?.map((w)=>w.type));}

Signpost

Crisis resources by country. Filters can be passed at the top level or under config; scope and population values come from the exported ServiceScope and Population unions (SERVICE_SCOPES, POPULATIONS), and the API returns 400 for unknown values.

import{NopeClient}from'@nope-net/sdk';constclient=newNopeClient({apiKey: process.env.NOPE_API_KEY});// Basic lookup (free, key required). urgent: true keeps every match and ranks the// 24/7 ones first among resources tied on relevance and priority tier.constbasic=awaitclient.signpost({country: 'GB',scopes: ['suicide'],subdivisions: ['GB-NIR'],urgent: true});for(constresourceofbasic.resources){console.log(resource.type,resource.name,resource.is_24_7,resource.phone??resource.website_url,resource.open_status?.message);}// Ranked for a described situation ($0.001 per call, up to 5 picks)constranked=awaitclient.signpostSmart({country: 'US',query: 'teen struggling with an eating disorder'});for(constpickofranked.ranked){console.log(`${pick.rank}. ${pick.resource.name}: ${pick.why}`);}// Semantic search across the whole directory (free, key required)consthits=awaitclient.signpostSearch({query: 'lgbtq youth support',country: 'GB',limit: 5});for(consthitofhits.results){console.log(hit.id,hit.name,hit.similarity.toFixed(2),hit.phone??hit.website_url);}// Public routes (no key)constone=awaitclient.signpostById(hits.results[0].id);constcountries=awaitclient.signpostCountries();constgeo=awaitclient.detectCountry({countryHint: 'GB'});console.log(one.resource.name,countries.count,geo.detected ? geo.country_code : 'unknown');

urgent: true is a ranking hint, not a filter: the API keeps every matching resource and, where two resources tie on relevance score and priority tier, places the one flagged is_24_7 first. Resources without 24/7 hours still appear in the list.

detectCountry() reads geo headers a proxy injects (cf-ipcountry, x-country, x-vercel-ip-country). Called directly against api.nope.net it returns the miss shape with detected: false; pass countryHint to send x-country yourself.

signpost() and signpostSearch() need a key and are refused in demo mode. The resources* methods still work against /v1/resources/*, log a one-time deprecation warning, and stop on 2027-01-01.

Webhooks

NOPE signs each delivery with HMAC-SHA256 over "${timestamp}.${body}" and sends X-NOPE-Signature, X-NOPE-Timestamp, X-NOPE-Event, X-NOPE-Delivery-ID and X-NOPE-Webhook-ID. Four events exist:

EventSent whenPayload type
evaluate.alert/v1/evaluate finds risk at or above the webhook's min_risk_levelEvaluateAlertPayload
oversight.alertOversight finds high or critical concernOversightAlertPayload
oversight.ingestion.completeAn ingest batch finishesOversightIngestionCompletePayload
test.pingThe dashboard test button or client.webhooks.test()TestPingPayload

Verify with the raw request body. Configure your framework so req.body is the unparsed string or Buffer; a re-serialised object only matches the signature when key order survived parsing.

import{Webhook,WebhookSignatureError}from'@nope-net/sdk';app.post('/webhooks/nope',(req,res)=>{try{const{ payload, deliveryId }=Webhook.verifyRequest(req.body,req.headers,process.env.NOPE_WEBHOOK_SECRET!);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(err){if(errinstanceofWebhookSignatureError){res.status(401).send('Invalid signature');return;}throwerr;}});

Webhook.verify(body, signature, timestamp, secret, { maxAgeSeconds }) takes the two header values directly. maxAgeSeconds defaults to 300; 0 disables the timestamp check. verifyRequest also returns deliveryId (the X-NOPE-Delivery-ID header: the stored delivery's id, repeated on each of the API's four retry attempts over an hour, so it is the key to deduplicate them), eventType (X-NOPE-Event) and webhookId (X-NOPE-Webhook-ID). deliveryId is not payload.event_id, and it is undefined when the header is absent, as on a body signed locally with Webhook.sign. eventId is a deprecated alias of deliveryId.

For tests, Webhook.sign(body, secret, timestamp?) produces the signature and timestamp the API would send. The optional third argument (unix seconds) fixes the moment the signature is bound to, which is how to exercise the stale-timestamp path:

import{Webhook,WebhookSignatureError}from'@nope-net/sdk';constbody=JSON.stringify({event: 'test.ping',event_id: 'evt_local',timestamp: newDate().toISOString(),api_version: '2025-01',message: 'Webhook configured successfully',});const{ signature, timestamp }=Webhook.sign(body,'whsec_local');constpayload=Webhook.verify(body,signature,timestamp,'whsec_local');console.log(payload.event);conststale=Webhook.sign(body,'whsec_local',Math.floor(Date.now()/1000)-600);try{Webhook.verify(body,stale.signature,stale.timestamp,'whsec_local');}catch(err){console.log(errinstanceofWebhookSignatureError,(errasError).message);// true, "Timestamp too old: 600s ago (max: 300s)"}

Managing webhook endpoints

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

import{NopeClient}from'@nope-net/sdk';constclient=newNopeClient({apiKey: process.env.NOPE_API_KEY});constcreated=awaitclient.webhooks.create({url: 'https://api.example.com/webhooks/nope',min_risk_level: 'high',include_conversation: false,});console.log(created.id,created.secret);// secret is returned onceconstping=awaitclient.webhooks.test(created.id);console.log(ping.success,ping.http_status,ping.duration_ms);// a failed delivery comes back with success: falseconst{ webhooks }=awaitclient.webhooks.list();const{ events }=awaitclient.webhooks.events(created.id,{limit: 10});awaitclient.webhooks.update(created.id,{enabled: false});constrotated=awaitclient.webhooks.regenerateSecret(created.id);awaitclient.webhooks.delete(created.id);console.log(webhooks.length,events.length,rotated.secret.length);

Billing

Amounts are in mills (1 mill = $0.001). pricing() needs no key; the rest need one. All five are refused in demo mode.

import{NopeClient}from'@nope-net/sdk';constclient=newNopeClient({apiKey: process.env.NOPE_API_KEY});constbalance=awaitclient.billing.balance();console.log(balance.balance_formatted,balance.low_balance,balance.estimated_evaluates);constusage=awaitclient.billing.usage({start_date: '2026-09-01'});for(constlineofusage.breakdown){console.log(line.endpoint,line.calls,line.cost_formatted);}consthistory=awaitclient.billing.usageHistory({limit: 20,endpoint: '/v1/evaluate'});console.log(history.total,history.records[0]?.created_at);constpricing=awaitclient.billing.pricing();console.log(pricing.pricing.evaluate.cost_display,pricing.free_credit_display);constcheckout=awaitclient.billing.topup({amount_mills: 10000,success_url: 'https://example.com/billing/ok'});console.log(checkout.checkout_url);

Errors, retries and response metadata

Every error extends NopeError and carries statusCode, code, message, responseBody (the raw response text) and body (the same text parsed, when it was a JSON object).

StatusClassExtra fields
400, 413NopeValidationErrordetails (body extras such as max_bytes, max_messages, invalid_scopes)
401NopeAuthError
402NopeInsufficientBalanceErrorbalanceMills, requiredMills, formattedCurrent, formattedRequired, topupUrl; ingest adds perConversationMills, conversations
403NopeFeatureErrorfeature, requiredAccess, or feature: 'paid_plan' with upgradeUrl
404NopeNotFoundError
429NopeRateLimitErrorretryAfter (seconds), limit, remaining, reset (epoch ms)
503NopeServiceUnavailableErrorretryAfter (seconds); extends NopeServerError
other 5xxNopeServerErrorretryAfter when a header was sent
no responseNopeConnectionErrororiginalError; covers the client-side timeout

Client-side validation (an empty messages array, a role other than user or assistant, more than 100 messages, neither messages nor text, and the per-method checks above) and demo-mode refusals throw NopeValidationError before any request is sent. Those errors have no statusCode; their code is invalid_request or not_available_in_demo, and details is empty.

For API errors, code is present only when the API sends a machine string in the body. Today 402 and 429 always do (insufficient_balance, rate_limit_exceeded), 403 and 503 do on some bodies (paid_plan_required, auth_unavailable), and 400, 401, 404 and 413 carry a sentence, so code is undefined there. Branch on the class or on statusCode, and read code only as extra detail.

import{NopeClient,NopeAuthError,NopeInsufficientBalanceError,NopeRateLimitError,NopeValidationError,NopeServiceUnavailableError,NopeServerError,NopeConnectionError,}from'@nope-net/sdk';constclient=newNopeClient({apiKey: process.env.NOPE_API_KEY,maxRetries: 2});try{constresult=awaitclient.evaluate({text: 'hello',config: {country: 'US'}});console.log(result.speaker_severity);console.log(client.lastResponseMeta?.balance?.costMills,client.lastResponseMeta?.rateLimit?.remaining);}catch(error){if(errorinstanceofNopeInsufficientBalanceError){console.log(`Balance ${error.formattedCurrent}, need ${error.formattedRequired}: ${error.topupUrl}`);}elseif(errorinstanceofNopeRateLimitError){console.log(`Rate limited; retry after ${error.retryAfter} seconds`);}elseif(errorinstanceofNopeValidationError){// statusCode is undefined when the SDK rejected the input before sending (code 'invalid_request').console.log(error.statusCode??'client-side',error.code,error.message,error.details);}elseif(errorinstanceofNopeAuthError){console.log('Invalid API key');}elseif(errorinstanceofNopeServiceUnavailableError){console.log(`Temporarily unavailable; retry after ${error.retryAfter} seconds`);}elseif(errorinstanceofNopeServerError){console.log(`Server error ${error.statusCode}`,error.body?.error??error.responseBody);}elseif(errorinstanceofNopeConnectionError){console.log('No response',error.originalError?.message);}else{throwerror;}}

The client retries 429 and 503 responses up to maxRetries times, waiting Retry-After seconds (falling back to the body's retry_after_seconds, then to 1 s, 2 s, 4 s), capped at 30 s per wait. Timeouts, connection failures and other 5xx are never retried: paid routes charge before the handler runs, so a blind retry could bill twice.

client.lastResponseMeta holds { status, rateLimit, balance } from the most recent response. rateLimit comes from the X-RateLimit-* headers on every route; balance (balanceMills, costMills) is present on paid routes only.

TypeScript

Every request and response type is exported. NopeClient is generic on the demo flag, so oversight.analyze and ocular return the demo response types on a demo client and the authenticated types otherwise. Annotate a client that may be either as NopeClient<boolean>.

importtype{EvaluateResponse,EvaluateResource,Risk,CrisisResource,OcularResponse,OversightAnalyzeResponse,OversightBehaviorCode,SignpostSearchResult,WebhookPayload,ServiceScope,}from'@nope-net/sdk';import{NopeClient}from'@nope-net/sdk';consteitherClient: NopeClient<boolean>=newNopeClient({demo: true});constscope: ServiceScope='domestic_violence';constcode: OversightBehaviorCode='dependency_reinforcement';console.log(typeofeitherClient,scope,code);

Versioning

The package follows Semantic Versioning. 4.0.0 is a breaking release; see CHANGELOG.md for every change from 3.x, including the switch of retryAfter to seconds and the removed response fields.

License

MIT. See LICENSE.

Support

About

Node.js/TypeScript SDK for NOPE API

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages