jev_api
A sans-io Gleam client for TypeSafe AI’s System One API and its Jev decision model.
Jev does not generate text. You send it one piece of state and any number of typed questions, and it answers all of them in a single call with calibrated probabilities, a chosen option, or a score on a scale you define. That makes it a good fit for classification, routing, rubric scoring and automated checks inside ordinary code.
gleam add jev_api
Sans-io
This library never touches the network. It builds a
gleam/http/request.Request(String) for you to send with whatever HTTP client
suits your target, and decodes the gleam/http/response.Response(String) you
get back. That keeps it free of target-specific code and trivial to test: the
core depends only on gleam_stdlib, gleam_http and gleam_json.
On Erlang, gleam_httpc sends the
request:
import gleam/httpc
import gleam/json
import jev_api
import jev_api/question
pub fn triage(api_key: String, ticket: String) {
let client = jev_api.new(api_key)
let req =
jev_api.evaluate_request(
client,
state: json.string(ticket),
questions: [
#("urgent", question.noul("Does this message express urgency?")),
#(
"team",
question.choice("Which team should handle this?", [
"billing",
"technical",
"other",
]),
),
#(
"frustration",
question.score("How frustrated is the customer?", [
"Calm",
"Frustrated",
"Very angry",
]),
),
],
)
let assert Ok(res) = httpc.send(req)
jev_api.evaluate_response(res)
}
On JavaScript, send the same request with
gleam_fetch instead. If you are not
using gleam_http at all, the request’s body, headers, method and URL
fields are plain values, and evaluate_response only needs a
response.Response(status:, headers:, body:) built from what your client
returned.
Reading answers
evaluate_response returns an Evaluation: the concrete model that answered,
token usage, TypeSafe’s request id, and one Answer per question under the key
you asked it with.
import gleam/dict
import jev_api
let assert Ok(evaluation) = jev_api.evaluate_response(res)
case jev_api.answer(evaluation, "urgent") {
Ok(jev_api.NoulAnswer(probability:)) if probability >. 0.8 -> escalate()
_ -> queue()
}
case jev_api.answer(evaluation, "team") {
Ok(jev_api.ChoiceAnswer(choice:, confidence:, ..)) if confidence >. 0.7 ->
route_to(choice)
_ -> route_to_human()
}
case jev_api.answer(evaluation, "frustration") {
// `score` can fall between levels: 1.68 is "Frustrated" leaning "Very angry".
Ok(jev_api.ScoreAnswer(score:, levels:, ..)) -> record(score, levels)
_ -> Nil
}
| Question | Answer |
|---|---|
Noul | NoulAnswer(probability), 0.0 to 1.0 for “yes” |
Choice | ChoiceAnswer(choice, probabilities, confidence) |
Score | ScoreAnswer(score, levels, confidence), where each Level has its index, the label you sent, and its probability |
Confidence measures how peaked a distribution is, not how likely the answer is to be correct. TypeSafe’s confidence guide covers choosing thresholds.
Questions
jev_api/question has constructors for the common plain-string case:
question.noul("Is the customer asking for a human agent?")
question.noul_with_criteria(
"Has the customer contacted support about this before?",
when_true: "Mentions a prior attempt, ticket, or that they asked before",
when_false: "No sign of any previous contact",
)
question.choice("Which team should handle this?", ["billing", "technical", "other"])
question.described_choice("Which team should handle this?", [
#("billing", "Charges, invoices, refunds, subscriptions"),
#("technical", "Bugs, outages, integrations"),
#("other", "None of the above"),
])
question.score("How frustrated is the customer?", ["Calm", "Frustrated", "Very angry"])
Instructions and criteria are JSON on the wire, so a structured rubric is just
a gleam/json value in the record:
question.Score(
instructions: json.string("How large is this pull request?"),
levels: [
json.object([
#("summary", json.string("One change, clearly stated")),
#("signals", json.array(["A single fix or feature"], json.string)),
]),
json.object([
#("summary", json.string("Several independent changes bundled together")),
#("signals", json.array(["Two or more unrelated fixes"], json.string)),
]),
],
)
Structured levels come back verbatim in Level.label as a Dynamic.
State
state is any JSON. TypeSafe recommends an object so each part of the context
has a descriptive name, a string for a single piece of text, or an array for a
sequence of messages or records. A request is budgeted at roughly 32,000
tokens. null is rejected with a ValidationError.
json.object([
#("ticket", json.object([
#("subject", json.string("Duplicate charge")),
#("messages", json.array(messages, message_to_json)),
])),
#("refund_policy", json.string("Duplicate charges are eligible for a refund.")),
])
Errors
evaluate_response and models_response classify failures by the API’s own
error envelope first and the HTTP status second:
| Error | When |
|---|---|
AuthenticationError(message) | missing, invalid or unauthorised key (401/403) |
ValidationError(problems) | the request body failed validation (422), one problem per field |
RateLimited(message, retry_after) | 429; retry_after is the retry-after header in seconds |
Overloaded(message) | 529; retry after a delay |
ApiError(status, error_type, message) | any other error_type envelope, e.g. api_usage_error for an unknown model |
UnexpectedResponse(status, body) | a non-success status this library does not recognise |
MalformedResponse(status, body, error) | a success status whose body did not decode |
jev_api.request_id(res) reads TypeSafe’s request id from any response, including
errors, for support requests.
Models and configuration
// Ask a different model. `models_request`/`models_response` list the ones
// your account can use (currently `jev-latest` and `jev-preview`).
let client = jev_api.new(api_key) |> jev_api.with_model("jev-preview")
// Go through a proxy or a mock server. A path prefix is kept.
let assert Ok(client) = jev_api.with_base_url(client, "https://proxy.internal/typesafe")
Not modelled yet: the undocumented bounding_box question type the API
advertises in its validation errors.
Development
The toolchain is pinned in mise.toml (Gleam 1.18.1, Erlang/OTP 29).
gleam test # unit tests against captured API responses
TYPESAFE_API_KEY=... gleam dev # live smoke test: lists models, evaluates a ticket
gleam format src test dev
Releasing
Releases are automated with version_bump,
a Gleam port of semantic-release, installed as a dev dependency and configured
under [tools.version_bump] in gleam.toml. Write
Conventional Commits: fix: cuts a
patch, feat: a minor, and a BREAKING CHANGE: footer or ! a major (a minor
while the package is in 0.x, because initial_development = true). Commits of
other types release nothing.
Preview the next version and release notes locally:
gleam run -m version_bump -- --dry-run
On every push to main, .github/workflows/release.yml runs the tests, then
gleam run -m version_bump, which bumps version in gleam.toml, commits and
tags it, publishes to Hex and creates a GitHub Release. It needs one secret,
HEX_API_KEY (a hex.pm key with publish permission), and the workflow’s
contents: write permission covers the push and the release.