Jev API returns structured answers, and TypeScript can make that structure work for you. With the right types, answers.department.choice is typed as "billing" | "technical" | "sales", not string. A switch over it is checked for exhaustiveness, and a typo in a question key fails at compile time. Types alone do not prove anything about a network response, though, so this tutorial pairs them with a small runtime validator and a fetch-based client with timeouts and retries.
The code needs Node.js 18 or newer, which has global fetch and AbortSignal.timeout, and TypeScript 4.9 or newer for satisfies. It has no runtime dependencies. It also runs on other runtimes with standard fetch, such as Cloudflare Workers, if you pass the key in explicitly.
Types that mirror the API
The request has model, state and questions. Each question is one of three types. choice takes an object of criteria. score takes an ordered array, lowest level first. noul needs only instructions. The response has model, answers keyed by your question IDs, and usage with input_tokens and output_tokens. Save this as jev-types.ts:
export type ChoiceQuestion = {
type: "choice";
instructions: string;
criteria: Record<string, string>;
};
export type ScoreQuestion = {
type: "score";
instructions: string;
criteria: readonly string[]; // ordered from lowest to highest
};
export type NoulQuestion = {
type: "noul";
instructions: string;
criteria?: Record<string, string>; // optional descriptions of the two ends
};
export type Question = ChoiceQuestion | ScoreQuestion | NoulQuestion;
export type Questions = Record<string, Question>;
export type ChoiceAnswer<L extends string = string> = {
type: "choice";
choice: L;
confidence: number;
probabilities: Record<string, number>;
};
export type ScoreAnswer = {
type: "score";
score: number;
confidence: number;
legend: Record<string, string>; // "0", "1", ... -> your criteria text
probabilities: Record<string, number>;
};
export type NoulAnswer = {
type: "noul";
noul: number; // 0..1
confidence?: number;
};
// Map each question to the answer type it produces.
export type AnswerFor<Q> =
Q extends { type: "choice"; criteria: infer C } ? ChoiceAnswer<Extract<keyof C, string>> :
Q extends { type: "score" } ? ScoreAnswer :
Q extends { type: "noul" } ? NoulAnswer :
never;
export type ModelId = "jev-latest" | "jev-preview" | "jev-1.13.0";
export type SystemOneRequest<Qs extends Questions> = {
model: ModelId;
state: unknown; // string, object or array
questions: Qs;
};
export type SystemOneResponse<Qs extends Questions> = {
model: string; // a versioned ID such as "jev-1.13.0"
answers: { [K in keyof Qs]: AnswerFor<Qs[K]> };
usage: { input_tokens: number; output_tokens: number };
};
export type JevErrorBody = {
error: { code: string; message: string; request_id: string };
};
The key piece is AnswerFor. It reads the literal type of each question. For a choice question, it pulls out the keys of criteria and uses them as the type of choice. That only works if TypeScript keeps the literal types, which is why the questions are declared with as const further down.
noul answers may or may not include confidence, so that field is optional. Code that reads it must handle undefined.
Validate at runtime
A cast like as SystemOneResponse tells the compiler to trust you, and that trust is misplaced at a network boundary. The validator below checks the envelope and every answer against the question that produced it, then returns the typed value. It is deliberately strict about the fields your code reads, and ignores fields it does not know about, so additions to the response will not break it. Save it as jev-validate.ts:
import type { Question, Questions, SystemOneResponse } from "./jev-types";
const isRecord = (v: unknown): v is Record<string, unknown> =>
typeof v === "object" && v !== null && !Array.isArray(v);
const isNum = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v);
const isProbMap = (v: unknown): boolean => isRecord(v) && Object.values(v).every(isNum);
export class JevValidationError extends Error {
name = "JevValidationError";
}
export function validateResponse<Qs extends Questions>(questions: Qs, data: unknown): SystemOneResponse<Qs> {
if (!isRecord(data) || typeof data.model !== "string" || !isRecord(data.answers) || !isRecord(data.usage)) {
throw new JevValidationError("Malformed response envelope");
}
if (!isNum(data.usage.input_tokens) || !isNum(data.usage.output_tokens)) {
throw new JevValidationError("Malformed usage");
}
for (const [key, q] of Object.entries(questions) as [string, Question][]) {
const a = data.answers[key];
if (!isRecord(a) || a.type !== q.type) {
throw new JevValidationError("Missing or mismatched answer for " + key);
}
const ok =
q.type === "choice"
? typeof a.choice === "string" && a.choice in q.criteria && isNum(a.confidence) && isProbMap(a.probabilities)
: q.type === "score"
? isNum(a.score) && isNum(a.confidence) && isRecord(a.legend) && isProbMap(a.probabilities)
: isNum(a.noul) && a.noul >= 0 && a.noul <= 1;
if (!ok) throw new JevValidationError("Invalid " + q.type + " answer for " + key);
}
return data as SystemOneResponse<Qs>;
}
If you already use a schema library such as Zod or Valibot, you can express the same checks there. The important part is to check something before the data reaches business logic. In particular, check that choice is one of the options you sent.
A fetch client with timeouts and retries
The client sends the request, turns error envelopes into a typed JevApiError, and retries only what is worth retrying: 429, 5xx and network failures. 400, 401 and 402 are thrown immediately, because repeating them cannot succeed. Timeouts are reported but not retried. A timed-out request may still have completed on the server and been charged, and retrying it could pay twice. Save as jev-client.ts:
import type { JevErrorBody, Questions, SystemOneRequest, SystemOneResponse } from "./jev-types";
import { validateResponse } from "./jev-validate";
export class JevApiError extends Error {
name = "JevApiError";
status: number | null; // null when no HTTP response was received
code: string;
requestId: string | null;
constructor(message: string, status: number | null, code: string, requestId: string | null) {
super(message);
this.status = status;
this.code = code;
this.requestId = requestId;
}
get retryable(): boolean {
if (this.status === null) return this.code === "network";
return this.status === 429 || this.status >= 500;
}
}
export type DecideOptions = {
apiKey: string;
url?: string;
timeoutMs?: number;
maxRetries?: number;
};
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
export async function decide<Qs extends Questions>(
request: SystemOneRequest<Qs>,
options: DecideOptions,
): Promise<SystemOneResponse<Qs> & { requestId: string | null }> {
const maxRetries = options.maxRetries ?? 3;
for (let attempt = 0; ; attempt++) {
let res: Response;
try {
res = await fetch(options.url ?? "https://jev-api.com/v1/systemone", {
method: "POST",
headers: { Authorization: "Bearer " + options.apiKey, "Content-Type": "application/json" },
body: JSON.stringify(request),
signal: AbortSignal.timeout(options.timeoutMs ?? 30_000),
});
} catch (err) {
const timedOut = err instanceof Error && err.name === "TimeoutError";
const error = new JevApiError(timedOut ? "Request timed out" : "Network error: " + String(err),
null, timedOut ? "timeout" : "network", null);
if (!error.retryable || attempt >= maxRetries) throw error;
await sleep(500 * 2 ** attempt + Math.random() * 500);
continue;
}
const requestId = res.headers.get("x-request-id");
const body: unknown = await res.json().catch(() => null);
if (res.ok) {
return { ...validateResponse(request.questions, body), requestId };
}
const e = (body as Partial<JevErrorBody> | null)?.error;
const error = new JevApiError(e?.message ?? "HTTP " + res.status, res.status,
e?.code ?? "http_error", e?.request_id ?? requestId);
if (!error.retryable || attempt >= maxRetries) throw error;
const retryAfter = Number(res.headers.get("retry-after"));
await sleep(retryAfter > 0 ? Math.min(retryAfter, 60) * 1000 : 500 * 2 ** attempt + Math.random() * 500);
}
}
Putting it together
Declare the questions with as const satisfies Questions. as const keeps the literal types that AnswerFor needs. satisfies checks the object against the question schema without widening it. Then call decide and work with fully typed answers (this example uses top-level await, so run it as an ES module):
import { decide, JevApiError } from "./jev-client";
import type { Questions } from "./jev-types";
const questions = {
department: {
type: "choice",
instructions: "Which team should handle this message?",
criteria: {
billing: "Charges, invoices, refunds or subscription changes",
technical: "Bugs, outages, errors or integration problems",
sales: "Pricing questions or new purchases",
},
},
urgency: {
type: "score",
instructions: "How urgent is this message?",
criteria: ["Routine", "Needs attention this week", "Needs attention today"],
},
is_angry: { type: "noul", instructions: "Is the customer angry?" },
} as const satisfies Questions;
const apiKey = process.env.JEV_API_KEY;
if (!apiKey) throw new Error("Set JEV_API_KEY");
try {
const result = await decide(
{ model: "jev-latest", state: "The export button has crashed the app since the last update.", questions },
{ apiKey, timeoutMs: 20_000 },
);
const { department, urgency, is_angry } = result.answers;
switch (department.choice) { // "billing" | "technical" | "sales"
case "billing": /* route to billing queue */ break;
case "technical": /* open an engineering ticket */ break;
case "sales": /* hand to sales */ break;
}
// Most likely urgency level, as a label from the legend.
const [level] = Object.entries(urgency.probabilities).sort((a, b) => b[1] - a[1])[0];
console.log(department.choice, urgency.legend[level], is_angry.noul.toFixed(2));
console.log(result.model, result.usage.input_tokens, result.requestId);
} catch (err) {
if (err instanceof JevApiError && (err.status === 401 || err.status === 402)) {
// Needs a human: revoked key or empty balance. Alert instead of retrying.
}
throw err;
}
Rename sales to presales in the criteria and the compiler flags the stale case "sales" straight away. That feedback loop is the main benefit of keeping the types this close to the API.
From typed answers to typed decisions
Typed answers make the next step easier to write: turning an answer into an action. Every answer carries a type field ("choice", "score" or "noul"), so a union of answers narrows cleanly in a switch or an if. That helps when you write generic helpers, such as a logger that records every answer in a request. For a single question, you rarely need the narrowing, because the mapped type already knows each answer's shape.
Keep the thresholds that turn numbers into actions in one typed module, next to the questions. A function such as routeTicket(answers) that returns { queue: "billing" | "technical" | "sales" | "manual_review" } gives the rest of your code a small, stable contract. It also gives you one place to change when you retune thresholds or add a review band for low-confidence answers. The question design guide explains how to pick those thresholds from labelled examples.
Module setup
The examples use extensionless relative imports such as "./jev-types". That works with bundlers and with "moduleResolution": "Bundler". If you compile with "module": "NodeNext" and run the output directly in Node, change them to "./jev-types.js" and so on, and set "type": "module" in package.json so top-level await works. Keep "strict": true. Without strict null checks, the optional confidence on noul answers and the null request IDs lose their protection.
Design notes
- The request ID travels with every result.
decidereturnsrequestIdnext to the typed response, andJevApiErrorcarries it too. The API returns it in thex-request-idheader on every response and inerror.request_idon failures. Log it with the decision so you can match an individual answer to a dashboard entry or a support conversation. - The validator ignores unknown fields. It checks what your code reads and nothing more. If the response gains new fields, existing code keeps working. If a field you rely on disappears or changes type, you get a clear
JevValidationErrorinstead of anundefineddeep inside business logic. ModelIdis a closed union. The three IDs are the ones the API accepts. A typo such as"jev-lates"fails at compile time instead of coming back as a 400Unknown modelerror. KeepSystemOneResponse["model"]asstring, though. The response reports a versioned ID such asjev-1.13.0even when you request an alias.- Retries are bounded. Three retries with exponential backoff and jitter add up to a few seconds of waiting. A
Retry-Afterheader, if present, is honoured but capped at 60 seconds, so a single response cannot stall a worker indefinitely. Tune both to your latency budget.
Where to run it
Run this code on a server, in a background job or in a serverless function, never in a browser bundle. The API does send permissive CORS headers, but any key shipped to a browser is readable by anyone who opens the developer tools, and every call made with it is billed to your balance. Give each service its own key, named after the service, so one integration can be revoked without touching the others. On Cloudflare Workers, read the key from a secret binding (env.JEV_API_KEY) and pass it as apiKey. The client itself does not depend on Node-specific APIs.
Testing
Because decide calls the global fetch, you can stub it in tests. With Vitest, vi.stubGlobal("fetch", vi.fn()) lets you return a new Response(JSON.stringify(body), { status: 402 }) and assert that the function throws once without retrying. Test validateResponse with hand-written payloads too: an answer with the wrong type, a choice outside your criteria, and a missing question key should each throw.
Next steps
Typed answers are only as good as the questions behind them. The next two guides cover question design and keeping spend predictable:
- Docs: account, key and credit setup from start to finish.
- API reference: the full request and response contract for
POST /v1/systemoneandGET /v1/models. - Pricing: current per-token price and how prepaid credit works.
More tutorials on this blog:
- Getting started with Jev API: your first structured decision in 5 minutes
- Calling Jev API from Python: a robust client with timeouts, retries and error handling
- Designing decision questions: how to frame state and criteria for reliable typed answers
- Controlling cost on Jev API: estimating tokens, tracking usage and managing prepaid credit