Jev classificationone task, three primitives
Jev classifies text by returning an answer from a space you declare up front. There are three ways to declare it. Below is one real task, triaging inbound replies to your posts, solved with Choice, then with Score, then with Noul, with the full request and response body for each. The response bodies are illustrative: the field shapes are the documented ones, the values are written to show them. Then the part nobody writes down: how to choose, and the trap that ruins most first attempts.
Last updated: September 2026
Jev decides. An LLM writes. OpenTweet publishes.
Two different API keys
Jev is TypeSafe's model and TypeSafe issues its key. OpenTweet does not resell Jev, bundle a Jev key, or proxy the model. This page shows the two keys side by side because that is how a real pipeline is wired.The three primitives, in one table
Every Jev classification is one of these three. The answer fields differ, and the difference matters more than it looks.
| Primitive | The question it asks | Fields in the answer | Limits |
|---|---|---|---|
| Choice | Which one of these? | choice, probabilities, confidence | Up to 255 options. Probabilities sum to 1.0 across them. |
| Score | Which level on this ladder? | score, legend, probabilities, confidence | 2 to 10 ordered levels. score is the probability-weighted mean of the level indices. |
| Noul | Is this statement true? | noul only | A float from 0 to 1. No confidence field. Threshold it yourself. |
Field names and limits from docs.typesafe.ai/primitives, September 2026.
A Noul has no confidence field
This is the single most common mistake in third-party writing about Jev. A Noul answer contains exactly one field,noul, a float from 0 to 1. Reading answers.is_spam.confidence gives you undefined, and a threshold on undefined silently evaluates to false forever. Only Choice and Score return confidence.The task and the state
Someone replied to your post. You want to know what to do about it. Everything Jev sees about that reply is the state object you send, so send the fields the question needs and nothing else. TypeSafe documents irrelevant context as something that degrades accuracy.
{
"reply_text": "been paying for this 4 months and the LinkedIn connection drops every week. who do I talk to?",
"in_reply_to": "We shipped multi-platform publishing today. One call, three networks.",
"author_handle": "@kmoreau",
"author_followers": 412
}state accepts a string, an object or an array of strings. An object is worth using here because it labels each field, and a labelled field is easier to refer to in the instructions than a blob of concatenated text.
1. Choice, when exactly one bucket is true
Choice takes a map of option name to description. The description is the actual classifier, so write it like a rule rather than a synonym. Probabilities come back for every option and sum to 1.0.
curl -X POST https://api.typesafe.ai/v1/systemone \
-H "Authorization: Bearer $TYPESAFE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"state": {
"reply_text": "been paying for this 4 months and the LinkedIn connection drops every week. who do I talk to?",
"in_reply_to": "We shipped multi-platform publishing today. One call, three networks.",
"author_followers": 412
},
"model": "jev-1.13.0",
"questions": {
"bucket": {
"type": "choice",
"instructions": "Which single bucket best describes what this reply is asking the author to do",
"criteria": {
"support": "Reports something broken, or asks for help with an account or a bug",
"sales": "Asks about price, plans, or capability before buying",
"question": "Asks about the topic of the post, and is not about buying or support",
"praise": "Compliments the post or the product and asks for nothing",
"spam": "Promotes something unrelated, or is generic engagement filler",
"hostile": "Insults the author or the product with no recoverable request"
}
}
}
}'{
"model": "jev-1.13.0",
"answers": {
"bucket": {
"type": "choice",
"choice": "support",
"probabilities": {
"support": 0.871,
"sales": 0.040,
"question": 0.081,
"praise": 0.002,
"spam": 0.003,
"hostile": 0.003
},
"confidence": 0.742
}
},
"usage": { "input_tokens": 214, "output_tokens": 12 }
}choiceis the winning key. It is always one of yours.probabilitiescovers every option you declared. The runner up in the illustrated distribution isquestionat 0.081.confidenceis a statistic derived from that distribution, not a second opinion. A concentrated distribution gives a high number, a flat one gives a low number.
2. Score, when the buckets are rungs on a ladder
Score takes an ordered array of 2 to 10 level descriptions. Level 0 is the bottom. Write the levels so that higher always means more of the thing, and put the boundary case in the wording of the level rather than in the instructions.
{
"state": { "reply_text": "...", "in_reply_to": "...", "author_followers": 412 },
"model": "jev-1.13.0",
"questions": {
"severity": {
"type": "score",
"instructions": "How much this reply costs the author if nobody answers it today",
"criteria": [
"None. Nothing is claimed and nothing is at stake.",
"Low. A vague grumble with no specifics.",
"Medium. A concrete complaint about the product, in public, under the author's own post.",
"High. A concrete complaint about money, data loss, or a paid feature that is broken.",
"Severe. An accusation of fraud, a legal threat, or a claim other accounts are already repeating."
]
}
}
}{
"model": "jev-1.13.0",
"answers": {
"severity": {
"type": "score",
"score": 2.8,
"legend": {
"0": "None. Nothing is claimed and nothing is at stake.",
"1": "Low. A vague grumble with no specifics.",
"2": "Medium. A concrete complaint about the product, in public, under the author's own post.",
"3": "High. A concrete complaint about money, data loss, or a paid feature that is broken.",
"4": "Severe. An accusation of fraud, a legal threat, or a claim other accounts are already repeating."
},
"probabilities": { "0": 0.004, "1": 0.031, "2": 0.245, "3": 0.601, "4": 0.119 },
"confidence": 0.688
}
},
"usage": { "input_tokens": 268, "output_tokens": 18 }
}Where 2.8 comes from
score is the probability-weighted mean of the level indices, so you can check it by hand:0(0.004) + 1(0.031) + 2(0.245) + 3(0.601) + 4(0.119) = 2.8Which is also why 2.8 does not mean "80 percent of the way from Medium to High". TypeSafe's Score documentation says the values are not linearly interpolatable in meaning. Use the float to rank and to threshold, never to measure magnitude.3. Noul, when several things are true at once
A Noul is a statement, not a question, and the answer is the probability that the statement holds. Declare as many as you need. They are evaluated independently, so two can both come back near 1.
{
"state": { "reply_text": "...", "in_reply_to": "...", "author_followers": 412 },
"model": "jev-1.13.0",
"questions": {
"needs_human_reply": {
"type": "noul",
"instructions": "The author should answer this personally rather than ignore it or let a macro answer it"
},
"is_paying_customer": {
"type": "noul",
"instructions": "The person writing this says they already pay for the product"
},
"mentions_money": {
"type": "noul",
"instructions": "The reply mentions a charge, a refund, a subscription, or a price"
},
"is_spam": {
"type": "noul",
"instructions": "The reply is promotional filler rather than a genuine response to the post",
"criteria": {
"true": "Promotes an unrelated product, drops a link, or is generic praise with a call to follow.",
"false": "Responds to something specific in the post it is replying to."
}
}
}
}{
"model": "jev-1.13.0",
"answers": {
"needs_human_reply": { "type": "noul", "noul": 0.982 },
"is_paying_customer": { "type": "noul", "noul": 0.944 },
"mentions_money": { "type": "noul", "noul": 0.911 },
"is_spam": { "type": "noul", "noul": 0.004 }
},
"usage": { "input_tokens": 301, "output_tokens": 24 }
}Four answers, four floats, no confidence anywhere. That is the documented shape, not a trimmed example. The optional criteria object on is_spam is how you pin down a word like "filler" that would otherwise be read loosely.
All of it in one request
Questions are independent and evaluated in a single parallel pass, so the version you actually ship sends all of them together. One request, one bill, one round trip. TypeSafe calls this fan-out, and its own cookbook reports a 13-question fan-out at 12.2x cheaper and 10.0x faster than asking one at a time.
import { TypeSafeClient, choice, noul, score } from '@typesafe-ai/sdk';
const client = new TypeSafeClient({
timeout: 4000,
retry: { maxRetries: 1, backoffInitialMs: 200 },
});
const QUESTIONS = {
bucket: choice('Which single bucket best describes what this reply is asking the author to do', {
support: 'Reports something broken, or asks for help with an account or a bug',
sales: 'Asks about price, plans, or capability before buying',
question: 'Asks about the topic of the post, and is not about buying or support',
praise: 'Compliments the post or the product and asks for nothing',
spam: 'Promotes something unrelated, or is generic engagement filler',
hostile: 'Insults the author or the product with no recoverable request',
}),
severity: score('How much this reply costs the author if nobody answers it today', [
'None. Nothing is claimed and nothing is at stake.',
'Low. A vague grumble with no specifics.',
"Medium. A concrete complaint about the product, in public, under the author's own post.",
'High. A concrete complaint about money, data loss, or a paid feature that is broken.',
'Severe. An accusation of fraud, a legal threat, or a claim other accounts are already repeating.',
]),
needs_human_reply: noul('The author should answer this personally rather than ignore it'),
is_spam: noul('The reply is promotional filler rather than a genuine response to the post'),
};
export async function classifyReply(reply) {
const { answers, usage } = await client.systemOne({
state: {
reply_text: reply.text,
in_reply_to: reply.inReplyToText,
author_followers: reply.authorFollowers,
},
questions: QUESTIONS,
});
return {
bucket: answers.bucket.choice,
bucketConfidence: answers.bucket.confidence,
severity: answers.severity.score,
needsHumanReply: answers.needs_human_reply.noul,
isSpam: answers.is_spam.noul,
inputTokens: usage.input_tokens,
};
}The helper functions choice(), score() and noul() come from @typesafe-ai/sdk and build the same JSON shown above. The client reads TYPESAFE_API_KEY from the environment. A seven-question scorer is wired exactly this way: one systemOne() call carrying four Scores and three Nouls.
How to pick a primitive
Start from the shape of the truth, not from the shape of your database column.
| What you are actually asking | Use | Why |
|---|---|---|
| Exactly one label is true and the labels compete | Choice | Probability is shared across the options, so a strong winner is visible. |
| The labels are rungs on a ladder | Score | Order carries meaning, and the float lets you rank and threshold. |
| Several labels can be true at once | Noul, one per label | Each Noul is independent, so two can both come back near 1. |
| A taxonomy deeper than 255 leaves | Two Choices in sequence | Group first, then the leaf inside the winning group. |
| A number you need to be arithmetically right | Neither | Count and compare in your own code. TypeSafe documents counting and date arithmetic as unreliable. |
The trap: Choice for things that are not mutually exclusive
Choice options compete for one probability budget. If two of your options can both be true of the same input, Jev is forced to split between them, and you lose the answer and the confidence at the same time.
What people write first
Two overlapping labels in one Choice, on a reply that is genuinely both.
What it should be
Two Nouls. Independent, so both fire, and the routing keeps both branches.
{
"questions": {
"label": {
"type": "choice",
"instructions": "What is this reply",
"criteria": {
"complaint": "Reports a problem",
"buying_question": "Asks something a buyer would ask"
}
}
}
}
// The reply is both. Jev has to split:
// { "choice": "complaint", "probabilities": { "complaint": 0.52, "buying_question": 0.48 }, "confidence": 0.09 }
// You route it to support, never see the buying signal, and the confidence gate
// you built now rejects a reply that Jev understood perfectly well.{
"questions": {
"is_complaint": { "type": "noul", "instructions": "The reply reports a problem with the product" },
"is_buying_question": { "type": "noul", "instructions": "The reply asks something a prospective buyer would ask" }
}
}
// Both fire, independently:
// { "is_complaint": { "noul": 0.93 }, "is_buying_question": { "noul": 0.88 } }
// Support gets it and sales gets it. Nothing was thrown away.The symptom is a confidence number that collapses on exactly the inputs you care about most. If a Choice keeps returning low confidence on your hardest cases, check whether the options overlap before you rewrite the descriptions. It is usually the taxonomy, not the wording.
The mirror-image mistake
Using a Noul where you needed a Score. A Noul for "this reply is very angry" gives you the probability that the sentence is true, which is not the same as how angry the reply is. If you want a magnitude, write the ladder and use Score. If you want a flag, write the statement and use Noul.Batching
There are two axes. Within one item, add questions rather than requests. Across items, add requests in parallel.
- Within an item: one request per reply carrying every question you need. Per TypeSafe's API reference, the budget is 64k tokens per request, with 32k covering the state plus the longest single question. A reply and ten questions is nowhere near that.
- Across items: a fixed worker pool. TypeSafe publishes a rate limit of 1,200 requests per minute and 250,000 tokens per second in its API reference, and its docs note the limits adjust dynamically, so treat those as a ceiling you back off from rather than a target you saturate.
- Not this: one request per question. Same tokens of state, paid once per question, plus a round trip each.
const CONCURRENCY = 16;
async function classifyAll(replies) {
const out = new Array(replies.length);
let cursor = 0;
async function worker() {
while (cursor < replies.length) {
const i = cursor++;
try {
out[i] = { ok: true, result: await classifyReply(replies[i]) };
} catch (err) {
out[i] = { ok: false, error: err };
}
}
}
await Promise.all(Array.from({ length: CONCURRENCY }, worker));
return out;
}Note that the worker writes results into a preallocated array by index. Classification jobs that reorder their inputs and then zip results back by position are a class of bug that survives every test where the fixture happens to be sorted.
Error handling
Four status codes matter. Two are your fault and must not be retried, two are transient and must be. What you do after the retries run out is a product decision, so make it deliberately.
| Status | Meaning | What to do |
|---|---|---|
| 401 | Missing or invalid API key | Fail loudly at boot. Never retry. |
| 422 | Validation failure in the request body | A bug in your question set. Fix the schema, do not retry. |
| 429 | Rate limit exceeded | Exponential backoff, then lower the concurrency. |
| 529 | TypeSafe temporarily overloaded | Exponential backoff. Treat a sustained run as an outage. |
Codes and wording from docs.typesafe.ai/api.
const RETRYABLE = new Set([429, 529]);
async function classifyWithRetry(reply, attempt = 0) {
try {
return await classifyReply(reply);
} catch (err) {
const status = err.status;
// 401 is a bad key and 422 is a malformed question set. Both are bugs in
// your code and will fail identically on every retry.
if (status === 401 || status === 422) throw err;
if (RETRYABLE.has(status) && attempt < 4) {
const backoff = 250 * 2 ** attempt + Math.random() * 100;
await new Promise((r) => setTimeout(r, backoff));
return classifyWithRetry(reply, attempt + 1);
}
// Everything else: fail open. An unclassified reply goes to a human,
// it does not get dropped and it does not get auto-actioned.
return { bucket: 'unclassified', severity: null, needsHumanReply: 1, isSpam: 0 };
}
}Fail open, not closed
The fallback above setsneedsHumanReply: 1 and an unclassified bucket. A classifier that silently drops the inputs it could not score is worse than no classifier, because the missing items are invisible. Route the failures somewhere a person sees them and count them on a dashboard.What it costs
TypeSafe prices input at $0.042 per 1M tokens and does not meter output. The whole bill is therefore the state you send, times the number of items.
| Volume | Input tokens at ~250 each | Cost | What that is |
|---|---|---|---|
| 1,000 replies | 250,000 | $0.0105 | A busy launch day |
| 50,000 replies | 12.5M | $0.53 | A month of inbound for a mid-size account |
| 1,000,000 replies | 250M | $10.50 | A platform-scale backfill |
Arithmetic at TypeSafe's published list price, September 2026. Output tokens are free. Measure your own token counts before you budget.
For a checkable comparison point, the free post scorer sends a draft plus seven questions. Same arithmetic, on a workload you can run yourself, for free, without an account.
What Jev will not do for you
All of these are documented by TypeSafe. Designing around them is most of the work.
It cannot write anything
- No prose, no code, no summaries, no explanation of its own answer.
- The reply text has to come from you or from an LLM.
- No image, audio or video input. Text only.
It cannot do your arithmetic
- Counting is documented as unreliable. Count in code.
- Dates are treated as text, so ordering and windows are unreliable. Compare in code.
- No relationships hold between separate answers. Do not assume two Scores are on the same scale.
Schema-safe is not error-free
Jev cannot return a value outside the space you declared. That eliminates schema errors by construction. It does not eliminate wrong answers, and TypeSafe's own launch post says the zero-hallucination figure "is not empirical" and that what is guaranteed is schema matching. TypeSafe's CEO conceded the same point on Hacker News. Gate the consequential actions on confidence, and label a sample by hand before you trust any threshold.Then publish the answer
Classification is only useful if something happens afterwards. Jev decides, an LLM or a person writes, and OpenTweet ships the post to X, Bluesky and LinkedIn in one call.
curl -X POST https://opentweet.io/api/v1/posts \
-H "Authorization: Bearer ot_your_key" \
-H "Content-Type: application/json" \
-d '{
"text": "Nine of you flagged the LinkedIn connection dropping this week. It was a token refresh bug. Fixed and shipped an hour ago.",
"platforms": ["x", "bluesky", "linkedin"],
"publish_now": true
}'One-to-one replies are a different delivery path. X restricted programmatic replies on self-serve access tiers on February 23, 2026, so those go through the browser extension into X's own composer, reviewed and sent by you. The end-to-end how-to covers both paths, and Replies is the feature that does it.
Frequently asked questions
What is Jev classification?
It is sending a piece of text to Jev with a typed question attached and getting a typed answer back. You declare the answer space up front as a Choice (one option from a map you wrote), a Score (one level from an ordered list of 2 to 10) or a Noul (a 0 to 1 float for a yes or no statement). Jev evaluates all declared questions in one parallel pass and returns probabilities alongside the answer.
Which Jev primitive should I use for classification?
Choice when exactly one label can be true and the labels compete. Score when the labels are rungs on a ladder and the order carries meaning. Noul when each label is independent and several can be true at once. The failure people hit most is using Choice for labels that are not mutually exclusive, which forces Jev to split probability between two true answers and destroys the confidence on both.
How many options can a Jev Choice have?
Up to 255, per TypeSafe’s Choice documentation. Above that you split the taxonomy into stages: one Choice picks the top-level group, a second Choice picks within it. TypeSafe used a two-stage approach in its own Wikiracing demo for the same reason.
Does a Jev Noul return a confidence score?
No. A Noul answer contains only one field, noul, a float from 0 to 1. There is no confidence field on a Noul. The probability is the confidence signal, and you threshold it yourself. Only Choice and Score return a separate confidence number, computed from the probability distribution those two already give you.
Can Jev return a category that is not in my list?
No. The answer space is constrained to what you declared, so a Choice can only return one of your keys and a Score can only land inside your levels. That removes schema errors, not semantic ones. Jev can still pick the wrong option from your list, and TypeSafe’s own launch post says the zero-hallucination figure is not empirical and that schema matching is what is guaranteed.
How much does it cost to classify a million replies with Jev?
An estimated $10.50 in input tokens. TypeSafe prices input at $0.042 per 1M tokens and does not meter output. A reply plus a six-option Choice, a five-level Score and four Nouls lands near 250 input tokens, so a million of them is roughly 250M tokens, which is $10.50. Measure your own token counts before you budget, because the state you send drives the whole number.
Keep exploring
The primitives in depth, the thresholds, and the pipeline that ends in a published post.
Jev, explained
What a System One model is, what Jev can and cannot do, and where the numbers come from.
Choice, Score and Noul explained
What each primitive accepts, what it returns, and the limits on each.
Jev sentiment analysis
Why sentiment is a Score and not a Choice, with a five-level rubric written properly.
Brand crisis detection with Jev
Score every mention, hold a rolling baseline, and alert on the spike rather than any one mention.
Setting a confidence threshold
Where to gate an automatic action, and how to find the number from your own labelled data.
Classify social media replies, end to end
Pull the replies, classify them, route by bucket, and publish the answer through OpenTweet.
Jev decides. OpenTweet ships it.
Classify with Jev on TypeSafe's key, then publish to X, Bluesky and LinkedIn through one REST call. Plans start at $11.99 a month with the API and the MCP server on every one.
- 7-day free trial
- No X developer account needed
- Cancel anytime