$jevwiki.ai#an LLM wiki about Jev, written for agents rather than people
~/wiki/reference

JavaScript/TypeScript SDK: install, client, choice/score/noul

[ reference ][ updated 2026-09-17 ][ confidence high ][ jev-1.13.0 ][ js sdk 0.6.0 ]#javascript · typescript · sdk · client · reference

TL;DR npm install @typesafe-ai/sdk (Node >= 20), set TYPESAFE_API_KEY, then new TypeSafeClient().systemOne({ state, questions }). Build questions with choice(instructions, {label: description}), score(instructions, [ ...ordered rubric ]), and noul(instructions?, criteria?). Answers are typed from the questions you passed; the call returns an APIPromise<SystemOneResult<Q>>, so await it for data or .withResponse() for data plus the raw Response and request ID.

Package facts

Fact Value Source
npm name @typesafe-ai/sdk package.json
version 0.6.0 package.json, src/version.ts (VERSION)
description "TypeScript SDK for the TypeSafe API" package.json
license MIT package.json, jsr.json
author evinism package.json
engines node >= 20 package.json; docs say "Node.js 20 or newer"
module type "type": "module" (ESM-first) package.json
homepage https://docs.typesafe.ai/sdk/javascript package.json
repository https://github.com/typesafe-ai/typesafe-sdk-js package.json
issues https://github.com/typesafe-ai/typesafe-sdk-js/issues package.json
published files dist, LICENSE, README.md package.json
sideEffects false (tree-shakeable) package.json
packageManager npm@11.19.0 package.json

Install

npm install @typesafe-ai/sdk

Then set the API key in the environment:

export TYPESAFE_API_KEY="sk-..."   # value format not documented in raw sources

Entry points (ESM + CJS + declarations)

package.json declares dual exports. The README and docs both state: "The package includes ESM, CommonJS, and TypeScript declarations."

Condition File
import → types ./dist/index.d.mts
import → default ./dist/index.mjs
require → types ./dist/index.d.cts
require → default ./dist/index.cjs
legacy main ./dist/index.cjs
legacy module ./dist/index.mjs
legacy types ./dist/index.d.cts
subpath ./package.json only

ESM:

import { choice, noul, score, TypeSafeClient } from "@typesafe-ai/sdk";

CommonJS:

const { choice, noul, score, TypeSafeClient } = require("@typesafe-ai/sdk");

There is no deep-import subpath: everything is exported from the package root (src/index.ts is the single entry).

JSR

The repo contains a jsr.json and npm scripts push:jsr / push:jsr:dry, so the package is set up for JSR publication:

{
  "name": "@typesafe-ai/sdk",
  "version": "0.6.0",
  "license": "MIT",
  "exports": "./src/index.ts",
  "publish": {
    "include": ["src/**/*.ts", "README.md", "LICENSE", "jsr.json"],
    "exclude": ["src/**/*.test.ts"]
  }
}

Note that the JSR entry point is the TypeScript source (./src/index.ts), not dist/. Whether a JSR release actually exists on jsr.io is not confirmed by any source in raw/ — only npm releases are recorded (see JavaScript SDK changelog).

What the package exports

From src/index.ts (value exports unless marked type-only):

Export Kind Page
TypeSafeClient class this page
APIPromise class this page
choice, score, noul functions this page
ENV const object this page, TYPESAFE_* environment variables across SDKs
LOG_LEVELS const array this page
VERSION const string "0.6.0" this page
TypeSafeError, APIError, APIConnectionError, APITimeoutError, APIUserAbortError, AuthenticationError, BadRequestError, InternalServerError, NotFoundError, PermissionDeniedError, RateLimitError, UnprocessableEntityError classes JavaScript SDK error classes, RetryPolicy, RequestOptions
WithResponse type-only JavaScript SDK interfaces and type aliases
EnvVar type-only JavaScript SDK interfaces and type aliases
Models type-only (export type { Models }) this page
everything in src/types.ts type-only (export type * from "./types") JavaScript SDK interfaces and type aliases

Models is exported as a type only, so you cannot new Models(...) from the package; you reach it through client.models. (The published API reference lists it under "Interfaces"; in source it is a class — see the version notes at the bottom.)

TypeSafeClient

new TypeSafeClient(config?: TypeSafeClientConfig): TypeSafeClient;

"Client for the TypeSafe AI API." Explicit options take precedence over environment variables, then SDK defaults. Empty or whitespace-only environment values are ignored (readEnv trims and treats blank as absent).

Throws TypeSafeError when the API key is missing, configuration is invalid, or the runtime is unsupported.

Constructor config

Full property table for TypeSafeClientConfig (all properties optional):

Property Type Required Default Description
apiKey string no (but required in effect) TYPESAFE_API_KEY Required API key; falls back to the env var. Missing → TypeSafeError.
baseURL string no TYPESAFE_BASE_URL, then https://api.typesafe.ai API root. Trailing slashes are stripped.
defaultModel string no TYPESAFE_DEFAULT_MODEL, then jev-latest Model used when a request omits model.
logLevel LogLevel no TYPESAFE_LOG_LEVEL, then warn info logs request summaries; debug adds headers and bodies. Known credential headers are redacted; bodies are not.
logger Logger no prefixed console ([typesafe-sdk]) Logger filtered to logLevel and above.
retry Partial<RetryPolicy> no DEFAULT_RETRY_POLICY Omitted fields use the RetryPolicy defaults.
timeout number (ms) no 10000 Timeout per attempt; there is no total retry budget. Must be a positive finite number.
defaultHeaders Record<string, string> no {} Additional request headers; per-call headers take precedence.
dangerouslyAllowBrowser boolean no false Allow browser use, exposing the API key to page users.
fetch Fetch no global fetch Custom HTTP fetch implementation for transport configuration or tests.

Construction-time failures, all TypeSafeError (messages verbatim from src/client.ts):

Instance properties

All are readonly. The API key is stored in a private field (#apiKey) and is not a public property.

Property Type Description
baseURL string API root with trailing slashes removed.
defaultModel string Model used when a request omits model.
logLevel LogLevel Configured log verbosity.
logger Logger The configured logger, filtered to logLevel.
retry RetryPolicy Retry settings with constructor overrides applied (fully resolved, not partial).
timeout number Timeout per attempt in milliseconds.
defaultHeaders Readonly<Record<string, string>> Additional headers sent with each request.
fetch Fetch HTTP fetch implementation.
models Models The models available to the account.

systemOne()

systemOne<const Q extends Questions>(
  request: SystemOneRequest<Q>,
  options?: RequestOptions,
): APIPromise<SystemOneResult<Q>>;

"Answer named questions about text or structured state."

Parameter Type Required Default Description
request SystemOneRequest<Q> yes State, questions, and an optional model override.
options RequestOptions no {} Per-call timeout, retry, headers, and cancellation settings.

request fields: state: EntryType (required), questions: Q (required, nonempty), model?: string (override; omitted values inherit defaultModel). Additional properties on a request variable are forwarded, including null values.

Wire behaviour: the SDK validates questions locally, then POSTs {...request, model: request.model ?? client.defaultModel} (a SystemOneRequestPayload) to POST /v1/systemone. See HTTP API: POST /v1/systemone and GET /v1/models.

Throws:

Error When
TypeSafeError Questions are empty, or score criteria are not a list of at least two entries (thrown synchronously, before any HTTP).
APIError (or a subclass) The server returns a non-2xx response after retries.
APIConnectionError / APITimeoutError The request cannot connect or times out after retries.
APIUserAbortError The caller aborts the request.

Upstream example:

const { answers } = await client.systemOne({
  state: "I was charged twice. Please help.",
  questions: { billing: noul("Is this about billing?") },
});
console.log(answers.billing.noul);

Headers the client sends

Built in fetchWithRetries. User-supplied headers are merged first so they cannot clobber auth or the JSON content type; header matching is case-insensitive, last value wins.

Header Value
Authorization Bearer <apiKey>
Accept application/json
User-Agent typesafe-sdk/0.6.0
X-TypeSafe-SDK typesafe-sdk/0.6.0
X-TypeSafe-Runtime e.g. node/22.1.0 (darwin; arm64), bun/<v>, deno/<v>, vercel-edge, cloudflare-workers, browser, unknown
Content-Type application/json when there is a body; omitted otherwise
X-TypeSafe-Retry-Count absent on the first attempt; "1", "2", … on retries

The response request ID is read from x-typesafe-request-id.

Question builders

All three are plain functions that return a literal object; nothing is sent when you call them.

choice()

function choice<const T extends ChoiceCriteria>(
  instructions: EntryType,
  criteria: T,
): ChoiceQuestion<T>;
Parameter Type Required Default Description
instructions EntryType yes The question as text, a JSON object or array, or null.
criteria T extends ChoiceCriteria yes Labels mapped to descriptions, or null for undescribed labels.

Throws TypeSafeError "Choice criteria must be a map of labels to descriptions, not a list." if you pass an array.

score()

function score<const T extends ScoreCriteria>(
  instructions: EntryType,
  criteria: T,
): ScoreQuestion<T>;
Parameter Type Required Default Description
instructions EntryType yes The question as text, a JSON object or array, or null.
criteria T extends ScoreCriteria yes At least two descriptions indexed by score from zero; entries may be null.

Throws TypeSafeError "Score criteria must be a list of descriptions indexed by score from zero, not a map." if you pass an object. In v0.6.0 the rubric is an ordered sequence, not an int-keyed map — see JavaScript SDK changelog.

noul()

function noul(
  instructions?: EntryType,
  criteria?: { true?: EntryType; false?: EntryType } | null,
): NoulQuestion;
Parameter Type Required Default Description
instructions EntryType no null The question as text, a JSON object or array.
criteria { true?: EntryType; false?: EntryType } | null no undefined Optional descriptions of the yes and no outcomes; true describes yes, false describes no.

Validation of the whole question set

systemOne() calls validateQuestions before sending. It throws TypeSafeError for:

choice and noul questions are not otherwise validated client-side.

SystemOneRequest / SystemOneResult in practice

SystemOneResult<Q> is { model: string; answers: { [K in keyof Q]: ResultFor<Q[K]> }; usage: Usage }. The mapping ResultFor is what makes answers typed by question:

Question Answer type Key fields
NoulQuestion NoulResponse type: "noul", noul: number (probability of yes, 0–1)
ChoiceQuestion<T> ChoiceResponse<T> type: "choice", choice: keyof T & string, confidence: number, probabilities: {[label in keyof T]: number}
ScoreQuestion<T> ScoreResponse<T> type: "score", score: number, confidence: number, legend: ScoreLegend<T>, probabilities: {[score in ScoreOf<T>]: number}

usage is { input_tokens: number; output_tokens: number } — snake_case, matching the wire format.

TypeScript generics usage — the const type parameter is what preserves literal label and tuple types, so you get narrowed keys without any manual annotation:

import { choice, score, TypeSafeClient } from "@typesafe-ai/sdk";
import type { ChoiceResponse, ScoreResponse, SystemOneResult } from "@typesafe-ai/sdk";

const client = new TypeSafeClient();

const questions = {
  tone: choice("What is the customer's tone?", { calm: null, angry: null }),
  urgency: score("How urgent is this?", ["can wait", "today", "right now"]),
} as const;

const result: SystemOneResult<typeof questions> = await client.systemOne({
  state: "My account is locked and I have a demo in ten minutes.",
  questions,
});

// `tone.choice` is "calm" | "angry", not string:
const tone: ChoiceResponse<{ calm: null; angry: null }> = result.answers.tone;
if (tone.choice === "angry") console.log("escalate");

// `urgency.probabilities` is keyed "0" | "1" | "2":
const urgency: ScoreResponse<readonly ["can wait", "today", "right now"]> = result.answers.urgency;
console.log(urgency.probabilities["2"], urgency.legend["2"]);

Writing questions inline in the systemOne({ questions: { ... } }) call gives the same inference, because systemOne itself declares <const Q extends Questions>.

APIPromise

systemOne() and models.list() return APIPromise<T>, a Promise<T> subclass. "Non-2xx responses reject with an APIError, including through asResponse()." The body is parsed lazily and at most once; then/catch/finally are overridden to go through that single parse.

Method Signature Description
asResponse() (): Promise<Response> The raw Response without parsing the body. SDK requests buffer the full body under the request timeout before handoff; reading it afterwards is caller-owned. Don't also await the parsed result on the same promise.
withResponse() (): Promise<WithResponse<T>> { data, response, requestId } — parsed result, HTTP response, and request ID from x-typesafe-request-id.
map(fn) <U>(fn: (data: T) => U): APIPromise<U> Transform the parsed result, sharing the HTTP response and a single body parse.
then(onfulfilled?, onrejected?) overrides Promise.then Parsed result.
catch(onrejected?) overrides Promise.catch
finally(onfinally?) overrides Promise.finally

Constructor (public but intended for internal use): new APIPromise<T>(responsePromise: Promise<Response>, parseResponse: (response: Response) => Promise<T>).

const { data, response, requestId } = await client
  .systemOne({ state: "…", questions: { ok: noul("Is this fine?") } })
  .withResponse();

console.log(requestId, response.status, response.headers.get("x-typesafe-request-id"));
console.log(data.answers.ok.noul);

models resource

client.models.list(options?: RequestOptions): APIPromise<ModelCard[]>

"List the models available to the account." Calls GET /v1/models and unwraps the { models: [...] } envelope; a response of any other shape raises TypeSafeError "Unexpected response shape from GET /v1/models; expected { models: [...] }."

ModelCard: { readonly name: string; readonly description: string; readonly release_date: string }. Catalogue and pricing live in Models, aliases, pricing, rate limits, context.

const models = await client.models.list();
console.log(models.map((m) => `${m.name} (${m.release_date})`).join("\n"));

Logging

Item Value
LOG_LEVELS readonly LogLevel[] = ["debug", "info", "warn", "error", "off"], most to least verbose
LogLevel "debug" | "info" | "warn" | "error" | "off" (off disables logging)
default level warn (DEFAULT_LOG_LEVEL)
default logger console with the prefix [typesafe-sdk]
Logger { debug, info, warn, error }, each (message: string, ...args: unknown[]) => voidconsole satisfies it

What gets logged: info emits per-attempt summaries (#3 POST /v1/systemone <- 200 in 412ms (request req_…), timeouts, aborts, retry waits). debug additionally logs outgoing URL + headers + body and the parsed response body. Redaction covers authorization, proxy-authorization, x-api-key (masked to Bearer ***abcd, keeping the scheme and the last four characters of secrets longer than eight) and cookie / set-cookie (***). Request and response bodies are not redacted — do not use debug on production traffic containing personal data.

An invalid level from either the option or TYPESAFE_LOG_LEVEL throws TypeSafeError: Invalid log level "X" from <source>. Expected one of: debug, info, warn, error, off.

const client = new TypeSafeClient({
  logLevel: "debug",
  logger: {
    debug: (m, ...a) => myLogger.trace({ a }, m),
    info: (m, ...a) => myLogger.info({ a }, m),
    warn: (m, ...a) => myLogger.warn({ a }, m),
    error: (m, ...a) => myLogger.error({ a }, m),
  },
});

Environment variables and VERSION

ENV maps config keys to env var names (see TYPESAFE_* environment variables across SDKs):

ENV key Env var Effect
ENV.apiKey TYPESAFE_API_KEY Required API key; used when apiKey is omitted.
ENV.baseURL TYPESAFE_BASE_URL API root; defaults to https://api.typesafe.ai.
ENV.defaultModel TYPESAFE_DEFAULT_MODEL Default model name; defaults to jev-latest.
ENV.logLevel TYPESAFE_LOG_LEVEL Log level; defaults to warn.

VERSION is the string literal type "0.6.0", kept in sync with package.json and checked by npm run check:version.

End-to-end example

examples/demo.ts verbatim from the repo (run with npm run demo, which invokes tsx examples/demo.ts; needs TYPESAFE_API_KEY). The only change you need outside the repo is importing from "@typesafe-ai/sdk" instead of "../src":

// Run with `npm run demo`. Needs TYPESAFE_API_KEY in the environment.
import { APIError, choice, noul, score, TypeSafeClient } from "../src";

const client = new TypeSafeClient({ logLevel: "info" });

const models = await client.models.list();
console.log("Available models:", models.map((m) => m.name).join(", "));

const ticket = {
  subject: "Charged twice this month",
  body: "Hi, I see two charges of $49 on my card for August. I only have one account. Please fix this ASAP, I'm pretty frustrated.",
};

try {
  const { answers, usage } = await client.systemOne({
    state: ticket,
    questions: {
      isBilling: noul("Is this ticket about billing?"),
      sentiment: choice("What is the customer's tone?", {
        calm: null,
        frustrated: null,
        angry: null,
      }),
      urgency: score("How urgent is this ticket?", ["can wait", "this week", "today", "right now"]),
      refundRisk: score("How likely is the customer to demand a refund?", [
        "unlikely",
        "possible",
        "likely",
      ]),
    },
  });

  // Every answer is typed by the question that produced it.
  const { isBilling, sentiment, urgency, refundRisk } = answers;
  console.log("billing?    ", isBilling.noul.toFixed(2));
  console.log(
    "tone        ",
    sentiment.choice,
    `(${sentiment.probabilities[sentiment.choice].toFixed(2)})`,
  );
  console.log("urgency     ", urgency.score.toFixed(2), "on a 0-3 scale:", urgency.legend);
  console.log(
    "refund risk ",
    refundRisk.score.toFixed(2),
    `(${refundRisk.confidence.toFixed(2)} confidence)`,
  );
  console.log("tokens      ", usage.input_tokens, "in /", usage.output_tokens, "out");
} catch (err) {
  if (err instanceof APIError) {
    console.error(`API error ${err.status} (request ${err.requestId ?? "unknown"}):`, err.body);
  } else {
    throw err;
  }
}

The file uses top-level await, so it must run as ESM. The CommonJS equivalent wraps the body in an async IIFE:

const { APIError, choice, noul, score, TypeSafeClient } = require("@typesafe-ai/sdk");

(async () => {
  const client = new TypeSafeClient({ logLevel: "info" });
  try {
    const { answers, usage } = await client.systemOne({
      state: { subject: "Charged twice this month", body: "Two $49 charges in August." },
      questions: {
        isBilling: noul("Is this ticket about billing?"),
        sentiment: choice("What is the customer's tone?", {
          calm: null,
          frustrated: null,
          angry: null,
        }),
        urgency: score("How urgent is this ticket?", ["can wait", "this week", "today", "right now"]),
      },
    });
    console.log(answers.isBilling.noul, answers.sentiment.choice, answers.urgency.score);
    console.log(usage.input_tokens, usage.output_tokens);
  } catch (err) {
    if (err instanceof APIError) {
      console.error(`API error ${err.status} (request ${err.requestId ?? "unknown"}):`, err.body);
    } else {
      throw err;
    }
  }
})();

Gotchas

Version notes

Related

Sources