@molecule/api-ai-decisions
Core interface · ai-decisions · API (Node) · v1.0.0 · Apache-2.0
Typed AI decisions for molecule.dev — answer choice, score and yes/no questions about text or JSON with calibrated probabilities, behind swappable bonds (Laya, Jev, any LLM)
npm install @molecule/api-ai-decisionsHow it works
@molecule/api-ai-decisions is the ai-decisions core interface on the API (Node) side: the API your app calls, with no vendor inside.
Choose the implementation by bonding one of its 3 providers: @molecule/api-ai-decisions-jev, @molecule/api-ai-decisions-laya, @molecule/api-ai-decisions-llm.
import { setProvider, requireProvider } from '@molecule/api-ai-decisions'
import { provider as laya } from '@molecule/api-ai-decisions-laya'
setProvider(laya) // at startup — reads LAYA_URL / LAYA_API_KEY on first use
const { answers } = await requireProvider().decide({
state: { subject: 'Charged twice', body: 'I was billed twice this month, I want my money back' },
questions: {
queue: {
type: 'choice',
instructions: 'Which team should handle this?',
criteria: {
billing: 'invoices, refunds, charges',
tech: 'bugs, login, outages',
other: 'anything else',
},
},
urgency: {
type: 'score',
instructions: 'How upset is the customer?',
criteria: ['calm', 'firm', 'angry', 'furious'],
},
refund: { type: 'yesNo', instructions: 'The customer is asking for a refund.' },
},
minConfidence: 0.7,
})
answers.queue.choice // 'billing'
answers.urgency.level // 2 (answers.urgency.score is the expected level, e.g. 2.64)
answers.refund.probability // 0.97
if (answers.queue.lowConfidence) {
// send to a human instead of auto-routing
}Providers (3): @molecule/api-ai-decisions-jev, @molecule/api-ai-decisions-laya, @molecule/api-ai-decisions-llm
Works with: @molecule/api-bond, @molecule/api-i18n
Reference
Typed AI decisions for molecule.dev.
Ask typed questions about a piece of text or JSON and get probabilities back,
not generated text: pick one option (choice), rate on an ordered scale
(score), or test a statement (yesNo). Use it for routing and triage
(which queue, how urgent), guardrails and moderation (is this spam, a
jailbreak, a refund request), and any branch in your code that needs a
judgment call about language. Many questions share one call.
This core defines the AIDecisionsProvider contract and its bond accessor
only. Bond one provider:
| Bond | What answers | When |
|---|---|---|
@molecule/api-ai-decisions-laya | the open-weights Laya model (Apache-2.0) on a laya-serve host you run — or any other self-hosted /v1/systemone server, such as Kev (Qwen-based, GPU/MLX) | self-hosted, ~30–40 ms/question on a GPU (Laya), data stays with you |
@molecule/api-ai-decisions-jev | TypeSafe's hosted Jev API | no model to host; English-first |
@molecule/api-ai-decisions-llm | whatever ai chat bond is bonded | no extra service; slower and costlier per call |
Quick Start
import { setProvider, requireProvider } from '@molecule/api-ai-decisions'
import { provider as laya } from '@molecule/api-ai-decisions-laya'
setProvider(laya) // at startup — reads LAYA_URL / LAYA_API_KEY on first use
const { answers } = await requireProvider().decide({
state: { subject: 'Charged twice', body: 'I was billed twice this month, I want my money back' },
questions: {
queue: {
type: 'choice',
instructions: 'Which team should handle this?',
criteria: {
billing: 'invoices, refunds, charges',
tech: 'bugs, login, outages',
other: 'anything else',
},
},
urgency: {
type: 'score',
instructions: 'How upset is the customer?',
criteria: ['calm', 'firm', 'angry', 'furious'],
},
refund: { type: 'yesNo', instructions: 'The customer is asking for a refund.' },
},
minConfidence: 0.7,
})
answers.queue.choice // 'billing'
answers.urgency.level // 2 (answers.urgency.score is the expected level, e.g. 2.64)
answers.refund.probability // 0.97
if (answers.queue.lowConfidence) {
// send to a human instead of auto-routing
}
Type
core
Installation
npm install @molecule/api-ai-decisions @molecule/api-bond @molecule/api-i18n
API
Interfaces
AIDecisionsProvider
AI decisions provider interface. Implemented by the Laya, Jev and LLM bonds.
interface AIDecisionsProvider {
/** Provider identifier. */
readonly name: string
/**
* Answer every question about `state`.
*
* @param input - The state, the questions and options.
* @returns One typed answer per question id.
*/
decide<Q extends Record<string, DecisionQuestion>>(
input: DecideInput<Q>,
): Promise<DecideResult<Q>>
}
AnswerBase
Fields every answer carries.
interface AnswerBase {
/**
* Probability mass on the reported answer (the highest option probability;
* `max(p, 1 - p)` for yes/no), in `0..1`. Every bond computes it this same
* way from the probabilities, so a threshold means the same thing whichever
* provider is bonded — it is NOT the vendor's own `confidence` field.
*/
confidence: number
/** Set only when `minConfidence` was passed: `true` when `confidence` fell below it. */
lowConfidence?: boolean
}
ChoiceAnswer
Answer to a ChoiceQuestion.
interface ChoiceAnswer extends AnswerBase {
type: 'choice'
/** The most likely option — always one of the question's `criteria` keys. */
choice: string
/** Probability per option (every `criteria` key present), summing to ~1. */
probabilities: Record<string, number>
}
ChoiceQuestion
Pick exactly one option.
interface ChoiceQuestion {
type: 'choice'
/** What is being decided, e.g. `'Which team should handle this ticket?'`. */
instructions: string
/**
* The options, keyed by the label you want back, each with a short
* description of when it applies. `{ billing: 'invoices, refunds', tech: 'bugs, outages' }`.
*/
criteria: Record<string, string>
}
DecideInput
Input to one decision request.
interface DecideInput<
Q extends Record<string, DecisionQuestion> = Record<string, DecisionQuestion>,
> {
/** What the questions are about. */
state: DecisionState
/** The questions, keyed by an id you choose; answers come back under the same ids. */
questions: Q
/** Provider-specific model / checkpoint id (e.g. `'jev-latest'`, `'multilingual'`). */
model?: string
/** Mark answers whose `confidence` is below this (`0..1`) with `lowConfidence: true`. */
minConfidence?: number
/** Abort signal to cancel the in-flight request. */
signal?: AbortSignal
}
DecideResult
Result of one decision request.
interface DecideResult<
Q extends Record<string, DecisionQuestion> = Record<string, DecisionQuestion>,
> {
/** One answer per question id. */
answers: AnswersFor<Q>
/** The model or checkpoint that answered, when the provider says. */
model?: string
/** Token usage, when reported. */
usage?: DecisionUsage
}
DecisionUsage
Token usage, when the provider reports it.
interface DecisionUsage {
inputTokens: number
outputTokens: number
}
ScoreAnswer
Answer to a ScoreQuestion.
interface ScoreAnswer extends AnswerBase {
type: 'score'
/** Expected level index — may fall between levels (e.g. `2.64`). */
score: number
/** The most likely level index (`0..criteria.length - 1`). */
level: number
/** Probability per level, indexed like `criteria`. */
probabilities: number[]
}
ScoreQuestion
Place the state on an ordered scale.
interface ScoreQuestion {
type: 'score'
/** What is being rated, e.g. `'How urgent is this?'`. */
instructions: string
/**
* The levels, lowest first. Level `i` is described by `criteria[i]`:
* `['calm', 'firm', 'angry', 'furious']`.
*/
criteria: string[]
}
YesNoAnswer
Answer to a YesNoQuestion.
interface YesNoAnswer extends AnswerBase {
type: 'yesNo'
/** Probability that the statement is true, in `0..1`. */
probability: number
/** `probability >= 0.5`. Prefer thresholding `probability` yourself when the cost of each mistake differs. */
answer: boolean
}
YesNoQuestion
How likely is a statement true. (Called noul on the Jev/Laya wire.)
interface YesNoQuestion {
type: 'yesNo'
/** The statement to test, e.g. `'The customer is asking for a refund.'`. */
instructions: string
/** Optional descriptions of what counts as yes and as no. */
criteria?: { yes?: string; no?: string }
}
Types
AnswersFor
Maps a questions object to its answers object, so
result.answers.department.choice is typed when the questions are literal.
type AnswersFor<Q extends Record<string, DecisionQuestion>> = {
[K in keyof Q]: Q[K] extends ChoiceQuestion
? ChoiceAnswer
: Q[K] extends ScoreQuestion
? ScoreAnswer
: YesNoAnswer
}
DecisionAnswer
Any answer. answers[id].type matches questions[id].type.
type DecisionAnswer = ChoiceAnswer | ScoreAnswer | YesNoAnswer
DecisionQuestion
Any question a decision provider answers.
type DecisionQuestion = ChoiceQuestion | ScoreQuestion | YesNoQuestion
DecisionState
What the questions are about: plain text, or a JSON object / array (an email with headers, a ticket with metadata, a chat log).
type DecisionState = string | Record<string, unknown> | unknown[]
Functions
getAllProviders()
Retrieves all named AI decisions providers as a Map keyed by name.
function getAllProviders(): Map<string, AIDecisionsProvider>
Returns: Map of provider name → AIDecisionsProvider.
getProvider()
Retrieves the singleton AI decisions provider, or null if none is bonded.
Falls back to a single named provider when no singleton is bonded. When
multiple named providers are bonded the fallback declines (returns null)
because the choice is ambiguous — use getProviderByName(name) instead.
function getProvider(): AIDecisionsProvider | null
Returns: The bonded AI decisions provider, or null.
getProviderByName(name)
Retrieves a named AI decisions provider, or null if not bonded.
function getProviderByName(name: string): AIDecisionsProvider | null
name— The provider name.
Returns: The named AI decisions provider, or null.
hasProvider(name)
Checks whether an AI decisions provider is currently bonded.
function hasProvider(name?: string): boolean
name— Optional provider name. If omitted, checks the singleton.
Returns: true if the provider is bonded.
requireProvider()
Retrieves the bonded AI decisions provider, throwing if none is bonded.
function requireProvider(): AIDecisionsProvider
Returns: The bonded AI decisions provider.
setProvider(provider)
Registers an AI decisions provider in singleton mode.
function setProvider(provider: AIDecisionsProvider): void
provider— The default provider implementation for this process.
Available Providers
| Provider | Package |
|---|---|
| Jev | @molecule/api-ai-decisions-jev |
| Laya | @molecule/api-ai-decisions-laya |
| LLM | @molecule/api-ai-decisions-llm |
Injection Notes
Requirements
Peer dependencies:
@molecule/api-bond^1.0.1@molecule/api-i18n^1.0.1
Runtime Dependencies
-
@molecule/api-bond -
@molecule/api-i18n -
Interface + accessor only. Use the core's
setProvider(provider)/setProvider('name', provider), thenrequireProvider()orgetProviderByName('name'). -
confidenceis the probability of the reported answer (maxof the distribution), computed the same way by every bond. Vendors define their ownconfidencedifferently (Jev:(n·pmax − 1)/(n − 1); Laya: normalized entropy), so a threshold copied from a vendor's docs does not transfer — pick thresholds on your own data. -
Base models are not a finished classifier for your domain. Laya's own benchmarks put its base checkpoints near chance (0.36) on the typed-decisions set zero-shot; its fine-tuned checkpoint reaches 0.77. Measure accuracy on a labelled sample of YOUR inputs before letting an answer act unattended, and gate on
minConfidence→ a human or an LLM fallback for the rest. -
Probabilities ship over-confident until calibrated on your traffic (Laya reports ECE 0.47 → 0.08 after temperature fitting). A 0.95 is not "right 95% of the time" until you have checked.
-
Keep option lists short. Accuracy drops past ~20
choiceoptions on Laya (the option texts share a ~192-token window); Jev accepts up to 255,laya-serverefuses >100.scoretakes 2–10 levels on Jev. -
Keep state short. Laya's English checkpoint reads 512 tokens (the multilingual one 1,024); longer state is truncated, not refused. Put the decisive text first.
-
Never use it to generate text — there is no text output. For a free-text label set that changes per request, use
@molecule/api-ai-classification. -
Server-side only. The provider key and the model host never belong in browser code.
E2E Tests
Integration checklist — drive the real UI (live preview, no mocks), adapt each item to this app's actual screens/flows, and check every box off one by one. A box you can't check is an integration bug to fix — not a skip:
- Each flow that makes a decision (routing, triage, moderation, a guardrail) runs it from the real UI, and the answer DRIVES what happens next (the item lands in the chosen queue, the badge shows, the action is blocked) — not just printed.
- Both directions: a clearly-billing input routes to billing AND a clearly-technical one routes elsewhere. One label for every input is a broken integration.
- A low-confidence answer takes the app's fallback path (human review, "unsure" state) instead of being acted on.
- Provider errors (service down, bad key) show a visible, recoverable state — never a blank screen or an unhandled rejection.
- The call runs server-side: no provider request or key in the browser's Network tab.