Quick taste · Pick your adventure · Cookbook · FAQ · Limits
laya-java runs Laya typed-decision models inside your JVM. No Python sidecar, no PyTorch, no GPU server, no per-token bill.
Laya is not a chat model. It never writes a paragraph. You give it some text and a question, it does one forward pass, and it answers with numbers:
| 🎯 You ask | 🧠 Laya answers |
|---|---|
| "Does the customer want money back?" (yes/no) | 0.84 |
| "Which team: billing or technical?" (choice) | billing 92 % · technical 8 % |
| "How frustrated is this person? Calm / Frustrated / Very angry" (score) | 1.09 → mostly Frustrated, a touch of Very angry |
Zero output tokens. Same input → same answer. Perfect for routing, triage, moderation, guardrails, ranking and scoring where asking an LLM would be slow, pricey and a little too creative.
⚠️ Independent port. laya-java is not an official release of Convai Innovations or the Laya project. Model weights are not bundled: they download from Hugging Face on first use under their own Apache-2.0 license.
try (Agent agent = Agent.load("aac6fef/laya-mlx")) { // first run downloads ~800 MB, once
var qs = new LinkedHashMap<String, Question>();
qs.put("dept", Question.choice("Which team should handle this?", List.of("billing", "technical", "sales")));
qs.put("urgent", Question.noul("Does this convey urgency?", null));
qs.put("mood", Question.score("How frustrated is the customer?", List.of("Calm", "Frustrated", "Very angry")));
var result = agent.predict("Help! My payouts have been failing for 3 days.", qs);
// dept → billing (89 %) urgent → 0.74 mood → 1.09
}Or skip the code and use HTTP:
curl -s localhost:8080/v1/decisions -H 'content-type: application/json' -d '{
"model": "laya",
"input": "I was billed twice, please refund.",
"questions": [{"type":"predicate","name":"refund","instructions":"Does the customer want money back?"}]
}'
# → {"object":"decision","answers":[{"name":"refund","type":"predicate","probability":0.8436}], ...}flowchart LR
A[Your Java code] -->|Agent.predict| C[laya-core]
B[Any app or language] -->|HTTP| S[laya-api server<br/>or Spring Boot starter]
S --> C
C -->|downloads once| HF[(Hugging Face<br/>checkpoints)]
C -.optional, macOS.-> X[laya-accelerate<br/>Apple Accelerate]
T[spring-ai-typesafe] -->|/v1/systemone| S
O[OpenAI SDKs] -->|/v1/decisions| S
Seven artifacts, all on Maven Central under io.github.apscot, all at version 0.1.0. Use the BOM and you never have to repeat the version.
| I want to… | Use | Jump to |
|---|---|---|
| Call Laya from Java code, no server | laya-core |
↓ |
| Make it faster on my Mac | laya-accelerate |
↓ |
| Add a Laya HTTP endpoint to my Spring Boot app | laya-spring-boot-starter |
↓ |
| Run Laya as a standalone server (any language can call it) | laya-api |
↓ |
| Call a Laya/Jev server from Java | laya-client |
↓ |
| Start/stop a local Laya in my Gradle build and tests | laya-gradle-plugin |
↓ |
| Keep all the versions aligned | laya-bom |
↓ |
Needs: JDK 17+ (that's it). About 2.5 GB of free memory for the English model. Internet on first run to fetch the weights.
Every artifact section below shows Gradle (Kotlin and Groovy) and Maven snippets with the version written out, so each one works on its own.
Using several Laya artifacts? Import the BOM once and drop the versions: see laya-bom.
The heart of the project: tokenizer, ModernBERT encoder, decision heads, calibration. No dependencies at all.
Gradle (Kotlin DSL)
implementation("io.github.apscot:laya-core:0.1.0")Gradle (Groovy DSL)
implementation 'io.github.apscot:laya-core:0.1.0'Maven
<dependency>
<groupId>io.github.apscot</groupId>
<artifactId>laya-core</artifactId>
<version>0.1.0</version>
</dependency>import io.github.apscot.laya.core.Agent;
import io.github.apscot.laya.core.Question;
try (Agent agent = Agent.load("aac6fef/laya-mlx")) {
Map<String, Object> result = agent.predict(state, questions);
}The three question types
Question.noul("Does the customer ask for a refund?", null); // yes/no → probability
Question.choice("Which team?", List.of("billing", "technical", "sales")); // pick one
Question.choice("Which team?", Map.of("billing", "invoices, refunds", // …with descriptions
"technical", "bugs and outages"));
Question.score("How urgent is this?", List.of("not urgent", "soon", "critical")); // ordered scaleWhat goes in: the state is the thing being judged: a string, a JSON-like Map/List, or a chat history (a list of messages). Every question is evaluated independently against the same state.
Checkpoints (Agent.load(...) takes a Hugging Face id or a local folder):
| Model | Id | Best for |
|---|---|---|
| English | aac6fef/laya-mlx |
English text |
| Multilingual | aac6fef/laya-multilingual-mlx |
Everything else (中文, Русский, Español…) |
| Typed decisions | aac6fef/laya-typed-decisions-mlx |
Upstream's typed-decisions workflows (1,024-token context) |
Weights are cached in ~/.cache/laya (set LAYA_CACHE to move it). Load the Agent once and reuse it (loading is the slow part).
What does result look like?
{
"model": "laya-rl-agent",
"answers": {
"dept": { "type": "choice", "choice": "billing",
"probabilities": { "billing": 0.8914, "technical": 0.069, "sales": 0.0396 },
"confidence": 0.6224, "answer_confidence": 0.8914 },
"urgent": { "type": "noul", "noul": 0.7419, "confidence": 0.7419 },
"mood": { "type": "score", "score": 1.0889,
"legend": { "0": "Calm", "1": "Frustrated", "2": "Very angry" },
"probabilities": { "0": 0.0317, "1": 0.8477, "2": 0.1206 } }
},
"usage": { "input_tokens": 118, "output_tokens": 0, "truncated": false }
}Drop-in speed-up on macOS. It calls Apple's built-in Accelerate library (cblas_sgemm) through JNA; no native code to build.
Gradle (Kotlin DSL)
runtimeOnly("io.github.apscot:laya-accelerate:0.1.0")Gradle (Groovy DSL)
runtimeOnly 'io.github.apscot:laya-accelerate:0.1.0'Maven
<dependency>
<groupId>io.github.apscot</groupId>
<artifactId>laya-accelerate</artifactId>
<version>0.1.0</version>
<scope>runtime</scope>
</dependency>That is the whole setup. laya-core finds it automatically. On Linux and Windows it quietly steps aside.
| Backend | Median, 31-token question (M5) |
|---|---|
laya-accelerate |
~60 ms |
| Portable Java (Vector API) | ~186 ms |
Switch it off for comparison: LAYA_BACKEND=portable. Seeing WARNING … jna … restricted method? That's a harmless JDK notice about native access.
Add the starter and your Spring Boot 3 app serves the model. Nothing else to write.
Gradle (Kotlin DSL)
implementation("io.github.apscot:laya-spring-boot-starter:0.1.0")Gradle (Groovy DSL)
implementation 'io.github.apscot:laya-spring-boot-starter:0.1.0'Maven
<dependency>
<groupId>io.github.apscot</groupId>
<artifactId>laya-spring-boot-starter</artifactId>
<version>0.1.0</version>
</dependency>The starter targets Spring Boot 3.x (tested with 3.4) and already brings in spring-boot-starter-web and, at runtime, laya-accelerate.
# application.properties (all optional)
laya.preload=true # load the model at startup instead of on first request
laya.api-key=secret # require "Authorization: Bearer secret" on /v1/*Run your app and you get:
| Endpoint | Speaks |
|---|---|
POST /v1/decisions |
OpenAI Decisions API |
POST /v1/systemone |
Jev / System One (what Spring AI TypeSafe uses) |
GET /v1/models |
model list |
All properties
| Property | Meaning | Default |
|---|---|---|
laya.models.<name> |
Hugging Face id or folder for a model name | the three published checkpoints |
laya.default-model |
Model used when no name matches | english |
laya.api-key |
Bearer token for /v1/* |
none |
laya.preload |
Load the default model at startup | false |
laya.endpoint.enabled |
Expose the endpoints | true |
laya.endpoint.base-path |
Prefix for the paths | empty |
JVM flags (--add-modules=jdk.incubator.vector -Xmx3g) give you speed and headroom:
// Gradle
tasks.bootRun { jvmArgs("--add-modules=jdk.incubator.vector", "-Xmx3g") }<!-- Maven -->
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<jvmArguments>--add-modules=jdk.incubator.vector -Xmx3g</jvmArguments>
</configuration>
</plugin>For a packaged jar: java --add-modules=jdk.incubator.vector -Xmx3g -jar app.jar (or JAVA_TOOL_OPTIONS="--add-modules=jdk.incubator.vector"; note the =).
🤔 "Why is there a model field? Laya is the model." Correct! OpenAI's and Jev's request formats require the field, so we accept it. Use english, multilingual or typed-decisions to pick a checkpoint. Any other value (laya, gpt-6-luna, banana) means "you choose": the server routes by the language of the text.
🔑 "Do I need an API key?" No. It's off unless you set laya.api-key. (One exception: the Spring AI TypeSafe client refuses an empty key, so give it any string.)
The brains behind the endpoints, with no Spring dependency. Use it to run a standalone server, or to bolt Laya onto any other framework (Quarkus, Micronaut, Javalin, Vert.x…).
Gradle (Kotlin DSL)
implementation("io.github.apscot:laya-api:0.1.0")
runtimeOnly("io.github.apscot:laya-accelerate:0.1.0")Gradle (Groovy DSL)
implementation 'io.github.apscot:laya-api:0.1.0'
runtimeOnly 'io.github.apscot:laya-accelerate:0.1.0'Maven
<dependency>
<groupId>io.github.apscot</groupId>
<artifactId>laya-api</artifactId>
<version>0.1.0</version>
</dependency>
<dependency>
<groupId>io.github.apscot</groupId>
<artifactId>laya-accelerate</artifactId>
<version>0.1.0</version>
<scope>runtime</scope>
</dependency>(laya-accelerate is optional.)
A standalone server in six lines
import io.github.apscot.laya.api.*;
public class Server {
public static void main(String[] args) throws Exception {
ModelRegistry models = ModelRegistry.defaults(); // english / multilingual / typed-decisions
LayaHttpServer server = new LayaHttpServer(models, "0.0.0.0", 8000, /* apiKey */ null);
server.start(); // GET /health, POST /v1/decisions, /v1/systemone
}
}Or configure it with environment variables and run LayaHttpServer directly:
| Variable | Meaning | Default |
|---|---|---|
LAYA_PORT |
listen port | 8000 |
LAYA_HOST |
bind address | 127.0.0.1 |
LAYA_API_KEY |
require a Bearer token | none |
LAYA_MODELS |
comma list, e.g. english,multilingual |
all three |
LAYA_PRELOAD |
1 to load at startup |
off |
Bring your own framework: call the handlers with parsed JSON maps and return the map they give you.
var registry = ModelRegistry.defaults();
Map<String, Object> reply = new DecisionsApi(registry).handle(requestBodyAsMap); // OpenAI shape
Map<String, Object> jev = new SystemOneApi(registry).handle(requestBodyAsMap); // Jev shape
// LayaApiException carries the right HTTP status and error body for bad requestsLimits (same as the upstream server): 64 questions, 50,000 characters of state, 2 MB body.
Zero dependencies beyond the JDK. Talks to laya-java, the upstream Python laya-serve, or any Jev-compatible endpoint.
Gradle (Kotlin DSL)
implementation("io.github.apscot:laya-client:0.1.0")Gradle (Groovy DSL)
implementation 'io.github.apscot:laya-client:0.1.0'Maven
<dependency>
<groupId>io.github.apscot</groupId>
<artifactId>laya-client</artifactId>
<version>0.1.0</version>
</dependency>LayaClient client = new LayaClient("http://localhost:8080", /* apiKey or null */ null);
Map<String, Question> qs = new LinkedHashMap<>();
qs.put("refund", Question.noul("Does the customer want money back?", null));
qs.put("dept", Question.choice("Which team?", List.of("billing", "technical")));
Map<String, Object> r = client.systemOne("I was billed twice, please refund.", qs);
double refund = LayaClient.noul(r, "refund"); // 0.84
String dept = LayaClient.choice(r, "dept"); // "billing"Also: client.decisions(Map) for the OpenAI format, client.models(), and LayaClient.score(r, "id"). Errors raise LayaClient.ApiException with .status and .body.
Start a real Laya server for local development and integration tests, then shut it down.
The plugin is published to Maven Central, so tell Gradle to look there. In settings.gradle.kts:
pluginManagement {
repositories {
mavenCentral()
gradlePluginPortal()
}
}Gradle (Kotlin DSL) — build.gradle.kts
plugins {
java
id("io.github.apscot.laya") version "0.1.0"
}
laya {
model.set("aac6fef/laya-mlx") // Hugging Face id or local folder
port.set(8002)
testIntegration.set(true) // start before `test`, stop after, pass -Dlaya.base-url
}Gradle (Groovy DSL) — build.gradle (and in settings.gradle: pluginManagement { repositories { mavenCentral(); gradlePluginPortal() } })
plugins {
id 'java'
id 'io.github.apscot.laya' version '0.1.0'
}
laya {
model = 'aac6fef/laya-mlx'
port = 8002
testIntegration = true
}Maven: this is a Gradle plugin, so there is no Maven plugin. In a Maven build, start the server from your tests with laya-api (test scope):
<dependency>
<groupId>io.github.apscot</groupId>
<artifactId>laya-api</artifactId>
<version>0.1.0</version>
<scope>test</scope>
</dependency>@BeforeAll static void start() throws Exception {
server = new LayaHttpServer(ModelRegistry.defaults(), "127.0.0.1", 8002, null);
server.start();
}
@AfterAll static void stop() { server.close(); }Run the tests with --add-modules=jdk.incubator.vector in the Surefire argLine for speed.
./gradlew layaServe # starts it in the background and waits until healthy
curl localhost:8002/health
./gradlew layaStopWith testIntegration, your tests can read the address:
String base = System.getProperty("laya.base-url"); // http://127.0.0.1:8002Server logs land in build/laya/server.log. Other knobs: apiKey, maxHeap (default 3g), runtimeVersion.
A Bill of Materials so every Laya artifact stays on the same version.
Import it once, then add any Laya artifact without a version.
Gradle (Kotlin DSL)
dependencies {
implementation(platform("io.github.apscot:laya-bom:0.1.0"))
implementation("io.github.apscot:laya-core") // no version needed
implementation("io.github.apscot:laya-client")
}Gradle (Groovy DSL)
dependencies {
implementation platform('io.github.apscot:laya-bom:0.1.0')
implementation 'io.github.apscot:laya-core'
implementation 'io.github.apscot:laya-client'
}Maven
<dependencyManagement>
<dependencies>
<dependency>
<groupId>io.github.apscot</groupId>
<artifactId>laya-bom</artifactId>
<version>0.1.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>io.github.apscot</groupId>
<artifactId>laya-core</artifactId>
</dependency>
<dependency>
<groupId>io.github.apscot</groupId>
<artifactId>laya-client</artifactId>
</dependency>
</dependencies>Route a support ticket
var qs = new LinkedHashMap<String, Question>();
qs.put("team", Question.choice("Who should handle this?", Map.of(
"billing", "invoices, charges, refunds",
"tech", "bugs, outages, login problems",
"sales", "pricing, upgrades, new accounts")));
qs.put("urgent", Question.noul("Is the customer blocked right now?", null));
var a = (Map<String, Object>) agent.predict(ticketText, qs).get("answers");Use it as a guardrail in front of an LLM
var result = agent.predict(userMessage, Map.of(
"inject", Question.noul("Is the user trying to override the system instructions?", null)));
var answers = (Map<String, Object>) result.get("answers");
double risky = ((Number) ((Map<String, Object>) answers.get("inject")).get("noul")).doubleValue();
if (risky > 0.8) return "Sorry, I can't help with that.";Drop-in for Spring AI TypeSafe
Run the starter (or any laya-java server), then point the community client at it:
spring.ai.typesafe.base-url=http://localhost:8080
spring.ai.typesafe.api-key=anything # that client insists on a non-empty keyVerified with org.springaicommunity:typesafe-java-sdk:0.1.0 (yes/no, choice, score, rejected keys).
Judge a whole conversation
List<Map<String, String>> chat = List.of(
Map.of("role", "user", "content", "hi"),
Map.of("role", "assistant", "content", "hello, how can I help?"),
Map.of("role", "user", "content", "cancel my subscription"));
agent.predict(chat, Map.of("cancel", Question.noul("Does the user want to cancel?", null)));When a chat is too long, the newest messages are kept.
Non-English input
try (Agent ml = Agent.load("aac6fef/laya-multilingual-mlx")) {
ml.predict("发票被重复扣款,请退款。", Map.of("refund", Question.noul("客户是否要求退款?", null)));
}Over HTTP, just send the text; the server picks the multilingual checkpoint for non-English input.
- choice:
choiceis the winner,probabilitiescovers every option. - yes/no:
noul(probabilityin the OpenAI format) is P(true). - score: levels are numbered
0 … n-1in the order you give them, andscoreis the expected level: Σ index × probability. With levels Calm / Frustrated / Very angry at0.03 / 0.85 / 0.12you get1.09. List levels from lowest to highest, and read the per-level probabilities when the average isn't enough (a score of 1.0 can be "certainly level 1" or "half level 0, half level 2"). answer_confidence= probability of the top answer.confidence= how peaked the whole distribution is (1 certain, 0 evenly spread). Different scales; don't share a threshold.
Probabilities are temperature-calibrated using values from the checkpoint, but they are not guaranteed to match real-world frequencies. Measure your threshold on labelled examples from your own data before shipping it.
Apple M5 (10 cores), JDK 21, English checkpoint, warm, 31-token question:
| Backend | Median |
|---|---|
laya-accelerate (Apple Accelerate) |
~60 ms |
| Portable Java (Vector API) | ~186 ms |
| Python / MLX on a GPU (published, M3 Max) | ~13 ms |
A 512-token input takes about 1 s with Accelerate. laya-java is slower than MLX on a GPU; what you get in return is runs anywhere a JVM runs, with no Python in your deployment.
Make it quick: load the Agent once · add --add-modules=jdk.incubator.vector · on Macs add laya-accelerate · preload in servers.
Is this an LLM? No. It's a small encoder model with decision heads. It scores; it doesn't write.
Does it need a GPU or Python? Neither. It's plain Java (optionally using Apple's CPU math library on Macs).
Is it deterministic? Yes. Same input, same model, same numbers.
How big is it? About 800 MB on disk (English) and ~2.5 GB of memory while running.
Is it production-ready? It's a 0.x release: tested against the Python reference on all three checkpoints, but young. Validate on your own data and pin the version.
How do I know the numbers are right? Tokenizer ids, scores and answers are compared with the Python laya-mlx reference on a fixed set of cases for every checkpoint. That shows agreement with the reference, not accuracy on every possible input.
Can I use my own fine-tuned Laya checkpoint? If it follows the same layout (model.safetensors, rl_agent_config.json, encoder/config.json, tokenizer/), point Agent.load("/path/to/dir") or laya.models.mine=/path/to/dir at it.
- Checkpoints using the "parallel" option layout aren't supported (none are published yet).
- No image input:
/v1/decisionsreturns an error for image parts. - Not ported from the Python library:
predict_shortlist,predict_tournament,min_confidencegating, email presets, streaming. - Language routing between the English and multilingual checkpoints is a simple heuristic, not the full upstream detector.
- The
/v1/decisionsresponse follows OpenAI's public docs and published examples. If you depend on exact field names, check them against the OpenAI SDK you use. - Like the original, Laya is weaker on criteria needing world knowledge. Test it on your data.
git clone https://github.com/apscot/laya-java.git
cd laya-java
gradle buildTests that need model checkpoints are skipped unless LAYA_MODEL_DIR, LAYA_MODEL_DIR_MULTILINGUAL and LAYA_MODEL_DIR_TYPED point at local checkpoints. The expected values in laya-core/src/test/resources/fixtures were generated from the Python laya-mlx reference.
Issues and pull requests are welcome.
Apache License 2.0: see LICENSE and NOTICE.
Built on the shoulders of Laya by Convai Innovations and contributors, the laya-mlx port (our numerical reference), and the ModernBERT architecture. Thank you all. 💙