Getting started
September 17, 2026 ยท View on GitHub
This guide takes you from installation to a first set of answers, and shows where the pieces go in a Mix project. For what System One is and when to use it, read TypeSafe's introduction and How to build with TypeSafe.
Install
Add the dependency and fetch it:
def deps do
[
{:typesafe, "~> 0.1.0"}
]
end
$ mix deps.get
TypeSafe needs Elixir 1.18 or later and Erlang/OTP 27 or later.
Set your API key
Create a key as described in the TypeSafe quick start and export it:
$ export TYPESAFE_API_KEY=...
TypeSafe.new/1 reads TYPESAFE_API_KEY when you do not pass :api_key. You can also set it
in application config, which is useful when the key comes from a secret store:
# config/runtime.exs
config :typesafe, api_key: System.get_env("TYPESAFE_API_KEY")
An explicit option wins over the environment variable, and the environment variable wins over
application config. A client with no key anywhere raises ArgumentError when it is built, not
on the first request.
Ask a first question
Start iex -S mix, build a client and ask one Noul (yes/no) question:
iex> client = TypeSafe.new()
iex> TypeSafe.ask(client, "Limited offer! Buy 3 get 1 free, click now.", %{
...> spam: TypeSafe.noul("Is this unsolicited advertising?")
...> })
{:ok,
%TypeSafe.Response{
model: "jev-1.13.0",
request_id: "req_01a0ac9ee6e476b894913942ccab3d77",
raw: %{...},
answers: %{spam: %TypeSafe.Answer.Noul{noul: 0.82}},
usage: %TypeSafe.Usage{input_tokens: 287, output_tokens: 20}
}}
The request has three parts:
- State is the content to judge: here a string. It can also be a map or a list, which is sent as JSON structure. See State.
- Questions is a map (or keyword list) of questions keyed by ids you choose. The ids are not sent to the model.
- Answers come back under the same ids, with the same key type.
noul is the probability that the answer is yes. What counts as "yes" for your application is
decided in your code, as the Confidence guide explains.
Ask several questions at once
Every question in a request sees the same state and is answered independently, so send all the questions you have about one state together. Mixing types is fine:
questions = %{
is_urgent:
TypeSafe.noul("Does this convey urgency?",
criteria: %{true: "Explicitly time-sensitive", false: "No urgency expressed"}
),
department:
TypeSafe.choice("Which team should handle this?",
billing: "Payments, invoicing, refunds",
technical: "Bugs, outages, integrations",
sales: nil
),
frustration: TypeSafe.score("How frustrated is the customer?", ["Calm", "Frustrated", "Very angry"])
}
{:ok, response} = TypeSafe.ask(client, "Help! My payouts have been failing for 3 days.", questions)
The response for this request:
%TypeSafe.Response{
model: "jev-1.13.0",
request_id: "req_01a0ac97618c76898e35b41bf6d9aa5e",
raw: %{...},
answers: %{
is_urgent: %TypeSafe.Answer.Noul{noul: 0.95},
department: %TypeSafe.Answer.Choice{
choice: :billing,
confidence: 0.81,
probabilities: %{billing: 0.87, technical: 0.13, sales: 0.0}
},
frustration: %TypeSafe.Answer.Score{
score: 1.05,
confidence: 0.93,
legend: %{0 => "Calm", 1 => "Frustrated", 2 => "Very angry"},
probabilities: %{0 => 0.0, 1 => 0.95, 2 => 0.05}
}
},
usage: %TypeSafe.Usage{input_tokens: 414, output_tokens: 73}
}
TypeSafe.noul/2asks a yes/no question.:criteriaoptionally describes what yes (true) and no (false) mean.TypeSafe.choice/3picks one option. Criteria map each option to a description, or tonilwhen the name says enough. A keyword list keeps your option order.TypeSafe.score/3rates the state against an ordered list of at least two levels. The score is probability-weighted, so it can land between levels, as1.05does here.
The Questions guide covers every criteria shape, structured JSON and validation.
Read the answers
Answers are plain structs, so pattern matching and field access both work:
%{department: department, frustration: frustration} = response.answers
department.choice
#=> :billing
TypeSafe.Answer.Choice.ranked(department)
#=> [billing: 0.87, technical: 0.13, sales: 0.0]
TypeSafe.Answer.Choice.margin(department)
#=> 0.74
TypeSafe.Answer.Score.expected_level(frustration)
#=> {1, "Frustrated"}
TypeSafe.Answer.Noul.yes?(response.answers.is_urgent, 0.8)
#=> true
TypeSafe.Response.fetch!/2 raises a KeyError that lists the ids that do have answers, which
catches a typo or an atom and string mix-up:
TypeSafe.Response.fetch!(response, "department")
** (KeyError) no TypeSafe answer for question id "department"; answers exist for [:department, :frustration, :is_urgent]
Handle errors
TypeSafe.ask/4 returns {:error, %TypeSafe.Error{}} for every failure. Match on :type:
case TypeSafe.ask(client, ticket.body, questions) do
{:ok, response} ->
{:ok, route(response.answers)}
{:error, %TypeSafe.Error{type: type} = error} when type in [:rate_limited, :overloaded, :timeout] ->
# Already retried by the client's policy; try again later.
{:retry_later, error}
{:error, %TypeSafe.Error{} = error} ->
Logger.error(Exception.message(error))
{:error, error}
end
Transient failures are retried for you before ask/4 returns. TypeSafe.ask!/4 returns the
response directly and raises the error instead, which suits scripts and one-off tasks. See
TypeSafe.Error for every type, and TypeSafe.Retry for the retry policy.
Keep a client in your application
A client is an immutable value. It holds no process and needs no supervision, and building one does no I/O. A small module that owns your questions and builds the client from config keeps call sites short and lets tests swap the transport through config:
defmodule MyApp.Triage do
@questions %{
is_urgent: TypeSafe.noul("Does this convey urgency?"),
department:
TypeSafe.choice("Which team should handle this?",
billing: "Payments, invoicing, refunds",
technical: "Bugs, outages, integrations",
sales: "Pricing, upgrades, new accounts"
)
}
def classify(text, opts \\ []) do
TypeSafe.ask(client(), text, @questions, opts)
end
defp client, do: TypeSafe.new()
end
Questions are plain structs, so they can live in module attributes. Build the client at
runtime, as client/0 does, rather than in a module attribute: its Req.Request holds
functions that cannot be stored at compile time, and config such as the test transport is only
read when the client is built. When you make many calls in a loop, build the client once and
pass it along.
Choose a model
The client uses "jev-latest" unless you set :model on the client, TYPESAFE_DEFAULT_MODEL,
or config :typesafe, model: .... You can also override it for one call:
TypeSafe.ask(client, text, questions, model: "jev-preview")
TypeSafe.list_models/2 returns what your account can use:
iex> TypeSafe.list_models(client)
{:ok,
[
%TypeSafe.Model{
name: "jev-latest",
description: "The latest iteration of TypeSafe's System One Model: Jev",
release_date: "2026-09-10T18:38:01.391457+00:00"
},
%TypeSafe.Model{
name: "jev-preview",
description: "A preview version of `jev-latest`: should be better in most ways",
release_date: "2026-09-10T18:39:06.057655+00:00"
}
]}
response.model reports the concrete version that answered, such as "jev-1.13.0". Log it
next to decisions you store, so you can tell which model version made them.
Next steps
- Questions: criteria shapes, structured JSON, ids and validation.
- Confidence: turn probabilities into decisions, and route uncertain cases.
- Batching and concurrency: many questions per request, many requests at once.
- Testing: stub TypeSafe in
async: truetests. - Telemetry: logs, metrics and token usage.