@syncanix/sdk-node
v0.1.0
Published
Syncanix Node SDK — install + syncanix.init() in your app, get framework registry discovery + drift detection + runtime tool registration.
Readme
@syncanix/sdk-node
The Syncanix Node SDK. Install it in your customer-facing API, call syncanix.boot() at startup, and Syncanix knows what your app exposes — so the agent loop can call your tools, drift-detect new endpoints, and verify intents before honouring sensitive requests.
npm install @syncanix/sdk-nodeRequires Node ≥ 20.
What the SDK does
- Discovers your live HTTP routes by walking your framework's runtime route registry (NestJS / Express / Fastify) — no static scan, no source-file shipping.
- Uploads the resulting capability catalog to the Syncanix server (with idempotent ETag short-circuit so re-boots cost a 304).
- Detects drift at runtime via Node's
diagnostics_channel— new endpoints called in production that aren't in the catalog get reported back to Syncanix. - Registers SDK-native tools (
syncanix.tool({...})) that the LLM agent can invoke without a public HTTP route. - Verifies inbound intents — when the agent invokes a tool/route, the Syncanix server signs an HMAC envelope; the SDK middleware (Express) or guard (NestJS) checks it before the handler runs.
The customer pattern
Every integration follows the same four-step shape:
walker → boot → drift subscriber → verify-intent middlewareThe sections below show this pattern realised in NestJS, Express, and Fastify.
Quick start — NestJS
// src/main.ts
import 'reflect-metadata';
import { NestFactory } from '@nestjs/core';
import { DiscoveryService } from '@nestjs/core';
import { boot, subscribeToHttpChannel, walkNestJS } from '@syncanix/sdk-node';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(3000);
// 1 — walk the NestJS controller registry
const discovery = app.get(DiscoveryService);
const capabilities = walkNestJS(discovery);
// 2 — boot: upload catalog + measure overhead (<30 ms target)
const result = await boot({
apiKey: process.env.SYNCANIX_API_KEY!,
capabilities,
frameworks: ['nestjs'],
repoRoot: process.cwd(),
scannerVersion: '@syncanix/[email protected]',
});
console.log(`[syncanix] boot ${result.bootDurationMs.toFixed(1)}ms`);
// 3 — subscribe to runtime drift (optional)
subscribeToHttpChannel({
capabilities,
onDrift: (event) => {
console.warn('[syncanix] drift:', event.type, event.method, event.path);
},
});
}
bootstrap();Add the verify-intent guard to any controller that handles agent-invoked traffic:
// src/tools/tools.module.ts
import { Module, UseGuards } from '@nestjs/common';
import { createVerifyIntentNestGuard } from '@syncanix/sdk-node';
import { ToolsController } from './tools.controller';
const VerifyIntent = createVerifyIntentNestGuard({
secret: process.env.SYNCANIX_INTENT_SECRET!,
});
@UseGuards(VerifyIntent)
@Module({ controllers: [ToolsController] })
export class ToolsModule {}The guard throws an IntentVerificationError on failure. Add a Nest ExceptionFilter to convert it to a 403 response with the failure reason in the body.
Quick start — Express
// src/server.ts
import express from 'express';
import {
boot,
createVerifyIntentExpressMiddleware,
subscribeToHttpChannel,
walkExpress,
} from '@syncanix/sdk-node';
import { router } from './routes';
const app = express();
app.use(router);
// 1 — walk Express's runtime route registry
const capabilities = walkExpress(app);
// 2 — boot
await boot({
apiKey: process.env.SYNCANIX_API_KEY!,
capabilities,
frameworks: ['express'],
repoRoot: process.cwd(),
scannerVersion: '@syncanix/[email protected]',
});
// 3 — drift detector
subscribeToHttpChannel({
capabilities,
onDrift: (event) => console.warn('[syncanix] drift:', event),
});
// 4 — verify-intent middleware on agent-invoked routes
app.use(
'/tools',
createVerifyIntentExpressMiddleware({
secret: process.env.SYNCANIX_INTENT_SECRET!,
}),
);
app.listen(3000);The middleware responds 403 {error: "forbidden", reason: "..."} on failure. On success, the verified payload is attached to req.syncanixIntent.
Quick start — Fastify
// src/server.ts
import Fastify from 'fastify';
import {
boot,
subscribeToHttpChannel,
walkFastify,
type FastifyRouteOptionsLike,
} from '@syncanix/sdk-node';
const fastify = Fastify();
// 1 — collect routes via Fastify's documented onRoute hook
const routes: FastifyRouteOptionsLike[] = [];
fastify.addHook('onRoute', (opts) => routes.push(opts));
// ... register your routes (fastify.get(...), fastify.post(...), etc.)
await fastify.ready();
// 2 — walk the collected routes
const capabilities = walkFastify(routes);
// 3 — boot
await boot({
apiKey: process.env.SYNCANIX_API_KEY!,
capabilities,
frameworks: ['fastify'],
repoRoot: process.cwd(),
scannerVersion: '@syncanix/[email protected]',
});
// 4 — drift detector
subscribeToHttpChannel({
capabilities,
onDrift: (event) => console.warn('[syncanix] drift:', event),
});
await fastify.listen({ port: 3000 });SDK-native tools
For agent-invocable logic that does not live as a public HTTP route, use createToolRegistry():
import { boot, createToolRegistry } from '@syncanix/sdk-node';
const registry = createToolRegistry();
registry.tool({
name: 'refund',
description: 'Refund a customer order.',
handler: async (args, ctx) => {
const { orderId, amount } = args as { orderId: string; amount: number };
// ... your refund logic, scoped to ctx.tenantId
return { ok: true };
},
});
// Compose tools alongside walker output
await boot({
apiKey: process.env.SYNCANIX_API_KEY!,
capabilities: [...walkerCapabilities, ...registry.getRegisteredCapabilities()],
frameworks: ['express', 'sdk-tool'],
repoRoot: process.cwd(),
scannerVersion: '@syncanix/[email protected]',
});
// Invoke at agent-loop time
const result = await registry.invoke(
'refund',
{ orderId: 'o_1', amount: 50 },
{
tenantId: 't_123',
},
);Tool name regex: ^[a-z][a-z0-9-]*$. Hyphens allowed; underscores rejected. Names must be unique per registry — duplicate registration throws.
API key resolution
boot() (via init()) resolves the API key in this priority order:
- The
apiKeyoption passed toboot({...}). - The
SYNCANIX_API_KEYenvironment variable. ~/.syncanix/credentials— convenience for local dev.
Production callers should always set SYNCANIX_API_KEY via your secrets manager (Secrets Manager → CDK → Lambda env var).
Drift detection
subscribeToHttpChannel listens to Node's built-in 'http.server.request.start' channel. For every request whose {method, path} does not match any known capability, the onDrift handler fires once (deduped on <METHOD>:<path> for the lifetime of the subscriber).
Common pattern: buffer drift events and flush every minute via uploadDriftEvents:
import { subscribeToHttpChannel, uploadDriftEvents, type DriftEvent } from '@syncanix/sdk-node';
const buffer: DriftEvent[] = [];
subscribeToHttpChannel({
capabilities,
onDrift: (event) => buffer.push(event),
});
setInterval(async () => {
if (buffer.length === 0) return;
const batch = buffer.splice(0, buffer.length);
await uploadDriftEvents({
apiKey: process.env.SYNCANIX_API_KEY!,
events: batch,
});
}, 60_000).unref();Send notifications
notify() tells an end user a background process finished — even when the chat widget is closed. The widget surfaces it as a launcher badge and, on open, as an assistant message they can reply to. It is one authenticated POST /v1/notifications (secret gak_ key), validated against the shared contract before it leaves your process.
import { notify } from '@syncanix/sdk-node';
await notify({
apiKey: process.env.SYNCANIX_API_KEY!, // gak_… (secret, server-side only)
endUserId: 'user_123', // YOUR stable id for the human
kind: 'deploy.finished', // machine-readable slug
title: 'Your deploy finished', // localize to the user's language
body: 'Build #1820 is live in production.',
action: { label: 'View deploy', url: 'https://app.example.com/deploys/1820' },
dedupeKey: 'deploy-1820', // optional — retries collapse to one
});
// → { id: '…', created: true } (created:false when reused via dedupeKey)title/body are rendered verbatim — localize them before sending. Supply a dedupeKey for anything you might send twice (retried jobs, at-least-once queues); the call is then idempotent and safe to retry. A bad call fails fast client-side with a typed NotifyValidationError before any network I/O. Full contract + non-JS snippets: Send closed-widget notifications.
Live endpoint reporting (witness reporter)
Where drift detection reports only new endpoints, the witness reporter reports
every endpoint your server actually serves — method, path template, and the
inferred request/response shape (never raw bodies) — so the catalog's
runtime-observed surface stays live. It's the dd-trace-style "exporter": the
@syncanix/api-witness middleware observes each request, and the reporter
batches those observations and ships them to the catalog via the
submitWitnessBatch API, authenticated by your API key.
One-call drop-in for Express:
import express from 'express';
import { installExpressWitnessReporter } from '@syncanix/sdk-node';
const app = express();
app.use(express.json());
// Resolves the API key (option → SYNCANIX_API_KEY → ~/.syncanix/credentials),
// installs the witness middleware, and starts the background reporter.
const reporter = await installExpressWitnessReporter(app, { environment: 'production' });
// ...your routes...
// graceful shutdown (e.g. in your SIGTERM handler):
process.on('SIGTERM', () => void reporter.close());Lower-level: wire the middleware and reporter yourself.
import { createWitnessMiddleware } from '@syncanix/api-witness';
import { createWitnessReporter } from '@syncanix/sdk-node';
const reporter = createWitnessReporter({ apiKey: process.env.SYNCANIX_API_KEY! });
app.use(createWitnessMiddleware({ onWitness: (w) => reporter.record(w) }));Serverless (Lambda)
A frozen Lambda can't run a background timer between invocations, so disable it and flush per invocation:
const reporter = createWitnessReporter({ apiKey, flushIntervalMs: 0 });
// ...inside the handler, after observing...
await reporter.flush();Notes
- Never breaks the host app. A failed flush re-queues (bounded by
maxQueueSize) and is surfaced viaonError; it never throws into the request path. The background timer isunref()'d so it never keeps a process alive. - Deduped at the server's granularity (
method+pathTemplate): colliding observations merge their shapes and keep a non-error status over an error one. - Environment is derived from the API key (
gak_<env>_…); passing anenvironmentthat disagrees with the key warns and uses the key's.
Verify-intent envelope
When the Syncanix LLM agent decides to call one of your tools/routes, the server issues a short-lived signed envelope:
header value: X-Syncanix-Intent: base64url(JSON({ payload, signature }))
payload = { toolCallId, tenantId, userId?, method, path, issuedAt, expiresAt }
signature = hex(HMAC-SHA256(JSON(payload), SYNCANIX_INTENT_SECRET))The middleware/guard verifies:
- HMAC signature matches (constant-time compare).
- The intent has not expired (
now <= expiresAt). - The inbound request's
{method, path}matches the signed pair (no replay against a different route).
Failure responses include a stable reason enum (missing-header, malformed, bad-signature, expired, method-mismatch, path-mismatch) so failures are debuggable from CloudWatch logs without leaking token contents.
Boot overhead
boot() targets a <30 ms total overhead (init + catalog build + upload). The function emits a one-line console.warn if the budget is exceeded, and returns per-stage timings for diagnostics:
const result = await boot({ ... });
result.bootDurationMs; // total (ms)
result.initDurationMs; // API-key resolution
result.catalogDurationMs; // build + uploadCustomer infrastructure (cold Lambda, container start, slow CI) may exceed 30 ms legitimately. Raise the budget per-call rather than tolerate noise:
await boot({ ..., budgetMs: 200 });What the SDK does not do
- No static source scan. The CLI (
syncanix init/syncanix scan) covers that surface. The SDK observes only what the live framework registry exposes at runtime. - No handler-source extraction.
Function.prototype.toStringreturns compiled JS that is unhelpful for the LLM enrichment pipeline. The CLI's static scan populates handler text when the same capability is re-scanned at deploy time. - No persistent global state. Every entry point is a pure function — Lambda-friendly. Drift detection is the only stateful surface, and it's bounded by the subscriber's lifetime + the in-memory dedup set.
License
See repository root.
