Your First Jev Integration, End to End
curl to Python to TypeScript: the request shape, reading answers correctly, error handling with backoff, and why you should pin the version.
One endpoint, three question types
The whole API surface is a single POST. There is no session, no conversation, no streaming. You send a state and a map of typed questions; you get back a map of typed answers under the same keys.
POST https://api.typesafe.ai/v1/systemone
Authorization: Bearer <API_KEY>
Content-Type: application/jsonThree top-level fields in the body: state (the content to evaluate, a string, object, or array of text), model, and questions. Each question has a type of choice, score, or noul, an instructions field, and for Choice and Score a criteria field.
Start with curl so you can see the shapes before an SDK abstracts them.
curl -X POST https://api.typesafe.ai/v1/systemone \
-H "Authorization: Bearer $TYPESAFE_API_KEY" \
-H "Content-Type: application/json" \
-d @- <<'EOF'
{
"state": "Hi, I've been trying to connect my Stripe account for 3 days and the integration keeps failing. I'm losing sales. Please help ASAP.",
"model": "jev-1.13.0",
"questions": {
"department": {
"type": "choice",
"instructions": "Which team should handle this",
"criteria": {
"billing": "Payment or subscription issues",
"technical": "Bugs or integration problems",
"sales": "Pricing or account questions"
}
},
"frustration": {
"type": "score",
"instructions": "How frustrated the customer appears",
"criteria": [
"Calm, just stating facts",
"Frustrated but civil",
"Very angry, strong language"
]
},
"is_urgent": {
"type": "noul",
"instructions": "The message conveys urgency or time-sensitivity"
}
}
}
EOFThe response:
{
"model": "jev-1.13.0",
"answers": {
"department": {
"type": "choice",
"choice": "technical",
"confidence": 0.78,
"probabilities": { "technical": 0.85, "sales": 0.0, "billing": 0.15 }
},
"frustration": {
"type": "score",
"score": 1.0,
"confidence": 1.0,
"legend": {
"0": "Calm, just stating facts",
"1": "Frustrated but civil",
"2": "Very angry, strong language"
},
"probabilities": { "0": 0.0, "1": 1.0, "2": 0.0 }
},
"is_urgent": { "type": "noul", "noul": 1.0 }
},
"usage": { "input_tokens": 392, "output_tokens": 65 }
}Four things to notice before moving on.
The question keys are yours and are not sent to the model. department, frustration, is_urgent are identifiers for your code. The API reference states that the key is not used in inference. Write the complete question in instructions; a self-documenting key documents nothing to jev.
Answer shapes differ by type. A Choice returns choice, probabilities, and confidence. A Score returns score, legend, probabilities, and confidence. A Noul returns only noul, with no confidence field, because a two-outcome distribution is fully described by one number.
The model field in the response is the versioned ID that actually answered. Log it.
Output tokens are free. Per the Models page, jev-1.13 is charged per input token only. This is why asking extra questions is nearly free and why the fan-out pattern makes economic sense.
Python
Requires Python 3.10 or newer.
pip install typesafe-sdkThe client reads TYPESAFE_API_KEY from the environment.
from typesafe_sdk import Choice, Noul, Score, TypeSafeClient
client = TypeSafeClient(model="jev-1.13.0")
ticket = ("Hi, I've been trying to connect my Stripe account for 3 days and "
"the integration keeps failing. I'm losing sales. Please help ASAP.")
response = client.system_one(
state=ticket,
questions={
"department": Choice(
instructions="Which team should handle this",
criteria={
"billing": "Payment or subscription issues",
"technical": "Bugs or integration problems",
"sales": "Pricing or account questions",
},
),
"frustration": Score(
instructions="How frustrated the customer appears",
criteria=[
"Calm, just stating facts",
"Frustrated but civil",
"Very angry, strong language",
],
),
"is_urgent": Noul(
instructions="The message conveys urgency or time-sensitivity",
),
},
)
print(response.answers["department"].choice) # "technical"
print(response.answers["department"].confidence) # 0.78
print(response.answers["frustration"].score) # 1.0
print(response.answers["is_urgent"].noul) # 1.0The client also works as a context manager, which is the form the docs use for one-shot scripts:
with TypeSafeClient() as client:
response = client.system_one(state=ticket, questions=questions)For a Noul that needs its boundary spelled out, NoulCriteria carries the true and false descriptions:
from typesafe_sdk import Noul, NoulCriteria
Noul(
instructions="Has the customer contacted support about this before?",
criteria=NoulCriteria(
true="Mentions a prior attempt, ticket, or that they have asked before",
false="No sign of any previous contact",
),
)The one Python gotcha: integer keys
This will bite you exactly once, and it is worth knowing before it does.
On the wire, a Score's probabilities and legend are JSON objects keyed by level number as a string: "0", "1", "2". The API reference is explicit about it.
The Python SDK keys them by integer instead. From the Score documentation: the SDK keys probabilities and legend by integer level rather than by string.
score = response.answers["frustration"]
# Python SDK: integer keys
score.probabilities[2] # 0.0
score.legend[2] # "Very angry, strong language"
# Raw JSON / curl / TypeScript: string keys
# answers.frustration.probabilities["2"]
score.probabilities["2"] # KeyError with the Python SDKThe failure is loud in Python, which is a mercy. The dangerous direction is code that moves between the raw HTTP API and the SDK, or a serialization layer that round-trips through JSON and silently converts integer keys to strings. Normalize at the boundary if you do both:
def level_probs(score_answer) -> dict[int, float]:
"""Accept either SDK integer keys or JSON string keys."""
return {int(k): v for k, v in score_answer.probabilities.items()}Choice probabilities are keyed by your option names in both worlds, so this only applies to Score.
TypeScript
Requires Node.js 20 or newer.
npm install @typesafe-ai/sdkimport { choice, noul, score, TypeSafeClient } from "@typesafe-ai/sdk";
const client = new TypeSafeClient();
const response = await client.systemOne({
state: { document: "I was charged twice. Please fix this ASAP." },
questions: {
category: choice("What is this ticket about?", {
billing: null,
technical: null,
other: null,
}),
},
});
console.log(response.answers.category.choice);Answer types are inferred from your questions, so response.answers.category.choice is typed as the union of your option keys rather than a bare string. That is the main reason to use the SDK over fetch: a typo in an option name becomes a compile error instead of a branch that never runs.
Note the Choice criteria shape: a map of option to description, where null means the option needs no extra detail. The Choice docs recommend writing descriptions that separate the options from each other, since both the option names and their descriptions are sent to the model. Reach for null only when the name genuinely says it all.
A Choice accepts up to 255 options, and adding options costs a few tokens each, so give the model the full list rather than a shortlist. Add an other or none of the above option whenever your list might not cover every input, so the model can say none of these fit rather than being forced into the nearest wrong bucket.
The package ships ESM, CommonJS, and TypeScript declarations.
Error handling
Four documented status codes:
| Status | Meaning |
|---|---|
401 Unauthorized |
Missing or invalid API key. Check the Authorization header. |
422 Unprocessable Entity |
The request body failed validation. The body details the offending field. |
429 Too Many Requests |
You have exceeded your rate limit. Back off and retry after a short delay. |
529 Overloaded |
TypeSafe is temporarily overloaded. Retry after a short delay. |
They split cleanly into two classes.
401 and 422 are your bug. Never retry them; you will get the same answer with a delay attached. A 422 body names the offending field, so log it in full. Common causes: a Score with fewer than 2 or more than 10 levels, a Choice over 255 options, a missing criteria on a Choice or Score (it is required for both, optional only for Noul), or a state that blew the context budget.
429 and 529 are transient. Both call for exponential backoff rather than an immediate retry. Published limits for jev-1.13 are 250,000 tokens per second and 1,200 requests per minute, and a request over either returns 429. Those limits are noted as adjusting dynamically while TypeSafe scales, so treat 429 as a normal operating condition rather than an exceptional one.
If you use an SDK, this is already handled. Both client SDKs retry with backoff by default and honor the retry-after header when the response carries one. The default per-operation timeout in the Python SDK is 10 seconds.
If you call the HTTP API directly, you own the backoff:
import random, time
import httpx
TRANSIENT = {429, 529}
def system_one(payload: dict, *, api_key: str, max_attempts: int = 5) -> dict:
for attempt in range(max_attempts):
r = httpx.post(
"https://api.typesafe.ai/v1/systemone",
headers={"Authorization": f"Bearer {api_key}"},
json=payload,
timeout=10.0,
)
if r.status_code == 200:
return r.json()
if r.status_code not in TRANSIENT:
# 401 and 422 are our bug. Fail loudly, with the body.
r.raise_for_status()
if attempt == max_attempts - 1:
r.raise_for_status()
# Honor retry-after when present, otherwise exponential backoff with jitter.
retry_after = r.headers.get("retry-after")
delay = float(retry_after) if retry_after else (2 ** attempt) + random.random()
time.sleep(delay)
raise RuntimeError("unreachable")The jitter matters more than it looks. Jev calls are fast enough to sit on a request path, which means a burst of traffic produces a burst of 429s that will re-synchronize into another burst if every client backs off by the same amount.
One design note. Because jev answers in roughly 100ms and sits inline in user-facing paths, decide up front what happens when all retries fail. Failing the user request is rarely right. Usually the correct fallback is the same path a low-confidence answer takes: route to a human, use a safe default, or queue for later classification. Write that path before you need it.
Why pin jev-1.13.0
jev-latest resolves to jev-1.13.0 today. It is the SDK default and the name the docs use in examples. Use it while you are exploring.
Pin the versioned ID before you ship anything with a tuned number in it.
An alias moves when a new release ships, which means the answers behind it can change with no change on your side. TypeSafe's own guidance is direct: if you have tuned confidence thresholds against a specific version, pin that version's ID instead of the alias and move to the new one on your own schedule.
The stakes are higher than a general "pin your dependencies" instinct, because of how jev integrations are built. Your business logic is not just calling the model, it is comparing its output to constants you chose by measurement: confidence > 0.85 before auto-approving a transfer, noul > 0.7 before flagging a refund, score > 1.5 before escalating. Those constants are calibrated against one model's behavior on your data. A model change that improves accuracy overall can still shift the distribution enough to move every one of those decision boundaries.
# Exploring
client = TypeSafeClient() # jev-latest
# Shipping
client = TypeSafeClient(model="jev-1.13.0") # pinnedWhichever you send, log the response's model field with every decision you store. When you do migrate, that log lets you replay real traffic against the new version and re-measure your thresholds instead of guessing at them. Note also that jev-preview currently points at the same model as jev-latest, since there is no preview build right now, so it offers no early-warning signal today.
Reading the response correctly
A short checklist of things that are easy to get subtly wrong.
Noul has no confidence. Do not look for it. The noul value is the answer and the certainty in one: near 1 is a strong yes, near 0 a strong no, near 0.5 means the model gives both similar probability. Threshold it, and consider sending the middle band to a human rather than to either code path.
Choice confidence is not the probability of the choice. It is derived from how peaked the distribution is. Branch on choice, gate on confidence, and read probabilities when you need the runner-up.
A Score expectation can land between levels, and that is a distribution summary, not a measurement. A score of 1.43 comes from probabilities like 0.57 on level 1 and 0.43 on level 2. Threshold it, normalize it, sort by it. Do not interpolate it into a real-world quantity. The docs state the reason: jev-1.13's score levels are weak in numerical calibration. See the traps post.
Point questions at parts of a structured state with backtick paths. When the state is an object, name the field a question is about with a dot-and-index path, backticks included: "Does `ticket.messages[0].text` request a refund?". This removes ambiguity and cuts the indirection the model has to resolve.
Budget context deliberately. 64k tokens per request for state plus all questions, 32k for state plus the longest single question. Accuracy degrades from irrelevant material well before you reach either ceiling, so filter in code first.
A minimal production shape
Everything above, assembled:
from typesafe_sdk import Choice, Noul, TypeSafeClient
client = TypeSafeClient(model="jev-1.13.0")
QUESTIONS = {
"intent": Choice(
instructions="What is the customer asking for in `ticket.body`?",
criteria={
"refund": "Money back for a purchase",
"exchange": "Swap for a different item",
"information": "An answer only, no action needed",
"other": "Something none of the above covers",
},
),
"is_urgent": Noul(
instructions="Does `ticket.body` express time pressure or urgency?",
),
}
def triage(ticket: dict) -> str:
# Filter in code: send only what the questions need.
state = {"ticket": {"body": ticket["body"], "status": ticket["status"]}}
response = client.system_one(state=state, questions=QUESTIONS)
log_decision(
ticket_id=ticket["id"],
model=response.model, # the versioned id that answered
intent=response.answers["intent"].choice,
confidence=response.answers["intent"].confidence,
input_tokens=response.usage.input_tokens,
)
intent = response.answers["intent"]
if intent.confidence < 0.6 or intent.choice == "other":
return "human_review"
if response.answers["is_urgent"].noul > 0.7:
return f"{intent.choice}_priority"
return intent.choiceTwo questions in one call, a state filtered down to two fields, a pinned model, a confidence floor before any automated branch, an other option so the Choice can decline, and every decision logged with the model version that produced it.
From here, the next things worth reading are the four patterns for how to structure larger systems, and the nine failure modes for what jev will get wrong before you find out in production.