@znt/sdk-nodejs
v1.0.1
Published
Node.js-only client SDK for the znt-core JSON-RPC IPC API
Maintainers
Readme
znt-sdk-nodejs
Dependency-free, Node.js-only client SDK for the znt-core JSON-RPC 2.0 IPC
API. The SDK uses one reusable Unix socket or Windows named-pipe connection,
supports concurrent requests, preserves structured RPC errors, and includes
TypeScript declarations.
Requirements: Node.js 18 or newer. The SDK owns the znt-core daemon lifecycle:
it reuses a running daemon or starts the installed binary when requested.
This package runs only in a trusted Node.js environment. It imports Node's
node:net module and cannot run directly in browsers, Web Workers, or Vue/React
client bundles. For a web application, use it in a local Node.js backend or an
Electron main/preload process and expose only the required operations to the
frontend.
Vue/browser -> local Node.js host -> znt-sdk-nodejs -> znt-core| Runtime | Supported | |---|---| | Node.js 18+ backend or local host | Yes | | Electron main/preload process | Yes | | Browser or Vue/React renderer | No | | Web Worker | No | | Deno or Bun | Not officially supported |
Install
For local development in this repository:
cd znt-sdk-nodejs
npm install
npm run buildFrom another local package:
npm install ../znt-sdk-nodejsQuick start
Start or reuse the daemon from Node.js:
import { ZntClient } from "znt-sdk-nodejs";
const znt = new ZntClient();
try {
await znt.startCore();
const info = await znt.info();
console.log(info.languages);
const status = await znt.status();
console.log(status);
const results = await znt.semanticSearch({
query: "SemanticService.SearchWithOptions",
mode: "lexical",
limit: 10,
compact: true,
});
console.log(results);
} finally {
znt.disconnect();
}semanticSearch() normalizes the server's valid null no-results response to
an empty array.
startCore() is concurrency-safe per client instance. If no daemon answers at
the configured endpoint, it checks the managed installation and starts
znt-core daemon. It never downloads a binary automatically.
Managed core installation
The SDK checks a per-user installation directory, so application startup does not require administrator privileges:
| Platform | Default directory |
|---|---|
| Linux | $XDG_DATA_HOME/znt/bin, or ~/.local/share/znt/bin |
| macOS | ~/Library/Application Support/Znt/bin |
| Windows | %LOCALAPPDATA%\\Znt\\bin |
The executable is named znt-core on Linux/macOS and znt-core.exe on
Windows. ZNT_CORE_HOME overrides the managed directory;
ZNT_CORE_BINARY overrides the complete executable path for development.
Daemon options can also be provided directly to ZntClient:
const znt = new ZntClient({
configPath: "/absolute/path/to/config.yaml",
daemonStartTimeoutMs: 10_000,
});Downloading is always a separate, explicit operation:
await znt.downloadCore(); // Latest release for the current OS and CPU architecture.The SDK reads the release manifest from znt-app/core, selects the binary for
the current OS and CPU architecture, verifies its SHA-256 checksum, makes it
executable on Unix, and moves it into the managed installation path. Downloading
the binary does not create or overwrite config.yaml; the MCP setup flow creates
the working YAML next to the managed binary. To
install a specific release, pass { version: "1.0" }. The release source is fixed to
the official znt-app/core GitHub repository and cannot be overridden by a URL
or environment variable. Call startCore() separately after installation.
The complete lifecycle is explicit:
if (!(await znt.isCoreInstalled("1.0"))) {
await znt.downloadCore({ version: "1.0" });
}
const defaults = await znt.getDefaultConfig(); // YAML from znt-core.
// A setup UI writes the selected YAML to znt.resolveManagedConfigPath().
await znt.validateConfig(znt.resolveManagedConfigPath(), { checkProvider: true });
await znt.startCore();
// Use the RPC API.
await znt.stopCore();
await znt.removeCore(); // Optional uninstall; does not stop a running daemon.isCoreInstalled() checks the executable independently from configuration.
isCoreConfigured() checks ZNT_CONFIG or the adjacent managed config.yaml.
When an expected version is supplied, isCoreInstalled() also compares the version
stored by downloadCore() in the adjacent znt-core.metadata.json file. A
manually copied executable without version metadata does not satisfy a versioned check.
removeCore() removes both the executable and its metadata and returns whether
an executable was present. Stop the daemon before removing it.
Configuration and setup
The SDK provides methods to inspect and configure znt-core managed configuration, store API keys securely in the OS keyring via znt-core config secret set, and validate the provider setup:
// Inspect configuration status
const status = await znt.getSetupStatus();
console.log(status.status); // "ready" | "unconfigured"
console.log(status.config_managed); // true if managed by SDK, false if explicit external path
console.log(status.model); // model name or "not_required (fast mode)"
console.log(status.recommendations);
// Configure managed config with selected provider and options
const result = await znt.setupManagedConfig({
mode: "openapi", // "openapi" | "ollama" | "bm25"
provider_url: "https://openrouter.ai/api/v1",
api_key: "sk-or-...",
model: "qwen/qwen-2.5-coder-32b-instruct",
embed_model: "bge-m3",
semantic_mode: "llm", // "llm" | "fast"
description_language: "ru",
exclude: ["**/dist/**"],
languages: {
go: { exclude: ["**/*_test.go"] },
},
check_provider: true,
});
console.log(result.status); // "ready"The setup helper:
- Loads default YAML template from
znt-core config defaultsor merges with existing configuration. - Applies optimized concurrency, throttling, retry, and payload bounds (
retry_delays_seconds: [60, 120, 300],max_bytes: 25000,max_embed_bytes: 63768,semantic_workers: 6,delay_ms: 10). - Writes the configuration atomically to
config.yamland validates it withznt-core config validate. - Saves API keys directly to the OS keyring using
znt-core config secret setover stdin (no plain text secret in the YAML file). - Validates provider connectivity when
check_provider: true. - When
semantic_mode: "fast", chat LLM generation is not required (AST heuristics are used);status.modelexplicitly reports"not_required (fast mode)"if no chat model is configured.
Endpoint selection
const znt = new ZntClient({
endpoint: "/custom/path/znt.sock",
connectTimeoutMs: 5_000,
requestTimeoutMs: 30_000,
});Resolution order when endpoint is omitted:
ZNT_ENDPOINT;~/.znt/znt.sockon Linux/macOS;\\.\pipe\znt-coreon Windows.
ZNT_RUNTIME_DIR changes znt-core process files such as znt.log, but does
not change the default endpoint. Set ZNT_ENDPOINT on both processes when a
custom endpoint is required.
Scanning
scan() returns immediately with a scan_id:
const started = await znt.scan({
file_path: "/absolute/path/to/workspace",
language: "auto",
});
console.log(started.scan_id);Set restart: true to remove only the workspace .znt index and rebuild it
from scratch:
await znt.scanAndWait({
file_path: "/absolute/path/to/workspace",
language: "auto",
restart: true,
});info().config contains the absolute path of the configuration file loaded by
the active daemon. Pass configPath to ZntClient or set ZNT_CONFIG before
calling startCore(). The SDK forwards the path when it launches core but
does not own or modify the configuration file.
Use scanAndWait() when the caller must wait for semantic indexing and HNSW:
const completed = await znt.scanAndWait(
{ file_path: "/absolute/path/to/workspace", language: "auto" },
{ timeoutMs: 10 * 60_000, pollIntervalMs: 250 },
);
console.log(completed.phase); // completedscan_status describes the daemon's current or most recent global scan. The
SDK verifies that its scan_id remains current while scanAndWait() polls.
API
| SDK method | JSON-RPC method |
|---|---|
| startCore() | Reuse or start the installed znt-core process |
| stopCore() | Request daemon shutdown and disconnect the SDK client |
| downloadCore(options) | Explicitly download and install a core artifact |
| isCoreInstalled(expectedVersion?) | Check executable presence and optionally its installed version |
| isCoreConfigured() | Check whether the selected YAML configuration exists |
| resolveManagedConfigPath() | Return the adjacent managed config.yaml path |
| getDefaultConfig() | Return canonical default YAML emitted by core |
| validateConfig(path, options) | Validate YAML and optionally check the provider |
| storeSecret(reference, secret) | Store a credential through core stdin without process arguments |
| removeCore() | Remove the installed executable and version metadata |
| info() | info |
| status() | status |
| logs() | logs |
| watchStatus(options) | Repeated status polling |
| watchScanStatus(options) | Repeated scan_status polling |
| watchLogs(options) | Repeated logs polling |
| scan(params) | scan |
| scanStatus() | scan_status |
| semanticSearch(params) | znatok_semantic_search |
| findSimilar(params) | znatok_find_similar |
| getSubgraph(params) | znatok_get_subgraph |
| fileOutline(params) | znatok_file_outline |
| shutdown() | shutdown |
| call(method, params) | Any current or future JSON-RPC method |
Parameter names intentionally match the wire specification (file_path,
include_code, edge_types, and so on), while SDK method names follow normal
JavaScript camelCase conventions.
Logs
const snapshot = await znt.logs();
for (const entry of snapshot.entries) {
console.log(`${entry.time} [${entry.level}] ${entry.message}`);
}
const delta = await znt.logs({
stream_id: snapshot.stream_id,
after_id: snapshot.next_cursor,
});The first logs() call returns the current in-memory snapshot. Subsequent calls
must pass both stream_id and after_id and return only newer entries. A
changed stream_id or an expired cursor returns the complete retained buffer
with truncated: true. The buffer contains up to 100 entries and is reset when
the daemon restarts; the persistent znt.log file is not read by this method.
Polling streams
The SDK can expose status, scan progress, and log snapshots as async iterables. These helpers use repeated JSON-RPC calls over the existing persistent IPC connection; they do not require WebSocket support in znt-core.
const controller = new AbortController();
for await (const status of znt.watchStatus({
intervalMs: 1_000,
signal: controller.signal,
})) {
console.log(status.status, status.files);
}The same pattern is available through watchScanStatus() and watchLogs():
for await (const batch of znt.watchLogs({ intervalMs: 500 })) {
if (batch.truncated) {
replaceActivityLog(batch.entries);
} else {
appendActivityLog(batch.entries);
}
}watchLogs() manages stream_id and after_id automatically. Its first value
is the current snapshot; later values contain only new entries. With the
default distinct: true, empty unchanged polling responses are not yielded.
Polling options are:
intervalMs: delay between requests; defaults to 1 second for status/logs and 250 ms for scan progress;emitInitial: whentrue(default), request the first snapshot immediately;distinct: whentrue(default), skip snapshots identical to the last one;timeoutMs: timeout of each individual JSON-RPC request;signal: stops polling withZntAbortErrorwhen aborted.
Leaving a for await loop stops that iterator cleanly. For prompt external
cancellation while it is sleeping or waiting for a response, use an
AbortController. A transport, protocol, RPC, or request-timeout error ends the
iterator and is propagated to the consumer.
Each watchLogs() value retains the server metadata (stream_id,
next_cursor, and truncated) so consumers can replace their local state when
continuity was lost.
Similar implementations
const similar = await znt.findSimilar({
target: "pkg/semantic/service.go::SemanticService.SearchWithOptions",
edge_types: "contains,call",
include_code: true,
});Subgraph
const graph = await znt.getSubgraph({
from: "SemanticService.SearchWithOptions",
depth: 2,
edge_types: "call,contains",
format: "mermaid",
});
if (graph.format === "mermaid") {
console.log(graph.mermaid);
}The TypeScript result is a discriminated union: text has only text, json
has nodes and edges, and mermaid has only mermaid.
File outline
const outline = await znt.fileOutline({
file_path: "internal/engine/watcher.go",
include_code: true,
});
console.log(outline.formatted_text);Errors, timeout, and cancellation
import { ZntRpcError, ZntTimeoutError } from "znt-sdk-nodejs";
try {
await znt.semanticSearch({ query: "Search", mode: "bogus" });
} catch (error) {
if (error instanceof ZntRpcError) {
console.error(error.code, error.message, error.data);
if (error.retryable) {
// Only -32001 Busy is marked retryable.
}
} else if (error instanceof ZntTimeoutError) {
console.error(error.timeoutMs);
}
}Every request accepts timeoutMs and AbortSignal:
const controller = new AbortController();
const request = znt.status({ timeoutMs: 2_000, signal: controller.signal });
controller.abort();
await request;Error classes:
ZntRpcError: server error withcode,data,method, andrequestId;ZntTransportError: connection or socket failure;ZntProtocolError: malformed response or oversized request;ZntTimeoutError: client-side request or scan timeout;ZntAbortError: cancellation throughAbortSignal;ZntScanError: terminal scan failure with the complete progress DTO.
Low-level connection
ZntConnection is exported for clients that only need raw typed calls:
import { ZntConnection } from "znt-sdk-nodejs";
const connection = new ZntConnection();
const info = await connection.call("info", {});
connection.disconnect();Tests
npm test
npm run test:live
npm pack --dry-runtest:live uses the endpoint selected by ZNT_ENDPOINT or the platform
default and never calls scan or shutdown.
The authoritative wire contract is
../znt-core/docs/sdk-specification.md.
