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

@ligandal/sdk

v0.7.1

Published

Official Node/TypeScript client for the LIGANDAI platform — peptide/binder generation, folding, DeltaForge scoring, receptor search, and target discovery.

Readme

@ligandal/sdk

Official Node/TypeScript client for the LIGANDAI platform — de novo peptide/binder generation, Boltz-2 folding, DeltaForge thermodynamic scoring, receptor search, and transcriptomics-driven target discovery.

This is a faithful TypeScript port of the ligandai Python SDK. The HTTP surface, endpoints, auth, and semantics match the Python client; names are camelCased.

npm install @ligandal/sdk

Requires Node 18+ (uses the built-in fetch). No runtime dependencies.

Quickstart

import { LigandAI } from "@ligandal/sdk";

// Reads LIGANDAI_API_KEY from the environment by default.
const client = new LigandAI();

// Or pass the key explicitly (string shorthand or options object):
const client2 = new LigandAI("lgai_pro_...");
const client3 = new LigandAI({ apiKey: "lgai_pro_...", baseURL: "https://ligandai.com" });

// Discover targets: SI-ranked surface receptors enriched in a tissue.
const markers = await client.discovery.tissueMarkers({
  targetTissues: ["Liver"],
  receptorOnly: true,
  topN: 200,
});
const gene = (markers.top as any[])[0].gene;

// Generate peptides against the top target, auto-fold, and wait for results.
const job = await client.peptides.generate(gene, { numPeptides: 50, autoFold: true });
const result = await job.wait();
console.log(result);

CommonJS

const { LigandAI } = require("@ligandal/sdk");
const client = new LigandAI({ apiKey: "test" });

Configuration

new LigandAI(options) accepts:

| Option | Env fallback | Default | | --- | --- | --- | | apiKey | LIGANDAI_API_KEY | — | | baseURL | LIGANDAI_BASE_URL | https://ligandai.com | | timeout (ms) | — | 600000 | | maxRetries | — | 3 | | impersonateUser | LIGANDAI_IMPERSONATE_USER | — (superadmin only) | | clientSessionId | — | — | | defaultOrganism | — | "human" | | checkEntitlement | — | true | | fetch | — | global fetch (injectable for tests) |

Auth uses Authorization: Bearer <key>. Set LIGANDAI_DEBUG=1 to log every request URL.

Resource namespaces

All 23 namespaces are attached to the client as camelCase getters:

account, analysis, bivalent, charts, deltaforge, discovery, diseases, foldCompare, folds, goals, jobs, legal, ligands, linkerModifications, memory, msa, peptides (alias peptide), programs, proteins, receptors, reports, structures, synthesis.

await client.account.credits();
await client.receptors.search("EGFR");
await client.deltaforge.scoreFold("fold_job_123");
const score = await client.ligands.scoreLigand({ pdbContent, ligandSmiles: "CCO" });

Legal (ToS / EULA review + acceptance)

client.legal reviews and accepts the platform's Terms of Service / EULA programmatically — no browser required for the read/accept-known-version path (docs/clickwrap_legal_acceptance.md):

await client.legal.versions();          // current ToS + EULA version metadata
await client.legal.document("tos");     // full text, for review before accepting
await client.legal.status();            // { tosAccepted, eulaAccepted, needsReAcceptance, ... }
await client.legal.acceptCurrent();     // accept whatever the server currently requires

A 403 caused by an outstanding acceptance is a distinct, actionable error — LigandAILegalAcceptanceRequired (extends LigandAIForbidden), carrying acceptanceUrl / needsToS / needsEULAacceptanceUrl is also baked into .message ("... Accept at: <url>") so it's visible in logs even for callers who never inspect the field. client.legal.resolveInteractively() (mirrors the Python SDK's Legal.resolve_interactively() — same name, same options, same semantics) can drive the user through it, opt-in only:

import { LigandAILegalAcceptanceRequired } from "@ligandal/sdk";

try {
  const result = await client.peptides.generate("EGFR", { numPeptides: 50 });
} catch (err) {
  if (err instanceof LigandAILegalAcceptanceRequired) {
    // Opens the system browser, polls GET /api/legal/status on a bounded
    // schedule until acceptance lands, then calls `retry` AT MOST ONCE and
    // returns its result.
    const result = await client.legal.resolveInteractively(
      err,
      () => client.peptides.generate("EGFR", { numPeptides: 50 }),
      { autoOpenBrowser: true },
    );
  }
}

retry is optional — omit it and resolveInteractively() just confirms acceptance and resolves true; you retry your own call.

autoOpenBrowser defaults to falseresolveInteractively() is itself the opt-in interactive path; without the flag, nothing in the SDK spawns a process or opens a tab, it only polls. Headless/CI never hangs: before touching the network at all, and regardless of autoOpenBrowser, if the process can't reasonably prompt a human (CI set, stdin is not a TTY, Linux with no DISPLAY/WAYLAND_DISPLAY, or a runtime that's neither a browser nor Node) — or, when autoOpenBrowser: true, the opener fails to spawn — this re-throws the same error instance immediately. retry is never called on any failure path (headless, opener failure, or timeout). The TTY check looks at stdin only (matches the Python SDK's sys.stdin.isatty() exactly) — stdout's TTY state is never consulted.

The SDK cannot assert a document version onto the server — accept() keeps its { tosVersion, eulaVersion } signature for back-compat, but the server always resolves and records the current version itself (§3.6).

Species / organism (human | mouse)

The species selector targets the human (default) or mouse namespace on species-aware calls (receptors.search / receptors.byGene, target discovery, transcriptomics, and foldCompare). Set a client-level default, or override per call:

// Client-level default organism.
const client = new LigandAI({ apiKey, defaultOrganism: "mouse" });

// Per-call override (organism wins over the `species` alias).
await client.receptors.search("Egfr", { organism: "mouse" });
await client.discovery.geneExpression("Egfr", { organism: "mouse" });

The selector is an entitled capability, enforced fail-closed. The SDK calls GET /api/cross-species/entitlement (cached) before honoring a non-default organism and coerces mousehuman when the key is not entitled — a client can never force mouse when not entitled, mirroring the server's effectiveSpecies. Any species alias the server recognizes (mus_musculus, mmu, 10090, …) is normalized. Inspect the grant with:

const ent = await client.speciesEntitlement(); // { entitled, species: ["human"|..], ... }

Set checkEntitlement: false to skip the pre-flight and let the server be the sole arbiter (it still coerces a non-entitled caller).

Fold-compare (selectivity / off-target comparison)

Fold a designed binder against its on-target's isoforms + off-targets and get a ranked selectivity report (predicted Kd, bind probability / AUC, selectivity margin, cross-reactivity flags). Real folding engines + our shared MSA — never a fabricated affinity.

// One-call submit-and-wait:
const report = await client.foldCompare.run(binderSeq, "EGFR", {
  isoforms: "all",
  offTargets: ["ERBB2", "MET"],
  engine: "boltz2",            // or "protenix"
  organism: "human",           // fail-closed like every species-aware call
});
report.ranked;                 // Kd-ascending: tightest (most concerning) first
report.cross_reactivity_risks; // labels flagged as a cross-reactivity risk

// Or submit, then poll / stream / cancel:
const job = await client.foldCompare.start(binderSeq, "EGFR", { offTargets: ["ERBB2"] });
const snapshot = await job.status();
for await (const ev of job.stream()) console.log(ev.data);
await job.cancel();

Inputs are validated with actionable LigandAIInvalidConfig errors (bad binder, unknown engine, unresolvable comparison set, malformed isoform ids) before any request is sent.

Abstract goal-driven design

Use goals.drive() to express a high-level design objective and have the SDK drive the underlying primitives — generate, fold, then select on measured structure. A site-restricted binder (e.g. one that has to leave an orthosteric pocket clear) is the common case:

// EGFR binders required to contact the allosteric pocket and to leave the
// orthosteric ATP site clear. Both requirements are scored on the FOLDED
// complexes — see the caveat below.
const run = await client.goals.drive({
  gene: "EGFR",
  goal: "allosteric site binder — avoid the orthosteric ATP site",
  hotspotResidues: ["A:792", "A:855"],                         // must contact
  avoidResidues: [{ chain: "A", start: 719, end: 836 }],       // must stay clear
  pocketExpansionRadiusA: 6,
  numPeptides: 50,
  autoFold: true,
  numTrajectories: 4,   // production default — 4 Boltz-2 diffusion trajectories
});

const result = await run.generationJob.wait();

// This is the step that decides the outcome: keep only designs whose FOLDED
// structure contacts the allosteric pocket, ranked by the binder metric.
const elites = await client.peptides.search({
  gene: "EGFR",
  ipsaeMin: 0.5,
  hotspotHit: true,
});

Neither hotspotResidues nor avoidResidues places a peptide. They act on which receptor rows are featurized before generation — see Residue targeting semantics for what was measured. A run gets its site selectivity from folding the candidates and filtering on measured contacts, so generate more than you need and expect the post-fold filter to reject a large fraction.

Domain rules enforced by the SDK:

  • ipSAE (not global ipsae) is the binder metric throughout. ipSAE is more reliable than iPTM for Boltz-2 binder scoring.
  • Receptor chains always use MSA (our shared MSA server). goals.drive() does not expose a path to fold a receptor without MSA.
  • Only single B200 GPU is exposed (b200_plus); multi-GPU variants are rejected.

Generation settings

peptides.generate(gene, opts) exposes the same targeting knobs as the web UI:

await client.peptides.generate("EGFR", {
  targetResidues: [{ chain: "A", start: 55, end: 70 }],
  pocketExpansionRadiusA: 6.0,      // restrictive-hotspot expansion
  // pocketExpansionRadiusA: 0,     // strictly restrictive — no expansion, sends
  //                                //  pocketExpansionRadiusA=0 explicitly so the server
  //                                //  does not apply its default 6.0Å expansion.
  disableEcDomainTargeting: true,   // force EC-domain trimming OFF
  hotspotResidues: ["A:60", "A:62"],
  avoidResidues: [{ chain: "A", start: 719, end: 836 }], // exclusion region
  flexibleResidues: ["A:100"],      // receptor-residue visibility=0 (flexible regions)
  residueNumbering: "pdb",
  // Ensemble auto-fold (multi-engine Phase-2) — Python SDK parity:
  foldEngines: ["boltz2", "protenix"],
  foldCascade: true,
  cascadeGateMetric: "ipsae",       // ipSAE gate (recommended for binders)
  cascadeGateThreshold: 0.67,
});

Residue targeting semantics

targetResidues, avoidResidues and hotspotResidues are PDB residue numbers as numbered in the receptor structure you are designing against — not indices into any internal array and not sequence offsets. chain is honoured. If your construct is renumbered 1..N, use the construct's own numbering.

avoidResidues is applied in every targeting mode. You do not need targetResidues, hotspotResidues or a pocket config for it to reach the featurization; passing an exclusion on its own is a supported request.

The server removes the residues you named and, by default, the residues packing against them within 8 Å (Cα-Cα), so the removal covers a contiguous patch of surface rather than isolated positions.

avoidShellAngstrom sets that radius. Omit it for the 8.0 Å default (recommended), or pass 0 to disable the shell so only the residues you named are excluded. Accepted range is [0, 50]; outside it throws LigandAIInvalidConfig before any request is sent. An explicit 0 is transmitted as 0, not omitted.

await client.peptides.generate("BRD4", {
  numPeptides: 100,
  avoidResidues: [{ chain: "A", start: 82, end: 102 }],
  avoidShellAngstrom: 8.0,   // default; 0 = named residues only
});

⚠ Targeting does not place a peptide

Both parameters act on which receptor rows enter the pocket featurization. Neither has been shown to put a design on a chosen site, and they fail in different ways:

  • avoidResidues does not change generation at all. Running the same request with and against the exclusion produced statistically indistinguishable sequence distributions (two-sample test p = 1.00, Jensen-Shannon divergence 2.4e-5). Downstream, designs that cleared the quality gates still contacted the excluded patch — at roughly twice the rate expected by chance. It records your intent on the request and in run provenance; it does not constrain the output.
  • hotspotResidues does change which sequences are generated — it selects the featurized rows, and the shift is real and reproducible (six receptor faces, p ≈ 1e-4, 64 designs per arm). It does not aim: contact at the named face was not enriched on either receptor tested, and a randomly scattered residue set moved the distribution as much as a genuine contiguous face did. Use it to vary a run, not to choose a binding site.

The workflow that holds is fold, then measure, then filter. Fold the candidates (autoFold: true, or peptides.foldBatch()), then select on measured interface contacts — peptides.search({ hotspotResidues, hotspotHit: true }) for a required contact, or the same query against the excluded residues to find the designs that violated an exclusion. The avoid_conformance_violation / avoid_conformance_clean fold-stage diagnostics report the same check.

Request-side targeting events remain observable and confirm only what the server accepted: residue numbers with no counterpart in the receptor's featurized residue set are returned as unresolved_resnums on the generation stream's targeting events rather than failing the job. They are not evidence that a design landed where you asked.

targetingStrategy ("full_surface" | "pocket_targeted") is a run label. It is not read by the generation backend and does not by itself move the design surface.

Client-side validation

targetResidues / avoidResidues / hotspotResidues / avoidShellAngstrom are validated before any HTTP request is sent — a bad value throws synchronously, not a 400 from the server after the call already fired. Every generation entry point (peptides.generate(), goals.drive()) runs this. All hard errors throw LigandAIInvalidConfig:

| Condition | Example | | --- | --- | | start/end < 1 | target_residues[0]: residue numbers are PDB numbering and start at 1; got start=0. If you meant an array index, that is not supported — pass the residue number shown in the structure. | | start/end not an integer (fraction, NaN, boolean, unparsable string) | avoid_residues[0]: start (12.5) and end (20) must be integers. | | chain not a 1-2 character identifier | target_residues[0]: chain must be a 1-2 character identifier; got "ABC". | | region spans > 5000 residues | target_residues[0]: region spans 6001 residues, which exceeds the 5000-residue sanity limit. | | region isn't a valid shape | target_residues[0]: must be an object with start/end (or startResidue/endResidue), a single residue, or an integer residue number; got string. | | avoidShellAngstrom outside [0, 50] | avoid_shell_angstrom must be between 0 and 50 Angstrom; got 75. | | hotspotResidues residue number not an integer or < 1 | hotspot_residues[0]: residue numbers are PDB numbering and start at 1; got 0. … |

start > end is auto-swapped rather than rejected (matches server behaviour), but still emits a warning. Non-fatal conditions are emitted on Node's own warning channel (process.emitWarning, code LigandAITargetingWarning; observe via process.on("warning", ...) or node --trace-warnings), never thrown. Each one carries a canonical taxonomy code — the same identifier the Python SDK raises for the same condition, so the two clients are comparable:

| Condition | code | Severity | | --- | --- | --- | | start > end, auto-swapped | targeting_region_swapped | info | | region missing chain when chain context exists elsewhere | targeting_chain_ambiguous | warning | | avoidShellAngstrom === 0 | avoid_shell_disabled | info | | avoidResidues span ≥ targetResidues span | degenerate_pocket_risk | warning | | a residue in both hotspotResidues and avoidResidues | targeting_contradiction | warning |

const { warnings, diagnostics } = validateTargeting({
  hotspotResidues: ["A:745"],
  avoidResidues: [{ chain: "A", start: 740, end: 750 }],
});
warnings[0].code;              // "targeting_contradiction"
diagnostics[0].remediation;    // "…Remove it from one of the two lists."

validateTargeting() returns both warnings ({field, message, code, details}) and diagnostics — the same conditions as canonical Diagnostic envelopes, so client-side pre-flight can flow through the same reporting path as server-sent diagnostics. The warning channel stays LigandAITargetingWarning for these; LigandAIDiagnostic is reserved for what the server sent.

targeting_contradiction fires when the same residue appears in hotspotResidues and avoidResidues — you have asked for a design that both contacts and avoids it, which no acceptance check can pass. Neither list constrains generation (see above), so nothing rejects the request downstream: the contradiction would survive all the way to the post-fold filter and silently discard the whole run. Catching it client-side is what keeps that from costing GPU time. It is checked against hotspotResidues and deliberately not against targetResidues: excluding a sub-region of the targeted pocket face is the entire point of avoidResidues, so firing there would be a false positive on every run.

Every alias shape — {startResidue,endResidue}, {residue}, {resnum}, a bare integer — normalizes to the canonical {chain, start, end} form before the request is built. Accepted region shapes, the validator (validateTargeting), and the type guards below are all exported from @ligandal/sdk if you want to pre-validate without submitting.

Targeting SSE events

A generation job's event stream (job.stream()) carries three targeting-specific event types alongside the usual progress events:

import {
  isTargetResiduesAppliedEvent,
  isAvoidResiduesAppliedEvent,
  isTargetingWarningEvent,
  isDegeneratePocketWarning,
} from "@ligandal/sdk";

const job = await client.peptides.generate("BRD4", {
  avoidResidues: [{ chain: "A", start: 82, end: 102 }],
});
for await (const event of job.stream()) {
  if (isAvoidResiduesAppliedEvent(event)) {
    console.log(`pocket ${event.payload.pocket_before} -> ${event.payload.pocket_after}`);
  }
  if (isTargetingWarningEvent(event) || isDegeneratePocketWarning(event)) {
    console.warn(event.payload);
  }
}

Job.stream() forwards every SSE event regardless of eventType — nothing is dropped for being unrecognized, so a future event type reaches your callback today even before the SDK ships a typed shape for it. The event name is read from the SSE event: <name> line (the server does not repeat it inside the JSON body); a name embedded in the body still takes precedence, so both the current and the legacy server framing resolve correctly.

Fixed in [Unreleased]. Before this release the SDK parsed only data: lines and discarded the event: line entirely, so every server event collapsed to eventType: "message" and the three guards above could never fire against production. If you previously found these guards "never matched", that was this bug.

Diagnostics

Some things go wrong on the server without failing the job. Residue numbers you asked to exclude don't resolve against the structure. A pocket goes degenerate after the exclusion. A finished fold contacts the surface you told it to avoid. A parameter you set is accepted by the schema and then ignored. Historically the SDK said nothing about any of it — a customer's exclusion was silently a no-op and 25/25 designs bound the excluded region; they only found out by eyeballing the structures.

Every such condition now arrives as a Diagnostic:

interface Diagnostic {
  schema_version: number;
  code: string;             // stable join key, e.g. "avoid_residues_none_resolved"
  severity: "info" | "warning" | "error";
  stage: "request" | "generate" | "fold" | "results" | "client";
  title: string;
  message: string;          // interpolated with the specifics of this occurrence
  remediation: string;      // what to actually do about it
  details: Record<string, unknown>;
  doc_anchor: string;
  occurred_at: string;      // ISO-8601 UTC
  subject_id?: string;      // which design/peptide, when applicable
}

There are four ways to receive them — you do not have to choose:

import { onDiagnostic, worstSeverity } from "@ligandal/sdk";

const job = await client.peptides.generate("BRD4", {
  avoidResidues: [{ chain: "A", start: 36, end: 46 }],
});

// 1. A callback on wait() — for callers that never open a stream.
const result = await job.wait({
  onDiagnostic: (d) => console.warn(`[${d.severity}] ${d.title}: ${d.message}`),
});

// 2. The accumulated list, from BOTH the SSE stream and every status poll.
for (const d of job.diagnostics) console.log(d.code, d.message, d.remediation);
if (job.worstDiagnosticSeverity === "error") { /* results are probably not what you asked for */ }

// 3. A process-wide subscription (every job, plus transport-level advisories).
const unsubscribe = onDiagnostic((d) => myLogger.warn(d));

// 4. Node's warning channel — nothing to wire up at all.
process.on("warning", (w) => {
  if ((w as any).code === "LigandAIDiagnostic") console.error(w.message);
});

Notes that matter:

  • A wait()-only caller sees everything. Diagnostics are read off the diagnostics array on each /api/jobs/{id} status response, not just off the SSE stream.
  • severity: "error" means the run produced results that are probably not what you asked for — not that the job failed. "warning" is degraded or partial; "info" is notable-but-fine.
  • Unknown codes are never filtered. A newer server can emit a code this SDK build has never heard of and it still reaches job.diagnostics, your callbacks, and the warning channel, with its own severity respected. There is no known-code allowlist anywhere in the path.
  • De-duplicated, keyed on code + subject + details, so a job polled a hundred times warns once.
  • info-severity diagnostics do not hit process.emitWarning (they would be noise); they still land on job.diagnostics and every callback.
  • The legacy targeting SSE events are normalized into canonical diagnostics client-side, so you get the same code/remediation whether the server or the SDK did the normalizing.
  • LigandAIDiagnostic is the warning code for server-side diagnostics; LigandAITargetingWarning remains the code for the SDK's own client-side pre-flight validation warnings.

The full taxonomy is exported as DIAGNOSTIC_REGISTRY / DIAGNOSTIC_CODES, along with makeDiagnostic, coerceDiagnostic, extractDiagnostics, mergeDiagnostics, worstSeverity, diagnosticDocHref, and the isDiagnosticEvent type guard.

SDK version advisory

An SDK that is behind the server can silently drop request parameters the server now expects — the same class of silent failure as above, so it is reported the same way. Every response carries X-LigandAI-SDK-Status (current / outdated / unsupported / unknown), X-LigandAI-SDK-Latest, and X-LigandAI-SDK-Min-Supported; the transport reads them at zero extra round-trips and emits an sdk_outdated or sdk_unsupported diagnostic through the channel above.

  • Emitted at most once per process.
  • Never upgrades anything — it reports, you decide.
  • Set LIGANDAI_SKIP_VERSION_CHECK=1 to disable it entirely.
  • A server that sends no advisory headers produces silence, not a guess.
  • For a deliberate pre-flight check (CI, or the top of a long campaign script):
const compat = await client.checkSdkCompatibility(); // GET /api/sdk/compatibility
if (compat.diagnostic) console.warn(compat.diagnostic.message);
// { status, latest, minSupported, installed, diagnostic, raw }

Jobs

Long-running work (generation, folding, scoring) returns a Job<T>:

const job = await client.peptides.fold(
  [receptorSeq, peptideSeq],
  { targetGene: "EGFR" },
);
const fold = await job.wait({ pollIntervalMs: 2000, timeoutMs: 1_800_000 });

// Or stream live progress events (SSE with polling fallback):
for await (const event of job.stream()) {
  console.log(event.eventType, event.progress);
}

Job.wait() for fold jobs is durable by default — it does not resolve until the structural payload (PDB/CIF) has landed. Pass { durable: false } to opt out.

Batch folds return a BatchFoldJob:

const batch = await client.peptides.foldBatch(peptides, {
  targetGene: "EGFR",
  diffusionSamples: 4,
});
const results = await batch.wait(); // Array<FoldResult | null>, aligned with input order

Errors

All errors extend LigandAIError. Status-specific subclasses (and OpenAI-style aliases) are exported:

import {
  LigandAIError,
  LigandAIAuthError,          // 401  (alias: AuthenticationError)
  LigandAICreditError,        // 402  (alias: InsufficientCreditsError)
  LigandAITierError,          // 403 tier gate
  LigandAIForbidden,          // 403  (alias: PermissionDeniedError)
  LigandAILegalAcceptanceRequired, // 403 ToS/EULA not accepted — extends LigandAIForbidden
  LigandAINotFoundError,      // 404  (alias: NotFoundError)
  LigandAIValidationError,    // 400/422 (alias: UnprocessableEntityError)
  LigandAIRateLimitError,     // 429  (alias: RateLimitError)
  LigandAIServerError,        // 5xx  (alias: InternalServerError)
} from "@ligandal/sdk";

try {
  await client.peptides.foldBatch(peptides, { targetGene: "EGFR" });
} catch (err) {
  if (err instanceof LigandAICreditError) {
    console.log(`Need ${err.shortfall} more credits (top up: ${err.recoveryUrl})`);
  }
}

Transport, retries & idempotency

Retries (429/500/502/503/504, connection errors, timeouts; default maxRetries: 3) use exponential backoff with jitter so many clients retrying the same degraded endpoint don't re-synchronize on the same instant. A Retry-After or X-RateLimit-Reset response header is honoured over the computed backoff (capped at 120s so a misbehaving server can't hang the client indefinitely).

Non-idempotent methods (POST/PATCH) are never auto-retried unless you pass idempotencyKey — a mid-flight timeout/500 on generate() or fold() throws immediately rather than risking a duplicate submission (double credit spend), since the first attempt may actually have succeeded server-side. GET/HEAD/ OPTIONS/PUT/DELETE are idempotent by HTTP semantics and always retry:

// Opts a POST into automatic retries and sends Idempotency-Key on the wire.
await client.transport.request("POST", "/api/some-endpoint", {
  body: { ... },
  idempotencyKey: "my-stable-request-id",
});

Connection-level failures (DNS, refused, reset) always surface as LigandAIConnectionError — never a generic error, and never silently swallowed. Both the client-level timeout and any per-request timeout override are explicit (DEFAULT_TIMEOUT_MS = 600 000 ms / 10 min).

CLI

The package installs a ligandai binary:

export LIGANDAI_API_KEY=lgai_pro_...

ligandai credits                    # billing widget
ligandai credits top-up --amount 50
ligandai credits auto-reload --enable --threshold 10000 --amount 200

ligandai keys mint --scope fold --target MKTAYIAKQR... --count 5
ligandai keys status
ligandai keys revoke

MCP server

A companion Model Context Protocol server (@ligandal/mcp) exposes the high-value capabilities as MCP tools for Claude Desktop / Claude Code. See mcp/README.md.

License

Copyright © 2026 Ligandal, Inc. All rights reserved. See LICENSE.