> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.milliseconds.ai/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.milliseconds.ai/_mcp/server.

# TypeScript SDK

```bash
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

```ts
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](/concepts/writing-good-questions).

## Client options

```ts
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`.

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

## The eight capabilities

```ts
// 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.

```ts
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
```

> **Warning**
>
> 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.

```ts
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](/capabilities/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.

```ts
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.

```ts
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

```ts
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.

> **Note**
>
> 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](/reference/limits).

## Errors and retries

```ts
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.

> **Warning**
>
> 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](/reference/errors) lists every code.

## Any path, any body

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

```ts
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`:

```ts
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

* Every endpoint answers `400` `invalid_request` for a body that carries none of `text`, `texts` and `image`. The SDK always sends one, so that body cannot reach the API. An empty text is a `400` too, 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.