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.

Package Version Hex Docs

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
}
QuestionAnswer
NoulNoulAnswer(probability), 0.0 to 1.0 for “yes”
ChoiceChoiceAnswer(choice, probabilities, confidence)
ScoreScoreAnswer(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:

ErrorWhen
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.

Search Document