# How to use Jev in Node.js

> How to use Jev in Node.js with the official TypeSafe SDK: install it, ask Noul, Choice and Score questions, handle errors and retries, and build a full script.

Author: [Flavio Copes](https://flaviocopes.com/about/) | Published: 2026-09-28 | Topics: [AI](https://flaviocopes.com/tags/ai/) | Canonical: https://flaviocopes.com/jev-nodejs/

To use Jev in Node.js, install the official `@typesafe-ai/sdk` package, create a `TypeSafeClient`, and call `client.systemOne()` with a state and a set of questions built with the `noul()`, `choice()` and `score()` helpers. The client reads your key from the `TYPESAFE_API_KEY` environment variable, and the answers come back fully typed.

```ts
import { noul, TypeSafeClient } from '@typesafe-ai/sdk'

const client = new TypeSafeClient()

const { answers } = await client.systemOne({
  state: 'Hi Flavio, we would like to sponsor two issues of your newsletter in November. What are your rates?',
  questions: {
    is_sponsor_inquiry: noul('Does the message ask to sponsor the site or the newsletter?'),
  },
})

console.log(answers.is_sponsor_inquiry.noul)
```

Jev is TypeSafe's decision model. It doesn't generate text. It returns typed decisions with probabilities: a yes/no probability, one option from a list you wrote, or a position on a scale you described. If you're new to it, start with my [deep dive into Jev](https://flaviocopes.com/jev/). Here we stay in Node.js and build one real script from the first call to the final version.

## The quick answer

| What you want | Code |
| --- | --- |
| Install the SDK | `npm install @typesafe-ai/sdk` |
| Create a client | `new TypeSafeClient()` |
| Ask a yes/no question | `noul(instructions, criteria?)` |
| Pick one option from a list | `choice(instructions, { label: description })` |
| Place something on a scale | `score(instructions, [level0, level1, ...])` |
| Send the questions | `await client.systemOne({ state, questions })` |
| Pin the model | `model: 'jev-1.13.0'` in the request |
| Timeout, retries or cancel for one call | second argument `{ timeout, retry, signal }` |
| Get the HTTP response | `.withResponse()` or `.asResponse()` |
| List models | `await client.models.list()` |

## What do you need before you start?

You need Node.js 20 or newer and a TypeSafe API key.

Create the key under API Keys in the [TypeSafe console](https://console.typesafe.ai/keys). As of late September 2026, TypeSafe has paused new signups because of demand, while existing accounts keep working, so check [typesafe.ai](https://typesafe.ai) for the current state. The steps are in [how to get access to Jev and an API key](https://flaviocopes.com/jev-api-key/).

The script we'll build triages messages from a blog's contact form: sponsor requests, reader questions, broken links, and a steady flow of "could you add my link to your article?" emails. Each one needs a different reply.

## How do I set up the project?

Create a folder, turn it into an ES module package, and install the SDK:

```bash
mkdir inbox-triage
cd inbox-triage
npm init -y
npm pkg set type=module
npm install @typesafe-ai/sdk
```

`"type": "module"` lets us use `import` and top-level `await`. The package also ships a CommonJS build if an older project needs `require()`.

Put the key in a `.env` file and keep that file out of Git:

```bash
echo 'TYPESAFE_API_KEY=your_key_here' > .env
echo '.env' >> .gitignore
```

I write the script in TypeScript, because the types are the best part of this SDK. Node.js 24 and newer run `.ts` files directly by stripping the types, and `--env-file` loads the key without installing `dotenv`:

```bash
node --env-file=.env triage.ts
```

On older Node.js versions, run the same file with `npx tsx --env-file=.env triage.ts`.

Stripping types doesn't check them. To get type errors in your editor and in CI, add TypeScript:

```bash
npm install --save-dev typescript @types/node
```

Then create a `tsconfig.json`:

```json
{
  "compilerOptions": {
    "target": "es2022",
    "module": "nodenext",
    "strict": true,
    "noEmit": true,
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true
  }
}
```

`npx tsc` now checks every file, and `erasableSyntaxOnly` rejects syntax Node.js can't strip, like enums.

## Step 1: how do I ask one yes/no question?

We start with the simplest question: is this message a sponsor request?

A yes/no question in Jev is a **Noul**. The `noul(instructions, criteria)` helper takes the question and, optionally, an object with a `true` and a `false` description when the line between yes and no needs explaining.

Save this as `triage.ts`:

```ts
import { noul, TypeSafeClient } from '@typesafe-ai/sdk'

const client = new TypeSafeClient()

const message = {
  name: 'Marta Bianchi',
  subject: 'Newsletter sponsorship in November',
  body: 'Hi Flavio, I run marketing at a small managed Postgres company. We would like to sponsor two issues of your newsletter in November. Could you send me your rates?',
}

const { answers, model, usage } = await client.systemOne({
  state: message,
  questions: {
    is_sponsor_inquiry: noul('Does `body` ask to sponsor the site or the newsletter?'),
  },
})

console.log(answers.is_sponsor_inquiry.noul)
console.log(model)
console.log(usage)
```

The **state** is what Jev looks at. It can be a string, a JSON object or an array. I pass the message as an object, so a question can point at one field by writing its name in backticks, like `` `body` ``.

The key `is_sponsor_inquiry` is only for your code. The API doesn't send it to the model, so the whole question has to be in the instructions.

Run it and you get something like this (the numbers are illustrative):

```text
0.98
jev-1.13.0
{ input_tokens: 96, output_tokens: 20 }
```

`noul` is the probability that the answer is yes. `model` is the versioned model that answered. We didn't ask for one, so the SDK sent the default `jev-latest` alias, which points to `jev-1.13.0` as of September 2026. `usage` counts tokens, and only input tokens are billed ($0.042 per million, same date). I explain how to estimate a bill in [how much does Jev cost?](https://flaviocopes.com/jev-pricing/).

## Step 2: how do I pick one option with a Choice?

A yes/no isn't enough for this inbox. A sponsor request needs a rate card, a broken link needs a fix, and a link request needs a polite no. We want one label out of five, and that's a **Choice**.

`choice(instructions, criteria)` takes the question and an object that maps each label to a description. Use `null` for a label that needs no description, like a catch-all `other`. Add `choice` to the import and replace the call:

```ts
const { answers } = await client.systemOne({
  state: message,
  questions: {
    kind: choice('What does the sender of `body` want?', {
      sponsorship: 'Wants to pay for a placement on the site or in the newsletter',
      link_request: 'Asks to add a link or mention a product in an existing article, without offering a paid sponsorship',
      reader_question: 'Asks a question about programming or about one of the posts',
      broken_link: 'Reports a broken link, a typo or a bug on the site',
      other: null,
    }),
  },
})

console.log(answers.kind.choice)
console.log(answers.kind.probabilities)
console.log(answers.kind.confidence)
```

Always include an `other` label. Jev has to pick one of your labels, so without one a message that fits none of them gets pushed into the closest match.

The answer has three fields. `choice` is the label with the highest probability, `probabilities` has a number for every label, and `confidence` goes from 0 to 1. It's high when one label clearly wins and low when the probability is spread out.

TypeScript knows the labels. Hover over `answers.kind.choice` in your editor and its type is `'sponsorship' | 'link_request' | 'reader_question' | 'broken_link' | 'other'`, not `string`. A typo like `answers.kind.choice === 'sponsor'` is a type error. The helpers and `systemOne()` use `const` type parameters, so the exact labels you wrote flow through to the answer types.

## Step 3: how do I ask a Choice and a Score in one call?

For sponsor requests, two more things matter. A template sent to 500 blogs deserves a different reply than an email that names a product and a month. And did they ask for prices?

Effort sits on a scale, so it's a **Score**. `score(instructions, criteria)` takes an array of levels from lowest to highest, between 2 and 10 of them. Describe each level as a situation Jev can recognize, not as "low", "medium" and "high". The price question is another Noul.

We send all three in the same call. Jev evaluates every question against the same state in parallel, so an extra question adds its own tokens and very little time. TypeSafe calls this [speculative fan-out](https://docs.typesafe.ai/patterns/fan-out): ask every question you might need, even `effort` for a message that turns out to be a broken link, and let your code pick the answers it uses.

I move the questions into a constant:

```ts
const questions = {
  kind: choice('What does the sender of `body` want?', {
    sponsorship: 'Wants to pay for a placement on the site or in the newsletter',
    link_request: 'Asks to add a link or mention a product in an existing article, without offering a paid sponsorship',
    reader_question: 'Asks a question about programming or about one of the posts',
    broken_link: 'Reports a broken link, a typo or a bug on the site',
    other: null,
  }),
  effort: score('How much effort did the sender put into `body`?', [
    'Generic template that could be sent to any website',
    'Mentions this site, but makes no concrete request',
    'Concrete request that names a product, a date or a budget',
  ]),
  asks_for_rates: noul('Does `body` ask for prices or a rate card?'),
}

const { answers } = await client.systemOne({ state: message, questions })
```

A Score answer looks like this (illustrative numbers again):

```json
{
  "type": "score",
  "score": 1.9,
  "legend": {
    "0": "Generic template that could be sent to any website",
    "1": "Mentions this site, but makes no concrete request",
    "2": "Concrete request that names a product, a date or a budget"
  },
  "probabilities": { "0": 0.0, "1": 0.1, "2": 0.9 },
  "confidence": 0.86
}
```

`score` is the probability-weighted average of the level numbers: `1 × 0.1 + 2 × 0.9 = 1.9`, so almost certainly level 2. `legend` maps each level number back to your text. In TypeScript, `probabilities` and `legend` are keyed by `'0' | '1' | '2'`, inferred from the length of the array.

## How do I branch on the answer with an exhaustive switch?

The decision lives in your code. We check confidence first, then switch on the label. The `never` in the `default` branch makes the switch exhaustive: add a sixth label to the Choice and TypeScript flags this function until you handle it.

```ts
type ContactMessage = {
  name: string
  subject: string
  body: string
}

async function triage(message: ContactMessage) {
  const { answers } = await client.systemOne({ state: message, questions })
  const { kind, effort, asks_for_rates } = answers

  if (kind.confidence < 0.6) {
    return 'read_manually'
  }

  switch (kind.choice) {
    case 'sponsorship':
      return effort.score > 1.5 && asks_for_rates.noul > 0.8 ? 'send_rate_card' : 'review_sponsor'
    case 'link_request':
      return 'decline_link_request'
    case 'reader_question':
      return 'reply_later'
    case 'broken_link':
      return 'fix_site'
    case 'other':
      return 'read_manually'
    default: {
      const unhandled: never = kind.choice
      throw new Error(`Unhandled kind: ${unhandled}`)
    }
  }
}
```

Notice that `ContactMessage` is a `type`, not an `interface`. The SDK types the state as JSON with an index signature, and TypeScript only gives type aliases an implicit one. Pass a value typed with an interface and you get a "not assignable" error.

The thresholds (0.6, 1.5 and 0.8) are starting points. Log the answers next to what you would have done by hand, then move them.

## How do I configure TypeSafeClient?

`new TypeSafeClient()` with no arguments works when `TYPESAFE_API_KEY` is set. These are all the constructor options, checked in September 2026:

| Option | Default | What it does |
| --- | --- | --- |
| `apiKey` | `TYPESAFE_API_KEY` | Required. The constructor throws when neither is set |
| `baseURL` | `TYPESAFE_BASE_URL`, then `https://api.typesafe.ai` | The API root |
| `defaultModel` | `TYPESAFE_DEFAULT_MODEL`, then `jev-latest` | Model for requests that don't set one |
| `timeout` | `10000` | Milliseconds per attempt |
| `retry` | See below | Overrides for the retry policy |
| `logLevel` | `TYPESAFE_LOG_LEVEL`, then `warn` | `debug`, `info`, `warn`, `error` or `off` |
| `logger` | `console`, with a prefix | Any object with `debug`, `info`, `warn` and `error` methods |
| `defaultHeaders` | none | Headers sent with every request |
| `fetch` | global `fetch` | A custom fetch, for tests or custom networking |
| `dangerouslyAllowBrowser` | `false` | Lets the client run in a browser (see below) |

Options you pass win over environment variables, and environment variables win over the defaults.

By default the SDK retries a failed call up to 2 times, waiting 500 ms and doubling the wait up to 5 seconds, with some random jitter. It retries `408`, `429` and every `5xx` status (TypeSafe's `529 Overloaded` included), plus connection errors and timeouts, and it honors a `Retry-After` header up to 60 seconds. You can change any of those fields and keep the rest:

```ts
const client = new TypeSafeClient({
  timeout: 5000,
  retry: { maxRetries: 3, backoffInitialMs: 250 },
  logLevel: 'info',
})
```

`info` logs a line per request with its status code, duration and request ID. Be careful with `debug`, which also logs request and response bodies. The SDK redacts the API key but not the bodies, so for a contact form you'd get names and messages in your logs.

## How do I pin the model version?

The `jev-latest` alias moves when TypeSafe ships a new release. Your thresholds were tuned against one model, so pin its versioned ID before you go to production:

```ts
const { answers, model } = await client.systemOne({
  state: message,
  questions,
  model: 'jev-1.13.0',
})
```

You can also set `defaultModel: 'jev-1.13.0'` on the client, or `TYPESAFE_DEFAULT_MODEL` in the environment. Either way, log the `model` field of each response so you know which version made each decision.

## How do I set a timeout, retries or cancel one call?

`systemOne()` takes a second argument with options for that call only. They override the client settings:

```ts
const { answers } = await client.systemOne(
  { state: message, questions },
  {
    timeout: 2000,
    retry: { maxRetries: 1 },
    signal: AbortSignal.timeout(5000),
  },
)
```

`timeout` applies to each attempt, with no total budget across retries. With the defaults, a slow API can keep one call busy for three 10-second attempts plus the waits between them. `AbortSignal.timeout(5000)` caps the whole call: after 5 seconds it cancels the request and any pending retry, and the call throws `APIUserAbortError`. There's also a `headers` option, merged over `defaultHeaders`.

## How do I handle errors?

Every error the SDK throws extends `TypeSafeError`. HTTP failures are `APIError` subclasses with `status`, `body`, `headers` and `requestId`. Network failures are `APIConnectionError`.

| Class | When it's thrown |
| --- | --- |
| `BadRequestError` | `400` |
| `AuthenticationError` | `401`, a missing or wrong API key |
| `PermissionDeniedError` | `403` |
| `NotFoundError` | `404` |
| `UnprocessableEntityError` | `422`, the request failed validation and `body` says which field |
| `RateLimitError` | `429`, with `retryAfterMs` when the server sent a delay |
| `InternalServerError` | Any `5xx`, including `529 Overloaded` |
| `APIConnectionError` | DNS, TLS or a dropped connection |
| `APITimeoutError` | No full response within `timeout`, with `timeoutMs` |
| `APIUserAbortError` | Your `AbortSignal` fired |
| `TypeSafeError` | Local problems before any request: no API key, no questions, a Score with fewer than 2 levels |

By the time you catch a `RateLimitError` or an `InternalServerError`, the SDK has already retried it. Check the specific classes first, because `APITimeoutError` is also an `APIConnectionError` and every HTTP class is an `APIError`:

```ts
import {
  APIConnectionError,
  APIError,
  APITimeoutError,
  AuthenticationError,
  RateLimitError,
  TypeSafeError,
  UnprocessableEntityError,
} from '@typesafe-ai/sdk'

try {
  console.log(await triage(message))
} catch (error) {
  if (error instanceof RateLimitError) {
    console.error('Still rate limited after the retries, suggested wait in ms:', error.retryAfterMs)
  } else if (error instanceof AuthenticationError) {
    console.error('The API key is missing or wrong, check TYPESAFE_API_KEY')
  } else if (error instanceof UnprocessableEntityError) {
    console.error('The request is invalid:', error.body)
  } else if (error instanceof APIError) {
    console.error(`TypeSafe answered ${error.status} (request ${error.requestId})`)
  } else if (error instanceof APITimeoutError) {
    console.error(`No answer within ${error.timeoutMs} ms`)
  } else if (error instanceof APIConnectionError) {
    console.error('Could not reach api.typesafe.ai')
  } else if (error instanceof TypeSafeError) {
    console.error(error.message)
  } else {
    throw error
  }
}
```

For a triage script I'd treat them all the same: fall back to a person reading the message. The final script does that with one `catch`.

## How do I read the raw HTTP response?

`systemOne()` returns an `APIPromise`, a normal promise with a few extra methods. `withResponse()` gives you the parsed data, the `Response` object and the request ID from the `x-typesafe-request-id` header:

```ts
const { data, response, requestId } = await client
  .systemOne({ state: message, questions })
  .withResponse()

console.log(response.status, requestId)
console.log(data.answers.kind.choice)
```

`data` keeps the same inferred types as a normal call. The request ID is what you send TypeSafe when a call misbehaves, and errors carry it as `error.requestId`.

`asResponse()` returns the raw `Response` without parsing the body. If you use it, don't also `await` the same promise for the parsed result.

## How do I list the available models?

`client.models.list()` calls `GET /v1/models` and returns an array with a `name`, a `description` and a `release_date` for each entry:

```ts
const models = await client.models.list()

for (const model of models) {
  console.log(model.name, model.release_date, model.description)
}
```

As of September 2026 it lists the aliases, `jev-latest` and `jev-preview`, which both point to `jev-1.13.0`. Versioned IDs like `jev-1.13.0` work in the `model` field even though they aren't in the list.

## Why does Jev have to run on the server?

Because the API key would be public. Anyone who opens your page can read the JavaScript it runs, and with your key they can spend your tokens and use up your rate limit.

The SDK refuses to start in a browser. When it finds the `window`, `document` and `navigator` globals, the constructor throws:

```text
TypeSafeClient is running in a browser, which would expose your API key to anyone using the page. Call the API from a server instead, or pass `dangerouslyAllowBrowser: true` if you understand the risk.
```

`dangerouslyAllowBrowser: true` turns the check off, but the key is still exposed, so leave it alone. Make the call from a server route, a serverless function or a background job. For a contact form, that's the handler that receives the submission.

## The complete script

Here's everything in one file. It pins the model, asks the three questions in one call, picks an action, and falls back to a person when anything goes wrong:

```ts
import { choice, noul, score, TypeSafeClient, TypeSafeError } from '@typesafe-ai/sdk'

type ContactMessage = {
  name: string
  subject: string
  body: string
}

type Action =
  | 'send_rate_card'
  | 'review_sponsor'
  | 'decline_link_request'
  | 'reply_later'
  | 'fix_site'
  | 'read_manually'

const client = new TypeSafeClient({
  defaultModel: 'jev-1.13.0',
  timeout: 5000,
})

const questions = {
  kind: choice('What does the sender of `body` want?', {
    sponsorship: 'Wants to pay for a placement on the site or in the newsletter',
    link_request: 'Asks to add a link or mention a product in an existing article, without offering a paid sponsorship',
    reader_question: 'Asks a question about programming or about one of the posts',
    broken_link: 'Reports a broken link, a typo or a bug on the site',
    other: null,
  }),
  effort: score('How much effort did the sender put into `body`?', [
    'Generic template that could be sent to any website',
    'Mentions this site, but makes no concrete request',
    'Concrete request that names a product, a date or a budget',
  ]),
  asks_for_rates: noul('Does `body` ask for prices or a rate card?'),
}

async function triage(message: ContactMessage): Promise<Action> {
  const { answers, model, usage } = await client.systemOne(
    { state: message, questions },
    { signal: AbortSignal.timeout(15000) },
  )

  console.log(model, usage.input_tokens, JSON.stringify(answers))

  const { kind, effort, asks_for_rates } = answers

  if (kind.confidence < 0.6) {
    return 'read_manually'
  }

  switch (kind.choice) {
    case 'sponsorship':
      return effort.score > 1.5 && asks_for_rates.noul > 0.8 ? 'send_rate_card' : 'review_sponsor'
    case 'link_request':
      return 'decline_link_request'
    case 'reader_question':
      return 'reply_later'
    case 'broken_link':
      return 'fix_site'
    case 'other':
      return 'read_manually'
    default: {
      const unhandled: never = kind.choice
      throw new Error(`Unhandled kind: ${unhandled}`)
    }
  }
}

async function safeTriage(message: ContactMessage): Promise<Action> {
  try {
    return await triage(message)
  } catch (error) {
    if (error instanceof TypeSafeError) {
      console.error(`Jev failed, reading this one by hand: ${error.message}`)
      return 'read_manually'
    }
    throw error
  }
}

const message: ContactMessage = {
  name: 'Marta Bianchi',
  subject: 'Newsletter sponsorship in November',
  body: 'Hi Flavio, I run marketing at a small managed Postgres company. We would like to sponsor two issues of your newsletter in November. Could you send me your rates?',
}

console.log(await safeTriage(message))
```

Run it with `node --env-file=.env triage.ts` and it prints the answers, then one action, like `send_rate_card`.

From here, call `safeTriage()` from the handler that receives your form, log every answer next to what you would have done by hand, and let the actions run on their own only when the log agrees with you. The [JavaScript SDK reference](https://docs.typesafe.ai/sdk/javascript/api) lists every type if you need more.
