@swfte/nexus-sdk
v0.1.0
Published
Embedded agent observability for Node services — zero dependencies, bundler-proof.
Maintainers
Readme
@swfte/nexus-sdk
Agent observability and enforcement for Node services you run yourself.
nexus wrap captures what an agent does inside a developer's terminal. This package captures the
same thing inside your own Node application — a third attach point on one event ledger, stamped
producer="sdk" so a run in production and a run on a laptop are the same shape in the same
tables.
npm install @swfte/nexus-sdkNode 18+. Zero dependencies — see Why no dependencies. ESM and CommonJS both supported, from one instance.
Part of Nexus by Swfte — savings, governance and a real audit trail
for AI agents. This is the Node sibling of
swfte-nexus-sdk for Python: same concepts, same event
shapes, same collector. See also the
terminal wrapper (npm i -g @swfte/nexus).
Contents
| Section | | |---|---| | Start here, if you are bundling | read this before anything else if you deploy a bundle | | Three levels, pick one | the whole API, in increasing detail | | Guarantees | what this SDK promises never to do to your process | | Configuration | every variable and the precedence rule | | Privacy tiers | what actually goes on the wire, with a worked example | | OpenTelemetry | the OTLP bridge, and why it is a bridge | | Enforcement | the part that can say no | | What this does not do | honestly, and with reasons | | Why no dependencies | and what that costs us instead | | Development | running the suite, the layout |
Reference documentation
| Document | For |
|---|---|
| docs/API.md | every export, its signature, and what it returns |
| PARITY.md | line-by-line account of what matches the Python SDK, what deliberately does not, and why |
| SCOPE.md | the original feasibility spike — historical, kept for the record |
| AGENTS.md | orientation for coding agents working in this repository |
| CONTRIBUTING.md | how to run the suite and what review will ask |
| SECURITY.md | reporting a vulnerability |
| CHANGELOG.md | what changed, and why |
Start here, if you are bundling
Most Node advice about telemetry assumes it can patch your imports at startup. In a bundled application that does not work, and it fails silently, so it is worth knowing before you choose anything:
| | ESM app | CJS app | bundled (esbuild / webpack / ncc / Next.js) |
|---|---|---|---|
| Module hooks (--import, --require) | works | works | silently captures nothing |
| The Vercel AI SDK seam | works | works | works |
| The explicit API | works | works | works |
A bundler inlines your dependencies into your own file, so there is no import '@anthropic-ai/sdk'
left for a hook to intercept — the module boundary the technique needs no longer exists in the
artifact that runs. It is not degraded, it is absent, and worse: a bundled app carries its own
inlined copy of the SDK, so events would belong to a different session from the app's own runs.
Next.js bundles server code by default. That makes the deployment shape most likely to be running an agent in Node exactly the shape module hooks cannot serve.
So this SDK is built on the two columns that survive. It ships no module hooks, and it does not
offer zero-code capture in Node. When nothing is capturing, it says so — instrumentation()
returns "none" and every pipeline_health record carries it. An empty dashboard that looks
plausible is worse than an error.
The measurements behind all of this are in SCOPE.md, and reproducible.
Three levels, pick one
Level 1 — declare who you are. One call, at startup:
import * as nexus from '@swfte/nexus-sdk';
nexus.init({ service: 'checkout', env: 'prod', version: '2026.8.1' });Datadog's unified tagging. service / env / version land on every event as join keys. There is
no user identity anywhere in this SDK: a service does not need one, and inventing one is how a
telemetry pipeline becomes a compliance problem.
Level 2 — capture your model calls. If you use the Vercel AI SDK, one more call:
import { registerTelemetry } from 'ai';
import { telemetry } from '@swfte/nexus-sdk/ai';
registerTelemetry(telemetry());Token counts with the cache-read/cache-write split, model and provider ids, tool names and
durations, and exact cost from the cache split — with no changes at your call sites, and it survives
bundling. Note the direction: you
import ai, not us. This package has no dependency on it and no version range to conflict with
yours.
Level 3 — say what the agent did.
await nexus.withAgent('refund-request', { goalClass: 'transaction' }, async (run) => {
const act = run.action('db.write', 'refunds');
act.effect({ rows: 1, amountCents: 4200 }).end();
run.usage({ model: 'claude-opus-4', inputTokens: 1800, outputTokens: 240 });
run.outcome('success', { verified: true, verifiedBy: 'ledger_balance' });
});A run is a unit of agent work with an outcome. An action is something with an effect on the
world — the thing enforcement gates. The distinction is not stylistic: what an agent did
(behavior_trace) and what a model said it did (rationalisation) are different epistemic
classes, stamped as such, and only one is admissible as evidence.
Prefer withAgent to agent() in async code. The current run propagates across await correctly
there and only there (AsyncLocalStorage); the object returned by agent() cannot do that, and
action() several frames down will not find it.
All three levels compose.
Guarantees
A telemetry SDK must never be the reason a request fails. Concretely, each of these is a test:
- Every public entry point is wrapped in a guard that contains exceptions and returns a safe
default. A guarded failure hands back an inert proxy rather than
undefined, so host code written against aRunkeeps running instead of failing two lines later oncannot read property 'outcome' of undefined. - The calling thread never performs I/O.
flush()is asynchronous because there is no honest synchronous form to offer. - The queue is bounded and drops oldest, because the newest events describe the incident. Every
drop is counted and reported in
counters(). Silence about dropped data is a bug. - Shutdown flushes to a deadline. A hung collector must not hang your container — asserted by a test with a server that accepts the request and never answers.
SIGKILLloses the in-flight buffer. That is not fixable and we do not pretend otherwise.
The kill switch is real
NEXUS_ENABLED=0No timers, no sink opened, no exit handler, no socket. NEXUS_ENABLED=0 beats
init({ enabled: true }) — an operator disabling telemetry from outside the process has to beat
what the application says, or it is not a kill switch.
Configuration
Explicit arguments win over environment variables, which win over defaults. The one exception is
NEXUS_ENABLED=0, which wins over everything.
| Variable | Default | |
|---|---|---|
| NEXUS_ENABLED | 1 | 0 disables entirely |
| NEXUS_SERVICE / NEXUS_ENV / NEXUS_VERSION | unknown | unified tagging; the service name also falls back to OTEL_SERVICE_NAME / DD_SERVICE / K_SERVICE / AWS_LAMBDA_FUNCTION_NAME |
| NEXUS_COLLECTOR_URL | http://127.0.0.1:8791 | full URL; none for no collector |
| NEXUS_COLLECTOR_HOST / _PORT / _SCHEME | — | sidecar mode: not hardcoded loopback, IPv6 hosts bracketed |
| NEXUS_API_KEY | — | bearer token; see below |
| NEXUS_TIER | metadata_only | metadata_only / hashed / full |
| NEXUS_CONFIG_FILE | — | JSON file, read only when named; no implicit search path |
| NEXUS_HEALTH_INTERVAL_MS | 60000 | service_health window; 0 disables |
| NEXUS_SPILL_DIR | — | durable overflow for batches the collector permanently refused |
| NEXUS_QUEUE_CAPACITY | 10000 | events, then drop-oldest |
| NEXUS_BATCH_SIZE | 500 | |
| NEXUS_FLUSH_INTERVAL_MS / NEXUS_FLUSH_DEADLINE_MS | 2000 / 2000 | |
| NEXUS_HTTP_TIMEOUT_MS | 2000 | |
| NEXUS_SDK_SINK | — | append events to a file as NDJSON, for debugging |
| NEXUS_APPLICATION / NEXUS_REPO / NEXUS_COMMIT / NEXUS_BRANCH / NEXUS_DEPLOYMENT_ID | auto | provenance; auto-detected from Vercel and GitHub Actions |
| NEXUS_DEBUG | — | 1 writes contained errors to stderr |
Events are POSTed to {collector}/v1/events as {"events": [...]}.
The bearer token is withheld from plain HTTP to a non-loopback host. https: anywhere is fine,
and http: to loopback is fine; anything else and the token is left off the request rather than
sent in cleartext across a network. The request still goes, so you get a diagnosable 401 instead of
silence.
Privacy tiers
Every free-text field carries its shape at every tier — <name>_chars and <name>_fingerprint are
content-free, so "the same error as yesterday" stays answerable and a repeated target stays
groupable without the text leaving the process. What changes per tier is the content beside it.
| Tier | On the wire |
|---|---|
| metadata_only (default) | Shape only. The content key is absent — not blank, not masked. |
| hashed | plus <name>_preview: redacted text, truncated. |
| full | plus <name>: redacted text, at the field's full limit. |
A tier is a decision about content, never a waiver on credentials. full is not "unscrubbed",
it is "scrubbed, at greater length": bearer tokens, API keys in nineteen vendor shapes, PEM blocks,
Luhn-valid card numbers, mod-97-valid IBANs, national IDs, phone numbers and email addresses go
regardless of tier. Structured values are scrubbed too, which is the case a free-text redactor
misses — effect({ headers: { Authorization: 'Bearer …' } }) is masked by key name, and a number
under a key that is not a recognised measurement is masked below full.
The redactor is a port of the Python SDK's, and the two are checked against each other over a
shared corpus (node scripts/redact-parity.mjs, 178 cases). One tier setting means the same thing
in a Node service and a Python one.
It is not magic, and the limits are worth knowing. It is a bounded set of anchored patterns plus
key-name matching, not an entropy scan, because it runs on your request path. Jane Doe is not
recognisable by any regular expression, so a field naming a person still contains their name at
full. The default tier is the primary control; redaction is the second line.
The AI SDK bridge emits tool inputs and outputs on this ladder, and no model text. Tool payloads
are structured, which is exactly what the scrubber is for. Model text reaches the ledger through
@swfte/nexus-sdk/otel instead, as model_response and model_thinking, where
it is re-gated by your tier on ingest because upstream redaction is not trusted. The prompt itself
is emitted by neither path: there is no prompt event in the contract for a second producer to
write against.
OpenTelemetry
If you already run GenAI instrumentation — OpenInference, OpenLLMetry/OpenLIT, or anything emitting the OTel GenAI semantic conventions — the bridge turns its spans into nexus events:
import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node';
import { spanProcessor } from '@swfte/nexus-sdk/otel';
new NodeTracerProvider({ spanProcessors: [spanProcessor()] }).register();Same direction as the AI SDK bridge: you import OpenTelemetry, not us. spanProcessor() returns
a plain object with the four methods the interface requires, so this package still has no
dependency and no version range to conflict with yours.
Three things it does deliberately:
- An unclassified span is dropped, not guessed at. A GenAI span whose kind is not modelled is
counted under
bridge_unclassifiedand emitted nowhere — never defaulted intobehavior_trace, which is the class the ledger treats as evidence. A bridge that guesses produces confidently wrong events, which is worse than none.stats().unclassifiedis the number to alert on. - Model text is re-gated by your tier on ingest. Every instrumentation in this space has its own content-capture switch and its own idea of what a secret looks like; some have none. Whether text leaves your process is your decision, not theirs.
- A tool call's arguments contribute a shape, not their values.
{amount: 4200, note: '…'}becomes field names and types with no value at any depth, becausetool_action.targetis an identifier field and a payload must not be standing in front of a gate that assumes one.
Enforcement
Every agent-observability tool observes. This one can also refuse:
import * as policy from '@swfte/nexus-sdk/policy';
policy.install(signedEnvelope); // Ed25519, verified before a rule is read
run.action('db.write', 'prod-orders'); // throws Denied if a rule says noImporting that module gates run.action(...). It is the only gated call site, and it is the
only one that can be: enforcement needs a decision taken before the effect, which needs a call site
you own. Tool calls made through the AI SDK seam cannot be gated in-process — see below.
A policy is a signed envelope from your control plane. A rule refuses for real only if it carries
"enforce": true; everything else evaluates, records, and lets the call through, so a shadow
deployment tells you what would have been blocked without blocking it.
The failure semantics are the part worth reading twice, because they decide what happens on your worst day:
| Situation | What happens |
|---|---|
| No policy installed, or one that will not verify | Allow, and raise an integrity alert |
| Policy verified but stale | enforce-marked rules keep enforcing; unmarked ones become advice |
| Evaluation exceeds its latency budget | Allow, and record the overrun |
| A bug inside policy evaluation | Allow, and raise an integrity alert |
| A rule needs a human, and there is no approver | The rule's own on_timeout: deny if enforce-marked, allow if not |
Denying because we could not reach something is never a default. failClosed is the single
opt-in exception, and it says so in the reason string on every decision it produces — a service
that starts refusing should be able to tell you why without a support ticket.
Denied is the only exception this SDK will ever put in your stack trace, and you opted into it
twice: the rule carried enforce: true and the call site did not decline. With no policy
installed — the default — nothing here can throw.
What this does not do
Named, because a gap that is named is fine and a gap that is silent is not.
PARITY.md has the full list against the Python SDK.
- No module-hook auto-instrumentation. No
--import @swfte/nexus-sdk/register. It cannot serve bundled applications, and shipping a half-working version of it produces exactly the empty dashboard this SDK is supposed to make impossible. The spike's implementation is still in the repository undersrc/hooks/, excluded from the published package. - No provider adapters. No direct instrumentation of
@anthropic-ai/sdkoropenai. If you call those libraries directly rather than through the AI SDK, userun.usage(...). - No enforcement through the AI SDK. The seam is observe-only by its type signature — the
callback returns
void, there is no channel to refuse on, and a throw fromonToolExecutionStartneither stops the tool nor reaches your code. Measured, not assumed. In Node, enforcement means the explicit API, where you own the call site. - Enforcement reaches exactly one call site.
@swfte/nexus-sdk/policygatesrun.action(...), and nothing else. An application using the AI SDK seam cannot be gated in-process at all, now or later: that seam's callbacks returnvoid, so there is no channel to refuse on, and a throw fromonToolExecutionStartneither stops the tool nor reaches your code. Enforcement in Node means the explicit API where you own the call site, a gateway in front of your provider, or nothing. Worth knowing before you design around it. - No human-approval channel. A
require_approvalrule resolves to its ownon_timeout— deny if the rule is enforce-marked, allow if not — rather than waiting for a person. Node cannot park its event loop, and a synchronous wait would freeze the process. - The OTel bridge does not deduplicate retries.
@swfte/nexus-sdk/otelreads spans from whatever GenAI instrumentation you run and emitstoken_usage,tool_action,model_responseandmodel_thinking. What it does not port is Python's multi-span logical-call join, so against an instrumentation that emits one span per HTTP attempt, token counts sum across retries instead of being deduplicated. - No
nexus-runlauncher. There is no Node equivalent ofPYTHONPATH-based bootstrapping that survives bundling.
Why no dependencies
The core declares nothing. Not an HTTP client, not a UUID library.
This is a product decision, not asceticism. An observability SDK is installed into a dependency
graph somebody else has already resolved, and every requirement we add is a version range that can
conflict with theirs — a conflict at install time is where the adoption conversation ends, before
any of this code runs. scripts/npm-guard.cjs and a test both fail the build if dependencies,
peerDependencies or optionalDependencies stops being empty.
The /ai entry point is the shape this forces and it is a better shape anyway: we never import
ai, you hand us registerTelemetry.
Development
npm test # 339 tests, node --test, no third-party framework
npm run typecheck # tsc --strict against the public declarations
npm run guard # what `npm publish` would ship, checked against an allowlistTwo checks need a network and are not part of npm test:
npm run probe:ai # the bridge against the real `ai` package, end to end
npm run parity # redactor, rate card and policy envelope, diffed against Python
bash scripts/consumer-check.sh # install the tarball; run strict tsc against it as a consumertest/contract.test.mjs validates every emitted event against contract/events.v1.json from
nexus-devtools. It looks for it at ../nexus-devtools/contract/events.v1.json, overridable with
NEXUS_CONTRACT, and skips loudly rather than silently when it is absent.
Layout
src/core.cjs the SDK: config, context, queue, transport, run/action, contract
src/index.js .cjs ESM and CJS entries over the one core (dual-package hazard)
src/ai.cjs .js the Vercel AI SDK bridge
src/provenance.cjs where a commit and a repo came from, and how confident we are
src/hooks/ the spike's module hooks — NOT shipped, kept for the record
fixtures/ stand-in provider, probe applications, the real-library probes
bundle/ esbuild build + the start-up measurement harnessNexus, beyond this package
This SDK is one attach point of three. All three write the same events to the same ledger, so a run in production and a run on a laptop are the same shape in the same tables.
| | Install | What it attaches to |
|---|---|---|
| Terminal wrapper | npm i -g @swfte/nexus · pip install swfte-nexus | coding agents in a developer's terminal — Claude Code, Codex |
| Node SDK — this package | npm i @swfte/nexus-sdk | your own Node services |
| Python SDK | pip install swfte-nexus-sdk | your own Python services |
- Product, pricing and docs: www.swfte.com
- Self-hosting, procurement, security review, pilots: [email protected]
- Report a vulnerability:
SECURITY.md
Licence
Apache-2.0. See LICENSE and NOTICE.
Built by Swfte AI.
