npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

lightnode-sdk

v0.21.0

Published

Community TypeScript SDK + CLI for LightChain AI: end-to-end encrypted on-chain inference (with optional web search), multi-turn chat, batch + agent runners, read-only network analytics, an Ethereum bridge and LCAI Governor wrapper, and a full worker-oper

Downloads

446

Readme

lightnode-sdk

npm License: MIT

The community SDK for LightChain AI. Encrypted on-chain inference, network analytics, multi-turn chat, an Ethereum bridge wrapper, an LCAI Governor client, an on-chain model registry reader, worker preflight + watch, a full worker-operator surface (register, stake, settle, stuck-job recovery, exit), and a bundled CLI (npx lightnode-sdk). Non-custodial. Pure JS (works in Node 18+, browsers, StackBlitz, Cloudflare Workers, Bun). Built on viem.

npm install lightnode-sdk viem

LightChain's own docs list official SDKs as "soon"; this fills the gap. Not affiliated with LightChain.

New to blockchain or Node.js? Read the Getting Started guide first. It covers wallets, testnet vs mainnet, the .env file, and your first AI call in about 5 minutes. Then come back here for the full reference. The rest of this README assumes you're comfortable with TypeScript and a terminal.

Five-line "hello world"

import { runInferenceWithKey } from "lightnode-sdk";

const { answer, txs } = await runInferenceWithKey({
  network: "testnet",                                  // or "mainnet"
  privateKey: process.env.PRIVATE_KEY as `0x${string}`,
  prompt: "Reply with a one-sentence fun fact about the ocean.",
});

console.log(answer);              // the decrypted reply
console.log(txs.createSession);   // on-chain receipts

In the browser this works as-is. In Node, ws is auto-detected if installed, so you don't need to pass a WebSocket explicitly.

What's in the SDK

Inference (paid)

| API | Use when | |---|---| | runInferenceWithKey({ network, privateKey, prompt, ... }) | One call from a wallet. The SDK builds viem clients, runs SIWE, encrypts, signs, decrypts. ~5 lines total. | | runInference({ gateway, wallet, publicClient, network, prompt, ... }) | You already have viem clients + a SIWE JWT. Same internals, no setup duplication. The /playground uses this with a Reown wallet. | | runInferenceStream({ network, privateKey, prompt, ... }) | Modern AsyncIterable<string> of chunks plus a done promise for the final receipt. for await (const chunk of stream) ... | | Conversation / chat({ network, privateKey }) | Multi-turn chat helper. Keeps history client-side and (new in 0.19.0) keeps one session open across turns: the first send runs SIWE + the one createSession tx, and every follow-up costs a single submitJob. When the session window expires or the bound worker fails, the next send transparently reopens a fresh session and retries the turn once. currentSession() exposes the bound session (worker, sessionId, expiry) or null before the first send. Optional system prompt, maxHistoryTurns cap. | | connectWithKey({ network, privateKey, ... }) (new in 0.19.0) | The setup half of runInferenceWithKey, reusable on its own: validates the key, builds viem clients, runs the SIWE handshake against the consumer gateway, resolves a WebSocket ctor, and returns a KeyConnection ({ gateway, wallet, publicClient, network, networkId, WebSocket }). Pair it with openSession / runJobOnSession for custom session flows - it is exactly what Conversation does under the hood. | | prepareSession, submitPrompt, decryptResponse | Lowest-level: drive the protocol step by step. Build custom retry, batching, multi-turn-with-session-reuse on top. |

The four high-level entry points (runInference, runInferenceWithKey, runInferenceStream, Conversation) share:

  • Auto-retry on StalledWorkerError (default 2 retries, configurable).
  • Auto-resolve globalThis.WebSocket in browsers, dynamic-import ws in Node.
  • Streaming via onChunk(piece, totalSoFar) callback.
  • Live phase reporting via onStage(stage) ("Uploading prompt to chain...", "Thinking...", and when search is on, "Searching the web + uploading prompt...").
  • Optional web search: pass searchEnabled: true to route the job to a search-capable worker and get sources (a WebSearchSource[] of citations) back on the result. See Web search below.
  • Byte-perfect crypto vs LightChain's reference client (ECDH P-256 + raw 32-byte shared secret + AES-256-GCM, @noble/curves and @noble/ciphers under the hood).

Web search (new in 0.9.0)

Opt a paid inference into worker-side web search. The job is routed to a worker that advertises the search capability; the decrypted result carries sources, a typed list of citations the worker used.

import { runInferenceWithKey } from "lightnode-sdk";

const { answer, sources } = await runInferenceWithKey({
  network: "mainnet",
  privateKey: process.env.PRIVATE_KEY as `0x${string}`,
  prompt: "What shipped in the latest LightChain release? Cite sources.",
  searchEnabled: true,
  onStage: (stage) => console.log(stage),   // "Searching the web + uploading prompt..."
});

console.log(answer);
for (const s of sources ?? []) console.log(s.title, s.url);

searchEnabled is available on runInference, runInferenceWithKey, runInferenceStream, and Conversation. When no search-capable worker is available the call surfaces a normal stalled/failed error instead of silently dropping the search. sources is undefined when the worker returns none.

Read-only LightNode client (free, no key)

import { LightNode } from "lightnode-sdk";
const ln = new LightNode("mainnet"); // or "testnet" or a custom NetworkConfig

await ln.getNetworkStats();              // totals + active count + earnings
await ln.getModels();                    // ModelInfo[] (name, fee, max tokens)
await ln.getWorkers(200);                // Worker[], busiest first
await ln.getWorker("0x...");             // one worker record (or null)
await ln.getWorkerJobs("0x...", 20);     // recent jobs for one worker
await ln.getModelStats(1000);            // per-model completion / p50 / p95
await ln.getWorkerStats(1000, 25);       // per-worker reliability
await ln.getNetworkAnalytics(1000);      // network-wide rollup
await ln.isRegistered("0x...");          // chain-truth registration (no indexer lag)
await ln.getEarningsLcai("0x...");       // settled earnings in LCAI
await ln.estimateFee("llama3-8b");       // live per-job fee from AIConfig
await ln.modelId("llama3-8b");           // keccak256 of the model tag
await ln.getJobStatus(1234n);            // category + refundable flag (new in 0.5.0)
await ln.getWorkerLiveness("0x...");     // stuck-job + slash-risk diagnostic (new in 0.11.0)
await ln.getWorkerActions("0x...");      // action center: gas, claimable, settle, to-do (new in 0.12.0)
await ln.getServedModels("0x...");       // models served, reconciled vs chain (onchainEligible)
await ln.getWorkerModels("0x...");       // raw on-chain model whitelist rows
await ln.getJobOnchain(1234n);           // authoritative on-chain job struct (new in 0.13.0)
await ln.getWorkersBatch(["0x..","0x.."]); // many workers, bounded concurrency (new in 0.13.0)
await ln.getJobStatusesBatch([1, 2, 3]); // many job statuses at once (new in 0.13.0)
ln.gateway({ bearer });                  // pre-configured GatewayClient

Plus the bare-metal aggregators (aggregateModelStats, aggregateWorkerStats, networkAnalytics), CSV exporters (modelStatsCsv, workerStatsCsv, workerJobsCsv), and utility helpers (toWei / fromWei, checksum, isValidAddress, truncateAddress, mapWithConcurrency) for reporting, dashboards, and scripts (new in 0.13.0).

Reliability (new in 0.14.0). new LightNode("mainnet", { cacheTtlMs: 30_000 }) TTL-memoizes the network-wide reads so a polling dashboard stops re-hitting the indexer every render (ln.clearCache() to force a refetch). The GatewayClient auto-retries 429 (any method) and 5xx (GETs only - a POST is never replayed, so a selectSession can't double-select) with exponential backoff (honoring Retry-After), configurable via { retry: { maxRetries, baseDelayMs } }; GatewayHttpError carries isRateLimited / isAuthError / isServerError + retryAfterMs.

Tuning (new in 0.15.0; viem reads honored in 0.18.0). new LightNode("mainnet", { timeoutMs: 30_000 }) bounds every network read with your own deadline (the built-in defaults are DEFAULT_SUBGRAPH_TIMEOUT_MS = 12s and DEFAULT_ONCHAIN_TIMEOUT_MS = 8s, both exported). Raise it for a slow/congested indexer, pass a small value to fail fast in a UI, or <= 0 to disable the deadline on the subgraph + raw on-chain reads. As of 0.18.0 a positive timeoutMs is also applied to the viem transport behind the viem-backed reads (getJobOnchain, getWorkerLiveness / getWorkerActions), so the whole call honors one timeout; with <= 0 those reads fall back to viem's own default (viem has no unbounded mode).

Worker liveness / stuck-job diagnostic (new in 0.11.0)

Surfaces the failure that is otherwise invisible until the stake is slashed: a worker that is registered and staked but has gone offline and is no longer acknowledging the jobs the chain assigns it. getWorkerLiveness classifies the worker's recent jobs against the LIVE protocol timeouts (read from AIConfig) and flags two stuck states - unacked (Submitted, past the ack deadline; the case plain job buckets miss, so an offline worker reads as merely idle) and incomplete (Acknowledged, past the completion deadline) - with the slash exposure and suspension risk. Read-only; no key.

import { LightNode } from "lightnode-sdk";

const ln = new LightNode("mainnet");
const r = await ln.getWorkerLiveness("0x...");
if (r.liveness === "stalled") {
  console.log(r.summary);            // "3 assigned but never acknowledged (worker offline), up to ~3000 LCAI at risk..."
  console.log(r.unackedCount, r.incompleteCount, r.slashExposureLcai, r.suspensionRisk);
  for (const j of r.stuckJobs) console.log(j.id, j.kind, j.pastDeadlineSec);
}

analyzeWorkerLiveness({ worker, jobs, config }) is the pure classifier behind it if you already hold the subgraph rows and WorkerOperator.config(). The report also carries an activity signal (active | processing | stalled | idle | unknown) derived from the job flow - an honest, gateway-free read of whether a worker is processing jobs (a recent completion = active; an acked job in flight = processing), useful for remote workers where there is no container status.

Worker action center (new in 0.12.0)

One read-only rollup of what an operator should do right now: claimable earnings, the worker WALLET's gas balance (and an outOfGas flag - the thing that silently blocks every settle / claim / deregister when the wallet is empty), which completed jobs are settleable now vs still in their dispute window, the liveness / stuck-job picture, and a prioritized to-do list. getWorkerActions reads the balances and live config from chain. Read-only; no key.

import { LightNode } from "lightnode-sdk";

const ln = new LightNode("mainnet");
const a = await ln.getWorkerActions("0x...");
console.log(a.summary);                       // e.g. "Fund the worker wallet to pay gas (+3 more)"
console.log(a.outOfGas, a.walletGasLcai, a.claimableLcai);
console.log(a.settlement.releasableNowCount, a.settlement.inWindowCount);
for (const todo of a.actions) console.log(todo.urgency, todo.kind, todo.title);

analyzeWorkerActions({ worker, jobs, status, walletGasWei, config }) is the pure composer, and analyzeSettlement(jobs, config) classifies completed jobs into settle-now vs still-in-dispute-window on their own.

Bridge SDK (new in 0.5.0)

Typed wrapper around the LightChain Hyperlane Warp Route bridge.

import { Bridge, BRIDGE_ROUTE } from "lightnode-sdk";
import { createPublicClient, createWalletClient, http, parseEther } from "viem";
import { privateKeyToAccount } from "viem/accounts";

const account = privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`);
const ethPub = createPublicClient({ transport: http(BRIDGE_ROUTE.ethereum.rpc) });
const ethWal = createWalletClient({ account, transport: http(BRIDGE_ROUTE.ethereum.rpc) });

// Real viem clients pass straight in - no casts needed.
const bridge = new Bridge(ethPub, ethWal);

// Quote the Hyperlane gas payment for one message
const fee = await bridge.quoteFee("ethereum", "lightchain-mainnet");

// One-time ERC-20 approval (MaxUint256 by default)
await bridge.approve();

// Send 100 LCAI to your own address on LightChain mainnet
await bridge.transfer({
  from: "ethereum",
  to: "lightchain-mainnet",
  amount: parseEther("100"),
  recipient: account.address,
  fee,
});

For the reverse direction, wire BRIDGE_ROUTE["lightchain-mainnet"].rpc instead and from: "lightchain-mainnet". The SDK attaches native LCAI as value automatically.

Confirmed addresses (baked in): | Side | Role | Address | |------|------|---------| | Ethereum | HypERC20Collateral | 0x01f80bb8e78e79881E8Ec7832fB6C2c59f64e353 | | Ethereum | LCAI ERC-20 | 0x9cA8530CA349c966Fe9ef903Df17a75B8A778927 | | LightChain | HypNative | 0xEc7096A3116EE769457C939617375Ec1785AA6f1 |

DAO SDK (new in 0.5.0)

OpenZeppelin Governor v5 wrapper. The SDK supports both deployed LCAI governors - pick one with the DaoChain key:

  • "ethereum": the LCAIGovernor on Ethereum mainnet (chain 1). Voting power comes from LCAI ERC-20 wrapped as LCAI-Ballots (IVotes) at https://ballots.lightchain.ai.
  • "lightchain": the governor on LightChain mainnet (chain 9200). Native LCAI itself votes via the NativeVotes precompile (0x...1001) - no wrapping step.
import { DAO, VoteSupport } from "lightnode-sdk";
import { createPublicClient, createWalletClient, http } from "viem";
import { privateKeyToAccount } from "viem/accounts";

// Read. Pass "lightchain" + a chain-9200 RPC for the LightChain governor.
const publicClient = createPublicClient({ transport: http("https://ethereum-rpc.publicnode.com") });
const dao = new DAO(publicClient, "ethereum");
const cfg = await dao.config();                   // delay / period / threshold
const p = await dao.proposal(12345n);             // state + votes + key blocks
console.log(p.stateLabel);                        // "active" | "queued" | ...

// Write (needs a wallet). Real viem clients pass straight in - no casts.
const account = privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`);
const walletClient = createWalletClient({ account, transport: http("https://ethereum-rpc.publicnode.com") });
const daoRW = new DAO(publicClient, "ethereum", walletClient);
await daoRW.castVote(12345n, VoteSupport.For, "I support this");

const targets: `0x${string}`[] = [dao.addresses.treasury];
const values = [0n];
const calldatas: `0x${string}`[] = ["0x"];
const description = "Example: empty call against the treasury";
await daoRW.propose({ targets, values, calldatas, description });
const descriptionHash = daoRW.descriptionHash(description);
await daoRW.queue({ targets, values, calldatas, descriptionHash });
await daoRW.execute({ targets, values, calldatas, descriptionHash });

Confirmed addresses (baked in, exported as DAO_ADDRESSES):

| Role | Ethereum mainnet ("ethereum") | LightChain mainnet ("lightchain") | |------|------|------| | Governor | 0x6dfa413B5900a1a7947BC75E68AbBA093cB2492d | 0x262E9f9232933E8565253918db703baD58DE93aB | | Timelock | 0xbE1c37F8C4DA77dD06F4A8AC5098Ec70273093d7 | 0x79e571420c5473Ca9b0FCd599B1b0062D7793c97 | | Votes | LCAIBallots 0x75F3D01c4D960FE986A598B7954A3b786B29cE49 | NativeVotes precompile 0x0000000000000000000000000000000000001001 | | Token | LCAI ERC-20 0x9cA8530CA349c966Fe9ef903Df17a75B8A778927 | native LCAI | | Treasury | 0x07A716a551E5f4CA7D6C71Da9dF1cb1429Dba826 | 0x786eDe8C42Ca54E54c9dCECa9b30052CF4743389 |

Voting params for the Ethereum governor (live-read via dao.config()): ~1 day delay, ~14 day period, 140k LCAI threshold, 3% quorum.

On-chain Model Registry reader (new in 0.5.0)

Typed reader for AIVMModelRegistry + BenchmarkRegistry. Since LightChain has not published a public deployment address, you pass yours explicitly:

import { OnchainModelRegistry, MODEL_STATUS_LABEL } from "lightnode-sdk";

const reader = new OnchainModelRegistry({
  publicClient,
  registry: "0x...",       // AIVMModelRegistry deployment
  benchmarks: "0x...",     // optional, only for benchmark methods
});

const baseIds = await reader.getBaseModelIds();
const variantIds = await reader.getAllVariants();
const variant = await reader.getVariant("...");
const policy = await reader.getAccessPolicy("...");   // tier: "free" | "paywalled" | "ticket-gated"
const variants = await reader.getVariantsForBaseModel(baseId);

Surfaces the full ABI for both contracts plus a builder-friendly tier heuristic derived from the raw AccessPolicyConfig.

Worker preflight + watch (new in 0.5.0)

Remote operational SDK for the worker network. No SSH, no Docker. Works from any machine with a funded wallet (preflight) or no key at all (watch).

import { workerPreflight, workerWatch, LightNode } from "lightnode-sdk";

// One real test inference. Returns verdict, elapsed time, on-chain receipts.
const r = await workerPreflight({
  network: "testnet",
  privateKey: process.env.PRIVATE_KEY!,
  model: "llama3-8b",
  deadlineMs: 60_000,
});
console.log(r.verdict);    // "ok" | "over-deadline" | "stalled" | "failed"
console.log(r.summary);    // human one-liner
console.log(r.txs);        // createSession + submitJob + jobCompleted

// Watch a worker's on-chain + indexer state. AsyncIterable of events.
const ln = new LightNode("mainnet");
const handle = workerWatch(ln, "0xWorker...", { intervalMs: 30_000 });
for await (const event of handle.events) {
  console.log(event.kind);   // "snapshot" | "registered" | "went-stale" | "back-online" | "jobs-completed" | "earnings-up"
  console.log(event.state);  // { registered, lastSeenSecsAgo, jobsCompleted, earningsLcai, ... }
}

Worker operator (new in 0.7.0)

The write/ops side of running a worker - the on-chain actions that are otherwise only reachable through the multi-GB worker Docker image, or by reverse-engineering the unverified contracts. Pure RPC: run it from a laptop, a server, or CI with no worker image at all. This complements (does not replace) workerPreflight/workerWatch above.

Its flagship is stuck-job recovery. When a worker acknowledges a job but never completes it (Ollama down, machine asleep), that job sits Acknowledged forever and blocks deregistration - and no official tool clears it. The JobRegistry's claimTimeout is permissionless, so the operator can self-clear it. unstickAndDeregister() is the one-call rescue.

The second thing it gets right is gas-correct writes. The worker daemon under-sets the gas limit on its WorkerRegistry writes, so addSupportedModel and deregisterWorker run out of gas and revert on-chain - the daemon reports a failure (or, for deregister, some indexers still flip the worker to "deregistered" while the stake never moves). Every write here estimates the gas first and sends with a margin, so the transaction lands. addModel() is the gas-correct version of the model-add the daemon botches; deregister() is the gas-correct exit.

import { WorkerOperator } from "lightnode-sdk";
import { createPublicClient, createWalletClient, http } from "viem";
import { privateKeyToAccount } from "viem/accounts";

const chain = { id: 8200, name: "LC Testnet",
  nativeCurrency: { name: "LCAI", symbol: "LCAI", decimals: 18 },
  rpcUrls: { default: { http: ["https://rpc.testnet.lightchain.ai"] } } };
const account = privateKeyToAccount(process.env.WORKER_KEY as `0x${string}`);
const publicClient = createPublicClient({ transport: http(chain.rpcUrls.default.http[0]), chain });
const walletClient = createWalletClient({ account, transport: http(chain.rpcUrls.default.http[0]), chain });

// Real viem clients pass straight in - no casts needed.
const op = new WorkerOperator("testnet", { publicClient, walletClient });

// Reads (no wallet needed): status, live protocol config, typed jobs.
await op.status();              // { registered, stakeLcai, claimableLcai, belowFloor, ... }
await op.config();              // live AIConfig: minStake, timeouts, slashBps, fee split
await op.getJob(974);           // typed Job { state, worker, escrowedFeeWei, timestamps, ... }

// Pre-flight gating - know WHY before you spend gas. Pass the worker's job IDs
// (from LightNode.getWorkerJobs / the subgraph).
await op.canDeregister([974, 976, 978, 979]);   // { ok, blockedBy: [974, 976], reason }

// Settlement + exit, Docker-free. clearStuck/releaseAll share one result shape:
//   { done: [{ jobId, tx }], skipped: [{ jobId, reason }] }
const { done, skipped } = await op.releaseAll([978, 979]); // settle completed jobs past their window
//   done    -> the jobs settled, with tx hashes
//   skipped -> completed jobs still inside the dispute window (each with the reason)
await op.withdraw();             // pull earned balance into the worker wallet

// The rescue: clear stuck acked jobs, then deregister + withdraw, in one call.
await op.unstickAndDeregister([974, 976, 978, 979]);

Full method reference (jobIds are the worker's IDs from LightNode.getWorkerJobs or the subgraph):

| Method | Wallet | What it does | |---|---|---| | status() | no | registration, stake, claimable balance, below-floor flag | | config() | no | live AIConfig: minStake, timeouts, slash bps, fee split | | getJob(id) | no | one job as a typed OnchainJob struct | | stuckJobs(jobIds) | no | the acked, past-deadline jobs (with seconds past deadline) | | canDeregister(jobIds) | no | { ok, blockedBy, reason } without sending a tx | | earnings({ ... }) | no | claimable now vs lifetime vs pending-release | | profitability({ ... }) | no | per-job worker fee net of gas, from the live fee split | | register(encryptionPubKey, opts?) | yes | (new in 0.19.0) register THIS wallet as a worker, staking native LCAI in the same tx (registerWorker is payable). Stake defaults to the LIVE AIConfig minimum; pass opts.stakeWei for more headroom. Pre-flights via eth_call so an ineligible registration reverts with a decoded reason before gas is spent. Follow with addModel() so the dispatcher can route jobs to the worker | | claimTimeout(id) | yes | time out one stuck job (mainnet: realizes a slash) | | clearStuck(jobIds) | yes | claimTimeout every past-deadline acked job; { done, skipped } (skipped = acked but not yet past deadline) | | releaseJob(id) | yes | settle one completed job past its window | | releaseAll(jobIds) | yes | settle all releasable completed jobs; { done, skipped } (skipped = still in the dispute window) | | withdraw() | yes | pull the earned balance into the worker wallet | | topUpStake(lcai) | yes | add stake | | withdrawStake(lcai) | yes | remove stake above the floor | | addModel(tagOrId) | yes | add a supported model to a registered worker on-chain, gas-correct (no-op if already served) | | reinstate() | yes | reactivate a suspended worker | | deregister() | yes | exit and release stake, gas-correct (reverts if in-flight jobs remain) | | unstickAndDeregister(jobIds) | yes | clear stuck + release + withdraw + deregister, in one call |

Result shape. clearStuck and releaseAll return the same exported BatchJobOpResult, so you don't special-case per method:

interface BatchJobOpResult {
  done: Array<{ jobId: bigint; tx: `0x${string}` }>;     // acted on, with the tx
  skipped: Array<{ jobId: bigint; reason: string }>;      // left alone, and why
}

done lists the jobs the op settled/cleared on-chain (with tx hashes); skipped lists the ones it deliberately left alone, each with a plain-English reason (e.g. "still inside the dispute window", "not yet past the completion deadline").

Mainnet slashing. claimTimeout / clearStuck / unstickAndDeregister finalize a stuck job as TimedOut, which realizes the completion-timeout slash on mainnet (config().slashBps.completionTimeout, 5% of stake per job at writing). Testnet has slashing disabled. It is the deliberate price of unblocking an exit a stuck job would otherwise block forever - only clear jobs you accept are lost.

Decoded reverts. The WorkerRegistry/JobRegistry custom errors aren't in the 4byte directory; decodeWorkerError(revertData) turns them into a sentence + the fix, and every write throws a WorkerOpError carrying the decoded cause:

| Error | Meaning | |---|---| | ActiveJobsExist(worker, n) | deregister blocked by n in-flight jobs - clearStuck() them first | | DisputeWindowNotElapsed(jobId, releaseAt, now) | releaseJob too early - retry after the window | | InsufficientStake(requested, available) | withdrawStake below the floor - topUpStake() + reinstate() | | WorkerNotRegistered(addr) | not a registered worker |

Scope: this is the operator surface (register/stake/settle/recover/exit). It does not serve jobs - that's the official Go worker daemon. Contracts are unverified and may change; treat as 0.x and lean on decodeWorkerError to surface drift.

Batch runner (new in 0.6.0)

Fan out many prompts as parallel encrypted inferences. Capped concurrency, stable result order, per-slot errors so one stalled worker does not kill the batch.

import { runInferenceBatch } from "lightnode-sdk";

const results = await runInferenceBatch({
  network: "testnet",
  privateKey: process.env.PRIVATE_KEY!,
  model: "llama3-8b",
  system: "Reply in one short sentence.",
  concurrency: 4,
  prompts: [
    "one-line fact about the ocean",
    "one-line fact about the moon",
    "one-line fact about coffee",
  ],
  onSlotComplete: ({ index, result, error }) => {
    console.log(`#${index}`, error?.message ?? result?.answer);
  },
});

for (const r of results) {
  if (r.error) console.warn(`slot ${r.index} failed:`, r.error.message);
  else console.log(r.result.answer);
}

Fits: batch evals, content scoring, RAG re-ranking, parallel rewrites. Pass {signal} (AbortSignal) to cancel queued slots mid-run.

Agent class (new in 0.6.0)

ReAct-style tool calling on top of runInferenceWithKey. The model emits <tool>name {"k":"v"}</tool> or <answer>...</answer>; the SDK parses, runs the handler, threads the observation back. Works on small open models (llama3-8b) without native function calling.

import { Agent } from "lightnode-sdk";

const agent = new Agent({
  network: "testnet",
  privateKey: process.env.PRIVATE_KEY!,
  model: "llama3-8b",
  system: "You are a careful research assistant.",
  tools: [
    {
      name: "add",
      description: "Add two integers and return the sum.",
      args: { a: "first integer", b: "second integer" },
      handler: ({ a, b }) => Number(a) + Number(b),
    },
  ],
  maxIterations: 3,
  onStep: (step) => console.log(step.kind, step),
});

const { answer, steps, hitLimit } = await agent.run("What is 17 + 25?");
console.log(answer);   // "42"
console.log(steps);    // [{ kind: "tool_call", ... }, { kind: "answer", text: "42" }]

Each iteration is one inference (one on-chain submitJob); cap maxIterations to keep wall-clock + cost bounded. Tool handlers are plain functions that may be async; return JSON-serializable data so the model can read the observation.

Cancellation (new in 0.6.0; mid-stream in 0.16.0)

runInference and runInferenceWithKey accept an AbortSignal. As of 0.16.0 the signal is honored at every await - the SIWE handshake, the relay-token poll, the WebSocket connect, and the mid-stream wait for JobCompleted - so a cancel stops the work promptly (and closes the relay socket) instead of running the poll loops out to their deadlines. In-flight on-chain transactions still settle (the protocol is the source of truth); the SDK just stops awaiting. It rejects with an InferenceAbortedError whose name is the web-standard "AbortError", so isAbortError(e) (or e.name === "AbortError") detects it.

import { runInferenceWithKey, isAbortError } from "lightnode-sdk";

const controller = new AbortController();
setTimeout(() => controller.abort(), 15_000);

try {
  await runInferenceWithKey({
    network: "testnet",
    privateKey: process.env.PRIVATE_KEY!,
    prompt: "short answer please",
    signal: controller.signal,
  });
} catch (e) {
  if (isAbortError(e)) console.log("cancelled by the caller");
  else throw e;
}

Reactive token refresh (new in 0.16.0)

Pass a function bearer to GatewayClient (or ln.gateway({ bearer })) and the SDK calls it fresh per request. If the gateway rejects a token with 401, the SDK calls the thunk once more with { forceRefresh: true } and replays the request - so a server-side revocation or clock-skew expiry recovers without the caller catching the error. A static-string bearer can't refresh, so its 401 surfaces immediately.

let cached: string | null = null;
const bearer = async ({ forceRefresh } = {}) => {
  if (forceRefresh || !cached) cached = await siweSignIn(/* ... */).then((s) => s.token);
  return cached;
};
const gw = ln.gateway({ bearer });

Typed errors

import { isStalledWorker, isAbortError, StalledWorkerError, OnChainRevertError, RelayTokenTimeoutError, GatewayAuthError, InferenceAbortedError } from "lightnode-sdk";

try {
  await runInferenceWithKey({ ... });
} catch (e) {
  if (isStalledWorker(e)) { /* worker never produced an answer; protocol refunds */ }
  // ...
}

| Error | When | |---|---| | StalledWorkerError | Worker ack'd then went silent. After maxRetries, raised. Protocol refunds. | | OnChainRevertError | createSession or submitJob reverted. Includes the tx hash. | | RelayTokenTimeoutError | Gateway dispatcher never issued the relay JWT (transient). | | GatewayAuthError | SIWE handshake or JWT issue. Re-auth and retry. | | InferenceAbortedError | Cancelled via an AbortSignal. name is "AbortError"; detect with isAbortError(e). | | GatewayHttpError | Non-2xx from the GatewayClient. Classified: isRateLimited / isAuthError / isServerError + retryAfterMs. |

Direct GatewayClient calls throw GatewayHttpError; branch on the classifier instead of the raw status:

import { GatewayHttpError } from "lightnode-sdk";

try {
  await gw.selectSession(modelId);
} catch (e) {
  if (e instanceof GatewayHttpError) {
    if (e.isRateLimited) await sleep(e.retryAfterMs ?? 1000); // respect Retry-After
    else if (e.isAuthError) await reauth();                   // 401/403
    else if (e.isServerError) { /* transient 5xx - the client already retried GETs */ }
  } else throw e;
}

CLI

The CLI is bundled: run it as npx lightnode-sdk <cmd> (the bare npm name lightnode is an unrelated package, so standalone npx lightnode runs the wrong thing). Inside a project that has lightnode-sdk installed, plain npx lightnode <cmd> resolves to the local bin and works too. Read-only commands work anywhere; chat / wallet / preflight need PRIVATE_KEY.

Read-only (no key)

npx lightnode-sdk network                    # network summary JSON
npx lightnode-sdk models [--json]            # registered models + fees (table, or JSON)
npx lightnode-sdk worker 0x...               # one worker + 5 recent jobs
npx lightnode-sdk worker doctor 0x...        # action center: gas, claimable, stuck, settle, to-do
npx lightnode-sdk worker liveness 0x...      # stuck-job + slash-risk + activity diagnostic
npx lightnode-sdk worker profitability 0x... # fee/gas/net per job + projected daily
npx lightnode-sdk jobs 0x... --csv           # job history (also --json)
npx lightnode-sdk registered 0x...           # true | false | null (chain truth)
npx lightnode-sdk fee llama3-8b              # per-job LCAI fee
npx lightnode-sdk analytics --json           # per-model performance (--csv or --json)
npx lightnode-sdk reliability --json         # per-worker reliability (--csv or --json)
npx lightnode-sdk job 1234                   # job status + refundable flag
npx lightnode-sdk worker watch 0x... --interval 30   # JSON event per state change
npx lightnode-sdk <cmd> --help               # usage for just that command
npx lightnode-sdk bridge addresses           # bridge route
npx lightnode-sdk dao addresses              # Ethereum LCAI Governor addresses
npx lightnode-sdk dao config                 # live voting delay / period / threshold (Ethereum governor)

Need PRIVATE_KEY

PRIVATE_KEY=0x... npx lightnode-sdk chat "Write me a haiku about LightChain"
PRIVATE_KEY=0x... npx lightnode-sdk batch prompts.json --concurrency 4   # N prompts in parallel
PRIVATE_KEY=0x... npx lightnode-sdk agent "research X and summarize"     # ReAct agent, built-in tools
PRIVATE_KEY=0x... npx lightnode-sdk wallet address
PRIVATE_KEY=0x... npx lightnode-sdk wallet balance --net testnet
                  npx lightnode-sdk wallet new           # generates a fresh key
PRIVATE_KEY=0x... npx lightnode-sdk worker preflight --net testnet

Worker operator (signs as the worker key)

Run a worker's on-chain lifecycle from the terminal. status and can-deregister are read-only; the rest sign with PRIVATE_KEY and act on the worker that key controls. Mainnet clearstuck and deregister realize a slash, so they require --yes.

                  npx lightnode-sdk worker status 0x...        # registration, stake, claimable, live config
                  npx lightnode-sdk worker models 0x...        # models served, reconciled vs chain (servingNow truth)
PRIVATE_KEY=0x... npx lightnode-sdk worker preflight           # one real test inference, print verdict + timings
PRIVATE_KEY=0x... npx lightnode-sdk worker can-deregister      # what blocks the exit, before spending gas
PRIVATE_KEY=0x... npx lightnode-sdk worker settle              # release completed jobs past their window + withdraw
PRIVATE_KEY=0x... npx lightnode-sdk worker withdraw            # pull the earned balance into the worker wallet
PRIVATE_KEY=0x... npx lightnode-sdk worker clearstuck --yes    # claimTimeout acked, past-deadline jobs that block exit
PRIVATE_KEY=0x... npx lightnode-sdk worker deregister --yes    # clear stuck + settle + withdraw + deregister

Scaffolders (write files into your project)

Thirteen add targets. Server-paid (you host a backend; your funded wallet pays per call):

npx lightnode-sdk add inference                    # encrypted inference route or script
npx lightnode-sdk add chat                         # chat UI with conversation history
npx lightnode-sdk add judge                        # pass/fail evaluator route (criteria + evidence)
npx lightnode-sdk add agent                        # scheduled inference (Vercel Cron / setInterval)
npx lightnode-sdk add analytics-dashboard          # read-only network + worker analytics page
npx lightnode-sdk add nft-mint-with-inference      # AI-generated NFT metadata with on-chain provenance

Standalone scripts (run with npx tsx; sign with your own key):

npx lightnode-sdk add batch                        # batch.ts: N prompts in parallel (runInferenceBatch)
npx lightnode-sdk add bridge                       # bridge.ts: LCAI across the Hyperlane bridge (mainnet-only)

For worker operators (a runnable Node console over the on-chain operator surface):

npx lightnode-sdk add worker-operator              # worker-ops.ts: status / settle / clearstuck / withdraw / deregister / profitability

add worker-operator writes a standalone worker-ops.ts (plus .env.example and a README) that drives WorkerOperator from code - no Docker, no worker image. npx tsx worker-ops.ts status prints registration, stake-vs-floor, claimable, gas, and a prioritized to-do list as JSON; the write commands settle, withdraw, clear stuck jobs, and deregister (the mainnet-slashing ones gated behind --yes). Drop status in cron and alert when the to-do is non-empty.

User-paid (no backend; each visitor signs + pays from their own wallet):

npx lightnode-sdk add inference-web3               # one-shot inference UI, wallet-signed
npx lightnode-sdk add chat-web3                     # chat UI, wallet-signed (mainnet + testnet aware)
npx lightnode-sdk add judge-web3                    # evaluator UI, wallet-signed
npx lightnode-sdk add wagmi-setup                   # wallet wiring: lib/wagmi + providers + connect button

The *-web3 scaffolders are one command end to end: run in an empty folder and they scaffold a Next.js app, write the page with a wired Connect button, bundle the wagmi config + providers + connect button, wrap your layout with <Providers>, and npm install the deps. Run inside an existing Next.js app and they skip the scaffold and just add what's missing. Opt out of the automation with --no-scaffold and --no-install.

All add commands accept --template auto|nextjs-api|hono|node, --net testnet|mainnet, --force, --no-install, and --no-scaffold.

If add <name> reports an unknown target, your npx cache is serving an older CLI. Force the current release: npx lightnode-sdk@latest add <name>.

Networks

| | Testnet | Mainnet | |---|---|---| | Chain ID | 8200 | 9200 | | RPC | https://rpc.testnet.lightchain.ai | https://rpc.mainnet.lightchain.ai | | Explorer | https://testnet.lightscan.app | https://mainnet.lightscan.app | | Faucet | https://lightfaucet.ai (~2 LCAI / IP / day) | n/a (bridge from Ethereum) | | Inference cost | free | ~0.022 LCAI per call | | Worker stake | 5,000 LCAI | 50,000 LCAI |

Examples

Tiny standalone repo: https://github.com/marinom2/lightnode-examples. Eight runnable examples covering every SDK module:

  • quickstart-inference/ (30-line one-shot)
  • multi-turn-chat/ (interactive REPL)
  • nextjs-api-route/ (drop-in App Router route)
  • hono-server/ (any-Node microservice)
  • bridge-transfer/ (LCAI bridge in both directions)
  • dao-vote/ (read + vote LCAI Governor)
  • worker-preflight/ (one real test inference + watch)
  • model-registry-read/ (AIVMModelRegistry reader)

Open any of them in StackBlitz in about 5 seconds:

https://stackblitz.com/github/marinom2/lightnode-examples/tree/main/quickstart-inference

Non-custodial

  • The SDK never holds your key. Every on-chain call is signed via viem in your process.
  • End-to-end encryption: your prompt is encrypted to the worker's ECDH pubkey before it leaves your machine. The gateway, the relay, and any third party in the path see only ciphertext.
  • The session key is ephemeral (32 random bytes per session). Never persisted.
  • Browser bundles work too: noble-backed crypto, no Web Crypto algorithm dependency, no Node-only imports.

Compatibility

| Runtime | Status | |---|---| | Node 18+ | Tested; ws auto-detected. | | Modern browsers | Works via globalThis.WebSocket. The /playground uses it. | | StackBlitz / Bolt WebContainer | Works since 0.4.8 (noble crypto, lightnode.app CORS proxy). | | Cloudflare Workers / Bun | Works. Pass a WebSocket ctor if the runtime lacks one. |

Provenance

The protocol surface (consumer gateway, relay, JobRegistry ABI, crypto layout) is built against LightChain's reference client and cert-transparency host enumeration. Crypto is byte-perfect vs the reference (@noble/curves for P-256, @noble/ciphers for AES-256-GCM).

If LightChain ships official SDKs that supersede this one, we'll archive the inference path and keep the analytics + bridge + DAO + preflight modules.

License

MIT. Independent, community-built. Not affiliated with or endorsed by the LightChain team.