❓Advocaat
September 18, 2026 · View on GitHub
A small, type-safe client for asking AI questions about your data, powered by TypeSafe Jev.
Get probabilities, choices, and scores in one request.
Agent skill
skills/advocaat/SKILL.md teaches coding agents how to design questions and build with ask.
npx skills add pithings/advocaat
Quick start
Install the package:
npx nypm i advocaat
Set your TypeSafe API key:
export TYPESAFE_API_KEY="your-api-key"
Using Vercel instead? See Vercel AI Gateway.
Ask several questions about the same data in one request:
import { ask } from "advocaat";
const issue = { title: "Checkout is down", body: "No one can pay." };
const { kind, security, severity } = await ask(issue, {
kind: ask.choice`What kind of issue is this?`({ bug: "Something is broken", other: null }),
security: ask.if`Does this issue describe a security vulnerability?`,
severity: ask.score`How severe is this issue?`([
"Cosmetic",
"Workaround exists",
"Blocks production",
]),
});
console.log(kind.choice); // "bug" | "other"
console.log(security); // true | false
console.log(severity.ratio); // 0...1
Question types
Answers use the same keys as your questions. Mix any of these forms in one ask call:
| Want | Use | Read | Or, for the value alone |
|---|---|---|---|
| Yes/no | String or ask.chance | .chance | ask.if → boolean |
| Option | ask.choice | .choice | ask.switch → label |
| Rating | ask.score | .ratio |
The examples below reuse ask and issue from the quick start.
Yes/no questions
Use ask.if for a boolean, a string for a probability, or ask.chance to describe what counts as yes or no:
const { security, urgent, blocked } = await ask(issue, {
security: ask.if`Does this issue describe a security vulnerability?`,
urgent: "Does this need immediate attention?",
blocked: ask.chance`Are users blocked?`({
true: "Users cannot use the service.",
false: "Users can continue with a workaround.",
}),
});
console.log(security); // true | false
console.log(urgent.chance); // 0...1
console.log(blocked.chance); // 0...1
ask.if returns true when the probability of yes is above 0.5. You can set a different threshold.
Strings and ask.chance return { type: "chance", chance }, where chance is the probability of yes from 0 to 1. With ask.chance, either criterion can be omitted, or use ask.chance`Question?`() without criteria.
Choices
Use ask.choice with 2–255 named options:
const { kind } = await ask(issue, {
kind: ask.choice`What kind of issue is this?`({
bug: "Something is broken",
other: null,
}),
});
console.log(kind.choice); // "bug" | "other"
Returns { type: "choice", choice, confidence, probabilities }. choice is the selected label, typed as "bug" | "other" here. probabilities contains a probability for each label. confidence (0...1) is high when one label stands out and low when they are close.
When the labels speak for themselves, pass them as an array instead of an object with null descriptions: ask.choice`What kind of issue is this?`(["bug", "other"]).
Use ask.switch with the same options when you only need the label:
switch (await ask.switch`What kind of issue is ${issue}?`(["bug", "other"])) {
case "bug":
return label(issue, "bug");
case "other":
return triage(issue);
}
Scores
Use ask.score with 2–10 levels, ordered from lowest to highest:
const { severity } = await ask(issue, {
severity: ask.score`How severe is this issue?`([
"Cosmetic",
"Workaround exists",
"Blocks production",
]),
});
console.log(severity.ratio); // 0...1
Returns { type: "score", score, ratio, confidence, legend, probabilities }. score is the expected zero-based score and can be fractional (0–2 here). ratio scales it to 0...1. legend and probabilities are keyed by score level.
Asking one question
For a single question, interpolate the data into a tag and await it. You get the same answer as you would under its key in ask:
const kind = await ask.choice`What kind of issue is ${issue}?`(["bug", "other"]);
const label = await ask.switch`What kind of issue is ${issue}?`(["bug", "other"]);
const severity = await ask.score`How severe is ${issue}?`(["Cosmetic", "Blocks production"]);
const { chance } = await ask.chance`Does ${issue} need immediate attention?`();
const security = await ask.if`Does ${issue} describe a security vulnerability?`;
Each await sends a separate request. To ask several questions about the same data in one request, use ask(state, { ... }) instead. See Tag options for client settings and thresholds.
Passing data into tags
All tags support ${expression}. How a value is sent depends on its type.
Text and numbers
Text and numbers go directly into the question:
const service = "checkout";
const hours = 24;
const { urgent } = await ask(issue, {
urgent: ask.chance`Does this issue affect ${service} and need a fix within ${hours} hours?`(),
});
Objects and arrays
Objects and arrays become the request state, with a path in the question pointing to each value:
const existing = { number: 41, title: "Payments failing at checkout" };
const service = "checkout";
const duplicate = await ask.if`Is ${issue} a duplicate of ${existing} in ${service}?`;
- One object or array is sent under
input; its slot in the question becomes`input`. - Several are sent as an
inputarray; their slots become`input[0]`,`input[1]`, and so on. - Inside
ask, the objects of all tags in the request are sent once each in the sameinput, added to the state object. A text or array state becomesinput[0]of that array; point bare questions at it. Pass data as the state or in the tags, not both. A state that already hasinputthrows.
Without objects or arrays, the tag is sent with an empty state. Short text can go straight into the question as ask.if`Is "${message}" spam?` ; wrap longer text as ${{ message }} to keep it in the state. Use JSON.stringify(value) if you want an object included as JSON in the question text instead.
How data is sent
The duplicate check above sends this request:
{
"state": {
"input": [
{ "title": "Checkout is down", "body": "No one can pay." },
{ "number": 41, "title": "Payments failing at checkout" }
]
},
"questions": {
"input": {
"type": "noul",
"instructions": "Is `input[0]` a duplicate of `input[1]` in checkout?"
}
}
}
API reference
ask(state, questions, options?)
Sends all questions in one request and returns a promise of typed answers under the same keys.
state: the data to evaluate. Pass text, a JSON object or array, ornull.questions: an object with at least one named question. Use strings and tags or plain question objects.options: optional client and request settings, passed as the third argument.
const answers = await ask(
{ title: "Login fails", body: "All users are locked out." },
{ urgent: "Does this issue need immediate attention?" },
{
apiKey: "your-api-key",
baseURL: "https://api.typesafe.ai",
model: "jev-latest",
fetch: globalThis.fetch,
signal: AbortSignal.timeout(10_000),
headers: { "X-Request-ID": "triage-123" },
},
);
| Option | Description | Default |
|---|---|---|
apiKey | API key. Required unless set in the environment. | TYPESAFE_API_KEY, else AI_GATEWAY_API_KEY, else VERCEL_OIDC_TOKEN |
baseURL | API base URL. | TYPESAFE_BASE_URL or https://api.typesafe.ai; gateway: https://ai-gateway.vercel.sh/v4/ai |
model | Model name. | TYPESAFE_DEFAULT_MODEL or jev-latest; gateway: typesafe-ai/jev |
provider | "typesafe" (direct) or "vercel" (AI Gateway). | "vercel" only when the key comes from a Vercel env variable |
vercel | AI Gateway settings, e.g. { zeroDataRetention: true }. | None |
fetch | Custom fetch implementation. | globalThis.fetch |
signal | AbortSignal to cancel the request or set a timeout. | None |
headers | Extra request headers as Record<string, string>. | None |
Explicit options take priority over environment values. Environment values are read from globalThis.process?.env when available. The client sets the authorization and JSON headers itself. Requests are not retried.
Tag options
For standalone requests, pass client options after the criteria. ask.chance accepts undefined when you do not need criteria:
const options = { model: "jev-latest" };
await ask.choice`What kind of issue is ${issue}?`(["bug", "other"], options);
await ask.switch`What kind of issue is ${issue}?`(["bug", "other"], options);
await ask.score`How severe is ${issue}?`(["Cosmetic", "Blocks production"], options);
await ask.chance`Does ${issue} need immediate attention?`(undefined, options);
ask.if(options) takes the same options as ask, plus threshold (default 0.5), and returns a tag bound to them. It returns true only when the chance is strictly above the threshold:
const strictIf = ask.if({ threshold: 0.8 });
const security = await strictIf`Does ${issue} describe a security vulnerability?`;
Inside ask, set client options on ask itself; tags use the batch's settings. A threshold set with ask.if({ threshold }) still applies to that question. ask.if and ask.switch are also exported as askIf and askSwitch.
Tags are promise-like: awaiting them, passing them to Promise.all, or returning them from an async function sends a request. Awaiting the same tag again sends another request; it does not reuse an earlier answer.
Callable helpers
When you build questions without template strings, use:
ask.choice(instructions, criteria, options?)ask.switch(instructions, criteria, options?)ask.score(instructions, levels, options?)ask.chance(instructions, criteria?, options?)
These return the same awaitable questions as the tags. Instructions and criteria descriptions accept text, JSON objects or arrays, or null; choice and switch criteria may also be an array of labels:
const { kind } = await ask(issue, {
kind: ask.choice(
{ question: "What kind of issue is this?", focus: "title" },
{
bug: { what: "Something is broken", not_for: "Requests for new features" },
other: null,
},
),
});
Plain question objects
For questions built as data, use type: "noul" for yes/no, "choice" for a choice, or "score" for a score:
const { urgent } = await ask(issue, {
urgent: {
type: "noul",
instructions: "Does this need immediate attention?",
criteria: { true: "Production is blocked.", false: "Work can continue." },
},
});
console.log(urgent.chance); // 0–1
Optional instructions accept text, a JSON object or array, or null. criteria follows the corresponding helper's shape and is required for choices and scores. Answers have the same shape as with the helpers, including { type: "chance", chance } for "noul" questions. The tag shapes work too: { type: "if", instructions, threshold } resolves to a boolean and { type: "switch", instructions, criteria } to the selected label.
Vercel AI Gateway
To use Vercel AI Gateway, set AI_GATEWAY_API_KEY instead of TYPESAFE_API_KEY. On Vercel, VERCEL_OIDC_TOKEN works too. It is read on every ask() call, so token rotation works automatically.
- Requests use
https://ai-gateway.vercel.sh/v4/aiand modeltypesafe-ai/jev. - Automatic gateway selection applies when
TYPESAFE_API_KEYis not set and no explicitapiKeyis passed. - Pass
provider: "vercel"with an explicitapiKeyto force the gateway, orprovider: "typesafe"to opt out. TYPESAFE_BASE_URLandTYPESAFE_DEFAULT_MODELare ignored in gateway mode. UsebaseURLandmodelinstead; a bare model name gets thetypesafe-ai/prefix.
Answers have the same shape. The gateway does not return confidence, so it is computed locally from probabilities: the top probability rescaled so a flat spread is 0. This matches TypeSafe's own figure for choices and short scores; scores with four or more levels can come out a little lower when neighbouring levels share the mass.
License
Published under the MIT license 💛.