Run Laya — the open-source, Jev-compatible System 1 decision model by Convai Innovations — from Node.js / TypeScript.
Laya does not generate text. You hand it a state (a ticket, an email, a JSON object) and typed questions, and it returns every answer with calibrated probabilities in one forward pass:
choice— pick one option, with a probability per optionscore— an expected level on an ordered rubric, with the distributionnoul— a calibrated P(true) for a yes/no statement
This package runs the model with ONNX Runtime; PyTorch and Python are
not needed at runtime. The request/response shape is the same as the Python reference
implementation (RLAgent.system_one) and as TypeSafe Jev's system_one API, and the output
matches the Python implementation to four decimal places.
npm install @receptron/layaNode.js 20 or newer; CI runs on Node 20, 22 and 24 (Linux, macOS, Windows). The @types/node version
in devDependencies is only for type-checking this package and does not require Node 24.
The ONNX weights (about 1.7 GB, fp32) are downloaded from Hugging Face on first use and cached under
~/.cache/receptron-laya (override with LAYA_CACHE). Budget roughly 2 GB of RAM for the loaded model
plus a few hundred MB per batch of questions.
The first Laya.load() therefore includes that download, so time it after the cache is warm. To
fetch the weights ahead of time (in a build step or container image), call ensureBundle():
node -e 'import("@receptron/laya").then((m) => m.ensureBundle())'Later loads only send a cheap freshness check and read from the cache (about 4 s on an Apple M4, measured in #13).
import { Laya } from "@receptron/laya";
const laya = await Laya.load();
const result = await laya.systemOne(
{ subject: "Refund not received", body: "I cancelled two weeks ago and still have no refund..." },
{
department: {
type: "choice",
instructions: "Which team should handle this ticket?",
criteria: { billing: "payments, refunds, invoices", support: "product help and bugs", sales: "new purchases" },
},
urgency: {
type: "score",
instructions: "How urgent is this ticket?",
criteria: ["not urgent", "somewhat urgent", "urgent", "critical"],
},
churn_risk: { type: "noul", instructions: "Is the customer likely to cancel or dispute?" },
},
);
result.answers.department.choice; // "billing"
result.answers.department.probabilities; // { billing: 0.9415, support: 0.031, sales: 0.0275 }
result.answers.urgency.score; // 1.3886 (expected level, 0..3)
result.answers.churn_risk.noul; // 0.0988 (P(true))
result.answers.department.answer_confidence; // 0.9415 (max probability, on every answer type)
result.usage.input_tokens; // 267
await laya.close();The answer types follow the question types, so result.answers.department is a ChoiceAnswer
and result.answers.churn_risk a NoulAnswer without any casting.
To gate on confidence, use answer_confidence: the probability of the reported answer (max(p)),
which is the quantity the checkpoint's temperatures are fitted for and means the same thing for every
question type. confidence on choice / score answers is a different quantity (1 − normalized entropy).
await Laya.load({
modelDir: "./onnx", // use a local export instead of downloading (see below)
repo: "receptron/laya-onnx", // Hugging Face repo that holds the ONNX bundle
subfolder: "multilingual", // a checkpoint variant inside that repo
revision: "main", // pin a commit hash for reproducible results; "main" follows the repo
cacheDir: "/var/cache/laya",
token: process.env.HF_TOKEN, // for private repos
onProgress: ({ file, received, total }) => {}, // download progress
executionProviders: ["cpu"], // onnxruntime-node execution providers
sessionOptions: { intraOpNumThreads: 4 },
});Every question of one systemOne call is batched into a single run; a call with three questions
takes about 140 ms on an Apple-silicon CPU once the model is warm.
export/export_onnx.py turns the Hugging Face checkpoint (ModernBERT encoder + Laya's decision head)
into one ONNX graph and copies the tokenizer and calibration values next to it. You only need this
to build a bundle from a newer checkpoint or from a variant that is not published:
cd export
uv venv -p 3.12 .venv
uv pip install -p .venv/bin/python torch transformers safetensors onnx onnxscript onnxruntime huggingface_hub
.venv/bin/python -c "from huggingface_hub import snapshot_download; snapshot_download('convaiinnovations/laya', local_dir='model', allow_patterns=['model.safetensors','encoder/*','tokenizer/*','rl_agent_config.json','rl_common.py','rl_agent_api.py'])"
.venv/bin/python export_onnx.py model ../onnx # prints the max logit difference vs. PyTorch (≈1e-5)Then Laya.load({ modelDir: "./onnx" }). The bundle is the five files listed in BUNDLE_FILES:
laya.onnx, laya.onnx.data, laya_config.json, tokenizer/tokenizer.json, tokenizer/tokenizer_config.json.
- Each question's options must fit in
head_max_len(192) tokens;systemOnethrows otherwise. Fewer than about 20 options perchoicequestion is the model's own recommendation. - The state is truncated to
max_len(512 tokens for the English checkpoint) after the question header. A string or object state keeps its start; a list state (a conversation) keeps its newest end, as in training. choicelabels given as a list must be distinct; a repeated label throws instead of being merged.- Temperatures are clamped to [0.5, 5]. The published bundle uses 0.945 for
choicequestions with 11 or more options, where the upstream checkpoint ships 0.1006 (clamped to 0.5 by the Pythonlayapackage), so confidences for those questions differ from upstream by design. - A JSON state is serialized like Python's
json.dumps(ensure_ascii=False)so that tokens match the reference implementation; non-integer numbers may format differently between JS and Python.
yarn install
yarn test # unit tests; the model test runs when ./onnx holds a bundle (or LAYA_MODEL_DIR)
yarn typecheck
yarn build
LAYA_MODEL_DIR=./onnx yarn exampleMIT. The Laya model weights are published by Convai Innovations under Apache 2.0.