@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.
Maintainers
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/sdkRequires 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 requiresA 403 caused by an outstanding acceptance is a distinct, actionable error —
LigandAILegalAcceptanceRequired (extends LigandAIForbidden), carrying
acceptanceUrl / needsToS / needsEULA — acceptanceUrl 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 false — resolveInteractively() 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 mouse → human 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
hotspotResiduesnoravoidResiduesplaces 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:
avoidResiduesdoes not change generation at all. Running the same request with and against the exclusion produced statistically indistinguishable sequence distributions (two-sample testp = 1.00, Jensen-Shannon divergence2.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.hotspotResiduesdoes 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 onlydata:lines and discarded theevent:line entirely, so every server event collapsed toeventType: "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 thediagnosticsarray 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 hitprocess.emitWarning(they would be noise); they still land onjob.diagnosticsand every callback.- The legacy targeting SSE events are normalized into canonical diagnostics
client-side, so you get the same
code/remediationwhether the server or the SDK did the normalizing. LigandAIDiagnosticis the warning code for server-side diagnostics;LigandAITargetingWarningremains 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=1to 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 orderErrors
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 revokeMCP 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.
