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
Maintainers
Readme
lightnode-sdk
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 viemLightChain'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 receiptsIn 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.WebSocketin browsers, dynamic-importwsin 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: trueto route the job to a search-capable worker and getsources(aWebSearchSource[]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/curvesand@noble/ciphersunder 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 GatewayClientPlus 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/unstickAndDeregisterfinalize a stuck job asTimedOut, 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 testnetWorker 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 + deregisterScaffolders (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 provenanceStandalone 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 / profitabilityadd 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 buttonThe *-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, yournpxcache 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-inferenceNon-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.
