lmai
Drive a language model from a script: declare prompts, force a JSON shape, branch on the answer, and account for what it cost in tokens. Safe mode with no new env flag, and it needs the host's ai...
lmai — the prompt-orchestration language
Drive a language model from a script: declare prompts, force a JSON shape, branch on the answer, and account for what it cost in tokens. Safe mode with no new env flag, and it needs the host's ai bridge — so POST /api/lmx/run or Studio's LMX Playground, not the CLI.
MODEL auto
TEMPERATURE 0.2
SYSTEM "You are a terse support classifier."
PROMPT classify = "Label this as billing, bug or other:\n{{input}}"
SHAPE verdict = { "label": "billing", "confidence": 0.9 }
ASK classify -> r AS verdict
PRINT r.label
WHEN r.confidence < 0.7 THEN ASK "Why is this ambiguous?\n{{input}}" -> why
PRINT why
USAGE
RETURN r
[critical] Read this before you trust a run
providerRegistry.resolve() never fails. auto walks AUTO_PRIORITY — ollama, leumas-slm, openai, claude, gemini, cursor-agent, codex — and when none is available it falls back to a built-in echo stub that hands your own message back.
So on a machine with no API key and no Ollama, a naive prompt script runs green, prints something plausible, and means nothing at all.
lmai closes that hole three ways:
- every result records which provider actually answered;
USAGEprints it;- and because a script author cannot be relied on to write
USAGE, **the runner prints a warning of
its own accord** whenever the stub answered:
`` [warning] the ECHO STUB answered — no real model provider is configured, so this run proves nothing. ``
If you are automating on top of lmai, branch on usage.stub before anything else.
The shaped path makes this worse, not better. The stub echoes your prompt back — and a shaped prompt contains the SHAPE example, so the JSON extractor finds it and the run succeeds:
result.value: {"label":"billing","confidence":0.9}
result.usage: {"calls":1,"tokens":0,"providers":["stub"],"stub":true,"retries":0}
That is a perfectly plausible classification of the input. It is your own example, handed back. stub: true and tokens: 0 are the only things that say so.
Keys
lmai invents no policy. It calls resolveKeyForUser, already the single decision point behind every chatbot and coding-agent run:
| Caller | Key used |
|---|---|
| active member, or admin | the env-mounted key for that provider |
| free user | their own stored BYO key |
| neither | a 402 — "Add your own key in Settings, or upgrade your membership" |
That message is surfaced to the script verbatim, because it is already written for a human.
Local providers skip this entirely. ollama, leumas-slm and codex are configured with an endpoint or a binary path, not a credential, so lmai never demands a key for a model running on your own machine.
Directives
Config and declarations are hoisted, so a PROMPT may be declared after the ASK that uses it. Statements run in source order. # starts a comment.
Config
| Directive | Notes |
|---|---|
MODEL <provider>[:<model>] | MODEL auto, MODEL claude, MODEL claude:claude-sonnet-5 |
TEMPERATURE <n> | 0 is a setting, not "unset" — an omitted temperature is never defaulted |
MAXTOKENS <n> | same |
SYSTEM "…" or SYSTEM do … end | the system prompt |
Declarations
| Directive | Notes |
|---|---|
PROMPT <name> = "…" | a template; \n escapes work |
PROMPT <name> do … end | multi-line, taken verbatim |
SHAPE <name> = { … } | an example JSON document — a shape, not a schema |
SHAPE <name> do … end | multi-line JSON |
SET <name> = <value> | a variable; JSON-coerced, so SET n = 3 is the number 3 |
Statements
| Directive | Notes | |
|---|---|---|
| `ASK <name\ | "literal"> -> <var>` | one model turn; the reply text lands in <var> |
ASK … -> <var> AS <shape> | forces JSON; <var> is the parsed document | |
WHEN <cond> THEN <statement> | one guarded statement | |
PRINT <path> or PRINT "… {{slot}} …" | ||
USAGE | the accounting line | |
RETURN <path> | the script's value; stops the script |
WHEN conditions are path op value (= != > >= < <=) or a bare path for truthiness. A missing path is falsy, never an error.
{{slots}}
Templates interpolate from three sources, all in one namespace:
- the host's named
inputs({"input": "…"}from the API, or# inputs: input=…in a bundled demo); SETvariables;- any prior
ASKresult — including into a document:{{r.label}}.
An unresolved slot is an error, not an empty string. A prompt that silently loses its input still runs, still costs tokens, and answers a question nobody asked — the single most expensive way for a scripted model call to fail.
Non-string values are JSON-stringified rather than coerced, so {{r}} on an object gives {"a":1} and never [object Object].
The shaped path (AS)
SHAPE gives the model an example of the document you want. lmai appends it plus Respond with ONLY the JSON document — no commentary, no code fences., then parses the reply with the same fence-stripping, balanced-bracket extractor @leumas/generation uses.
If that fails it retries exactly once with a stricter nudge, then gives up with the first 200 characters of the reply. It never loops.
Both calls count toward tokens and toward the ASK budget. usage.retries tells you it happened.
USAGE
--- usage --- calls=1 tokens=12 retries=0 provider=claude (token counts only — the provider layer reports no cost)
Tokens, never money. cost is hardcoded 0 throughout the provider layer and there is no price table anywhere in the tree. USAGE says so in as many words rather than implying a figure it cannot compute.
Limits
| Limit | Value | Why |
|---|---|---|
| ASK budget | 20 per script (retries count) | a loop cannot spend a key |
| Run timeout | 60s default | the global LMX default is 5s, which kills any real model call |
What it deliberately cannot do
- Loop, or define functions. A prompt script that needs those wants
lmxwith theadapter()
bridge, not a second general-purpose language.
- Call tools.
toolCallscome back from the provider and are not surfaced; a tool loop is what
@leumas/chatbots and the coding agent are for.
- Report a cost. See above.
- Run from the CLI. No
aibridge there.
Running it
curl -s -X POST localhost:3000/api/lmx/run \
-H 'content-type: application/json' \
-d '{"mode":"lmai","source":"PROMPT p = \"hi {{input}}\"\nASK p -> r\nPRINT r\nUSAGE","inputs":{"input":"there"}}'
Bundled examples: shared/engines/lmx/src/scripts/demo.lmai, shared/engines/lmx/src/scripts/triage.lmai. Tests: shared/engines/lmx/src/test/lmai.test.js. Guard: pnpm smoke:lmx.