TypeScript SDK

Zero dependencies, ESM and CJS, and result types built from the labels you pass.
npm i @cloudraker/milliseconds # pnpm add, bun add, yarn add
export MS_API_KEY=test_sk-...

TypeScript 5.0 or later. Node 20 or later, Bun, Deno, Cloudflare Workers. Zero runtime dependencies. The package ships ESM, CJS and .d.ts.

Start here

import { DecisionMachine } from '@cloudraker/milliseconds'
const dm = new DecisionMachine() // reads MS_API_KEY
const r = await dm.classify(ticket, {
billing: 'payments, invoices, charges and refunds',
shipping: 'delivery, tracking and packages',
account: 'login, passwords and profile settings',
})
// r: ClassifyResult<'billing' | 'shipping' | 'account'>
// r.label: 'billing' | 'shipping' | 'account'
// r.scores: Record<'billing' | 'shipping' | 'account', number>
// r.probability: number
// r.confidence: number

r.label === 'shiping' is a compile error here. That is the whole point.

Describe every label. The description is the instruction, and the model reads it literally. Described labels score measurably better than bare names. See writing good statements and labels.

Client options

const dm = new DecisionMachine({
apiKey: process.env.MS_API_KEY, // falls back to MS_API_KEY
baseUrl: 'https://api.milliseconds.ai', // a trailing slash is trimmed
timeout: 60_000, // per attempt, in milliseconds
maxRetries: 2, // retries after the first attempt
headers: { 'x-team': 'support' }, // merged into every request
fetch: globalThis.fetch, // tests, proxies, a Workers service binding
dangerouslyAllowBrowser: false, // the key is a secret
})

Every capability takes the same per-call options as its last argument: timeout, maxRetries, signal and headers.

const controller = new AbortController()
await dm.classify(ticket, LABELS, { timeout: 5_000, maxRetries: 0, signal: controller.signal })

The eight capabilities

// 1. classify — pick one label, get the full distribution
const intent = await dm.classify(ticket, ['billing', 'shipping', 'account'])
intent.label // 'billing' | 'shipping' | 'account'
// 2. yesNo — one inference call, many statements
const [urgent, asking] = await dm.yesNo(
ticket,
['The customer expresses urgency.', 'The customer is asking about shipping.'],
{ when_true: 'Time pressure, ASAP, losing money' },
)
urgent.answer // boolean
urgent.statement // 'The customer expresses urgency.' — the literal, not string
// 3. rate — place the text on an ordered scale
const tone = await dm.rate(message, ['Calm', 'Annoyed', 'Angry', 'Threatening to leave'])
tone.score // number, 0 to 3. Route on this.
tone.level // 0 | 1 | 2 | 3
tone.label // 'Calm' | 'Annoyed' | 'Angry' | 'Threatening to leave'
// 4. answer — quote the answer out of the text, with offsets
const [who] = await dm.answer(article, ['Who announced the product?'])
if (who.answer !== null) article.slice(who.start, who.end) // start and end are numbers here
// 5. entities — every mention of every type
const found = await dm.entities(article, { person: 'a human name', place: 'a city or country' })
found[0]?.type // 'person' | 'place'
// 6. verify — check a value you already hold
const check = await dm.verify(invoiceText, 'invoice_number', 4471)
check.matches // boolean. check.found shows what the text says.
// 7. classifyTree — walk a nested taxonomy
const node = await dm.classifyTree(ticket, {
billing: {
description: 'payments, invoices, charges, refunds and subscriptions',
labels: {
refund_request: 'the customer asks for money back',
subscription_change: 'the customer wants to upgrade, downgrade or cancel a plan',
},
},
shipping: 'delivery, tracking, lost or damaged parcels',
})
node.label // 'refund_request' | 'subscription_change' | 'shipping' — a walk stops here
node.path // every label on the way down
// 8. extract — fill a JSON Schema
const data = await dm.extract(letter, {
type: 'object',
properties: {
due_date: { description: 'the date the payment is due' },
total: { type: 'number' },
},
})
data.total // number | null

classifyTree narrows label to the labels a walk can stop on, and path to every label at every level. probability and confidence are products over the levels, so they fall with depth.

Batching

One text gives one result. A tuple of texts gives a tuple of results, in order.

const one = await dm.classify(ticket, LABELS) // ClassifyResult<Intent>
const [a, b] = await dm.classify([t1, t2], LABELS) // a tuple of two
const many = await dm.classify(tickets, LABELS) // tickets: string[] -> ClassifyResult<Intent>[]
// Both axes at once: texts times statements.
const grid = await dm.yesNo([t1, t2], ['The text mentions a price.'])
// grid[0][0].answer

A hoisted label list needs as const to keep its union: const LABELS = ['billing', 'shipping'] as const. A plain string[], read from a config file for example, types r.label as string. The literal inline forms above need nothing.

A batch holds at most 32 texts, and each text at most 20,000 characters. The SDK never chunks for you.

Extraction

A JSON Schema object literal types the result on its own.

const data = await dm.extract(letter, {
type: 'object',
properties: {
reference: { type: 'string', enum: ['AB', 'CD'] },
tags: { type: 'array', items: { type: 'string' } },
line_items: { type: 'array', items: { type: 'object' } },
vendor: { type: 'object', properties: { name: { type: 'string' } } },
},
})
// reference: 'AB' | 'CD' | (string & {}) | null
// tags: string[] | null
// line_items: never[]
// vendor: { name: string | null }

Four degradations are real, and the types state them:

  1. A missing value is null. Every scalar leaf is nullable.
  2. An array of objects always comes back []. Line-item quality is not good enough to ship.
  3. An array of scalars comes back as strings. The runner calls String() on every element.
  4. An enum is not checked server side. (string & {}) admits reality and keeps autocomplete.

A nested object is never null. Only its leaves are. See Extract.

zod, valibot and arktype

A zod 4.2 schema passes straight in. Every schema carries its own toJSONSchema() method from that release, and the SDK calls it.

import { z } from 'zod'
const Invoice = z.object({ total: z.number(), currency: z.enum(['USD', 'EUR']) })
const data = await dm.extract(pdfText, Invoice)
// data.total: number | null
// data.currency: 'USD' | 'EUR' | (string & {}) | null

An arktype schema passes straight in too. The SDK calls its toJsonSchema() and reads the output type from ~standard.

valibot and zod 4.0 or 4.1 convert through a module function instead. Brand the output with typed<>, and the result gets your type.

import * as v from 'valibot'
import { toJsonSchema } from '@valibot/to-json-schema'
import { typed } from '@cloudraker/milliseconds'
const Invoice = v.object({ total: v.number() })
const data = await dm.extract(
pdfText,
typed<v.InferOutput<typeof Invoice>>(toJsonSchema(Invoice)),
)
// data.total: number | null

Usage and rate limits

const { result, usage, response } = await dm.classify(ticket, LABELS).withUsage()
usage.inputTokens // what this call bills
usage.inputChars // x-input-chars
usage.inferenceMs // model time, summed over the calls this request made
usage.rateLimit // RateLimit | null
usage.headers // every response header

withUsage() returns the result, the usage and the raw Response. Without it you await the result alone.

Request-limit values count inference request units, not HTTP calls. A batch with 24 texts consumes 24 units; text and statement or question counts multiply.

usage.rateLimit carries limitRequests, remainingRequests, resetRequests, limitTokens, remainingTokens and resetTokens. Tiers with unlimited TPM omit the token headers. Read usage.headers if the SDK returns null for a partial header set.

The API reserves units before processing the call. Rate-limit headers show a snapshot after admission; concurrent calls can change the remaining balance.

Honor retry-after on rate-limit refusals. See Limits and rate limits.

Errors and retries

import { isMillisecondsError } from '@cloudraker/milliseconds'
try {
await dm.classify(ticket, LABELS)
} catch (e) {
if (!isMillisecondsError(e)) throw e
switch (e.code) {
case 'rate_limit_exceeded':
return wait(e.retryAfter) // seconds, or null
case 'insufficient_quota':
return topUp() // never retried: a timer will not help
case 'invalid_request':
return fix(e.apiMessage) // the wire text, unchanged
default:
throw e
}
}

The SDK retries 429 rate_limit_exceeded, 502 runner_error, 529 overloaded, and transport failures and timeouts. Every capability is a pure function, so a retry is always safe. It never retries 400, 401, 403 or 429 insufficient_quota. maxRetries defaults to 2 and takes 0. The backoff is full jitter, capped at 8 seconds, and a retry-after header wins over the backoff.

e.attempts counts the attempts, including the first. e.status is 0 when the call never reached the API.

A 502, 503 or 504 with no JSON body is retried on the status alone. A Cloudflare error page never reaches the worker, so it carries no code.

The SDK also checks your call before it sends anything: the label, statement, scale and text limits, and the two API traps. Those throw code: 'client_error' with status: 0, and no token is billed. They throw synchronously, before the Decision exists. Catch them with try/catch around the call, not with .catch() on it.

Errors lists every code.

Any path, any body

dm.post() reaches the untouched body, and any future path:

const raw = await dm.post<unknown>('/v1/decision-machine-1/classify', { text, labels })

Runtimes

Node 20 or later, Bun and Deno work with no configuration. The SDK uses global fetch.

On Cloudflare Workers, pass a service binding’s fetch:

const dm = new DecisionMachine({
apiKey: env.MS_API_KEY,
fetch: env.MILLISECONDS.fetch.bind(env.MILLISECONDS),
})

In a browser the constructor throws. Your API key is a secret, and a bundle ships it to every visitor. Call the API from your server. dangerouslyAllowBrowser: true opts out, and is right only when the bundle never reaches a user.

Gotchas

  • yes-no answers 200 with {"results":[]} for a body that carries neither text nor texts. The SDK always sends one of the two, so that body cannot reach the API. An empty text is a 400, and the SDK’s local check only saves you the round trip.
  • classify-tree sums inference_ms over every level into the header, while x-input-chars counts one pass over the body. The per-level numbers do not sum to usage.inputChars.
  • retry-after rides on 429 rate_limit_exceeded only. e.retryAfter is null on insufficient_quota.
  • The six x-ratelimit-* headers arrive together or not at all. usage.rateLimit is null in the second case.