Drex guides
Questions
Choose between noul, choice, and score, shape each one so Drex accepts it, name the ids, and batch questions into one call.
Every request to POST /v1/systemone carries one state and a map of questions. Each question has a type of noul, choice, or score. This guide helps you pick the type, write the JSON that Drex accepts, and fit many questions into one call. To read what comes back, see Reading answers.
Pick the type by the decision you make next
Start from the code that consumes the answer, not from the text you are asking about.
| You need | Type | Example |
|---|---|---|
| A yes or no, with a threshold you set | noul | Is the customer asking for a refund? |
| One label from a fixed set with no order | choice | Is this ticket about billing, a technical problem, or the account? |
| A position on an ordered scale | score | How upset is the customer, from calm to angry? |
If the labels have an order, use score and you get a fractional position on the scale. If the labels have no order, use choice. If there are only two outcomes, use noul and compare the probability with a threshold.
Shape a noul question
{
"type": "noul",
"instructions": "Is the customer asking for a refund?",
"criteria": {
"true": "The customer asks for money back",
"false": "The customer asks for anything else"
}
}Drex applies these rules:
instructionsis optional. It is a string, ornull, which Drex reads as omitted. Any other JSON value (an object, an array, a number or a boolean), which Jev clients such as Pydantic AI send, is rendered to text the waystateis:{"field": "refund", "question": "Is it a refund?"}reads asfield: refundandquestion: Is it a refund?on two lines. It may nest at most 8 levels and render to at most 262,144 characters.criteriais optional. Only thetrueandfalsekeys are read. Each value is a string ornull. Other keys are ignored.- The question needs a non-empty
instructionsor at least one non-empty criterion. Otherwise Drex rejects it withnoul needs instructions or criteria.
The Nace SDKs leave an unset instructions out of the request, and refuse a noul with neither instructions nor a criterion before sending it. The TypeSafe SDK's noul() sends instructions: null, which Drex accepts as omitted, so a noul(null, criteria) with a criterion works unchanged. See Migrate from TypeSafe.
Shape a choice question
{
"type": "choice",
"instructions": "What is this support ticket about?",
"criteria": {
"billing": "Payments, invoices, refunds, or charges",
"technical": "Bugs, errors, or outages",
"account": "Login, profile, or permissions",
"other": null
}
}criteriais required and must be an object. A string or an array is rejected withmust be an object with options.- It needs at least one label. An empty object is rejected with
choice needs at least 1 option. - Each description is a string or
null. A number or an object is rejected withmust be a string or null.
The keys of criteria are the labels that come back in choice and probabilities. Keep them short and machine friendly, such as billing rather than Billing and payments.
Shape a score question
{
"type": "score",
"instructions": "How upset is the customer?",
"criteria": ["Calm", "Mildly annoyed", "Frustrated", "Angry"]
}criteriais required and must be an array of strings. Anything else is rejected withmust be an array of level labels.- The array index is the score.
Calmis 0 andAngryis 3. Order the levels from low to high. - The answer carries the expected
score, thelegend(your labels, so index 0 is the first), aprobabilitiesentry per level and aconfidence. See Reading answers. - The API accepts one level or more. An empty array is rejected with
score needs at least 1 level.
Name question ids
The keys of questions become the keys of answers. An id must be non-empty. Drex rejects a blank id with question ids must be non-empty.
Name each id for the field it fills in your code, for example wants_refund, category, sentiment. An id like q1 forces every reader of the answer to look up what q1 asked.
Batch questions into one call
One request accepts up to 512 questions about the same state. Drex bills input tokens, and a batch sends the state once instead of once per question, so one call with several questions costs less than one call per question.
We measured this with a 27-word support ticket. Output is an example and varies slightly per call.
| Request | usage.input_tokens |
|---|---|
| 1 noul question | 54 |
| 4 questions (2 noul, 1 choice, 1 score) in one call | 127 |
| The same 4 questions as 4 separate calls (54 + 78 + 55 + 54) | 241 |
The batch used 47% fewer input tokens than the separate calls, with the same answers.
import { NaceClient, choice, noul, score } from "nace-sdk";
const client = new NaceClient({ defaultModel: "drex-latest" });
const { answers, usage } = await client.systemOne({
state:
"Hi, I ordered a standing desk on March 3 and it arrived with a cracked top. I'd like a replacement, not a refund. Order #48213.",
questions: {
wants_replacement: noul("Is the customer asking for a replacement?"),
category: choice("What is this ticket about?", {
damaged_item: "Item arrived broken or damaged",
wrong_item: "A different item was delivered",
refund: "Customer wants money back",
other: null,
}),
urgency: score("How urgent is this ticket?", ["Low", "Medium", "High"]),
has_order_id: noul("Does the message include an order number?"),
},
});
console.log(answers.category.choice, answers.urgency.score, usage.input_tokens);More than 512 questions is rejected with at most 512 questions allowed (Drex Max has the same limit); the SDKs refuse it before sending, with a NaceError. Token limits depend on the model that answers, including the successor of a retired id. See State for state-and-row versus whole-request accounting, and Limits for the current values.
Write criteria that move the answer
Descriptions on choice labels change where the probability goes. We asked how to moderate the comment "This is the last time I'm warning you. Stop posting here or else." with the labels allow, flag, and remove, first with null descriptions and then with short ones.
| Descriptions | allow | flag | remove | confidence | input_tokens |
|---|---|---|---|---|---|
All null | 0.18 | 0.48 | 0.34 | 0.22 | 35 |
remove is "Explicit threat of violence or doxxing", flag is "Possible intimidation, send to a human reviewer", allow is "No policy violation" | 0.33 | 0.52 | 0.15 | 0.27 | 57 |
The comment is a vague threat. Once remove was defined as an explicit threat, its probability fell from 0.34 to 0.15 and the mass moved to allow and flag. Write a description for any label whose boundary is not obvious from its name. Each description costs input tokens, so keep descriptions short and skip them for labels like other.
For noul, criteria.true and criteria.false say what counts as yes and what counts as no. We asked whether the review "Would be great if the settings page didn't take five seconds to open every time." is a bug report, with the same instructions each time:
criteria | noul | input_tokens |
|---|---|---|
| None | 0.3505 | 33 |
true: "Anything that does not work as expected, including slowness", false: "A request for a new feature" | 0.9696 | 53 |
true: "A crash, an error, or a broken feature", false: "Slowness, polish, or a wish" | 0.0167 | 54 |
Without criteria the review is borderline. Each set of criteria settles it in its own direction. Use criteria when your team's definition of yes differs from the everyday meaning of the question. A sentence in instructions, such as "Treat slowness and performance complaints as bugs", also helps, but it moves the answer less than criteria do, because criteria state both sides of the boundary.
Fix a 422
When a question is malformed, Drex returns HTTP 422 with error.type of invalid_request_error. error.issues lists every problem with a path into your request body. error.message repeats the first one as path: message.
{
"error": {
"type": "invalid_request_error",
"message": "questions.category.criteria: choice needs at least 1 option",
"issues": [
{ "path": "questions.category.criteria", "message": "choice needs at least 1 option" }
]
},
"request_id": "req_<32 hex characters>"
}These are the messages a question mistake produces. <id> stands for your question id.
| Mistake | path | message |
|---|---|---|
Unknown type | questions.<id>.type | must be "noul", "choice", or "score" |
box question | questions.<id>.type | "box" questions are not supported; use "noul", "choice", or "score" |
| Question is not an object | questions.<id> | must be an object |
instructions is JSON nested more than 8 levels, or renders to more than 262,144 characters | questions.<id>.instructions | must be a string, or JSON nested at most 8 levels that renders to at most 262144 characters |
JSON instructions of all questions together render to more than 4,194,304 characters. The body itself stays under its 4,194,304-byte limit, so this happens only when nesting adds indentation: each level indents its lines by 2 more spaces, and each array item adds - | questions.<id>.instructions | JSON instructions across all questions must render to at most 4194304 characters |
| noul with no instructions and no criteria | questions.<id>.instructions | noul needs instructions or criteria |
noul criteria is a string | questions.<id>.criteria | must be an object |
choice criteria is an array or string | questions.<id>.criteria | must be an object with options |
choice criteria is {} | questions.<id>.criteria | choice needs at least 1 option |
| choice description is a number | questions.<id>.criteria.<label> | must be a string or null |
score criteria is not an array | questions.<id>.criteria | must be an array of level labels |
score criteria is [] | questions.<id>.criteria | score needs at least 1 level |
| score level is not a string | questions.<id>.criteria.<index> | must be a string |
questions is {} | questions | must contain at least one question |
questions is not an object | questions | must be an object |
| More than 512 questions | questions | at most 512 questions allowed |
| Blank question id | questions | question ids must be non-empty |
model is not a listed model name | model | unknown model "<model>"; use followed by public active model ids and aliases in that environment. These suggestions can differ from the full caller catalog returned by GET /v1/models, which can also include authorized previews and retired ids. |
A 422 is not retryable. Fix the body and send it again. For the other statuses, see Errors and retries and the full error reference.