JavaScript/TypeScript SDK: install, client, choice/score/noul
TL;DR
npm install @typesafe-ai/sdk(Node >= 20), setTYPESAFE_API_KEY, thennew TypeSafeClient().systemOne({ state, questions }). Build questions withchoice(instructions, {label: description}),score(instructions, [ ...ordered rubric ]), andnoul(instructions?, criteria?). Answers are typed from the questions you passed; the call returns anAPIPromise<SystemOneResult<Q>>, soawaitit for data or.withResponse()for data plus the rawResponseand 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):
- Browser detected and
dangerouslyAllowBrowsernot set: "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 passdangerouslyAllowBrowser: trueif you understand the risk." - No key: "No API key was provided. Pass
apiKeyto the TypeSafeClient constructor or set the TYPESAFE_API_KEY environment variable." - No global fetch and no
fetchoption: "No globalfetchis available in this runtime. Pass afetchimplementation to the TypeSafeClient constructor." - Invalid
timeout: "timeoutmust be a positive number of milliseconds, got X."
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:
- an empty object: "At least one question is required."
- a
scorequestion whosecriteriais not an array:Score question "<name>" has criteria that are not a list; score criteria must be a list of descriptions indexed by score from zero. - a
scorequestion with fewer than two criteria:Score question "<name>" has N criteria; at least two scores are required.
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[]) => void — console 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
- Browser use is refused by default.
isBrowser()checks forwindow.documentandnavigator; if present anddangerouslyAllowBrowseris nottrue, the constructor throws. Call the API from a server. timeoutis per attempt, not per call. WithmaxRetries: 2andtimeout: 10000, worst-case wall time is roughly three attempts plus backoff.- The body is fully buffered before the promise resolves, under the same timeout, so
asResponse()hands you a response whose body is already readable — but don't consume bothasResponse()and the parsed value on the sameAPIPromise. scorecriteria must be a list in 0.6.0; an int-keyed object throwsTypeSafeErrorat build time (score()) or at validation time (systemOne()).- Response parsing is lenient: bodies are
JSON.parsed even whencontent-typeis missing, falling back to the raw text, and an empty body parses toundefined. defaultHeaderscannot override auth:Authorization,Accept,User-Agent,X-TypeSafe-SDK,X-TypeSafe-Runtime, andContent-Typeare merged last and win.
Version notes
- This page describes
@typesafe-ai/sdk0.6.0 (repo commit66880ccded6cb642dc1809620c2b108c33730214, 2026-09-15). - Doc-vs-source discrepancy: the published API reference lists
Modelsunder Interfaces (# Interface: Models), butsrc/resources/models.tsdeclaresexport class Modelswith a constructor taking an internalTransport. Becausesrc/index.tsre-exports it withexport type { Models }, only the type is importable — the docs' classification is accurate from a consumer's point of view, but the source is a class. - The docs'
ChoiceCriteriaindex signature renders as[label: string]: EntryType; source writes[label: string]: Description, andDescription = EntryType, so they are the same type.
Related
- JavaScript SDK interfaces and type aliases — every interface and type alias in detail
- JavaScript SDK error classes, RetryPolicy, RequestOptions — error classes,
RetryPolicy,RequestOptions - JavaScript SDK changelog — release history
- HTTP API: POST /v1/systemone and GET /v1/models — the wire contract behind
systemOne()andmodels.list() - TYPESAFE_* environment variables across SDKs —
TYPESAFE_*across SDKs - Python SDK: install, clients, system_one() — the Python equivalent
- Primitives: Choice, Score, Noul — Choice, Score, Noul
- Quickstart: first call in HTTP, Python, JS — first call in HTTP, Python, JS
Sources
- raw/docs/sdk__javascript.md (https://docs.typesafe.ai/sdk/javascript)
- raw/docs/sdk__javascript__api.md (https://docs.typesafe.ai/sdk/javascript/api)
- raw/docs/sdk__javascript__api__classes__TypeSafeClient.md (https://docs.typesafe.ai/sdk/javascript/api/classes/TypeSafeClient)
- raw/docs/sdk__javascript__api__classes__APIPromise.md (https://docs.typesafe.ai/sdk/javascript/api/classes/APIPromise)
- raw/docs/sdk__javascript__api__functions__choice.md, __score.md, __noul.md
- raw/docs/sdk__javascript__api__interfaces__Models.md, __Logger.md
- raw/docs/sdk__javascript__api__variables__VERSION.md, __LOG_LEVELS.md, __ENV.md
- raw/github/typesafe-sdk-js (https://github.com/typesafe-ai/typesafe-sdk-js, commit 66880ccded6cb642dc1809620c2b108c33730214): README.md, package.json, jsr.json, examples/demo.ts, src/index.ts, src/client.ts, src/questions.ts, src/api-promise.ts, src/logging.ts, src/env.ts, src/runtime.ts, src/version.ts, src/resources/models.ts