How to use Jev in Node.js

By

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.

~~~

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.

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. Here we stay in Node.js and build one real script from the first call to the final version.

The quick answer

What you wantCode
Install the SDKnpm install @typesafe-ai/sdk
Create a clientnew TypeSafeClient()
Ask a yes/no questionnoul(instructions, criteria?)
Pick one option from a listchoice(instructions, { label: description })
Place something on a scalescore(instructions, [level0, level1, ...])
Send the questionsawait client.systemOne({ state, questions })
Pin the modelmodel: 'jev-1.13.0' in the request
Timeout, retries or cancel for one callsecond argument { timeout, retry, signal }
Get the HTTP response.withResponse() or .asResponse()
List modelsawait 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. As of late September 2026, TypeSafe has paused new signups because of demand, while existing accounts keep working, so check typesafe.ai for the current state. The steps are in how to get access to Jev and an 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:

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:

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:

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:

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

Then create a tsconfig.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:

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):

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?.

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:

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

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):

{
  "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.

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:

OptionDefaultWhat it does
apiKeyTYPESAFE_API_KEYRequired. The constructor throws when neither is set
baseURLTYPESAFE_BASE_URL, then https://api.typesafe.aiThe API root
defaultModelTYPESAFE_DEFAULT_MODEL, then jev-latestModel for requests that don’t set one
timeout10000Milliseconds per attempt
retrySee belowOverrides for the retry policy
logLevelTYPESAFE_LOG_LEVEL, then warndebug, info, warn, error or off
loggerconsole, with a prefixAny object with debug, info, warn and error methods
defaultHeadersnoneHeaders sent with every request
fetchglobal fetchA custom fetch, for tests or custom networking
dangerouslyAllowBrowserfalseLets 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:

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:

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:

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.

ClassWhen it’s thrown
BadRequestError400
AuthenticationError401, a missing or wrong API key
PermissionDeniedError403
NotFoundError404
UnprocessableEntityError422, the request failed validation and body says which field
RateLimitError429, with retryAfterMs when the server sent a delay
InternalServerErrorAny 5xx, including 529 Overloaded
APIConnectionErrorDNS, TLS or a dropped connection
APITimeoutErrorNo full response within timeout, with timeoutMs
APIUserAbortErrorYour AbortSignal fired
TypeSafeErrorLocal 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:

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:

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:

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:

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:

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 lists every type if you need more.

Tagged: AI · All topics

Want me to talk about your product? You can sponsor this site.

~~~

Related posts about ai: