typesafe.zig
Zig client for TypeSafe’s System One API and its Jev model. Ask typed Noul, Choice and Score questions about your application’s state in one request, and get back a struct of answers whose types come from your questions at compile time.
This is an unofficial community client. It is not an official TypeSafe SDK, and it is not affiliated with or endorsed by TypeSafe.
const std = @import("std");
const typesafe = @import("typesafe");
const Team = enum { billing, technical, sales };
const questions = .{
.is_urgent = typesafe.noul("Does this convey urgency?", .{
.yes = "Explicitly time-sensitive",
.no = "No urgency expressed",
}),
.department = typesafe.choice(Team, "Which team should handle this?", .{
.billing = "Payments, invoicing, refunds",
.technical = "Bugs, outages, integrations",
}),
.frustration = typesafe.score("How frustrated is the customer?", .{ "Calm", "Frustrated", "Very angry" }),
};
pub fn main(init: std.process.Init) !void {
var client: typesafe.Client = try .initFromEnv(init.gpa, init.io, init.environ_map, .{});
defer client.deinit();
var result = try client.ask("Help! My payouts have been failing for 3 days.", questions, .{});
defer result.deinit();
const answers = result.answers;
_ = answers.is_urgent.noul; // f64: 0.95
_ = answers.department.choice; // Team: .billing
_ = answers.department.probabilities.technical; // f64: 0.12
_ = answers.department.confidence; // f64: 0.79
_ = answers.frustration.score; // f64: 1.04
_ = answers.frustration.probabilities[2]; // f64: 0.04
}
The client returns judgments as data. Thresholds and policy stay in your code.
Nothing on this side of the wire is stringly typed. A Choice answer’s choice is your enum,
its probabilities are a struct with one f64 per tag, and a Score answer’s probabilities are a
[3]f64. A misspelled question id, an option that is not in the enum, or a Score with one level
is a compile error.
Contents
- Installation
- Questions
- What you get back
- Configuration
- Errors and diagnostics
- Retries and timeouts
- Concurrency
- Questions defined at run time
- Observability
- Testing your code
- Guides and examples
- Development
Installation
Requirements: Zig 0.16.0 or later. The package uses the standard library only (std.http,
std.json, std.Io), with no C dependencies, so it cross-compiles like any Zig code.
zig fetch --save git+https://github.com/mattneel/typesafe.zig#v0.1.0
// build.zig
const typesafe = b.dependency("typesafe", .{ .target = target, .optimize = optimize });
exe.root_module.addImport("typesafe", typesafe.module("typesafe"));
Get an API key from the TypeSafe quick start
and export it as TYPESAFE_API_KEY.
The installation guide takes it from there: wiring the module into libraries and test steps as well as executables, where the key belongs, a program you can run, the build options, and what the errors mean.
Questions
A question is a small, focused judgment about the state you send. Build questions with the three constructors and put them in a struct literal. Its field names are the question ids, and the answers come back under the same names. The ids are not shown to the model, so write the whole question in the instructions.
| Primitive | Build with | Criteria | Answer |
|---|---|---|---|
| Noul | typesafe.noul(instructions, criteria) | .{}, or .yes and .no descriptions | NoulAnswer: probability of yes |
| Choice | typesafe.choice(Enum, instructions, descriptions) | the enum’s tags, each optionally described | ChoiceAnswer(Enum): top option, probabilities, confidence |
| Score | typesafe.score(instructions, levels) | an ordered tuple or array of at least two levels | ScoreAnswer(N): weighted score, probabilities, confidence |
const Tone = enum { calm, frustrated, hostile, other };
const questions = .{
// Noul: yes/no. Criteria are optional.
.wants_refund = typesafe.noul("Does the customer request a refund?", .{}),
// Choice: options are the enum's tags, sent in declaration order. Options left out of the
// descriptions struct are sent without a description.
.tone = typesafe.choice(Tone, "What is the tone of the message?", .{
.hostile = "Insults, threats or strong language",
.other = "None of the above",
}),
// Score: a level's position is its value, so this score runs from 0 to 2.
.effort = typesafe.score("How much agent effort does this ticket need?", .{ "None", "Low", "High" }),
};
Option names that are not Zig identifiers use @"..." tags, such as
enum { @"Home & Kitchen", @"Sporting Goods" }. Option order is part of what the model reads, so
keep an enum’s order stable between calls whose answers you compare.
Structured JSON
State, instructions, descriptions, levels and criteria all accept JSON structure, which is sent
as JSON objects and arrays, never stringified. Anything std.json can write works: struct
literals, tuples, slices, your own structs, std.json.Value, and typesafe.RawJson for
pre-encoded text. See State and
Advanced: structure.
const state = .{
.ticket = .{ .subject = "Duplicate charge", .text = "I was charged twice for order A-104." },
.refund_policy = "Duplicate charges are eligible for a refund.",
};
const Resolution = enum { refund, investigate, reply_only };
const questions = .{
.policy_supports_refund = typesafe.noul("Does `refund_policy` support a refund for `ticket.text`?", .{}),
.resolution = typesafe.choice(Resolution, .{
.task = "Pick the resolution for this ticket",
.constraints = .{"Follow `refund_policy`"},
}, .{
.refund = .{ .action = "Refund the duplicate charge", .requires = .{ "a duplicate charge", "the policy allows it" } },
.investigate = .{ .action = "Send to the payments team" },
}),
.effort = typesafe.score("How much agent effort does this ticket need?", .{
.{ .level = "none", .example = "An automated reply" },
.{ .level = "low", .example = "One action in the admin panel" },
.{ .level = "high", .example = "An investigation across systems" },
}),
};
var result = try client.ask(state, questions, .{});
Values can be known at run time: any field of a struct literal can hold a runtime string or number. Only the shape of the questions (ids, enum options, level count) must be known at compile time. When even that comes from data, use dynamic questions.
The client validates what it encodes before sending, including the contents of a
std.json.Value. A string that is not valid UTF-8, a NaN or infinite float, invalid RawJson,
nesting deeper than 256 levels, a state that is not a string, object or array, a Noul whose
instructions and criteria all turn out empty at run time, or a null Score level fails with
error.InvalidRequest, and the diagnostics name the offending path, such as
state.ticket.text. The one exception is any other type with a jsonStringify method, such as
one of your own: the client writes it with that method, unchecked. See the
Questions guide for how to have such a type checked too.
What you get back
client.ask returns a typesafe.Result(@TypeOf(questions)). Call deinit to free it.
| Field | Type | Meaning |
|---|---|---|
answers | typesafe.Answers(@TypeOf(questions)) | one typed answer per question, under the same field names |
model | []const u8 | the concrete model that answered, such as jev-1.13.0, even when you asked for jev-latest |
usage | typesafe.Usage | input_tokens and output_tokens, each ?u64 |
request_id | ?[]const u8 | the x-typesafe-request-id header; quote it when contacting support |
attempts | u32 | attempts made, including the first |
body | []const u8 | the response body as the server sent it, for fields this version does not know |
| Answer | Fields | Helpers |
|---|---|---|
NoulAnswer | noul: f64 | isYes(threshold) |
ChoiceAnswer(E) | choice: E, probabilities (one f64 field per tag), confidence: f64 | probability(tag), ranked(), margin() |
ScoreAnswer(N) | score: f64, probabilities: [N]f64, confidence: f64, legend: [N]std.json.Value | probability(level), expectedLevel(), maxLevel(), ranked() |
ranked() returns a fixed-size array sorted from most to least likely, margin() is the top
probability minus the second (rounded to 10 decimal places, so 0.3 minus 0.2 is exactly 0.1),
expectedLevel() rounds the score to the nearest level, and
maxLevel() is the most likely level. See the Confidence guide for
how to turn these into decisions.
Noul and Choice answers, and a Score answer’s score, probabilities and confidence, are plain
numbers and enums: copy them out and keep them after deinit. Everything that points into
memory lives in the result’s arena and is freed by deinit: model, request_id, body, and a
Score answer’s legend.
Decoding is strict about what the client relies on and lenient about everything else. Every question must come back with an answer of its type, and probabilities must lie between 0 and 1. Answers you did not ask for and fields this version does not know are ignored, so a newer server never breaks an older client.
Configuration
A client holds its configuration and a keep-alive connection pool. Build one per program and
share it. After its first request a client must not be moved or copied, because open connections
point back at it, so keep it in a var and pass *Client.
// Explicit options
var client: typesafe.Client = try .init(gpa, io, .{
.api_key = key,
.model = "jev-preview",
.timeout = .fromSeconds(20),
.retry = .{ .max_retries = 4, .budget_ms = 60_000 },
.extra_headers = &.{.{ .name = "x-team", .value = "support" }},
});
// Or resolved from the environment: explicit option, then TYPESAFE_* variable, then default.
var client: typesafe.Client = try .initFromEnv(init.gpa, init.io, init.environ_map, .{});
| Option | Env var (initFromEnv) | Default | Description |
|---|---|---|---|
api_key | TYPESAFE_API_KEY | required | Sent as Authorization: Bearer <key>. Missing: error.MissingApiKey at init, never at request time. |
base_url | TYPESAFE_BASE_URL | https://api.typesafe.ai | An http or https URL, optionally with a path prefix. No credentials, query or fragment. The host cannot be an IPv6 literal such as [::1], which std.http.Client 0.16 cannot connect to, or longer than 255 bytes. |
model | TYPESAFE_DEFAULT_MODEL | jev-latest | jev-preview is also available; client.listModels lists them. |
timeout | 10 s | Limit for each attempt: connect, TLS handshake, send and receive. null or Io.Duration.max disables it. At most Client.max_timeout (one year). | |
retry | Retry{} | See Retries and timeouts. | |
max_response_bytes | 16 MiB | A larger body fails with error.ResponseTooLarge. | |
extra_headers | none | Added to every request. Headers the client sets itself are rejected with error.ReservedHeader. | |
hooks | none | See Observability. |
Blank environment values are ignored. init performs no I/O, and every invalid option is an
InitError (InvalidBaseUrl, InvalidApiKey, InvalidHeader, …). gpa must be thread-safe,
as std.http.Client requires; init.gpa in a std.process.Init main is.
Each call takes options that override the client’s for that call: model, timeout, retry,
extra_headers, plus diagnostics and user_data. An unset (null) per-call option uses the
client’s value; to turn the timeout off for one call, pass .timeout = .max. An invalid per-call
option, such as an empty model, a reserved header or a timeout above Client.max_timeout,
fails the call with error.InvalidOption, and the diagnostics path names the option.
var result = try client.ask(state, questions, .{ .model = "jev-preview", .retry = .disabled });
For tuning the client does not wrap, such as connection_pool.free_size, client.http is the
underlying std.http.Client. Do not give it an HTTPS proxy: std.http.Client in Zig 0.16 does
not run TLS inside a proxy tunnel, so the API key would travel in plaintext. While
client.http.https_proxy is set and the base URL is https, every call fails with
error.InvalidOption and the diagnostics path is base_url. Each attempt also refuses to send
over a connection that is not TLS or that goes through a proxy.
The client identifies itself with User-Agent and X-TypeSafe-SDK: typesafe-zig/<version>
and X-TypeSafe-Runtime: zig/<version> (<os>; <arch>), the header format TypeSafe’s official
SDKs use, under its own name. Redirects are never followed, so the API key only goes to the
base URL’s host.
std.http.Client reads the clock and the system’s root certificates once, at its first HTTPS
request, and checks every later certificate against them. A long-running client reloads both
before an HTTPS request once they are an hour old, so a certificate issued after the client
started is accepted and one that has since expired is not. That refresh reads std.http.Client
fields std does not promise to keep; build with -Dtls-trust-refresh=false (or
.tls_trust_refresh = false through b.dependency) to leave it out and stay on std’s supported
surface.
Errors and diagnostics
Every call returns typesafe.Error. Zig errors carry no payload, so pass a Diagnostics to get
the details behind one:
var diagnostics: typesafe.Diagnostics = .init(gpa);
defer diagnostics.deinit();
var result = client.ask(ticket.body, questions, .{ .diagnostics = &diagnostics }) catch |err| switch (err) {
error.RateLimited, error.Overloaded => return scheduleRetry(ticket, diagnostics.retry_after_ms),
// A bug in how the request was built: fail loudly.
error.InvalidRequest, error.BadRequest, error.Unprocessable => {
std.log.err("{f}", .{diagnostics});
return err;
},
else => {
std.log.warn("{f}", .{diagnostics});
return sendToReviewQueue(ticket);
},
};
defer result.deinit();
{f} renders one log-ready line:
BadRequest (HTTP 400): Unknown model: jev-0.0.1 [POST https://api.typesafe.ai/v1/systemone, request_id: req_01a0ad38e5c7716995a9123a240934ac]
| Error | Trigger | Retried by default |
|---|---|---|
InvalidRequest | The request could not be encoded (invalid UTF-8, NaN, invalid or too deep RawJson, an empty Noul, a null Score level, a state that is not a string, object or array) | no |
InvalidOption | A per-call option is invalid (an empty model, a reserved header, a timeout above Client.max_timeout, an HTTPS base URL with a proxy); the diagnostics path names the option | no |
BadRequest | HTTP 400, such as an unknown model | no |
Unauthorized | HTTP 401 | no |
PermissionDenied | HTTP 403 | no |
NotFound | HTTP 404 | no |
RequestTimeout | HTTP 408 | yes |
Unprocessable | HTTP 422; the message names the field | no |
RateLimited | HTTP 429 | yes |
Overloaded | HTTP 529 | yes |
ServerError | Any other 5xx | yes |
UnexpectedStatus | Any other non-2xx, including redirects | no |
ConnectionFailed | DNS, connect, reset, a malformed HTTP response, or a body cut short by a closed connection | yes |
TlsFailure | TLS handshake or certificate loading | yes |
Timeout | An attempt exceeded the timeout | yes |
InvalidResponse | A 2xx body that does not match the API schema, or a body in an unsupported content encoding | no |
ResponseTooLarge | The body exceeded max_response_bytes | no |
Canceled | The calling task was canceled | no |
OutOfMemory | An allocation failed | no |
Diagnostics fields: err, method, url, status, request_id, error_type (such as
authentication_error), message, body, path (such as answers.tone.confidence),
retry_after_ms, attempts, and cause, the underlying error behind a transport failure, such
as error.ConnectionRefused. A call resets them when it starts. On failure they describe the
final attempt. After a success, err, message, body, path, error_type, retry_after_ms
and cause are null, status and request_id describe the successful response, and
attempts counts every attempt.
Retries and timeouts
The default policy matches TypeSafe’s official Python and JavaScript SDKs: 2 retries after the
first attempt, exponential backoff from 500 ms to a 5 s cap with up to 25% jitter, retries on
408, 429 and every 5xx (including 529 Overloaded) plus connection failures, TLS failures and
timeouts,
retry-after-ms and Retry-After honoured (up to 60 s), and a 30 s budget for the whole call.
Retried attempts carry X-TypeSafe-Retry-Count. Retrying a POST is safe because asking
questions has no side effects.
.retry = .{ .max_retries = 4, .budget_ms = 60_000 }, // per client
.retry = .disabled, // per call
To choose which HTTP error statuses are retried, set isRetryableStatus. It replaces the
default list for non-2xx responses; transport errors still follow retry_transport_errors:
fn retryable(status: std.http.Status) bool {
return status != .not_implemented and typesafe.Retry.isRetryableStatusByDefault(status);
}
.retry = .{ .isRetryableStatus = retryable },
For your own escalation logic, such as sending a case that still fails to a slower fallback,
client.retry.isRetryable(err) classifies an error the way the default status list and
retry_transport_errors do, and client.retry.retriesStatus(status) applies the policy’s
isRetryableStatus to a status.
A pooled keep-alive connection that the server has closed is not a failed attempt. The client sends the request again at once on another connection, without a delay and without counting an attempt.
The timeout bounds each attempt from connect to the last byte of the response. It is enforced
by racing the request against a timer with Io.Select, and the losing request is canceled and
its connection closed. That needs an Io that can run tasks concurrently, such as
std.Io.Threaded (the default in std.process.Init). Without concurrency, requests run without
a timeout, and the client logs a warning. Canceling the task that called the client cancels the
request too, and the call returns error.Canceled.
Concurrency
One request carrying many questions is the cheapest and fastest shape: the TypeSafe docs measure a 13-question batch as 12.2× cheaper and 10× faster than separate calls. For the same questions over many states, run the calls concurrently on one client and let the connection pool reuse connections:
fn classify(client: *typesafe.Client, review: []const u8, out: *?typesafe.Answers(@TypeOf(questions))) std.Io.Cancelable!void {
var result = client.ask(review, questions, .{}) catch |err| switch (err) {
error.Canceled => |e| return e,
else => return,
};
defer result.deinit();
out.* = result.answers;
}
var group: std.Io.Group = .init;
defer group.cancel(io);
for (reviews, outputs) |review, *out| try group.concurrent(io, classify, .{ &client, review, out });
try group.await(io);
A Client is safe to share between tasks and threads. See the
Concurrency guide and examples/batch.zig.
Questions defined at run time
When the options or levels are not known at compile time, such as a taxonomy loaded from a
database, build typesafe.dynamic.Question values and call askDynamic. Answers come back keyed
by id and option name strings, and the questions are validated at run time instead.
const dynamic = typesafe.dynamic;
const questions = [_]dynamic.Question{
.noul("is_urgent", "Does this convey urgency?"),
.choice("department", "Which department should handle this?", .{ .names = department_names }),
.score("frustration", "How frustrated is the customer?", .{ .text = &.{ "Calm", "Frustrated", "Very angry" } }),
};
var result = try client.askDynamic(ticket_text, &questions, .{});
defer result.deinit();
const department = result.get("department").?.choice;
std.log.info("{s} ({d:.2})", .{ department.choice, department.probability(department.choice).? });
Instructions, descriptions, criteria, .json levels and extra field values take a
dynamic.Json: .null, .{ .string = ... }, .{ .value = std.json.Value } or
.{ .raw = "{...}" }. An invalid set of questions (none, a duplicate id, a Choice without
options, a Score with fewer than two levels, a level that is not a string, object or array) fails
with error.InvalidRequest before anything is sent, and the diagnostics name the question. See
examples/dynamic.zig.
Observability
Client.Options.hooks takes function pointers called when a call starts, before each retry, and
when it ends. The end event carries the operation, status, request id, attempt count, duration,
token usage and error, so a metrics library or tracer can attach without the client knowing
about it:
fn onRequestEnd(context: ?*anyopaque, event: *const typesafe.hooks.RequestEnd) void {
const metrics: *Metrics = @ptrCast(@alignCast(context.?));
metrics.record(event.operation, event.duration, event.attempts, event.err);
}
var client: typesafe.Client = try .init(gpa, io, .{
.api_key = key,
.hooks = .{ .context = &metrics, .onRequestEnd = onRequestEnd },
});
The client also logs through std.log.scoped(.typesafe). Retries, retries skipped because of the
budget, and schema mismatches are logged at .debug, which Debug builds print by default; in
release builds, raise the .typesafe scope with std_options.log_scope_levels. Requests sent
without the timeout because the Io cannot run a task or timer concurrently, and a failure to
reload the system root certificates, are logged at .warn. See the
Observability guide.
Testing your code
typesafe.testing.MockServer is a loopback HTTP server that plays scripted replies and records
requests, so code that uses the client can be tested offline, with no mocking library:
test "urgent billing tickets page the on-call" {
const gpa = std.testing.allocator;
const io = std.testing.io;
const server: *typesafe.testing.MockServer = try .create(gpa, io);
defer server.destroy();
try server.enqueueAnswers(questions, .{
.is_urgent = .{ .noul = 0.97 },
.department = .{ .choice = .billing, .probabilities = .{ .billing = 0.9, .technical = 0.1, .sales = 0 }, .confidence = 0.85 },
.frustration = .{ .score = 1.2, .probabilities = .{ 0.1, 0.6, 0.3 }, .confidence = 0.7 },
}, .{});
var client: typesafe.Client = try .init(gpa, io, .{ .api_key = "test", .base_url = server.url(), .retry = .disabled });
defer client.deinit();
try std.testing.expectEqual(.page_on_call, try routeTicket(&client, "Payouts failing for 3 days!"));
try std.testing.expectEqualStrings("/v1/systemone", server.request(0).target);
}
enqueueError scripts API errors (with retry_after_ms for rate limits), and enqueue scripts
any reply, including delays that trip timeouts, dropped connections, request bodies left unread,
truncated bodies and connections closed after a reply. Decision logic written
as a pure function of the answers struct needs no server at all. See the
Testing guide.
Guides and examples
- Installation: fetching the package and wiring it into a build
- Questions: the three primitives, structure, ids, validation
- Confidence: probabilities, confidence and thresholds in your code
- Concurrency: many questions per request, many requests at once
- Testing:
MockServerand testing decision logic - Observability: diagnostics, hooks and logging
Examples, each runnable with zig build run -Dexample=<name>:
route_ticket: urgency, team and frustration with thresholds in codestructured: invoice field verification with structured state, instructions and levelsbatch: many states concurrently on one clientdynamic: a taxonomy loaded at run timelist_models: the models available to your account
These pages are published together at https://mattneel.github.io/typesafe.zig/, built by
book/build.sh: the guides and the design notes as a book, with the API reference — generated
from the doc comments — at /api/. To read them locally, zig build docs serves the API
reference from zig-out/docs on its own.
Development
zig build test # offline unit and integration tests
TYPESAFE_API_KEY=... zig build test-live # live tests against api.typesafe.ai (billable)
zig build examples docs fmt # examples, API reference, format check
zig build ci # every offline gate
book/build.sh # the documentation site into zig-out/site
mdbook serve zig-out/book-src # preview the book while editing (run build.sh once first)
The book’s chapters are this README, the guides, the design notes, the changelog and the release
checklist, so there is no second copy of anything to keep in step. A push to master that
touches them publishes the site; book/build.sh is the whole of that build.
Links: TypeSafe documentation · HTTP API reference · Changelog · Elixir client
License
MIT. See the LICENSE file.
Installation
typesafe is a Zig package with an empty dependency table. It needs Zig 0.16.0 or later and
nothing else: no C toolchain, no system libraries, no vendored code. Adding it is three lines —
one command, one build.zig edit, one import.
1. Add the dependency
$ zig fetch --save git+https://github.com/mattneel/typesafe.zig#v0.1.0
zig fetch --save downloads the package, records its content hash in build.zig.zon, and adds
the entry with the key the package’s own manifest asks for:
.dependencies = .{
.typesafe = .{
.url = "git+https://github.com/mattneel/typesafe.zig#v0.1.0",
.hash = "typesafe-0.1.0-...",
},
},
Pin a tag, as above, for a released version. Pin a commit instead to follow master between
releases, using the full forty-character SHA — git will not resolve a short one, and
zig fetch reports ref not found:
$ zig fetch --save git+https://github.com/mattneel/typesafe.zig#3edd6c5d0889921d25017310308347e68930699e
The hash is of the package contents, not of the URL, so a tag that moved would be caught by the build rather than silently accepted.
The first zig fetch puts the package in Zig’s global cache. Later builds reuse it, so zig build
does not need the network again, and nothing is copied into your repository.
2. Wire it into build.zig
A dependency is not visible to your code until a module imports it. For an executable:
const std = @import("std");
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
const typesafe = b.dependency("typesafe", .{ .target = target, .optimize = optimize });
const exe = b.addExecutable(.{
.name = "my-app",
.root_module = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
.imports = &.{.{ .name = "typesafe", .module = typesafe.module("typesafe") }},
}),
});
b.installArtifact(exe);
}
The same three lines attach it anywhere else it is needed — a library module, a test module, an example:
const tests = b.addTest(.{
.root_module = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
.imports = &.{.{ .name = "typesafe", .module = typesafe.module("typesafe") }},
}),
});
const run_tests = b.addRunArtifact(tests);
const test_step = b.step("test", "Run the tests");
test_step.dependOn(&run_tests.step);
typesafe.module("typesafe") is the module the package exposes; the name on the left of the
imports entry is what @import in your code uses, and can be anything.
Pass .target and .optimize through as above unless you have a reason not to: they are what let
the package build for the same target and mode as the rest of your program, which is what keeps
cross-compilation working.
3. Import it
const typesafe = @import("typesafe");
4. Give it a key
Get a key from the TypeSafe quick start, then either export it and let the client find it:
$ export TYPESAFE_API_KEY=ts_...
var client: typesafe.Client = try .initFromEnv(init.gpa, init.io, init.environ_map, .{});
defer client.deinit();
or pass it, and any other setting you want to override, explicitly:
var client: typesafe.Client = try .init(init.gpa, init.io, .{ .api_key = key });
defer client.deinit();
initFromEnv reads TYPESAFE_API_KEY, TYPESAFE_BASE_URL and TYPESAFE_DEFAULT_MODEL; a blank
value counts as unset, and an explicit option beats the environment. Both functions resolve their
configuration at build time of the client, not of the program, so the environment is read when
main runs — a missing key is error.MissingApiKey there, never in the middle of a request.
A key is a secret: read it from the environment or your own secret store rather than committing it, and note that the client sends it only to the configured base URL’s host, because redirects are never followed.
5. Make one call
The whole flow, in a file you can run:
const std = @import("std");
const typesafe = @import("typesafe");
const Team = enum { billing, technical, sales };
pub fn main(init: std.process.Init) !void {
var client: typesafe.Client = try .initFromEnv(init.gpa, init.io, init.environ_map, .{});
defer client.deinit();
const questions = .{
.is_urgent = typesafe.noul("Does this convey urgency?", .{}),
.department = typesafe.choice(Team, "Which team should handle this?", .{
.billing = "Payments, invoicing, refunds",
}),
.frustration = typesafe.score("How frustrated is the customer?", .{ "Calm", "Frustrated", "Very angry" }),
};
var result = try client.ask("Help! My payouts have been failing for 3 days.", questions, .{});
defer result.deinit();
std.debug.print("{s} answered: urgent {d:.2}, {t} at {d:.2}, frustration {d:.2}\n", .{
result.model,
result.answers.is_urgent.noul,
result.answers.department.choice,
result.answers.department.confidence,
result.answers.frustration.score,
});
}
$ zig build run
jev-1.13.0 answered: urgent 0.95, billing at 0.87, frustration 1.04
The model is named exactly because jev-latest resolved to it; the numbers move a little between
calls, which is the reason the client hands you probabilities rather than verdicts.
From here, Questions covers the three primitives and what the compiler checks, Confidence and thresholds how to turn the numbers into a decision, and Testing your code how to exercise it offline.
Build options
The package has one build option. The hourly reload of the system root certificates and the clock
reads std.http.Client fields that std does not promise to keep, so it can be turned off:
const typesafe = b.dependency("typesafe", .{
.target = target,
.optimize = optimize,
.tls_trust_refresh = false,
});
On the command line the same option is -Dtls-trust-refresh=false when building the package’s own
workflows. With it off, the package stays on std’s supported surface at the cost of verifying
certificates against the roots and the clock loaded at the first HTTPS request, which matters for
a client that runs longer than a certificate’s validity window.
Troubleshooting
error: no module named 'typesafe' available within module ...
The import was not added to the module that contains the @import. Every module that uses the
package needs its own imports entry — the executable, the test, the library.
invalid option: -Dsomething
An unknown name was passed to b.dependency. The package declares only tls_trust_refresh; the
target and optimize arguments every dependency takes are handled by the build system itself.
error.MissingApiKey
The environment variable is not visible to the running program. It is read when the client is
initialised, so TYPESAFE_API_KEY=... zig build run works while exporting it in a different shell
does not. Pass .api_key explicitly if you would rather not depend on the environment.
Building for another target fails
It should not: there is nothing platform-specific outside the standard library, and examples are
cross-compiled for Windows, macOS, Linux and RISC-V in CI. Check that .target is the one you
intended — passing target in the dependency(...) arguments is what makes the package follow
your build.
A fetched tag will not resolve
Tags are immutable and content-hashed; a version that does not exist yet cannot be fetched. Use a
commit while a release is in preparation, or fetch without a fragment
(git+https://github.com/mattneel/typesafe.zig) for the default branch.
Requirements, and what is not needed
- Zig 0.16.0 or newer. The package declares
.minimum_zig_version = "0.16.0"; an older toolchain says so rather than failing somewhere inside a build. - Nothing else. No
libcurl, no OpenSSL, no mbedTLS, no C compiler, nopkg-config, no vendored JSON library. TLS, HTTP, JSON and concurrency all come fromstd. - The API reference for every public declaration is generated from the doc comments and published
with this book under
/api/;zig build docsproduces the same thing locally.
Questions
A question is a small, focused judgment about the state you send. TypeSafe has three question types, called primitives. This guide covers how to build each one in Zig, the criteria shapes they accept, structured JSON, extra wire fields, ids, and what is checked at compile time and at run time. For how to write good instructions and pick a type, read TypeSafe’s Primitives page first.
| Primitive | Build with | Criteria | Answer |
|---|---|---|---|
| Noul | typesafe.noul(instructions, criteria) | .{}, or a struct literal with .yes and .no | NoulAnswer: probability of yes |
| Choice | typesafe.choice(Enum, instructions, descriptions) | the enum’s tags, each optionally described | ChoiceAnswer(Enum): top option, probabilities, confidence |
| Score | typesafe.score(instructions, levels) | a tuple or array of at least two levels | ScoreAnswer(N): weighted score, probabilities, confidence |
Questions are ordinary Zig values. Their types carry the shape of each question (the options of a Choice, the number of levels of a Score), and the type of the answers is derived from them, so most mistakes are compile errors.
Noul
A Noul asks a yes/no question. The answer is the probability, from 0 to 1, that the answer is yes.
const wants_refund = typesafe.noul("Does the customer request a refund?", .{});
const is_urgent = typesafe.noul("Does this convey urgency?", .{
.yes = "Explicitly time-sensitive",
.no = "No urgency expressed",
});
The second argument is a struct literal describing what yes and no mean. .yes and .no are
sent as the wire’s true and false keys, and either can be left out. Pass .{} for no
criteria, and the request carries no criteria object at all.
Instructions can be null when at least one criterion says what is being asked, as in
typesafe.noul(null, .{ .yes = .{ .asks_for = "refund" } }). A Noul needs one or the other:
null instructions with no criteria, or only null ones, is a compile error. Values that turn
out empty at run time, such as empty strings or null optionals for the instructions and every
criterion, fail the call with error.InvalidRequest at questions.<id>.
Choice
A Choice picks one option from a set you define as a Zig enum. The answer’s choice is a value
of that enum, and its probabilities is a struct with one f64 field per tag.
const Team = enum { billing, technical, sales, other };
const department = typesafe.choice(Team, "Which team should handle this?", .{
.billing = "Payments, invoicing, refunds",
.technical = "Bugs, outages, integrations",
.sales = "Pricing, upgrades, new accounts",
});
The third argument maps some or all tags to a description. Options left out, such as other
above, are sent with a null description. Pass .{} when the option names say enough.
When the options might not cover every input, add an other option, as the TypeSafe docs
recommend. Otherwise the probability of an input that fits nowhere has to land on an option that
does not fit.
Options are sent as the enum’s tag names. Names that are not Zig identifiers use @"..." tags,
and the answer comes back as the same tag:
const Department = enum { @"Sporting Goods", @"Home & Kitchen", @"Baby & Toddler" };
const product_department = typesafe.choice(Department, "Which top-level department does this product belong to?", .{
.@"Sporting Goods" = .{ .Cycling = .{ "Bike Bottles & Cages", "Bike Lights", "Helmets" } },
.@"Home & Kitchen" = .{ .Drinkware = .{ "Water Bottles", "Travel Mugs", "Tumblers" } },
});
Option order
Options are sent in enum declaration order, whatever order the descriptions struct uses. That
order is part of what the model reads, and it can move probabilities by a few points. With the
same text, the same instructions and the same three options, the order alone changed the answer
from billing: 0.95 (billing listed first) to billing: 0.97 (technical listed first). The
choice was the same in both cases, but a threshold near those values would not have been.
Keep an enum’s order stable between calls whose answers you compare.
Score
A Score rates the state against ordered levels you define. The answer has a probability-weighted score, a probability for every level, and a confidence.
const frustration = typesafe.score("How frustrated is the customer?", .{ "Calm", "Frustrated", "Very angry" });
Levels are a tuple or an array whose length is known at compile time, such as
[_][]const u8{ "None", "Low", "High" }. A level’s position is its value, starting at 0, so the
score above runs from 0 to 2 and can land between levels, such as 1.05. The answer type is
ScoreAnswer(3), and its probabilities is a [3]f64 indexed by level.
When every level is a string, the question stores them as [N][]const u8, so
frustration.levels[answer.maxLevel()] is the text of the most likely level. The answer’s
legend holds each level as the server echoed it back, as a std.json.Value.
Each level is a string or a JSON object or array. Passing a string where the levels belong, as in
typesafe.score("How urgent is this?", "Low"), is a compile error, and so is a boolean or number
level. A level that is null fails the call with error.InvalidRequest and the message
score level 1 must not be null (for the second level) at questions.<id>.criteria.
Structured JSON
State, instructions, Choice descriptions, Score levels and Noul criteria all accept JSON structure. Structure is sent as JSON objects and arrays, never as strings. See State and Advanced: structure in the TypeSafe docs.
| Zig value | Sent as |
|---|---|
string ([]const u8, string literal) | string |
integer, float, bool | number, boolean |
| optional | its payload, or null |
| enum value, enum literal | the tag name as a string |
| struct literal, your own struct | object |
| tuple, array, slice | array |
| tagged union | an object with one key, the active tag |
std.json.Value | as the value describes |
typesafe.RawJson{ .text = ... } | the text verbatim, after validation |
Numbers and booleans can appear anywhere inside structure, but instructions, Choice descriptions,
Noul criteria and Score levels themselves must be a string, an object, an array or null
(levels cannot be null). A Zig bool, integer or float in one of those places is a compile
error.
A structured state keeps related records together under descriptive names. Instructions can point at one part of it with a path in backticks, as the TypeSafe docs describe:
const Resolution = enum { refund, investigate, reply_only };
const questions = .{
.refund_requested = typesafe.noul("Does `ticket.messages[0].text` request a refund?", .{}),
.resolution = typesafe.choice(Resolution, .{
.task = "Pick the resolution for this ticket",
.constraints = .{"Follow `refund_policy`"},
}, .{
.refund = .{ .action = "Refund the duplicate charge", .requires = .{ "a duplicate charge", "the policy allows it" } },
.investigate = .{ .action = "Send to the payments team" },
}),
.effort = typesafe.score("How much agent effort does this ticket need?", .{
.{ .level = "none", .example = "An automated reply" },
.{ .level = "low", .example = "One action in the admin panel" },
.{ .level = "high", .example = "An investigation across systems" },
}),
};
const state = .{
.ticket = .{ .messages = .{.{ .from = "customer", .text = "I was charged twice for order A-104. Please refund one." }} },
.order = .{ .id = "A-104", .charges = .{ .{ .amount = 49.0 }, .{ .amount = 49.0 } } },
.refund_policy = "Duplicate charges are eligible for a refund.",
};
var result = try client.ask(state, questions, .{});
defer result.deinit();
// Structured levels come back as JSON objects.
const effort = result.answers.effort;
const level = effort.legend[effort.maxLevel()].object.get("level").?.string;
Your own structs work the same way, so a state can be .{ .order = order, .history = history }
where order is a struct of yours and history is a std.json.Value or a
typesafe.RawJson{ .text = history_json } holding JSON you already have.
Runtime values
Only the shape of the questions must be known at compile time: the ids, the enum of a Choice, the number of Score levels, and the field names of structured values. The values themselves can come from run time. Build the questions struct inside the function that has them:
fn invoiceNumberIsCorrect(client: *typesafe.Client, invoice: Invoice, extracted: []const u8) !f64 {
const questions = .{
.invoice_number_is_correct = typesafe.noul(.{
.field = .{ .name = "invoice_number", .description = "The identifier printed on the invoice." },
.extracted_value = extracted,
.question = "Does `extracted_value` match the `field` as it appears in `source_text`?",
}, .{}),
};
var result = try client.ask(invoice, questions, .{});
defer result.deinit();
return result.answers.invoice_number_is_correct.noul;
}
When the shape itself comes from data, use dynamic questions.
Question ids
The field names of the questions struct are the question ids, and result.answers has the same
field names. Ids are for your code and are not shown to the model, so write the whole question
in the instructions. Ids that are not Zig identifiers use @"...", such as .@"is-urgent".
typesafe.Answers(@TypeOf(questions)) names the answers type, so decision logic can take it as a
parameter:
const ticket_questions = .{
.is_urgent = typesafe.noul("Does this convey urgency?", .{}),
.department = typesafe.choice(Team, "Which team should handle this?", .{}),
};
const TicketAnswers = typesafe.Answers(@TypeOf(ticket_questions));
// answers.is_urgent is a NoulAnswer, and answers.department a ChoiceAnswer(Team).
fn isUrgentBilling(answers: TicketAnswers) bool {
return answers.is_urgent.isYes(0.8) and answers.department.choice == .billing;
}
Questions are sent in field order. Answers are independent of each other, so the order does not
change the results. A misspelled id, such as result.answers.is_urgnet, is a compile error.
The answers struct holds numbers and enums, so you can copy it out of the result and keep it
after deinit. The exception is a Score answer’s legend, which points into the result’s arena
along with model, request_id and raw.
What is checked at compile time
These mistakes stop the build with a typesafe: compile error:
- The questions argument is not a struct literal with named fields. A tuple such as
.{ typesafe.noul("Is this spam?", .{}) }is rejected. - The questions struct is empty: “at least one question is required”.
- A field is not built with
noul,choiceorscore. - A Choice’s options are not an enum, the enum is non-exhaustive, or it has no tags.
- A Choice’s descriptions are not a struct literal, or name a tag the enum does not have, such
as
.legalforTeam. - A Noul’s criteria are not a struct literal or have a field other than
.yesand.no, or a Noul hasnullinstructions and no criteria, or onlynullones: “a noul question needs instructions or criteria”. - A Score’s levels are not a tuple or array whose length is known at compile time, such as a
slice, or are a single string such as
"Low". - A Score has fewer than two levels: “a score question needs at least two levels, got 1”.
- Instructions, a Choice description, a Noul criterion or a Score level is a
bool, an integer or a float, directly, through an optional such as??bool, or behind a pointer such as*bool: “noul instructions must be a string, a JSON object or array, or null, got bool”.
The encoder also rejects, at compile time, values that have no JSON form: an untagged union, a many-item pointer without a sentinel, or a type such as a function.
The helpers raise @setEvalBranchQuota for you, so question sets of a few dozen questions and
Scores with dozens of levels compile as they are. An unusually large set, such as many questions
over a 64-tag enum, can still reach the comptime branch limit; the compiler then says so and
names @setEvalBranchQuota for you to raise.
What is checked at run time
Before anything is sent, the client encodes the request with a strict encoder. A value that
would produce invalid or misleading JSON fails the call with error.InvalidRequest, no request
is made, and a Diagnostics passed in the call options names the offending path:
- a string or object key that is not valid UTF-8;
- a float that is NaN or infinite;
- a
std.json.Valuenumber_stringthat is not a JSON number; RawJsontext that is not exactly one valid JSON value;- nesting deeper than 256 levels, including inside
RawJsontext; - a selection from a non-exhaustive enum whose value has no name of its own, which would be sent as a number;
- a
jsonStringifymethod on your own type whose error is noterror.WriteFailed(aWriteFailed, the only error such a method can raise, arrives aserror.OutOfMemory); - a state that is not a JSON string, object or array, such as a number or
null; - a Noul whose instructions and criteria are all empty strings or
null, atquestions.<id>; - a Score level that is
null, atquestions.<id>.criteria.
var diagnostics: typesafe.Diagnostics = .init(gpa);
defer diagnostics.deinit();
var result = client.ask(.{ .ticket = .{ .text = text } }, questions, .{ .diagnostics = &diagnostics }) catch |err| {
std.log.err("{f}", .{diagnostics});
return err;
};
defer result.deinit();
InvalidRequest: string is not valid UTF-8 at state.ticket.text [POST https://api.typesafe.ai/v1/systemone]
Paths start at the request body, such as state.ticket.text or
questions.department.instructions.
The checks cover every value the encoder walks, including a std.json.Value and its
std.json.ObjectMap and std.json.Array, and typesafe.RawJson. The one exception is any other
type with a jsonStringify method, such as one of your own: the client hands it to that method
and writes whatever it produces, without these checks. To have such a type checked, give it a
writeTypesafeJson method that writes through the writer it is passed. The client calls it with
its checking encoder, and a jsonStringify that calls it keeps the type working with std.json,
as the question types do:
const Ticket = struct {
subject: []const u8,
body: []const u8,
pub fn writeTypesafeJson(ticket: Ticket, w: anytype) !void {
try w.beginObject();
try w.objectField("subject");
try w.write(ticket.subject);
try w.objectField("body");
try w.write(ticket.body);
try w.endObject();
}
pub fn jsonStringify(ticket: Ticket, jws: *std.json.Stringify) std.json.Stringify.Error!void {
return ticket.writeTypesafeJson(jws);
}
};
The writer offers beginObject, endObject, beginArray, endArray, objectField and write.
A body that is not valid UTF-8 in .{ .ticket = ticket } then fails at state.ticket.body.
Question types also expose writeFields, which writes their members without the enclosing
object.
The API validates too. A request the client accepts but the API rejects, such as an unknown
model, fails with error.BadRequest or error.Unprocessable, and the diagnostics carry the
server’s message.
Decoding and forward compatibility
Decoding is strict about what your code relies on:
- every question you asked has an answer, with the matching
type; - a Choice’s
choiceis one of your enum’s tags, and there is a probability for every tag; - a Score has a probability and a legend entry for every level;
- probabilities and confidence are numbers from 0 to 1, and
scoreis a finite number.
A response that breaks one of these fails with error.InvalidResponse. The diagnostics give the
path and the problem, such as answers.department.choice and
"legal" is not an option of the question.
Everything else is ignored: answers you did not ask for (including answers of a type this
version does not know), and fields it does not know at any level. A missing usage or token
count comes back as null. A newer server never breaks an older client, and result.body keeps
the response as the server sent it, so you can read a new field before the client exposes it. A
key repeated in one object keeps its last value.
Questions defined at run time
The typed API needs the options and level counts when the program is compiled. When they come
from a database or a config file, build typesafe.dynamic.Question values and call
askDynamic:
const dynamic = typesafe.dynamic;
const questions = [_]dynamic.Question{
.noul("is_urgent", "Does this convey urgency?"),
.choice("department", "Which department should handle this?", .{ .names = department_names }),
.score("frustration", "How frustrated is the customer?", .{ .text = &.{ "Calm", "Frustrated", "Very angry" } }),
};
var result = try client.askDynamic(ticket_text, &questions, .{});
defer result.deinit();
const department = result.get("department").?.choice;
std.log.info("{s} ({d:.2})", .{ department.choice, department.confidence });
For equivalent questions, the request is the same JSON the typed API sends. What you give up is
the types: answers are looked up by id with result.get, a Choice’s choice is a []const u8,
probability(name) returns ?f64, and ranked takes an allocator. The checks move to run time
too. These fail with error.InvalidRequest before anything is sent, and the diagnostics name the
question:
- no questions, or an empty or duplicate id;
- a Choice with no options, or an empty or duplicate option name;
- a Score with fewer than two levels, or a level that is not a string, object or array;
- a Noul with no instructions or criteria, where an empty string counts as missing;
- instructions, a description or a criterion that is a boolean or a number;
- an extra field with an empty, duplicate or reserved name (
type,instructions,criteria).
Structured values take a dynamic.Json (.null, .string, .value or .raw). kind()
returns the JSON type a value encodes as (a .raw value is classified by its first character and
validated when encoded), and isEmpty() is true for null and an empty string. The
constructors take text instructions, so for Noul criteria or structured instructions, fill in the
Question fields yourself. extra takes the extra wire fields, each a
dynamic.Question.Field with a name and a Json value:
const questions = [_]dynamic.Question{
.{
.id = "is_spam",
.spec = .{ .noul = .{ .instructions = .{ .string = "Is this spam?" } } },
.extra = &.{.{ .name = "future_field", .value = .{ .raw = "true" } }},
},
};
See examples/dynamic.zig.
Confidence
TypeSafe answers are probabilities, not verdicts. The client hands them to you unchanged, and your code decides what is certain enough to act on. This guide covers how to read probabilities and confidence, where thresholds belong, and how to route uncertain cases. The concepts come from TypeSafe’s Confidence page and the Confidence-gated routing pattern.
Probability and confidence
Each answer type carries a different signal:
- A Noul answer is one probability:
noulis how likely the answer is yes. A value near 1 is a strong yes, near 0 a strong no, and near 0.5 means the model gives yes and no about equal weight.NoulAnswerhas no separate confidence. - A Choice answer has
probabilities, onef64field per enum tag, andchoiceis the most likely tag. - A Score answer has
probabilities, onef64per level, andscoreis the probability-weighted position along the levels.
ChoiceAnswer and ScoreAnswer also carry confidence, a number from 0 to 1 that TypeSafe
derives from the shape of the distribution. A distribution concentrated on one outcome gives
high confidence, and a flat one gives low confidence. TypeSafe does not publish the exact
formula, and this client does not recompute or adjust it. If a different measure suits your
problem better, compute it from probabilities, which are always included.
Confidence is not the top probability. These are real answers to a department question like the one in the README (billing, technical or sales) for three messages:
| Message | Probabilities | confidence | margin() |
|---|---|---|---|
| “Help! My payouts have been failing for 3 days.” | billing 0.87, technical 0.13, sales 0.0 | 0.81 | 0.74 |
| “I want to talk to someone about my invoice and the API limits on our plan.” | billing 0.89, sales 0.1, technical 0.01 | 0.82 | 0.79 |
| “Hello?” | technical 0.67, sales 0.33, billing 0.0 | 0.5 | 0.34 |
“Hello?” has no department, and its top option still has a probability of 0.67. Acting on
choice alone would send it to the technical team. The confidence of 0.5 and the margin of 0.34
show that the model is not sure.
Thresholds belong in your code
The client has no thresholds, so nothing is decided behind your back. Put thresholds next to the actions they gate, and scale them with the cost of a mistake. A wrong read-only action is cheap, while a wrong refund or deletion is not:
const std = @import("std");
const typesafe = @import("typesafe");
pub const Intent = enum { refund, order_status, how_to, other };
pub const questions = .{
.intent = typesafe.choice(Intent, "What does the customer want?", .{
.refund = "Money returned for a charge",
.order_status = "Where an order is or when it arrives",
.how_to = "Help using the product",
}),
.is_urgent = typesafe.noul("Does this convey urgency?", .{}),
};
pub const Answers = typesafe.Answers(@TypeOf(questions));
pub const Priority = enum { high, normal };
pub const Decision = union(enum) {
automate: struct { intent: Intent, priority: Priority },
confirm_with_customer: struct { intent: Intent, priority: Priority },
human_review: struct { priority: Priority, ranked: [std.meta.fields(Intent).len]typesafe.ChoiceAnswer(Intent).Ranked },
};
// Below these, a person decides.
const min_confidence = 0.6;
const min_margin = 0.2;
// Refunds move money, so they need more certainty to run without confirmation.
const refund_confidence = 0.85;
pub fn route(client: *typesafe.Client, text: []const u8) typesafe.Error!Decision {
var result = try client.ask(text, questions, .{});
defer result.deinit();
return decide(result.answers);
}
pub fn decide(answers: Answers) Decision {
const intent = answers.intent;
const priority: Priority = if (answers.is_urgent.isYes(0.8)) .high else .normal;
if (intent.confidence < min_confidence or intent.margin() < min_margin or intent.choice == .other) {
return .{ .human_review = .{ .priority = priority, .ranked = intent.ranked() } };
}
if (intent.choice == .refund and intent.confidence < refund_confidence) {
return .{ .confirm_with_customer = .{ .intent = .refund, .priority = priority } };
}
return .{ .automate = .{ .intent = intent.choice, .priority = priority } };
}
decide is a pure function of the answers struct, so you can test every branch without a
server (see Testing).
Start with conservative thresholds, then adjust them against your own data. The TypeSafe docs are explicit that correct values depend on your domain and on how the model performs for your use case.
Reading Choice answers
ranked() returns every option with its probability, from most to least likely, as a
fixed-size array of Ranked structs with option and probability fields. Ties keep the enum’s
declaration order. margin() returns the top probability minus the second, rounded to 10
decimal places so that floating-point noise does not tip a threshold (0.3 minus 0.2 is exactly
0.1), and probability(tag) reads one option:
for (answer.ranked()) |entry| {
std.debug.print("{t}: {d:.2}\n", .{ entry.option, entry.probability });
}
std.debug.print("margin: {d:.2}\n", .{answer.margin()});
technical: 0.67
sales: 0.33
billing: 0.00
margin: 0.34
The margin tells you whether the model is split between two specific options. That can matter
more than overall confidence. A ticket split between billing and sales can go to either team
with a note, while a ticket split between refund and order_status needs a person. The ranked
array is also a useful payload for a review queue, because it shows the reviewer what the model
considered. It holds only enums and numbers, so it outlives the result.
Reading Noul answers
A Noul has no confidence field. The probability is the whole signal, so use two thresholds and
treat the middle as “not sure”. isYes(threshold) returns true when noul is at least the
threshold:
const Urgency = enum { urgent, unsure, not_urgent };
fn urgency(answer: typesafe.NoulAnswer) Urgency {
if (answer.isYes(0.8)) return .urgent;
if (answer.isYes(0.2)) return .unsure;
return .not_urgent;
}
Asked “Does this convey urgency?”, “Help! My payouts have been failing for 3 days.” scored 0.95
(.urgent) and “Hello?” scored 0.06 (.not_urgent). “Your docs say webhooks retry, but we
were billed for the failed calls.” scored 0.71, which lands in .unsure. That message is a
reasonable one to hand to a person or a slower check. A single threshold at 0.5 would have
labelled it urgent with no sign of doubt.
As the TypeSafe docs warn, a Noul of 0.5 means yes and no are equally likely. It does not mean “somewhat”. If you want a degree, such as how urgent or how skilled, ask a Score with defined levels.
Reading Score answers
score is a weighted average, so it can hide a split. Compare it with the distribution. Consider
an answer like this one for levels Calm, Frustrated and Very angry:
const answer: typesafe.ScoreAnswer(3) = .{
.score = 1.0,
.probabilities = .{ 0.45, 0.1, 0.45 },
.confidence = 0.3,
};
_ = answer.expectedLevel(); // 1: "Frustrated"
_ = answer.maxLevel(); // 0: "Calm"
_ = answer.ranked(); // level 0 (0.45), level 2 (0.45), level 1 (0.1)
The score rounds to “Frustrated”, the least likely level. expectedLevel() rounds the score to
the nearest level, clamped to the valid range. maxLevel() returns the single most likely
level, and ties go to the lower level. When the two disagree, or confidence is low, the levels
are probably ambiguous for this input, or the state does not contain enough to decide.
The helpers return level indexes. For string levels, index the question’s levels, such as
questions.frustration.levels[answer.maxLevel()]. A decoded answer also has legend, each level
as the server echoed it back.
For a clear answer the helpers agree. The frustration answer for “Help! My payouts have been
failing for 3 days.” was score 1.05 with probabilities 0.0, 0.95 and 0.05, and a confidence of
0.93. Both helpers return 1, “Frustrated”.
When you threshold a Score, a threshold on score itself (for example, escalate above 1.5)
works well once confidence is high enough to trust the position.
Routing uncertain cases
Low confidence is useful output. It is the model saying “I don’t know”, and your code can send those cases somewhere better equipped:
- A person. Put the case in a review queue with the ranked probabilities attached.
- A slower model. Send only the uncertain cases to a reasoning model or a larger pipeline. Most cases take the fast, cheap path, and the hard ones get more attention.
- The user. Ask a confirming question, as the refund branch above does.
- More context. Fetch more state, such as order history, and ask again.
With the router above in router.zig, the caller acts on each kind of decision:
const router = @import("router.zig");
fn process(client: *typesafe.Client, ticket: Ticket) void {
const decision = router.route(client, ticket.body) catch |err| {
// A failed call is an uncertain case too.
return review_queue.pushFailed(ticket, err, client.retry.isRetryable(err));
};
switch (decision) {
.automate => |d| support.handle(ticket, d.intent, d.priority),
.confirm_with_customer => |d| support.askToConfirm(ticket, d.intent),
.human_review => |d| review_queue.push(ticket, d.priority, d.ranked),
}
}
Once the client’s retries are spent, sending the ticket to a person is often better than
dropping it. client.retry.isRetryable(err) tells you whether the error is transient, so that
trying again later could help. It classifies HTTP status errors by the default retryable
statuses; a policy with a custom isRetryableStatus decides by status, which
client.retry.retriesStatus(status) applies.
Calibrating with your own data
Thresholds are guesses until you check them. Record enough with each decision to review it later:
- the probabilities and confidence (or
ranked()) for each answer that drove the decision; result.model, the concrete model version such asjev-1.13.0, because answers can shift between versions;result.request_id, for support questions;- what happened next: whether a reviewer agreed, or whether the automated action was reversed.
For example, route from above can log each decision with what drove it:
pub fn route(client: *typesafe.Client, text: []const u8) typesafe.Error!Decision {
var result = try client.ask(text, questions, .{});
defer result.deinit();
const decision = decide(result.answers);
const intent = result.answers.intent;
std.log.info("decision={t} intent={t} confidence={d:.2} margin={d:.2} model={s} request_id={s}", .{
decision,
intent.choice,
intent.confidence,
intent.margin(),
result.model,
result.request_id orelse "-",
});
return decision;
}
result.model and result.request_id live in the result’s arena, so copy them before deinit
if you store them rather than log them.
With a few hundred reviewed cases, you can see how often each confidence band was right and move
the thresholds to match your risk tolerance. The Observability guide shows
how to pass your own ids to hooks through user_data, so decisions and calls can be joined.
Concurrency
There are two ways to do more work with TypeSafe: ask more questions per request, and send more requests at once. Use the first wherever you can, and the second for the many independent states that remain.
Many questions, one request
Every question in a request sees the same state and is answered independently. One question’s answer does not affect another’s, so batching does not change results. What batching changes is cost and latency:
- The state is usually most of the input tokens. One request pays for it once, while N single-question requests pay for it N times.
- The questions in a request are evaluated in parallel, so adding questions barely changes response time.
TypeSafe’s Parallel questions cookbook measured this with 13 questions over a 54,000-character document. One batched call was 12.2x cheaper and 10x faster than 13 single-question calls, with no change in the answers.
In practice, build one questions struct per kind of state and send it whole:
const Team = enum { billing, technical, sales, other };
const questions = .{
.refund_requested = typesafe.noul("Does the customer request a refund?", .{}),
.is_urgent = typesafe.noul("Does this convey urgency?", .{}),
.mentions_competitor = typesafe.noul("Does the customer mention switching to a competitor?", .{}),
.department = typesafe.choice(Team, "Which team should handle this?", .{
.billing = "Payments, invoicing, refunds",
.technical = "Bugs, outages, integrations",
.sales = "Pricing, upgrades, new accounts",
}),
.frustration = typesafe.score("How frustrated is the customer?", .{ "Calm", "Frustrated", "Very angry" }),
};
var result = try client.ask(ticket_body, questions, .{});
defer result.deinit();
Ask questions you might not need, too. A question whose answer only matters for some inputs,
such as mentions_competitor, costs only its own few tokens. Your code reads it when it is
relevant. TypeSafe calls this speculative fan-out.
The limit is the request’s token budget, which the state and questions share. The TypeSafe docs
put it at around 32,000 tokens, roughly 150,000 characters of English text (see
Ask multiple questions together). result.usage reports
the input_tokens and output_tokens of each call, each a ?u64.
Many states, many requests
When the same questions run over many states, such as a backlog of tickets, send one request per
state and run them concurrently on one client with a std.Io.Group. This is the shape of
examples/batch.zig:
const Outcome = union(enum) {
pending,
answered: typesafe.Answers(@TypeOf(questions)),
failed: typesafe.Error,
};
fn classify(client: *typesafe.Client, text: []const u8, outcome: *Outcome) std.Io.Cancelable!void {
var result = client.ask(text, questions, .{}) catch |err| switch (err) {
error.Canceled => |e| return e,
else => {
outcome.* = .{ .failed = err };
return;
},
};
defer result.deinit();
outcome.* = .{ .answered = result.answers };
}
fn classifyAll(client: *typesafe.Client, io: std.Io, texts: []const []const u8, outcomes: []Outcome) !void {
var group: std.Io.Group = .init;
defer group.cancel(io);
for (texts, outcomes) |text, *outcome| {
try group.concurrent(io, classify, .{ client, text, outcome });
}
try group.await(io);
}
Notes on this pattern:
- Share one client. Calls from many tasks or threads can run on the same
Clientat the same time: each call creates its own request, and the connection pool is guarded by a mutex. Build it once and pass*Clientto every task. - Do not move the client. After its first request, open connections point back at the
client, so it must not be moved or copied. Keep it in a
varthat outlives every call, not in a container that can reallocate, and calldeinitonly after every call has returned. - Handle errors per item. A group task must return something that coerces to
std.Io.Cancelable!void, so a task handlestypesafe.Erroritself and returns onlyerror.Canceled. A rate limit or a timeout on one ticket should not lose the rest of the batch. Writing each outcome to its own slot keeps results in input order. - Copy the answers out. The answers struct holds numbers and enums, so it stays valid after
result.deinit(). A Score answer’slegendis the exception: it lives in the result’s arena. - Cancel on early return.
defer group.cancel(io)stops the tasks already started if the loop returns early, for example whengroup.concurrentfails witherror.ConcurrencyUnavailable. Aftergroup.awaitreturns,canceldoes nothing. - Use a thread-safe allocator. The client allocates from whichever task calls it, so the
gpapassed toClient.initmust be thread-safe, asstd.http.Clientrequires.init.gpain astd.process.Initmain is, and so isstd.testing.allocator.
Connection pool
The client’s std.http.Client keeps idle keep-alive connections and reuses them for later calls,
which saves a TCP and TLS handshake per call. It keeps up to 32 idle connections by default
(client.http.connection_pool.free_size). It does not cap open connections: each request in
flight holds its own, and when a connection is released while 32 are already idle, the least
recently used idle one is closed. Bounding your concurrency also bounds connections (see
Limiting concurrency).
Only a request whose response was read to the end returns its connection to the pool. An attempt
that times out or is canceled (even partway through sending its body), fails to write, or
receives a body that is larger than max_response_bytes or cut short closes its connection, so a
later request never lands in the middle of an unfinished one.
A server can close an idle keep-alive connection at any time. When a request fails on a pooled
connection because the server had already closed it, the client sends the request again at once
on another pooled connection or a new one. That does not count as an attempt, waits for no
backoff delay and fires no onRetry hook.
Timeouts need an Io with concurrency
The timeout limits each attempt, from connecting to reading the last byte. The client enforces
it by racing the request against a timer with std.Io.Select, which runs two concurrent tasks
per attempt. std.Io.Threaded, the Io in std.process.Init and std.testing.io, does this with
threads.
When the Io cannot start another concurrent task (a single-threaded build, or an
Io.Threaded at its concurrent_limit), the attempt runs without a timeout, and the client logs
a .warn message on the typesafe scope. Limit concurrency in your own code, as below, rather
than by starving the Io.
A call can override the client’s timeout with .timeout in its options. null, the default,
uses the client’s, and std.Io.Duration.max turns the limit off for that call:
var result = try client.ask(report, questions, .{ .timeout = .fromSeconds(60) });
A timeout that is not positive or is longer than Client.max_timeout (one year) fails the call
with error.InvalidOption.
Cancelation
Canceling the task that called the client cancels the request. The in-flight attempt is
abandoned, its connection is closed, and ask returns error.Canceled. A cancel during a retry
delay returns error.Canceled too. It is never retried.
group.cancel(io) cancels every task in a group. For a single call, run it with
io.concurrent and cancel its future:
fn askUrgent(client: *typesafe.Client, text: []const u8) typesafe.Error!f64 {
var result = try client.ask(text, questions, .{});
defer result.deinit();
return result.answers.is_urgent.noul;
}
var task = try io.concurrent(askUrgent, .{ &client, text });
// ...the caller decides it no longer needs the answer.
if (task.cancel(io)) |urgent| {
std.log.info("finished before the cancel: {d:.2}", .{urgent});
} else |err| switch (err) {
error.Canceled => {},
else => return err,
}
In your own task functions, pass error.Canceled up instead of treating it as a failed item, as
classify above does, so cancelation reaches the group.
Rate limits and retries
A 429 (RateLimited) or 529 (Overloaded) response is retried by the client’s Retry policy:
2 retries by default, with exponential backoff from 500 ms to 5 s and up to 25% jitter. When the
response carries retry-after-ms or Retry-After, the client waits that long instead, and a
requested wait longer than max_retry_after_ms (60 s) falls back to the backoff delay. Before
each retry the client checks the budget: when the elapsed time plus the next delay would reach
budget_ms (30 s), it stops and returns the error.
Under sustained high concurrency, some calls can still run out of retries or budget and fail with
error.RateLimited or error.Overloaded. That is a signal to lower concurrency, not to add more
retries. Diagnostics.retry_after_ms holds the server’s requested wait if you want to schedule
the item for later.
Retries show pressure before errors appear. result.attempts counts attempts including the
first, and the onRetry hook fires before each retry:
const Pressure = struct {
retries: std.atomic.Value(u64) = .init(0),
fn onRetry(context: ?*anyopaque, event: *const typesafe.hooks.RetryEvent) void {
const pressure: *Pressure = @ptrCast(@alignCast(context.?));
_ = pressure.retries.fetchAdd(1, .monotonic);
std.log.warn("retrying after {t} in {f}", .{ event.err, event.delay });
}
};
var pressure: Pressure = .{};
var client: typesafe.Client = try .init(gpa, io, .{
.api_key = key,
.hooks = .{ .context = &pressure, .onRetry = Pressure.onRetry },
});
A rising share of calls with retries means the batch is running too hot. See Observability.
For a background backfill, you can trade latency for fewer failures with a more patient policy:
var client: typesafe.Client = try .init(gpa, io, .{
.api_key = key,
.retry = .{ .max_retries = 5, .backoff_max_ms = 10_000, .budget_ms = 120_000 },
});
Limiting concurrency
Starting one task per item runs every call at once. To cap the number in flight, take a permit
from a std.Io.Semaphore before starting each task, and give it back when the task finishes:
fn classifyLimited(
client: *typesafe.Client,
io: std.Io,
semaphore: *std.Io.Semaphore,
text: []const u8,
outcome: *Outcome,
) std.Io.Cancelable!void {
defer semaphore.post(io);
return classify(client, text, outcome);
}
fn classifyAllLimited(client: *typesafe.Client, io: std.Io, texts: []const []const u8, outcomes: []Outcome, max_in_flight: usize) !void {
var semaphore: std.Io.Semaphore = .{ .permits = max_in_flight };
var group: std.Io.Group = .init;
defer group.cancel(io);
for (texts, outcomes) |text, *outcome| {
try semaphore.wait(io);
group.concurrent(io, classifyLimited, .{ client, io, &semaphore, text, outcome }) catch |err| {
semaphore.post(io);
return err;
};
}
try group.await(io);
}
Because the loop waits for a permit before it starts a task, at most max_in_flight calls run
at once, however long the input is, and so at most that many requests and connections are open.
semaphore.wait returns error.Canceled if the waiting task is canceled.
Pick the limit deliberately. Start low, watch retries and RateLimited errors, and raise it
gradually. Staying at or below 32 lets every connection go back to the pool between calls.
Testing
Code that uses the client can be tested at two levels. Decision logic written as a pure function
of the answers struct needs no server at all. Code that makes the call runs against
typesafe.testing.MockServer, a loopback HTTP server that plays scripted replies and records the
requests it receives. Both run offline under zig build test, with answers you choose and no
mocking library.
Testing decisions without a server
Keep the logic that turns answers into actions in a function that takes
typesafe.Answers(@TypeOf(questions)), and test it with answer structs you build by hand. Such
tests cover every threshold and branch, including rare confidence bands:
const std = @import("std");
const typesafe = @import("typesafe");
pub const Team = enum { billing, technical, sales };
pub const questions = .{
.is_urgent = typesafe.noul("Does this convey urgency?", .{}),
.department = typesafe.choice(Team, "Which team should handle this?", .{
.billing = "Payments, invoicing, refunds",
.technical = "Bugs, outages, integrations",
}),
};
pub const Route = enum { page_on_call, team_queue, triage };
pub fn decide(answers: typesafe.Answers(@TypeOf(questions))) Route {
const department = answers.department;
if (department.confidence < 0.6 or department.margin() < 0.2) return .triage;
if (answers.is_urgent.isYes(0.8)) return .page_on_call;
return .team_queue;
}
pub fn routeTicket(client: *typesafe.Client, text: []const u8) typesafe.Error!Route {
var result = try client.ask(.{ .ticket = text }, questions, .{});
defer result.deinit();
return decide(result.answers);
}
test "a split between two teams goes to triage" {
try std.testing.expectEqual(Route.triage, decide(.{
.is_urgent = .{ .noul = 0.97 },
.department = .{
.choice = .billing,
.probabilities = .{ .billing = 0.52, .technical = 0.48, .sales = 0 },
.confidence = 0.41,
},
}));
}
A ScoreAnswer literal needs score, probabilities and confidence. Its legend defaults to
nulls, so leave it out unless the code under test reads it. The
Confidence guide shows a larger router built the same way.
Setup
A test that makes calls creates a MockServer, points a client at server.url(), and scripts
one reply per request:
test "urgent tickets page the on-call" {
const gpa = std.testing.allocator;
const io = std.testing.io;
const server: *typesafe.testing.MockServer = try .create(gpa, io);
defer server.destroy();
try server.enqueueAnswers(questions, .{
.is_urgent = .{ .noul = 0.97 },
.department = .{
.choice = .billing,
.probabilities = .{ .billing = 0.9, .technical = 0.1, .sales = 0 },
.confidence = 0.85,
},
}, .{});
var client: typesafe.Client = try .init(gpa, io, .{
.api_key = "test",
.base_url = server.url(),
.retry = .disabled,
});
defer client.deinit();
try std.testing.expectEqual(Route.page_on_call, try routeTicket(&client, "Payouts failing for 3 days!"));
}
std.testing.allocatorchecks for leaks, so a test fails if a result is not freed. It is thread-safe, which the client and the server both need.std.testing.iois astd.Io.Threaded. The server runs its accept loop and each connection as concurrent tasks, so it needs anIothat supports concurrency.api_key = "test"is never checked. The key only goes to the loopback server..retry = .disabledmakes an error reply fail the call at once, instead of waiting out backoff delays. It matters for mistakes too: a request with no scripted reply gets a 500, which the default policy would retry twice.deferruns in reverse order, so the client is freed before the server is destroyed.destroystops the server, closes open connections, and frees the recorded requests.
MockServer has no configuration of its own:
| Call | What it does |
|---|---|
create(gpa, io) | starts a server on an ephemeral loopback port |
destroy() | stops the server and frees everything |
url() | the base URL to pass as base_url, such as http://127.0.0.1:41234 |
enqueue(reply) | adds any reply to the end of the script |
enqueueAnswers(questions, answers, options) | adds a successful ask reply |
enqueueError(status, options) | adds an API error reply |
requestCount() | the number of requests received so far |
request(index) | recorded request number index, starting at 0 |
Replies are consumed in order, one per request, whichever call makes it. A retried call consumes one reply per attempt, and a request the client sends again on a new connection (see dropped connections) consumes one more.
Scripting answers
enqueueAnswers encodes answers the way the API sends them. Its answers argument has the type
typesafe.Answers(@TypeOf(questions)), so a renamed, added or retyped question is a compile
error in the test rather than a silent mismatch. Score legends are taken from the questions’
levels.
Set confidence to the value the code path under test needs. There is no placeholder: TypeSafe
does not publish how confidence is derived, and the mock does not guess.
The options set what the rest of the response carries, for code that logs or stores it:
| Option | Default |
|---|---|
model | "jev-test" |
usage | .{ .input_tokens = 100, .output_tokens = 10 } |
request_id | "req_mock", sent as x-typesafe-request-id |
delay | .zero; wait this long before replying |
Answers you reuse across tests can live in a container-level constant. Give it a name other than
answers if a function in the same file has an answers parameter, because Zig rejects the
shadowing:
const billing_ticket: typesafe.Answers(@TypeOf(questions)) = .{
.is_urgent = .{ .noul = 0.9 },
.department = .{
.choice = .billing,
.probabilities = .{ .billing = 0.9, .technical = 0.1, .sales = 0 },
.confidence = 0.85,
},
};
Scripting errors
enqueueError replies with a status and the body shape the API uses for authentication and
usage errors, {"detail": {"error_type": ..., "message": ...}}. Its options are error_type
(default "api_error"), message ("mock error"), request_id ("req_mock") and
retry_after_ms, which sets the retry-after-ms header:
try server.enqueueError(.too_many_requests, .{ .error_type = "rate_limit_error", .retry_after_ms = 1200 });
var diagnostics: typesafe.Diagnostics = .init(gpa);
defer diagnostics.deinit();
try std.testing.expectError(error.RateLimited, client.ask("Hello", questions, .{ .diagnostics = &diagnostics }));
try std.testing.expectEqual(1200, diagnostics.retry_after_ms.?);
try std.testing.expectEqualStrings("rate_limit_error", diagnostics.error_type.?);
For any other reply, enqueue takes a MockServer.Reply:
| Field | Default | Use |
|---|---|---|
status | .ok | any std.http.Status, such as @enumFromInt(422) |
body | "" | the response body, copied |
headers | none | extra response headers, such as retry-after |
content_type | "application/json" | the content-type header |
delay | .zero | wait this long before replying |
drop | false | close the connection without replying |
read_body | true | false leaves the request body unread and closes the connection after delay without replying, like a server that stops reading mid-upload; the recorded body is empty |
truncate_body_to | null | send only this many body bytes, with a content-length for the whole body, then close the connection |
close_after | false | close the connection right after replying, although the reply allows keep-alive, like a server whose idle timeout expired |
try server.enqueue(.{
.status = @enumFromInt(422),
.body =
\\{"detail":[{"loc":["body","questions"],"msg":"Field required"}]}
,
});
try std.testing.expectError(error.Unprocessable, client.ask("Hello", questions, .{ .diagnostics = &diagnostics }));
try std.testing.expectEqualStrings("questions: Field required", diagnostics.message.?);
Scripting retries, timeouts and dropped connections
To test retries, build a client with retries on and no backoff. When a scripted error carries
retry_after_ms, also set .respect_retry_after = false, or the client waits that long:
try server.enqueueError(@enumFromInt(529), .{ .error_type = "overloaded_error" });
try server.enqueueAnswers(questions, billing_ticket, .{});
var client: typesafe.Client = try .init(gpa, io, .{
.api_key = "test",
.base_url = server.url(),
.retry = .{ .backoff_initial_ms = 0 },
});
defer client.deinit();
var result = try client.ask("Hello", questions, .{});
defer result.deinit();
try std.testing.expectEqual(2, result.attempts);
A reply’s delay longer than the client’s timeout fails the attempt with error.Timeout, and
drop fails it with error.ConnectionFailed. Both errors are retryable, so with retries on, the
client moves on to the next scripted reply. With retries off, each call sees its error:
try server.enqueueAnswers(questions, billing_ticket, .{ .delay = .fromSeconds(5) });
try server.enqueue(.{ .drop = true });
var client: typesafe.Client = try .init(gpa, io, .{
.api_key = "test",
.base_url = server.url(),
.timeout = .fromMilliseconds(100),
.retry = .disabled,
});
defer client.deinit();
try std.testing.expectError(error.Timeout, client.ask("Hello", questions, .{}));
try std.testing.expectError(error.ConnectionFailed, client.ask("Hello", questions, .{}));
The timeout needs an Io with concurrency, which std.testing.io has. destroy cancels a reply
that is still waiting out its delay, so a long delay does not slow down the end of the test.
The timed-out attempt closes its connection, so the drop above arrives on a new one. A failure
on a pooled keep-alive connection is different: the client takes it for a connection the server
closed while idle and sends the request again at once on a new connection, without counting an
attempt. So a drop reply that lands on a reused connection does not fail the call. The request
is recorded twice and consumes the next scripted reply too. close_after tests that case
deliberately:
try server.enqueue(.{ .body = "{\"models\":[]}", .close_after = true });
try server.enqueueAnswers(questions, billing_ticket, .{});
var models = try client.listModels(.{});
models.deinit();
// The server closed the pooled connection; the call still succeeds on its first attempt.
var result = try client.ask("Hello", questions, .{});
defer result.deinit();
try std.testing.expectEqual(1, result.attempts);
truncate_body_to cuts a response short, which fails the attempt with a retryable
error.ConnectionFailed whose diagnostics cause is error.ResponseTruncated:
try server.enqueue(.{ .body = "{\"models\":[]}", .truncate_body_to = 5 });
try std.testing.expectError(error.ConnectionFailed, client.listModels(.{ .diagnostics = &diagnostics }));
try std.testing.expectEqual(error.ResponseTruncated, diagnostics.cause.?);
read_body = false with a delay longer than the timeout, and a request body larger than the
socket buffers, tests a call that times out while still sending.
Asserting on requests
The server records every request: method, target (the path), headers and body.
header(name) looks a header up without regard to case. Parse the body to check the state and
questions your code built:
try std.testing.expectEqual(1, server.requestCount());
const request = server.request(0);
try std.testing.expectEqual(std.http.Method.POST, request.method);
try std.testing.expectEqualStrings("/v1/systemone", request.target);
try std.testing.expectEqualStrings("Bearer test", request.header("authorization").?);
const body = try std.json.parseFromSlice(std.json.Value, gpa, request.body, .{});
defer body.deinit();
const state = body.value.object.get("state").?;
try std.testing.expectEqualStrings("The API returns 500 on every request.", state.object.get("ticket").?.string);
try std.testing.expectEqualStrings("jev-latest", body.value.object.get("model").?.string);
Retried attempts carry an x-typesafe-retry-count header, so after one retry
server.request(1).header("x-typesafe-retry-count") is "1". Recorded requests stay valid until
destroy.
GET /v1/models works the same way: enqueue a reply whose body is
{"models": [{"name": ..., "description": ..., "release_date": ...}]} and call
client.listModels(.{}).
Live tests
The library’s own live tests make real, billable requests to api.typesafe.ai. zig build test
never runs them:
TYPESAFE_API_KEY=... zig build test-live
For live tests of your own, keep them in a separate build step, and read the key from the
environment with std.testing.environ:
fn liveClient() !typesafe.Client {
const gpa = std.testing.allocator;
var environ_map = try std.testing.environ.createMap(gpa);
defer environ_map.deinit();
return typesafe.Client.initFromEnv(gpa, std.testing.io, &environ_map, .{});
}
initFromEnv fails with error.MissingApiKey when TYPESAFE_API_KEY is unset or blank. The
environment is read at run time, so set has_side_effects = true on the step’s Run so the build
system never serves a cached result, as this repository’s build.zig does. Assert on shapes and
ranges rather than exact probabilities, which can shift between model versions.
Observability
The client gives you three ways to see what it is doing. Diagnostics holds the details behind
one failed call. Hooks are function pointers called around every call, for metrics, tracing and
logs. And the client logs retries, schema mismatches and warnings through std.log. This guide
covers all three, and the request ids that tie a call to TypeSafe’s side.
Diagnostics
Zig errors carry no payload, so every call returns a bare typesafe.Error. To get the status,
the server’s message and the rest, pass a *Diagnostics in the call options, the same pattern
as std.json.Diagnostics. Calls that pass nothing pay nothing.
var diagnostics: typesafe.Diagnostics = .init(gpa);
defer diagnostics.deinit();
var result = client.ask(ticket.body, questions, .{ .diagnostics = &diagnostics }) catch |err| {
std.log.err("{f}", .{diagnostics});
return err;
};
defer result.deinit();
| Field | Type | Meaning |
|---|---|---|
err | ?typesafe.Error | the error the call returned, or null when it succeeded |
method | ?std.http.Method | the request method |
url | ?[]const u8 | the full request URL |
status | ?std.http.Status | the status of the final response, or null when no response arrived |
request_id | ?[]const u8 | the x-typesafe-request-id header of the final response |
error_type | ?[]const u8 | the server’s error category from detail.error_type, such as authentication_error or api_usage_error |
message | ?[]const u8 | the server’s message for an HTTP error, or what failed for a client-side or decoding error |
body | ?[]const u8 | the body of an error response or an undecodable 2xx response, capped at Diagnostics.max_body_bytes (64 KiB) |
path | ?[]const u8 | for InvalidRequest, InvalidOption and InvalidResponse, the path to the offending value or option, such as state.ticket.text, timeout or answers.tone.confidence |
retry_after_ms | ?u64 | the wait the final response asked for, from retry-after-ms or Retry-After |
attempts | u32 | attempts made, including the first; 0 when the call failed before sending |
cause | ?anyerror | the underlying error behind a transport failure, such as error.ConnectionRefused behind ConnectionFailed, or error.ResponseTruncated when a closed connection cut a body short |
Most cause values come from the Zig standard library, and the set depends on its version, so
use the name in logs rather than switching on it.
When fields are set
- A call resets the diagnostics when it starts.
reset()does the same by hand: it clears every field and frees the strings, keeping up to 64 KiB of the allocated capacity for reuse. - On failure, the fields describe the final attempt, and
attemptscounts every attempt. - After a success,
err,message,body,path,error_type,retry_after_msandcausearenull,statusandrequest_iddescribe the final, successful response, andattemptscounts every attempt, including failed ones that were retried. - Strings belong to the diagnostics. They stay valid until the next call that uses it,
reset, ordeinit. - A call whose own options are invalid fails with
InvalidOption,attempts0, andpathnaming the option:model,timeout,retry,extra_headers, orbase_urlwhenclient.http.https_proxyis set for anhttpsbase URL. - Only the first
Diagnostics.max_body_bytes(64 KiB) of an error body are parsed for the server’s message and error type. For a longer JSON body that usually finds no message, andmessagequotes the start of the body instead.
One Diagnostics can serve many calls in sequence, but calls running at the same time must not
share one. Give each task its own. The allocator passed to init backs its strings and must be
thread-safe when the call runs its request on another thread, which is the case whenever
timeouts are enforced.
Formatting
{f} renders one log-ready line:
BadRequest (HTTP 400): Unknown model: jev-0.0.1 [POST https://api.typesafe.ai/v1/systemone, request_id: req_01a0ad38e5c7716995a9123a240934ac]
InvalidResponse (HTTP 200): missing required field at answers.is_urgent [POST https://api.typesafe.ai/v1/systemone]
Timeout: no complete response within 10s [POST https://api.typesafe.ai/v1/systemone, attempts: 3, cause: Timeout]
ConnectionFailed: ConnectionRefused [POST http://localhost:8080/v1/systemone, attempts: 3]
The line starts with the error name, then the status when a response arrived. The message comes
next, or the name of cause when there is no message, then at and the path when there is
one. The brackets hold the method and URL, the request id, attempts when there was more than
one, and cause when a message is also present. A diagnostics with no error starts with ok.
Hooks
Client.Options.hooks takes a typesafe.Hooks: a context pointer and three optional function
pointers. Zig has no standard telemetry library to emit events into, so a metrics library, a
tracer or a logger attaches here without the client knowing about it:
const Metrics = struct {
calls: std.atomic.Value(u64) = .init(0),
failures: std.atomic.Value(u64) = .init(0),
retries: std.atomic.Value(u64) = .init(0),
input_tokens: std.atomic.Value(u64) = .init(0),
fn onRetry(context: ?*anyopaque, event: *const typesafe.hooks.RetryEvent) void {
const metrics: *Metrics = @ptrCast(@alignCast(context.?));
_ = metrics.retries.fetchAdd(1, .monotonic);
std.log.info("typesafe {t}: attempt {d} failed with {t}, retrying in {f}", .{
event.operation, event.attempt, event.err, event.delay,
});
}
fn onRequestEnd(context: ?*anyopaque, event: *const typesafe.hooks.RequestEnd) void {
const metrics: *Metrics = @ptrCast(@alignCast(context.?));
_ = metrics.calls.fetchAdd(1, .monotonic);
if (event.input_tokens) |tokens| _ = metrics.input_tokens.fetchAdd(tokens, .monotonic);
if (event.err) |err| {
_ = metrics.failures.fetchAdd(1, .monotonic);
std.log.warn("typesafe {t} failed with {t} in {f} ({d} attempts, request_id: {s})", .{
event.operation, err, event.duration, event.attempts, event.request_id orelse "-",
});
} else {
std.log.info("typesafe {t} ok in {f} ({d} attempts, request_id: {s})", .{
event.operation, event.duration, event.attempts, event.request_id orelse "-",
});
}
}
};
var metrics: Metrics = .{};
var client: typesafe.Client = try .init(gpa, io, .{
.api_key = key,
.hooks = .{ .context = &metrics, .onRetry = Metrics.onRetry, .onRequestEnd = Metrics.onRequestEnd },
});
| Hook | Event | Called |
|---|---|---|
onRequestStart | hooks.RequestStart | once per call, before the call’s options are validated and its request body is encoded |
onRetry | hooks.RetryEvent | before sleeping ahead of each retry |
onRequestEnd | hooks.RequestEnd | once per call, when it finishes, successfully or not |
Every call that fires onRequestStart fires onRequestEnd. A call whose own options are invalid
(an empty model, a timeout that is not positive or longer than a year, a reserved header, an
HTTPS proxy) ends with err set to InvalidOption; a request that fails to encode ends with
InvalidRequest. Both have 0 attempts. Only running out of memory before the call starts skips both hooks.
A request sent again because its pooled keep-alive connection had been closed by the server is
not a retry: it fires no onRetry and does not count as an attempt.
Events
Every event has operation (.ask or .list_models), method, url and user_data.
| Event | Other fields |
|---|---|
RequestStart | model (?[]const u8, null for list_models), question_count (0 for list_models) |
RetryEvent | attempt (the attempt that failed, starting at 1), err, status (null for a transport failure), delay (std.Io.Duration) |
RequestEnd | model, question_count, err (null on success), status of the final response, request_id, attempts, duration (std.Io.Duration), input_tokens and output_tokens (?u64) |
model is the model the call asked for, such as jev-latest. The concrete version that
answered is result.model. duration is the wall time from the start of the call, including
retry delays, and {f} formats it, as in 1.204s. Token counts are set only by a successful
ask.
Context and user data
context is set once on the client and passed to every hook call, which suits a metrics
registry or a logger. user_data is set per call, in AskOptions or ListModelsOptions, and
passed through untouched, which suits a trace span or your own record id:
const CallContext = struct {
ticket_id: u64,
};
fn onRequestStart(context: ?*anyopaque, event: *const typesafe.hooks.RequestStart) void {
_ = context;
const call: *const CallContext = @ptrCast(@alignCast(event.user_data orelse return));
std.log.info("ticket {d}: asking {d} questions", .{ call.ticket_id, event.question_count });
}
fn classify(client: *typesafe.Client, ticket: Ticket) !void {
var call: CallContext = .{ .ticket_id = ticket.id };
var result = try client.ask(ticket.body, questions, .{ .user_data = &call });
defer result.deinit();
}
Rules for callbacks
- Callbacks run synchronously on the task that called the client, so their time adds to the call. Keep them short.
- When the client is shared between tasks or threads, callbacks can run on several of them at
the same time, so they must be thread-safe. Use
std.atomic.Value, as above, or a mutex for shared state. - Strings in an event, such as
urlandrequest_id, are valid only during the callback. Copy what you keep. - Whatever
contextanduser_datapoint to must outlive the calls that use them.
Logging
The client logs through std.log.scoped(.typesafe). At .debug:
- each retry, with the failed attempt, the error and the delay;
- a retry skipped because the delay would reach the retry budget;
- a 2xx response that does not match the API schema, with the path and the problem.
At .warn:
- an attempt sent, or waited on, without the timeout because the
Iocannot run a task or timer concurrently; - a failure to reload the system root certificates during the hourly refresh. The client keeps the certificates it already has.
debug(typesafe): POST https://api.typesafe.ai/v1/systemone: attempt 1 failed with RateLimited; retrying in 512 ms
debug(typesafe): POST https://api.typesafe.ai/v1/systemone: response does not match the API schema at answers.is_urgent: missing required field
warning(typesafe): POST https://api.typesafe.ai/v1/systemone: the Io cannot run a task concurrently; sending without the timeout
Log lines never include the API key, the state or the questions.
Whether a message prints depends on the build mode. The default std.options.log_level is
.debug in Debug builds and .info in release builds, so .warn messages print in both and
.debug messages only in Debug builds. Set a level for the typesafe scope in your root source
file to change that for this client alone:
pub const std_options: std.Options = .{
.log_scope_levels = &.{.{ .scope = .typesafe, .level = .debug }},
};
A scope level replaces log_level for that scope, so .level = .debug shows the messages in a
release build, and .level = .info hides them in a Debug build. To send them to your own logger,
set std_options.logFn. Under zig build test, the test runner prints only messages at
std.testing.log_level or more severe, which is .warn by default.
This client does not read TYPESAFE_LOG_LEVEL, the environment variable TypeSafe’s official
Python and JavaScript SDKs use.
Request ids
TypeSafe returns an id with each response in the x-typesafe-request-id header. Quote it, with
the model that answered, when contacting TypeSafe support. The client exposes it in four places:
| Where | When |
|---|---|
result.request_id | after a successful ask or askDynamic |
models.request_id | after a successful listModels |
diagnostics.request_id | after any call, from the final response |
RequestEnd.request_id | in the end hook, success or failure |
Each is null when no response arrived, such as after a connection failure or a timeout, or
when the response carried no id. The result’s copy lives in its arena, so duplicate it before
deinit if you store it:
var result = try client.ask(text, questions, .{});
defer result.deinit();
const request_id = if (result.request_id) |id| try gpa.dupe(u8, id) else null;
typesafe.zig — package spec
Status: implemented in v0.1.0 (unreleased) · Last updated 2026-09-17 · Repo: mattneel/typesafe.zig · Zig 0.16.0
This file started as the design draft for the package. It now describes the package as built:
every claim below is checked against the source, the offline suite, and live traffic against
api.typesafe.ai. Where the built client settled on something other than the draft, this file
states what it does and why.
Summary
typesafe.zig is a Zig 0.16.0 client for TypeSafe’s System One API (the Jev model). Questions
are Zig values: a noul, a choice over an enum you define, a score over a fixed list of
levels. One ask sends them with your state and returns a struct whose fields are typed answers:
the Choice answer is your enum, its probabilities are a struct with one f64 per enum tag, the
Score answer carries a fixed-size probability array. Nothing is stringly typed on this side of
the wire. The package uses the standard library only: std.http.Client, std.json, std.Io.
The core was built and validated on Zig 0.16.0: comptime answer-type generation with @Struct,
byte-for-byte wire encoding against the request fixtures shared with the Elixir client, decoding
through a path-tracking decoder, a loopback std.http.Server stub (typesafe.testing.MockServer)
driven by std.testing.io, and live HTTPS traffic against api.typesafe.ai. Two things only showed
up under test (a TLS flush gotcha and io.async versus io.concurrent); they are called out
where they apply.
Beyond the core, 0.1.0 ships the pieces the workflow needs: questions defined at run time
(typesafe.dynamic), observability callbacks (typesafe.hooks), a public loopback server for
downstream tests (typesafe.testing), and the guides under docs/guides/.
Design principles:
- Code owns the workflow. The client returns judgments as data (probabilities, distributions, confidence); thresholds and policy stay in the caller’s code.
- Types come from the caller. Questions are comptime values and the answer struct is derived from them, so a misspelled question name, a missing level or an unknown option is a compile error rather than a runtime surprise.
- Explicit allocator and
Io, passed in. No globals, no hidden threads, no process state; the client is one struct. - Standard library only. Zig has no package registry, every dependency is a hash the user has to
trust, and
stdalready covers HTTPS, JSON and concurrency. - Small surface, in layers: the five entry points (
Client.init,initFromEnv,deinit,ask,listModels) plus three question constructors cover the API;askDynamic,hooksandtestingare separate opt-in modules that the core does not depend on. Anything the API does not have a use for yet is left out rather than stubbed.
Naming: repo mattneel/typesafe.zig, package name .typesafe in build.zig.zon, module
typesafe (@import("typesafe")). The .zig suffix is a repo convention; the package name drops
it, as zig init recommends. The client identifies itself as typesafe-zig/<version>: it sends
the same header set, in the same format, as TypeSafe’s official SDKs, under its own name, so its
traffic is not attributed to them.
API surface the client covers
One endpoint does the work: POST https://api.typesafe.ai/v1/systemone takes state, model and
a questions map and returns one answer per question id, plus usage
(HTTP API reference). A second, GET /v1/models, returns
{"models": [{"name", "description", "release_date"}]}; it is missing from the HTTP reference but
both of TypeSafe’s own SDKs call it, per the path constants in
typesafe-sdk 0.6.0 and
@typesafe-ai/sdk 0.6.0.
| Field | Request | Response |
|---|---|---|
state | string, object or array; the content to judge | not echoed |
model | string, default jev-latest | string, model that answered |
questions | map of caller-chosen id to Question; ids are not sent to the model | answers: same ids to Answer |
usage | n/a | input_tokens, output_tokens (integers, may be absent) |
The three question types and their answers (primitives):
| Type | instructions | criteria | Answer fields |
|---|---|---|---|
noul | string, object or array | optional {true, false} descriptions | noul float 0 to 1 |
choice | string, object or array | required map of option to description or null | choice (top option), probabilities map summing to 1, confidence 0 to 1 |
score | string, object or array | required ordered array of at least 2 levels | score float (may land between levels), legend map "0".. to level text, probabilities keyed by level string, confidence 0 to 1 |
Wire details the package honours:
- Every
instructionsvalue, Choice option description, Score level and Noultrue/falseentry accepts JSON structure: string, object, array or null (advanced structure). In Zig this is any value the package’s encoder can write — a struct literal, a tuple, a slice, astd.json.Value, atypesafe.RawJson— plusnull. Booleans and bare numbers are rejected, at compile time where the type is known and at run time where it is not. - Auth is
Authorization: Bearer <key>; the response carriesx-typesafe-request-id, which the vendor SDKs surface on every response and error (exceptions). This client exposes it asResult.request_id,Models.request_idandDiagnostics.request_id. - Identification headers, in the format both vendor SDKs use, under this package’s own name:
User-Agent: typesafe-zig/<version>,X-TypeSafe-SDK: typesafe-zig/<version>,X-TypeSafe-Runtime: zig/<version> (<os>; <arch>),X-TypeSafe-Retry-Count: <n>on every retried attempt, andAccept: application/json. A request with a body also sendsContent-Type: application/json. - Defaults (constants): base URL
https://api.typesafe.ai, modeljev-latest, 10 s per HTTP operation, env varsTYPESAFE_API_KEY,TYPESAFE_BASE_URL,TYPESAFE_DEFAULT_MODEL. Explicit options beat env vars, which beat defaults; blank env values are ignored.TYPESAFE_LOG_LEVELis not mirrored: Zig programs setstd.options.log_levelandlog_scope_levelsinstead. - Documented errors: 401 (bad key), 422 (validation, body names the offending field), 429 (rate
limit), 529 (overloaded). The vendor SDKs also map 400, 403, 404 and other 5xx, and read
Retry-Afterandretry-after-ms; this client maps the same set. Error body shape, verified with a live unauthenticated POST:{"detail":{"error_type":"authentication_error","message":"..."}}. FastAPI-style validation bodies ({"detail":[{"loc":...,"msg":...}]}) and{"detail":"Not Found"}are also understood, and the location segments are joined into a readable message. - Retry policy (retries): 2 retries after the
first attempt, backoff 0.5 s doubling to a 5 s cap with 25% jitter, retry on 408, 429 and 500 to
599 plus connection and timeout errors, honour
Retry-After(seconds, fractional seconds, or any of the three RFC 9110 date formats) andretry-after-ms, 30 s total budget per call. This client uses the same numbers so behaviour matches across languages, and refuses a server-requested delay longer than 60 s, falling back to backoff as the JavaScript SDK does. - Client-side validation the vendor SDKs perform before sending: at least one question; Score criteria is a list of at least two entries. Here both are compile errors.
- Transport facts from the probe: api.typesafe.ai negotiates TLS 1.3 and answers HTTP/1.1;
std.http.Clientspeaks HTTP/1.1 and TLS 1.2/1.3, so no extra layer is needed.
Toolchain and dependencies
No third-party packages. The build.zig.zon dependencies table is empty and stays empty; every
capability below is std in Zig 0.16.0 (released 2026-04-13, the current stable as of this
writing per ziglang.org/download).
| Component | What it provides here | Notes |
|---|---|---|
| Zig 0.16.0 | std.Io as an interface, @Struct and the other type-building builtins, Io.net without ws2_32 on Windows | .minimum_zig_version = "0.16.0"; nothing older is supported, the Io API alone rules that out |
std.http.Client | HTTP/1.1 over TLS via std.crypto.tls, keep-alive connection pool (32 free connections by default), proxies | Thread-safe for opening connections; individual Requests are not, one per task. Exposed as client.http for tuning |
std.json | Stringify for the encode side, Value plus a strict path-tracking decoder for the response side, std.json.Value for structured entries | The request body is built by the package’s own validating Encoder, which wraps Stringify |
std.Io | Io.Threaded for real programs, std.testing.io in tests, io.concurrent, Io.Select, Io.Timeout, std.Random.IoSource for retry jitter | Cancelation works through the same interface (error.Canceled) |
std.crypto.Certificate.Bundle | System root certificates, rescanned on the client’s first HTTPS request and hourly thereafter | Verified: the std TLS client completes the handshake with api.typesafe.ai |
| Dev tooling | zig build test, zig build test --test-timeout 60s, zig build fmt, autodoc via Compile.getEmittedDocs | CI uses mlugg/setup-zig@v2 |
Consumption:
zig fetch --save git+https://github.com/mattneel/typesafe.zig#v0.1.0
// build.zig
const typesafe = b.dependency("typesafe", .{ .target = target, .optimize = optimize });
exe.root_module.addImport("typesafe", typesafe.module("typesafe"));
Considered and left out: libcurl or mbedTLS through @cImport (needs a C toolchain, breaks plain
zig build cross-compilation, and buys nothing since the std TLS handshake with the API works),
third-party HTTP or JSON packages (nothing std lacks for this API), a logging or metrics
dependency (a hooks struct plus std.log.scoped(.typesafe) covers it), HTTP/2 (the API accepts
HTTP/1.1; std.http.Client has no HTTP/2 and the API’s JSON bodies are small).
Package layout and public API
typesafe.zig/
├── build.zig module, test step, live-test step, examples step, docs step, fmt, ci
├── build.zig.zon .name = .typesafe, .version, .fingerprint, .minimum_zig_version = "0.16.0", .paths
├── src/
│ ├── typesafe.zig root: re-exports Client, noul/choice/score, answer types, Error, Diagnostics, Retry, hooks, dynamic, testing
│ ├── Client.zig Client struct: configuration, init, initFromEnv, deinit, ask, askDynamic, listModels, Result, Models
│ ├── request.zig one Call per ask or listModels: validation, headers, the retry loop, the timeout race, the TLS refresh
│ ├── question.zig Noul, Choice(Option), Score(levels), Answers(Questions) and the comptime checks
│ ├── answer.zig NoulAnswer, ChoiceAnswer(Option), ScoreAnswer(levels) and their helpers
│ ├── wire.zig encode request, decode response, error body parsing, test-response encoder
│ ├── json.zig the validating Encoder, the streaming path-tracking Reader, RawJson, Failure
│ ├── Retry.zig Retry policy struct, backoff and delay arithmetic, Retry-After parsing, retryable classification
│ ├── errors.zig Error set, InitError set, status mapping
│ ├── Diagnostics.zig Diagnostics struct, init/deinit/reset, `{f}` formatting
│ ├── hooks.zig Hooks and the start, retry and end events
│ ├── dynamic.zig questions built at run time (Question, Options, Levels, Json, Result)
│ ├── dynamic_wire.zig encoding and decoding for dynamic questions
│ ├── testing.zig MockServer: scripted replies and recorded requests
│ ├── client_test.zig client integration tests against MockServer
│ ├── wire_test.zig wire-format tests against the shared fixtures, encoder regressions
│ ├── oom_test.zig allocation-failure and leak tests
│ └── testdata/ request and response fixtures shared with the Elixir client
├── tests/live.zig smoke tests against api.typesafe.ai, skipped without TYPESAFE_API_KEY
├── examples/ route_ticket, structured, batch, dynamic, list_models
├── docs/guides/ questions, confidence, concurrency, testing, observability
├── book/ book.toml, SUMMARY.md, build.sh: the documentation site
├── README.md · CHANGELOG.md · RELEASING.md · LICENSE (MIT)
└── .github/workflows/ci.yml · live.yml · pages.yml
The main call, in the form the README opens with:
const std = @import("std");
const typesafe = @import("typesafe");
const Team = enum { billing, technical, sales };
pub fn main(init: std.process.Init) !void {
var client: typesafe.Client = try .initFromEnv(init.gpa, init.io, init.environ_map, .{});
defer client.deinit();
const questions = .{
.is_urgent = typesafe.noul("Does this convey urgency?", .{
.yes = "Explicitly time-sensitive",
.no = "No urgency expressed",
}),
.department = typesafe.choice(Team, "Which team should handle this?", .{
.billing = "Payments, invoicing, refunds",
.technical = "Bugs, outages, integrations",
}),
.frustration = typesafe.score("How frustrated is the customer?", .{ "Calm", "Frustrated", "Very angry" }),
};
var result = try client.ask("Help! My payouts have been failing for 3 days.", questions, .{});
defer result.deinit();
const a = result.answers;
_ = a.is_urgent.noul; // f64, 0.95
_ = a.is_urgent.isYes(0.5); // bool
_ = a.department.choice; // Team.technical
_ = a.department.probability(.technical); // f64, 0.12
_ = a.frustration.score; // f64, 1.04
_ = a.frustration.probabilities[2]; // f64, 0.04
}
Public declarations:
| Declaration | Signature and rules |
|---|---|
Client.init(gpa, io, Options) InitError!Client | Validates options, builds the std.http.Client, precomputes the identification headers. Options.api_key is required; base_url, model, timeout, retry, max_response_bytes, extra_headers, hooks have defaults |
Client.initFromEnv(gpa, io, environ_map, Options) InitError!Client | Same, reading TYPESAFE_API_KEY, TYPESAFE_BASE_URL and TYPESAFE_DEFAULT_MODEL from a *const std.process.Environ.Map (init.environ_map in a std.process.Init main). Empty values count as unset. A missing key is error.MissingApiKey here, never at request time. The fourth parameter is the explicit options, which win over the environment |
Client.deinit(client) | Closes pooled connections and frees the client. Every call must have returned |
Client.ask(client, state, questions, AskOptions) Error!Result(@TypeOf(questions)) | state is a string or anything the encoder writes as an object or array. questions is an anonymous struct literal; its field names are the question ids, echoed back as the answer field names. Per-call AskOptions: model, timeout, retry, extra_headers, diagnostics: ?*Diagnostics, user_data |
Client.askDynamic(client, state, questions: []const dynamic.Question, AskOptions) Error!dynamic.Result | Questions built at run time; validated before sending, answered by id |
Client.listModels(client, ListModelsOptions) Error!Models | GET /v1/models |
noul(instructions, criteria) Noul | criteria is a struct literal with optional .yes and .no entries, sent as the wire’s true and false; .{} for none. A Noul needs instructions or at least one criterion |
choice(comptime Options: type, instructions, descriptions) Choice(Options) | Options must be an exhaustive enum with at least one tag, checked with @compileError; descriptions is a struct literal mapping some or all tags to descriptions, and tags left out are sent as null |
score(instructions, levels) Score(levels.len) | levels is a tuple or array of at least two entries, known in length at compile time; fewer than two is a @compileError, and the answer type is sized by the level count |
Answers(Questions) | The struct of typed answers, built with @Struct from the questions struct’s field names and each question’s Answer type |
NoulAnswer, ChoiceAnswer(Option), ScoreAnswer(N) | The three answer types, with the helper methods listed under “What you get back” in the README |
typesafe.RawJson | Pre-encoded JSON text, sent verbatim after the encoder validates it (one value, at most 256 levels deep) |
Result(Questions) | answers, model, usage (?u64 token counts), request_id, attempts, body: []const u8 (the response as the server sent it), an owning std.heap.ArenaAllocator, deinit() |
Models | models: []const Model, request_id, attempts, body, an owning arena, find(name), deinit() |
Retry | Policy struct with the vendor defaults, plus isRetryable, retriesStatus, isRetryableStatusByDefault, backoffMs, delayMs, nextDelayMs, parseRetryAfter and disabled; see Client and transport |
Error, InitError, Diagnostics, errorFromStatus | See Errors |
hooks.Hooks | context plus onRequestStart, onRetry, onRequestEnd function pointers |
dynamic | Question, Options, Levels, Json, Result; see README |
testing.MockServer | Loopback server with scripted replies and recorded requests, for testing code that uses the client |
Every public declaration on the module root and on the exported types has a doc comment, so
zig build docs and zig build test cover the same surface.
Questions, answers and the wire
The whole type story is a few comptime functions plus one encoder and one decoder. The shape the implementation settled on:
/// The struct of typed answers with the same field names as the questions struct.
pub fn Answers(comptime Questions: type) type {
const fields = comptime checkQuestions(Questions); // at least one, each a question
var names: [fields.len][]const u8 = undefined;
var types: [fields.len]type = undefined;
for (fields, 0..) |field, i| {
names[i] = field.name;
types[i] = field.type.Answer;
}
return @Struct(.auto, null, &names, &types, &@splat(.{}));
}
ChoiceAnswer(Option).probabilities is std.enums.EnumFieldStruct(Option, f64, null), one field
per tag; ScoreAnswer(N).probabilities is [N]f64 and its legend is [N]std.json.Value, so a
"0"-keyed legend parses with no map allocation. Question values store their entries through
Stored(T), which normalizes string literals to []const u8 so that question types stay small
and nameable, while any other type is kept as it is.
Rules:
- Encoding goes through the package’s
json.Encoder, astd.json.Stringifywrapper that tracks the path to the value it is writing and validates as it goes. Each question type writestype,instructionsandcriteriain the documented shape; Noul omitscriteriawhen it has none; Choice writes one key per enum tag with its description ornull; Score writes the level array. The suite checks the output byte-for-byte against the shared request fixtures. - Decoding is one pass over the response bytes with a pull reader built on
std.json.Scanner(json.Reader), which tracks the path to the value it is reading, so a failure names it. Thetypediscriminator in each answer is checked against the question’s kind, so a mismatched answer is caught rather than silently accepted. Answers the request did not ask for, and fields this version does not know, are skipped, so a newer server never breaks an older client. Strings and numbers point into the response body unless they contain escapes, in which case the arena owns them; the body itself is kept for the caller asResult.body. Keys may arrive in any order, and a key repeated in one object keeps its last value. A body that is not valid JSON, that ends early or that carries data past the document fails witherror.InvalidResponse. The first problem in document order is the one reported. - Score
legendandprobabilitiesarrive as objects keyed"0","1", …; they are read by key, so no map allocation is needed and a missing level is reported atanswers.<id>.probabilities.<n>. - Choice
probabilitiesmust carry an entry for every option: a probability the server omitted iserror.InvalidResponsewithanswers.<id>.probabilities.<tag>as the path. - Every probability is checked to lie between 0 and 1 as it is decoded; a value outside that range
is
error.InvalidResponse, not a silently accepted number. - Keys round-trip by construction: question ids are struct field names and options are enum tags,
so
result.answers.department.choice == .technicaland there is nothing to map back. - Compile-time checks replace the vendor SDKs’ runtime ones: no questions, a questions value that
is not a struct literal, fewer than two Score levels, levels whose length is not compile-time
known, a Choice over a non-enum, a non-exhaustive enum or an empty enum, a description for an
option the enum does not have, a Noul criterion that is not
.yesor.no, and an entry that is a boolean or a number (directly, through an optional such as??bool, or behind a pointer such as*bool) all fail with a named@compileError. The comptime helpers raise@setEvalBranchQuotathemselves, so ordinary question sets need no help from the caller; an unusually large set can still reach the branch limit, and the compiler names the builtin to raise. - Run-time checks cover what is only known then: a string or object key that is not valid UTF-8, a
NaN or infinite float, an invalid
std.json.Valuenumber_string,RawJsonthat is not one valid value or nests deeper than 256 levels, a selection from a non-exhaustive enum whose value has no name, anullScore level, a Noul whose instructions and criteria are all empty, and a state that is not a string, object or array. Each fails witherror.InvalidRequestbefore anything is sent, with the path inDiagnostics.path. - Memory:
ResultandModelsown an arena. The response body, every string in the answers, the legends and the request id live in it;deinit()frees everything at once. The request body is freed before the response is read.
Client and transport
Client wraps one std.http.Client plus the resolved options and the precomputed header values.
It is safe to share between tasks and threads because std.http.Client guards its connection pool
with an Io.Mutex; each call creates its own Request, which is what std.http requires. After
its first request a client must not be moved or copied: open connections point back at it.
Request path, as validated against the live API:
var request = client.http.request(call.method, uri, .{
.redirect_behavior = .unhandled, // the API never redirects; keeps the key off other hosts
.headers = .{
.authorization = .{ .override = client.authorization }, // "Bearer <key>"
.user_agent = .{ .override = sdk_identifier }, // "typesafe-zig/<version>"
.content_type = if (body != null) .{ .override = "application/json" } else .omit,
},
.extra_headers = headers, // accept, x-typesafe-sdk, x-typesafe-runtime, the caller's
.connection = pooled, // an idle pooled connection for this host, if any
});
defer request.deinit();
if (body) |bytes| {
try request.sendBodyComplete(bytes); // sets content-length and flushes BOTH the TLS layer and the socket
} else {
try request.sendBodiless();
}
var response = try request.receiveHead(&.{});
const bytes = try response.reader(&transfer_buffer).allocRemaining(gpa, .limited(client.max_response_bytes +| 1));
The flush gotcha: on a TLS connection BodyWriter.end() flushes only the TLS writer, not the
underlying socket writer. A request sent with sendBody + writeAll + end() hangs forever in
receiveHead against api.typesafe.ai; sendBodyComplete flushes both layers, and any streaming
path must call req.connection.?.flush() after end().
version comes from build.zig.zon (const manifest = @import("build.zig.zon") in build.zig,
passed in through b.addOptions()), so the user agent and x-typesafe-sdk can never disagree with
the tag. runtime is std.fmt.comptimePrint("zig/{s} ({s}; {s})", .{ builtin.zig_version_string, @tagName(builtin.os.tag), @tagName(builtin.cpu.arch) }).
Other transport behaviour the suite and live traffic cover:
- A response body is capped at
max_response_byteswhile it is read (one byte over the limit distinguishes a body of exactly the limit from a larger one), and the connection is closed instead of drained when the limit is hit. - A body cut short by a closed connection is a retryable
error.ConnectionFailed, not a silently truncated answer; only a response read to the end returns its connection to the pool. - A pooled keep-alive connection the server has since closed is replaced at once, without a delay and without counting an attempt.
gzipanddeflateresponse bodies are decoded; any other content encoding, and a compressed body that does not decompress, iserror.InvalidResponse.- Certificate verification uses the system roots.
std.http.Clientreads the clock and the roots once, at its first HTTPS request; a long-running client reloads both hourly, so a certificate issued after the client started is accepted and one that has expired is not. That refresh readsstd.http.Clientfields std does not promise to keep, so it sits behind a build option,-Dtls-trust-refresh(.tls_trust_refresh = falsethroughb.dependency), which is on by default. Built with it off, the package touches only std’s supported surface, and a client running for longer than a certificate’s validity window verifies against the roots and the clock loaded at its first HTTPS request. std.http.Clientin Zig 0.16 does not run TLS inside a proxy tunnel, so an HTTPS base URL withhttps_proxyset is refused witherror.InvalidRequestrather than sending the key in plaintext. Each attempt also refuses a connection that is not TLS or that is proxied.
Timeouts: Options.timeout: ?Io.Duration = .fromSeconds(10), at most Client.max_timeout (one
year), with null or Io.Duration.max disabling it. The attempt runs under an Io.Select racing
the request task (spawned with io.concurrent) against timeout.sleep(io); whichever finishes
first wins and the other is canceled, which is what the 0.16 Io cancelation model is for. When
the Io cannot provide concurrency (-fsingle-threaded, or an implementation that returns
error.ConcurrencyUnavailable), the client logs a warning and sends without the timeout. There is
no per-read timeout in std.http.Client itself.
Retry, a Retry struct with the vendor defaults: max_retries: u32 = 2,
backoff_initial_ms: u32 = 500, backoff_max_ms: u32 = 5000, jitter: f64 = 0.25,
respect_retry_after: bool = true, max_retry_after_ms: u64 = 60_000,
retry_transport_errors: bool = true, budget_ms: ?u64 = 30_000, and
isRetryableStatus: ?*const fn (std.http.Status) bool to replace the default status list. ask
loops: a retryable status (408, 429, 500–599, so 529 Overloaded is included) or a transport error
(error.ConnectionRefused, error.ConnectionResetByPeer, error.TlsFailure, error.Timeout,
…) sleeps for retry-after-ms or Retry-After when present, else min(initial × 2ⁿ, max) with
±25% jitter from a std.Random.IoSource, then re-sends with x-typesafe-retry-count: n. The loop
stops when the next delay would cross budget_ms, measured on Io.Clock.awake. Retry.disabled
(or .max_retries = 0) turns it off. Retrying the POST is safe because evaluation has no side
effects. A retryable status whose error page is larger than max_response_bytes is still retried:
the status, not the unreadable body, decides.
Configuration (Client.Options), resolved as explicit option, then env var (only in
initFromEnv), then default:
| Option | Env var | Default |
|---|---|---|
api_key | TYPESAFE_API_KEY | none; error.MissingApiKey |
base_url | TYPESAFE_BASE_URL | https://api.typesafe.ai |
model | TYPESAFE_DEFAULT_MODEL | jev-latest |
timeout | none | 10 s |
retry | none | Retry{} |
max_response_bytes | none | 16 MiB; exceeding it is error.ResponseTooLarge |
extra_headers | none | none; authorization, accept, content-type, connection, host, user-agent, x-typesafe-* and friends are reserved and rejected with error.ReservedHeader |
hooks | none | .{} |
The base URL must be an absolute http or https URL with a host, without credentials, query or
fragment; an IPv6 literal host, which std.http.Client 0.16 cannot connect to, and a host longer
than 255 bytes are rejected at init. Per-call options (AskOptions, ListModelsOptions)
override the client’s for that call; an invalid one fails the call with error.InvalidRequest and
names the option in Diagnostics.path.
The client logs through std.log.scoped(.typesafe): retries, retries skipped because of the
budget, and schema mismatches at .debug; a request sent without a timeout because the Io
cannot run a task concurrently, and a failed root-certificate reload, at .warn.
Observability: there is no telemetry library in Zig, so Options.hooks: Hooks holds a context
pointer and three optional function pointers, onRequestStart(context, *const RequestStart),
onRetry(context, *const RetryEvent) and onRequestEnd(context, *const RequestEnd). The end event
carries the operation, status, request id, attempt count, duration, token usage, the error, and
user_data passed through from the call. A metrics library or a test can attach without the
client knowing about it. Every call that fires the start event fires the end event.
Concurrency guidance for the README: prefer one ask carrying many questions (the docs measure
batching a 13-question job as 12.2× cheaper and 10× faster than separate calls). For the same
questions over many states, spawn one ask per state into an Io.Group and share the Client;
the pool reuses connections and the group cancels everything on the first try failure.
Errors
Zig errors carry no payload, so the design is a small error set plus an optional out-parameter,
the same shape std.json.Diagnostics uses.
pub const Error = error{
InvalidRequest, InvalidOption, BadRequest, Unauthorized, PermissionDenied, NotFound,
RequestTimeout, Unprocessable, RateLimited, Overloaded, ServerError, UnexpectedStatus,
ConnectionFailed, TlsFailure, Timeout, InvalidResponse, ResponseTooLarge,
Canceled, OutOfMemory,
};
/// Configuration mistakes reported by Client.init and Client.initFromEnv.
pub const InitError = error{
MissingApiKey, InvalidApiKey, InvalidBaseUrl, InvalidModel, InvalidTimeout,
InvalidRetry, InvalidMaxResponseBytes, InvalidHeader, ReservedHeader, OutOfMemory,
};
pub const Diagnostics = struct {
arena: std.heap.ArenaAllocator, // owns the strings below; the caller calls deinit()
err: ?Error = null, // the error the call returned, null on success
method: ?std.http.Method = null,
url: ?[]const u8 = null,
status: ?std.http.Status = null, // the final response's status
request_id: ?[]const u8 = null, // x-typesafe-request-id
error_type: ?[]const u8 = null, // body.detail.error_type, e.g. "authentication_error"
message: ?[]const u8 = null, // body.detail.message, or what failed locally
body: ?[]const u8 = null, // raw body, capped at max_body_bytes (64 KiB)
path: ?[]const u8 = null, // InvalidRequest / InvalidOption / InvalidResponse: "answers.tone.confidence"
retry_after_ms: ?u64 = null,
attempts: u32 = 0, // including the first
cause: ?anyerror = null, // the underlying transport or body error
};
| Error | Trigger | Retried by default |
|---|---|---|
InvalidRequest | The request could not be encoded: an unencodable value, an empty Noul, a null Score level, an invalid dynamic question, a state that is not a string, object or array | no |
InvalidOption | A per-call option is invalid: an empty model, a timeout above Client.max_timeout, a reserved extra header, an HTTPS base URL with a proxy configured. Diagnostics.path names the option | no |
BadRequest | HTTP 400, such as an unknown model | no |
Unauthorized | HTTP 401, a missing or invalid API key | no |
PermissionDenied | HTTP 403 | no |
NotFound | HTTP 404 | no |
RequestTimeout | HTTP 408 | yes |
Unprocessable | HTTP 422; Diagnostics.message carries the server’s field detail | no |
RateLimited | HTTP 429 | yes |
Overloaded | HTTP 529 | yes |
ServerError | Any other 5xx | yes |
UnexpectedStatus | Any other non-2xx, including redirects, which are never followed | no |
ConnectionFailed | DNS, connect, reset, a malformed HTTP response, or a body cut short by a closed connection | yes |
TlsFailure | TLS handshake failure or unreadable system certificates | yes |
Timeout | An attempt did not complete within the configured timeout | yes |
InvalidResponse | A 2xx whose body did not parse into Response(Q), or an unsupported content encoding | no |
ResponseTooLarge | The body exceeded max_response_bytes | no, unless the status is retryable |
Canceled | The Io canceled the calling task | no |
OutOfMemory | An allocation failed | no |
Behaviour:
askreturns the error; whenAskOptions.diagnosticsis set, it is filled before returning, including after retries (the final attempt’s status,attemptsin total). Callers that only want the error pass nothing and pay nothing. A call resets the diagnostics when it starts; after a success it holds the status, request id and attempt count of the call that succeeded.- The error body is parsed as
{"detail":{"error_type","message"}}, the shape observed live; a body that does not fit that shape is kept raw inDiagnostics.bodyand quoted inmessage. std.http.Clienterror sets are wide; the client maps them to the four transport errors above and keeps the original name inDiagnostics.cause, so the public error set stays small and stable across Zig versions. The retry log at.debugnames the mapped error.Retry.isRetryable(err)is public for callers who implement their own escalation, such as sending an uncertain or failed case to a reasoning model.
Testing
Everything runs with zig build test, offline, against a loopback std.http.Server. The pattern
that works on 0.16.0 (validated, and wrapped by typesafe.testing.MockServer for downstream
users):
const io = std.testing.io;
const addr: Io.net.IpAddress = .{ .ip4 = .loopback(0) };
var server = try addr.listen(io, .{ .reuse_address = true });
defer server.deinit(io);
const port = server.socket.address.getPort();
var serve = try io.concurrent(StubServer.serveOnce, .{ io, &server, .ok, stub_body }); // NOT io.async
defer _ = serve.cancel(io) catch {};
// ... run the client against http://127.0.0.1:<port> ...
try serve.await(io);
Two details that cost time: io.async may run the callee inline before returning, which deadlocks
on accept, so the server task must use io.concurrent; and the stub must drain the request body
(request.readerExpectNone(&buf).discardRemaining()) before respond, or the client’s keep-alive
bookkeeping is wrong.
| Layer | What is covered | How |
|---|---|---|
| Wire encoding | Each constructor’s JSON equals the shared request fixture byte-for-byte; every entry kind (string, object, tuple, std.json.Value, RawJson) encodes as documented | std.testing.expectEqualStrings against src/testdata/requests/* |
| Answer decoding | Every documented response decodes into typed structs; "0"-keyed maps land in the fixed-size arrays; unknown fields ignored; a missing probability, an out-of-range probability and a mismatched type are InvalidResponse with a field path | Fixtures in src/testdata/responses/, plus inline bodies |
| Encoder and decoder | Invalid UTF-8, non-finite floats, deep nesting (including inside RawJson), an unnamed non-exhaustive enum, a key written outside an object, truncated failure messages, a body that is not JSON, one that ends early, one with trailing data, missing fields and mismatched answer types with their paths | src/wire_test.zig, src/json.zig tests |
| Comptime checks | Fewer than two levels, empty enum, non-exhaustive enum, no questions, boolean or number entries through optionals and pointers | Documented in doc comments; Zig has no negative-compilation test in std.testing, so these are checked by hand at review |
| Client | Auth and identification headers, content-length, no redirect following, x-typesafe-retry-count only on retries, per-call overrides, base-URL and option validation | MockServer records request heads and bodies for assertions |
| Retries | 429 then 200 succeeds and honours retry-after-ms (and is actually waited out); 529 retried; 401 and 400 not; the budget stops a further attempt; max_retries = 0 disables; the retry count header restarts per call; a retryable status with an oversized body is retried | MockServer scripted with a status sequence; tests pass a Retry with zero backoff |
| Timeouts | A server that sleeps past the deadline yields error.Timeout and cancels cleanly; Io.Duration.max disables; a value above max_timeout is rejected | MockServer with a delay; CI passes --test-timeout 60s, so a hang fails the job |
| Errors | Each status maps to its error; Diagnostics filled with status, request id, error_type, message, path, retry_after_ms, attempts and cause; a retried call that later fails holds only the final attempt | MockServer returning the live-observed error body shape, and the FastAPI validation shape |
| Hooks | Start, retry and end hooks fire once per call with attempts, duration and usage | A test hook counting calls |
| Memory | Every error path frees what it allocated; a leak-checking allocator is used throughout, plus explicit allocation-failure tests | std.testing.allocator, src/oom_test.zig |
| Live | POST /v1/systemone, GET /v1/models, structured state, non-identifier option names, dynamic questions, concurrency, hooks, TLS trust refresh and connection reuse with a real key | tests/live.zig, run by zig build test-live; tests that need TYPESAFE_API_KEY skip when it is unset, and CI gates the job on the secret |
MockServer is public (typesafe.testing), so downstream users stub the client in their own tests
without a mocking library; the README shows the same twelve lines.
Docs, quality and release
build.zig defines the steps: the typesafe module, test (offline unit and integration tests),
test-live (the live file, which skips without TYPESAFE_API_KEY), examples (builds every
program under examples/ so the README code cannot rot), run (runs one, -Dexample=<name>),
docs (autodoc from getEmittedDocs, installed to zig-out/docs), check (compiles the package
for the selected target, so -Dtarget=<triple> answers “does this build there?” without running
anything), fmt, and ci, which runs format, tests, examples and docs in one command.
build.zig.zon:
.{
.name = .typesafe,
.version = "0.1.0",
.fingerprint = 0x50d8d794ef27ec07, // generated once by `zig build`, then never changed
.minimum_zig_version = "0.16.0",
.dependencies = .{},
.paths = .{ "build.zig", "build.zig.zon", "src", "LICENSE", "README.md" },
}
Quality gates, in .github/workflows/ci.yml on every push and PR, matrix ubuntu-latest,
macos-latest, windows-latest with mlugg/setup-zig@v2 pinned to 0.16.0:
| Job | Command |
|---|---|
| Test | `zig build test -Doptimize=Debug |
| Format | zig build fmt |
| Examples and docs | zig build examples --summary all, zig build docs |
| Cross-compile | zig build examples -Dtarget=<target> -Doptimize=ReleaseSafe for x86_64-windows-gnu, aarch64-macos, x86_64-macos, aarch64-linux-musl, riscv64-linux-musl |
.github/workflows/live.yml runs the live suite plus every example on a daily schedule and on
workflow_dispatch, gated on github.repository == 'mattneel/typesafe.zig' and on the
TYPESAFE_API_KEY secret being set, so forks skip it instead of failing. It runs on Linux, macOS
and Windows: the transport is where platforms differ (Windows reports a closed socket as a bare
error.Unexpected, for one), and the offline suite only ever talks to a loopback server.
Documentation: doc comments on the public surface, rendered with zig build docs; the README holds
install, the ticket-routing example, the MockServer pattern, error handling with Diagnostics, and
a link per TypeSafe docs concept (state, questions and structure, confidence and thresholds,
batching) rather than restating them; docs/guides/ holds the longer treatments (installation,
questions, confidence, concurrency, testing, observability).
Those pages are published as one site by .github/workflows/pages.yml on every push to master
that touches them: book/build.sh stages the README, the guides, this file, the changelog and the
release checklist as the chapters of an mdBook, builds it, and merges the API reference beside it
at /api/. The chapters are the repository’s own files, so nothing is duplicated; they are staged
because mdBook copies every file under its src directory into the output, which would otherwise
publish .git and the build caches with the site.
Versioning and publishing: SemVer with v0.x tags; consumers pin with
zig fetch --save git+https://github.com/mattneel/typesafe.zig#v0.1.0, which records the content
hash, so a moved tag would be caught. Any change to an answer struct’s fields is at least a minor
bump with a CHANGELOG entry in Keep a Changelog format. The .fingerprint is generated once and
committed; the .version in the manifest is the single source for the user agent. The release
checklist in RELEASING.md covers bumping the version and CHANGELOG, running the local checks,
tagging, and verifying zig fetch of the tag from a scratch project.
Roadmap and decisions
0.1.0 is the package above. Later items, ordered by value:
Client.askMany: one question set over a slice of states, fanned out through anIo.Groupwith a concurrency limit, results returned in input order with per-item errors.- A
typesafeCLI (examples/cli.zig): pipe state in, questions from a.zonfile, JSON out, for trying questions from the shell. - Response streaming into a caller-provided
Io.Writerfor very large states, once a use case for it appears.
Shipped early, ahead of the original plan: typesafe.dynamic (run-time questions) and
typesafe.testing.MockServer, because the guides and the examples needed them and neither adds a
dependency to the core.
Decisions settled while building, with the evidence:
| Decision | Evidence |
|---|---|
Answer types generated with @Struct from the questions struct | Prototype compiled and its encode/decode tests passed on Zig 0.16.0 |
sendBodyComplete (or an explicit connection.flush()) for every request | sendBody + end() alone hung in receiveHead against api.typesafe.ai; Connection.flush flushes the TLS writer and the socket writer, BodyWriter.end only the former |
Stub server task spawned with io.concurrent | io.async deadlocked the loopback test; io.concurrent passed |
Error bodies parsed as detail.error_type / detail.message | Live 401 response; the FastAPI validation array shape was added after a live 422 |
std TLS is sufficient; no C TLS library | client.fetch and the manual request path both completed the TLS 1.3 handshake with api.typesafe.ai |
Models endpoint GET /v1/models returning {"models": [...]} | Path constants and schema in typesafe-sdk 0.6.0; same string in the JS bundle |
Send X-TypeSafe-SDK, X-TypeSafe-Runtime, X-TypeSafe-Retry-Count and User-Agent, formatted like the vendor SDKs but named typesafe-zig/<version> | Both vendor SDK sources build exactly these headers; the name is this package’s own so its traffic is not attributed to an official SDK |
| A validating encoder instead of a union wrapper for entries | The API takes string, object, array or null in six different places; one generic path with a path-tracked failure covers all of them, while still rejecting booleans and numbers |
Retries logged at .debug | Vendor SDKs log retries at info under a default warn level |
| No HTTP/2 | std.http.Client is HTTP/1.1 only and the API accepts it; bodies are small |
| Diagnostics as an optional out-parameter rather than a payload-carrying error | Zig errors are integers; std.json.Diagnostics is the established shape |
| A retryable status with an oversized body is retried | A gateway error page larger than max_response_bytes is common; the status, not the unreadable body, says whether the server may recover |
Responses are decoded in one pass with a pull reader over std.json.Scanner, not into a std.json.Value tree first | The tree was built on every call and thrown away except for the legend nodes and the body the caller may never read; the reader keeps the field paths in the error messages and lets the answer strings point straight into the response body. The typed decode shrinks what a call allocates to the body itself. Result.body gives a caller who needs an unknown field the bytes, which is strictly more useful than a decoded copy |
InvalidOption is separate from InvalidRequest | A caller can tell “your data cannot be encoded, look at the state” from “your call is misconfigured, look at the options”; the diagnostics name the option, and neither is retried |
| The comptime budget margin is per level, not per entry | Measured: with either the per-entry margin or the per-level margin the suite builds; with both absent a 100-level Score fails. One margin, proportional to the work, is enough |
The hourly TLS refresh is behind -Dtls-trust-refresh | It is the only code that reads std.http.Client fields std does not promise to keep (ca_bundle, ca_bundle_lock, now); a consumer that would rather track std can turn it off, and the suite passes in both configurations |
Retry-After accepts seconds, fractional seconds and the three RFC 9110 date formats, capped at 60 s | RFC 9110 allows all three; the cap matches the JavaScript SDK |
API reference
Changelog
All notable changes to this project are documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning. While the version is 0.x, any change to an answer type or the wire format is at least a minor version bump.
Unreleased
0.1.0 - 2026-09-18
First release: an unofficial community Zig client for TypeSafe’s System One API (the Jev model). It is not an official TypeSafe SDK and is not affiliated with or endorsed by TypeSafe.
Install it with zig fetch --save git+https://github.com/mattneel/typesafe.zig#v0.1.0. It
requires Zig 0.16.0 or later and uses only the standard library.
Added
typesafe.ClientwithinitandinitFromEnv. Options resolve from the explicit option, thenTYPESAFE_API_KEY,TYPESAFE_BASE_URLorTYPESAFE_DEFAULT_MODEL(blank values ignored), then the default. Invalid configuration is anInitErrorat init, never at request time. The base URL’s scheme is lowercased; IPv6 literal hosts, whichstd.http.Client0.16 cannot connect to, and hosts over 255 bytes areInvalidBaseUrl.Client.askforPOST /v1/systemonewith typed questions built bytypesafe.noul,typesafe.choiceandtypesafe.score. The answers struct is derived from the questions at compile time: a Choice answer is the caller’s enum with one probability field per tag, and a Score answer has a[N]f64of probabilities. Missing questions, unknown options, non-enum option types, Scores with fewer than two levels, levels given as a string instead of a tuple, booleans or numbers as instructions, descriptions, Noul criteria or levels (including through an optional or a pointer), and a Noul withnullinstructions and no non-nullcriteria are compile errors. The comptime helpers raise@setEvalBranchQuotafor you, so question sets of a few dozen questions and Scores with dozens of levels compile as they are; an unusually large set can still reach the comptime branch limit, which the compiler reports and names@setEvalBranchQuotafor.Client.askDynamicandtypesafe.dynamicfor questions defined at run time, validated before sending.dynamic.Jsonvalues report their JSON type withkind()and absence withisEmpty().Client.listModelsforGET /v1/models.- JSON structure for state, instructions, descriptions, levels and criteria: struct literals,
tuples, slices,
std.json.Valueandtypesafe.RawJson. A strict encoder, which walksstd.json.Valuetoo, rejects invalid UTF-8, non-finite numbers, invalidnumber_stringvalues, nesting over 256 levels, invalid raw JSON, a Noul whose instructions and criteria are all empty at run time and anullScore level witherror.InvalidRequestand a path to the value. Types with their ownjsonStringifyare written by it unchecked; awriteTypesafeJsonmethod has the encoder check a type’s contents. - Strict, forward-compatible decoding in one pass over the response bytes (no intermediate
std.json.Valuetree), with field paths in errors, such asanswers.tone.confidence: expected a number from 0 to 1. Answer strings point into the response body, which the result keeps asbody. Unknown answers and fields are skipped, keys may arrive in any order, a repeated key keeps its last value, and a body that is not valid JSON, ends early or carries trailing data iserror.InvalidResponse. gzip and deflate response bodies are decoded; any other content encoding iserror.InvalidResponse. - Answer helpers:
NoulAnswer.isYes,ChoiceAnswer.probability,rankedandmargin, andScoreAnswer.probability,expectedLevel,maxLevelandranked. Dynamic answers haveprobability,rankedandmargin(Choice) andexpectedLevelandmaxLevel(Score).marginis rounded to 10 decimal places, as in the Elixir client. typesafe.Error, one error set for every call, andtypesafe.Diagnostics, an optional out-parameter with the status, request id, server error type and message, body, field path,retry_after_ms, attempt count and underlying cause, plus a one-line{f}format. On failure it describes the final attempt; after a success it holds only the final response’s status and request id and the attempt count.typesafe.Retry, a retry policy with the same defaults as TypeSafe’s official Python and JavaScript SDKs: 2 retries, exponential backoff from 500 ms to 5 s with 25% jitter, retries on 408, 429, 5xx and transport errors,retry-after-msandRetry-After(seconds or any RFC 9110 HTTP date), and a 30 s budget per call.isRetryableStatusreplaces the default retryable statuses, andretriesStatusandisRetryableStatusByDefaultexpose the status decision.- A per-attempt timeout (10 s by default, at most one year, disabled with
nullorIo.Duration.max) enforced withstd.Io.Select, and cancelation throughstd.Io: canceling the calling task cancels the request. - Connection handling: only a response read to the end returns its connection to the pool; a
pooled connection the server has closed is replaced without using an attempt; a body cut short
by a closed connection is a retryable
error.ConnectionFailed; a body of exactlymax_response_bytesis accepted. A long-running client reloads the clock and system root certificates it checks TLS certificates against every hour, behind-Dtls-trust-refresh(on by default;.tls_trust_refresh = falsethroughb.dependencyleaves it out). HTTPS through a proxy is refused witherror.InvalidOption, becausestd.http.Client0.16 would not encrypt the proxied connection. Per-call option and header mistakes areerror.InvalidOptiontoo, with the option inDiagnostics.path;error.InvalidRequestis only for a request that cannot be encoded. - Identification headers in the format of TypeSafe’s official SDKs (
User-Agent,X-TypeSafe-SDK,X-TypeSafe-Runtime,X-TypeSafe-Retry-Count), identifying this client astypesafe-zig/<version>. Redirects are never followed. typesafe.Hooksfor observability: request start, retry and request end events with status, request id, attempts, duration, token usage and error. Every call that fires the start event, including one rejected for invalid options, fires the end event.- Logging through
std.log.scoped(.typesafe): retries and schema mismatches at.debug, requests sent without a timeout and failed root certificate reloads at.warn. typesafe.testing.MockServer, a loopback HTTP server with scripted replies (answers, API errors, delays, dropped connections, unread request bodies, truncated bodies, connections closed after a reply) and recorded requests, for testing code that uses the client.- Examples (
route_ticket,structured,batch,dynamic,list_models), guides (installation, questions, confidence, concurrency, testing, observability) and an API reference generated withzig build docs. The guides, this changelog, the release checklist and the README are published as one site bybook/build.shand the Docs workflow.
Releasing
This is the checklist for releasing a new version of typesafe.zig. Work through it in order.
Every step should pass before you go to the next one.
A release is an annotated vX.Y.Z tag on master and a GitHub release for that tag. Consumers
install it with:
$ zig fetch --save git+https://github.com/mattneel/typesafe.zig#vX.Y.Z
zig fetch records the content hash in the consumer’s build.zig.zon, so a tag must never move
after it is pushed.
The package follows Semantic Versioning. While the version is 0.x, any change to an answer type or the wire format is at least a minor bump.
1. Prepare the release pull request
- Start from an up-to-date
masterand create a branch such asrelease/vX.Y.Z. - Bump
.versioninbuild.zig.zon. The user agent (typesafe-zig/X.Y.Z) andtypesafe.versionread it from there. Never change.fingerprint. - If the minimum Zig version changes, update
.minimum_zig_versioninbuild.zig.zon,ZIG_VERSIONin.github/workflows/ci.ymlandlive.yml, and the requirement inREADME.md. - Update the install snippet to
#vX.Y.ZinREADME.md.zig build testchecks it against the version the client reports, so a release that updates one and not the other fails the suite instead of shipping quietly. - In
CHANGELOG.md, move the entries under## [Unreleased]to a new## [X.Y.Z] - YYYY-MM-DDheading below it, and leave## [Unreleased]empty. Use the date on which you will merge and tag. Add a[X.Y.Z]link at the bottom and point the[Unreleased]link athttps://github.com/mattneel/typesafe.zig/compare/vX.Y.Z...HEAD.
2. Run the local checks
Run them from a shell without TYPESAFE_API_KEY, TYPESAFE_BASE_URL or
TYPESAFE_DEFAULT_MODEL exported.
$ zig build ci --summary all
$ zig build test -Doptimize=ReleaseSafe --summary all
$ zig build test -Doptimize=ReleaseFast --summary all
zig build ci checks formatting and runs the offline tests, builds the examples and generates
the API reference.
- Run the live tests and every example against the real API if you will not rely on the Live
API workflow in step 4:
TYPESAFE_API_KEY=... zig build test-live, thenzig build run -Dexample=<name>for each example.
3. Review the docs locally
$ book/build.sh
$ python3 -m http.server -d zig-out/site
book/build.sh builds the book and the API reference into zig-out/site, exactly as the Docs
workflow publishes them; zig build docs on its own builds only the API reference.
- Every public declaration has a doc comment, and
.versioninbuild.zig.zon, whichtypesafe.versionreads, is X.Y.Z. (The generated reference does not print the version; check the manifest.) - The Docs workflow ran on
masterand the site at https://mattneel.github.io/typesafe.zig/ shows the new version, with the guides, the design notes and the API reference under/api/. - README links to
docs/guides/*.md,CHANGELOG.mdand the examples work from the release branch on GitHub.
4. Get CI green
Open a pull request for the release branch and wait for every job to pass: the test matrix on
Linux, macOS and Windows in Debug and ReleaseSafe, and the format, examples, docs and
cross-compilation job. Trigger the Live API workflow on the release branch head with
workflow_dispatch and check that every Live tests job ran and passed, on all three
platforms. A run in which it was skipped, for example because the TYPESAFE_API_KEY secret is
not set, does not count.
Merge the pull request once CI is green.
5. Tag the release
Tag the release pull request’s merge commit, not whatever master points at now:
$ git fetch origin
$ git show <release-commit-sha>:build.zig.zon | grep '.version = "X.Y.Z"'
$ git show <release-commit-sha>:CHANGELOG.md | grep '^## \[X.Y.Z\] - '
$ git tag -a vX.Y.Z <release-commit-sha> -m "vX.Y.Z"
$ git push origin vX.Y.Z
6. Verify the install from GitHub
Before publishing the GitHub release, in a scratch directory outside this repository:
$ mkdir typesafe-consumer && cd typesafe-consumer
$ zig init
$ zig fetch --save git+https://github.com/mattneel/typesafe.zig#vX.Y.Z
Add the dependency to build.zig as the README shows, import it from src/main.zig, and print
typesafe.version.
-
zig build runprintsX.Y.Z.
If the install fails, do not publish the GitHub release. Fix the problem on master and release
a new patch version.
7. Create the GitHub release
$ gh release create vX.Y.Z --verify-tag --title "vX.Y.Z" --notes "See CHANGELOG.md"
Paste the CHANGELOG.md section for the version into the release notes, joining hard-wrapped
lines.
If something goes wrong
- Do not move or delete a tag that has been pushed. Consumers have its content hash in their
build.zig.zon. Fix the problem onmasterand release a new patch version instead. - If a release is broken, say so at the top of its GitHub release notes and point to the fixed version.