zio-typesafe-ai
September 18, 2026 · View on GitHub
A Scala 3 / ZIO library for TypeSafe AI's Jev / System One API — a "System One model" that answers typed, atomic questions about a piece of state instead of generating text.
- Typed end-to-end. No raw JSON, no stringly-typed answer lookups.
- Ask several questions in one round-trip via a
NamedTuple; the typed answer comes back keyed and shaped exactly like the questions you asked. - Illegal states are unrepresentable:
ChoiceCriteria/ScoreCriteriacan't be built out of range (1–255 options, 2–10 levels), and everynoul/confidence/ probability is aProbability— aDoubleprovably within[0.0, 1.0]. - Built on ZIO HTTP's
Client.
Install
libraryDependencies += "com.jamesward" %% "zio-typesafe-ai" % "<version>"
Configure
TypeSafeAI.Client.live reads the environment variables the official
Python/JavaScript SDKs use:
| Var | Required | Default |
|---|---|---|
TYPESAFE_API_KEY | yes | — |
TYPESAFE_DEFAULT_MODEL | no | jev-latest |
import com.jamesward.zio_typesafe_ai.TypeSafeAI
import zio.http.Client
program.provide(Client.default, TypeSafeAI.Client.live)
Or construct the layer explicitly:
TypeSafeAI.Client.layer(TypeSafeAI.ApiKey("…"), TypeSafeAI.ModelId.JevLatest)
Asking questions
Jev has three question primitives — see docs.typesafe.ai/primitives:
Question.Noul— "is this true?" → a calibrated [Probability].Question.Choice— "which of these?" → the selected option plus the full probability distribution.Question.Score— "which level?" → a probability-weighted mean over an ordered scale.
Bundle one or more questions in a NamedTuple passed to TypeSafeAI.ask.
The keys become both the wire question ids and the compile-time field
names of the typed result — ask for isUrgent and department, get back
a value with .isUrgent: Probability and .department: ChoiceAnswer.
import com.jamesward.zio_typesafe_ai.TypeSafeAI
import com.jamesward.zio_typesafe_ai.TypeSafeAI.*
val message =
"Hi, I've been trying to connect my Stripe account for 3 days and it " +
"keeps failing. I'm losing sales. Please help ASAP."
val department: ChoiceCriteria =
ChoiceCriteria(
"billing" -> "Payment or subscription issues",
"technical" -> "Bugs or integration problems",
"sales" -> "Pricing or account questions",
).getOrElse(throw new IllegalArgumentException("bad criteria"))
val frustration: ScoreCriteria =
ScoreCriteria(
"Calm, just stating facts",
"Frustrated but civil",
"Very angry, strong language",
).getOrElse(throw new IllegalArgumentException("bad criteria"))
val program = ask(
message,
(
isUrgent = Question.Noul("Does this message express urgency or time pressure?"),
department = Question.Choice("Which team should handle this?", department),
frustration = Question.Score("How frustrated does the customer sound?", frustration),
),
).answers
// : ZIO[Client, Error, (isUrgent: Probability, department: ChoiceAnswer, frustration: ScoreAnswer)]
program.debug("answers").provide(zio.http.Client.default, TypeSafeAI.Client.live)
ask(state, questions) requires Client in the environment and a
non-empty NamedTuple of Questions; passing anything else is a compile
error. state and every question's instructions are generic in a
Schema type parameter — ask[S: Schema](state: S, ...),
Question.Noul[S: Schema](instructions: S, ...) — so a plain string
works out of the box, and any other Schema-derived record, taxonomy,
or database row works exactly the same way, with S inferred from
whatever you pass. question.instructions reads back as that exact
value, precisely typed — no wrapper, no decode step.
.answers vs .run
.answers returns just the typed NamedTuple. .run wraps it in a
Result, which also carries the responding model and token usage:
ask(message, (isUrgent = Question.Noul("Urgent?"))).run
// : ZIO[Client, Error, Result[(isUrgent: Probability)]]
// Result(answers, model, usage)
Overriding the model
ask(message, tools).model(TypeSafeAI.ModelId("jev-2025-11")).answers
Criteria smart constructors
ChoiceCriteria and ScoreCriteria validate their shape at
construction — Jev accepts 1–255 choice options and 2–10 score levels,
so building one outside that range is a compile-valid but always-Left
call, not a runtime exception surfacing later on the wire:
ChoiceCriteria("yes" -> "...", "no" -> "...") // Either[String, ChoiceCriteria]
ChoiceCriteria.fromContent(structuredOptionsMap) // for object/array descriptions
ScoreCriteria("low", "medium", "high") // Either[String, ScoreCriteria]
ScoreCriteria.fromContent(structuredLevelsList) // for object/array levels
A null Choice option description means "the option name is
self-explanatory" (matches the wire's own null convention).
Jev-driven state-machine loops
TypeSafeAI.loop repeatedly presents a host-generated finite action set to
Jev as one Choice, handles the selected action, and either continues with a
new state or completes. State and action types remain application-defined;
only compact Content views and option descriptions are sent to Jev.
val request = TypeSafeAI.loop[Int, String, Any, Nothing, Int](0)(
state => Content(state),
_ => ZIO.succeed(NonEmptyChunk(
LoopOption.text("increment", "increment", "Increase the state by one."),
LoopOption.text("finish", "finish", "Return the current state."),
)),
) { (state, action) =>
action match
case "increment" => ZIO.succeed(LoopStep.Continue(state + 1))
case "finish" => ZIO.succeed(LoopStep.Done(state))
}
request.maxIterations(10).run
// ZIO[Client, Error, LoopResult[Int]]
LoopResult contains the final output, every LoopTurn (selected choice,
full ChoiceAnswer, usage, and Jev request latency), aggregate Jev usage, and
aggregate Jev request latency. Option ids must be
non-empty and unique. Unknown model choices, invalid options, and non-positive
iteration limits fail with Error.InvalidLoop; exhausting the limit fails
with Error.MaxIterations.
An action handler may run arbitrary ZIO effects—MCP calls, database operations, or a no-tool generative model call—while Jev remains the outer decision loop.
See docs/loop-architecture.md for diagrams of the
loop's components, one iteration's request path, its per-iteration validation
gates and failure exits, and what LoopResult accumulates.
Errors
sealed trait Error extends Throwable
object Error:
// HTTP status codes
final case class BadRequest (message: String) extends Error // 400
final case class Authentication (message: String) extends Error // 401
final case class PermissionDenied (message: String) extends Error // 403
final case class NotFound (message: String) extends Error // 404
final case class UnprocessableEntity(message: String) extends Error // 422
final case class RateLimit (message: String) extends Error // 429
final case class ServiceOverloaded (message: String) extends Error // 529
final case class InternalServer (status: Status, message: String) extends Error // 5xx
final case class Unexpected (status: Status, body: String) extends Error
final case class Transport (cause: Throwable) extends Error
final case class MissingApiKey() extends Error
// Protocol / decode
final case class MalformedAnswer(questionId: QuestionId, message: String) extends Error
final case class MissingAnswer (questionId: QuestionId) extends Error
extension [R, A](zio: ZIO[R, Error, A])
/** Retries RateLimit / ServiceOverloaded / InternalServer up to 2x
* with backoff, per TypeSafe's own rate-limit guidance. */
def retryOnRetryable: ZIO[R, Error, A]
MalformedAnswer / MissingAnswer only fire on an actual protocol
violation (an out-of-range probability, an answer for the wrong question
type, a question Jev never answered) — never on ordinary model output.
File layout
src/main/scala/com/jamesward/zio_typesafe_ai/
TypeSafeAI.scala — top-level object: Client, Content, Question,
ChoiceCriteria, ScoreCriteria, NoulCriteria,
Probability, ChoiceAnswer, ScoreAnswer,
Result, Error, opaque ids, AnswerOf,
AnswersOf, AllQuestions, SystemOneRequest, ask().
internal/
Codecs.scala — the zio-schema <-> zio-json bridge (toJsonAst / fromJsonAst)
Wire.scala — wire-format types (POST /v1/systemone)
Http.scala — buildClient + private HttpClient impl
Helpers.scala — Question -> request JSON, wire Answer -> public Answer
RequestImpl.scala — ask(...).run: build request, send, decode in NamedTuple order
src/test/scala/com/jamesward/zio_typesafe_ai/
TypeSafeAIMock.scala — scripted TypeSafeAI.Client + MockBehavior ADT
SharedSpec.scala — scenarios shared by the mock and live specs
TypeSafeAIMockSpec.scala
TypeSafeAIIntegrationSpec.scala
The internal package is never imported by users. zio.schema.DynamicValue
doesn't appear anywhere in this codebase — state and every question's
instructions stay precisely typed as S all the way through; only
criteria descriptions (several, possibly differently typed, inside one
ChoiceCriteria / ScoreCriteria / NoulCriteria) need an erasure
boundary, and that's Content, wrapping zio.json.ast.Json.