Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

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.

PrimitiveBuild withCriteriaAnswer
Noultypesafe.noul(instructions, criteria).{}, or .yes and .no descriptionsNoulAnswer: probability of yes
Choicetypesafe.choice(Enum, instructions, descriptions)the enum’s tags, each optionally describedChoiceAnswer(Enum): top option, probabilities, confidence
Scoretypesafe.score(instructions, levels)an ordered tuple or array of at least two levelsScoreAnswer(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.

FieldTypeMeaning
answerstypesafe.Answers(@TypeOf(questions))one typed answer per question, under the same field names
model[]const u8the concrete model that answered, such as jev-1.13.0, even when you asked for jev-latest
usagetypesafe.Usageinput_tokens and output_tokens, each ?u64
request_id?[]const u8the x-typesafe-request-id header; quote it when contacting support
attemptsu32attempts made, including the first
body[]const u8the response body as the server sent it, for fields this version does not know
AnswerFieldsHelpers
NoulAnswernoul: f64isYes(threshold)
ChoiceAnswer(E)choice: E, probabilities (one f64 field per tag), confidence: f64probability(tag), ranked(), margin()
ScoreAnswer(N)score: f64, probabilities: [N]f64, confidence: f64, legend: [N]std.json.Valueprobability(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, .{});
OptionEnv var (initFromEnv)DefaultDescription
api_keyTYPESAFE_API_KEYrequiredSent as Authorization: Bearer <key>. Missing: error.MissingApiKey at init, never at request time.
base_urlTYPESAFE_BASE_URLhttps://api.typesafe.aiAn 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.
modelTYPESAFE_DEFAULT_MODELjev-latestjev-preview is also available; client.listModels lists them.
timeout10 sLimit for each attempt: connect, TLS handshake, send and receive. null or Io.Duration.max disables it. At most Client.max_timeout (one year).
retryRetry{}See Retries and timeouts.
max_response_bytes16 MiBA larger body fails with error.ResponseTooLarge.
extra_headersnoneAdded to every request. Headers the client sets itself are rejected with error.ReservedHeader.
hooksnoneSee 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]
ErrorTriggerRetried by default
InvalidRequestThe 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
InvalidOptionA 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 optionno
BadRequestHTTP 400, such as an unknown modelno
UnauthorizedHTTP 401no
PermissionDeniedHTTP 403no
NotFoundHTTP 404no
RequestTimeoutHTTP 408yes
UnprocessableHTTP 422; the message names the fieldno
RateLimitedHTTP 429yes
OverloadedHTTP 529yes
ServerErrorAny other 5xxyes
UnexpectedStatusAny other non-2xx, including redirectsno
ConnectionFailedDNS, connect, reset, a malformed HTTP response, or a body cut short by a closed connectionyes
TlsFailureTLS handshake or certificate loadingyes
TimeoutAn attempt exceeded the timeoutyes
InvalidResponseA 2xx body that does not match the API schema, or a body in an unsupported content encodingno
ResponseTooLargeThe body exceeded max_response_bytesno
CanceledThe calling task was canceledno
OutOfMemoryAn allocation failedno

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: MockServer and 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 code
  • structured: invoice field verification with structured state, instructions and levels
  • batch: many states concurrently on one client
  • dynamic: a taxonomy loaded at run time
  • list_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, no pkg-config, no vendored JSON library. TLS, HTTP, JSON and concurrency all come from std.
  • The API reference for every public declaration is generated from the doc comments and published with this book under /api/; zig build docs produces 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.

PrimitiveBuild withCriteriaAnswer
Noultypesafe.noul(instructions, criteria).{}, or a struct literal with .yes and .noNoulAnswer: probability of yes
Choicetypesafe.choice(Enum, instructions, descriptions)the enum’s tags, each optionally describedChoiceAnswer(Enum): top option, probabilities, confidence
Scoretypesafe.score(instructions, levels)a tuple or array of at least two levelsScoreAnswer(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 valueSent as
string ([]const u8, string literal)string
integer, float, boolnumber, boolean
optionalits payload, or null
enum value, enum literalthe tag name as a string
struct literal, your own structobject
tuple, array, slicearray
tagged unionan object with one key, the active tag
std.json.Valueas 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, choice or score.
  • 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 .legal for Team.
  • A Noul’s criteria are not a struct literal or have a field other than .yes and .no, or a Noul has null instructions and no criteria, or only null ones: “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.Value number_string that is not a JSON number;
  • RawJson text that is not exactly one valid JSON value;
  • nesting deeper than 256 levels, including inside RawJson text;
  • a selection from a non-exhaustive enum whose value has no name of its own, which would be sent as a number;
  • a jsonStringify method on your own type whose error is not error.WriteFailed (a WriteFailed, the only error such a method can raise, arrives as error.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, at questions.<id>;
  • a Score level that is null, at questions.<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 choice is 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 score is 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: noul is 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. NoulAnswer has no separate confidence.
  • A Choice answer has probabilities, one f64 field per enum tag, and choice is the most likely tag.
  • A Score answer has probabilities, one f64 per level, and score is 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:

MessageProbabilitiesconfidencemargin()
“Help! My payouts have been failing for 3 days.”billing 0.87, technical 0.13, sales 0.00.810.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.010.820.79
“Hello?”technical 0.67, sales 0.33, billing 0.00.50.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 as jev-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 Client at the same time: each call creates its own request, and the connection pool is guarded by a mutex. Build it once and pass *Client to 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 var that outlives every call, not in a container that can reallocate, and call deinit only 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 handles typesafe.Error itself and returns only error.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’s legend is 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 when group.concurrent fails with error.ConcurrencyUnavailable. After group.await returns, cancel does nothing.
  • Use a thread-safe allocator. The client allocates from whichever task calls it, so the gpa passed to Client.init must be thread-safe, as std.http.Client requires. init.gpa in a std.process.Init main is, and so is std.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.allocator checks 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.io is a std.Io.Threaded. The server runs its accept loop and each connection as concurrent tasks, so it needs an Io that supports concurrency.
  • api_key = "test" is never checked. The key only goes to the loopback server.
  • .retry = .disabled makes 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.
  • defer runs in reverse order, so the client is freed before the server is destroyed. destroy stops the server, closes open connections, and frees the recorded requests.

MockServer has no configuration of its own:

CallWhat 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:

OptionDefault
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:

FieldDefaultUse
status.okany std.http.Status, such as @enumFromInt(422)
body""the response body, copied
headersnoneextra response headers, such as retry-after
content_type"application/json"the content-type header
delay.zerowait this long before replying
dropfalseclose the connection without replying
read_bodytruefalse 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_tonullsend only this many body bytes, with a content-length for the whole body, then close the connection
close_afterfalseclose 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();
FieldTypeMeaning
err?typesafe.Errorthe error the call returned, or null when it succeeded
method?std.http.Methodthe request method
url?[]const u8the full request URL
status?std.http.Statusthe status of the final response, or null when no response arrived
request_id?[]const u8the x-typesafe-request-id header of the final response
error_type?[]const u8the server’s error category from detail.error_type, such as authentication_error or api_usage_error
message?[]const u8the server’s message for an HTTP error, or what failed for a client-side or decoding error
body?[]const u8the body of an error response or an undecodable 2xx response, capped at Diagnostics.max_body_bytes (64 KiB)
path?[]const u8for 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?u64the wait the final response asked for, from retry-after-ms or Retry-After
attemptsu32attempts made, including the first; 0 when the call failed before sending
cause?anyerrorthe 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 attempts counts every attempt.
  • After a success, err, message, body, path, error_type, retry_after_ms and cause are null, status and request_id describe the final, successful response, and attempts counts every attempt, including failed ones that were retried.
  • Strings belong to the diagnostics. They stay valid until the next call that uses it, reset, or deinit.
  • A call whose own options are invalid fails with InvalidOption, attempts 0, and path naming the option: model, timeout, retry, extra_headers, or base_url when client.http.https_proxy is set for an https base 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, and message quotes 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 },
});
HookEventCalled
onRequestStarthooks.RequestStartonce per call, before the call’s options are validated and its request body is encoded
onRetryhooks.RetryEventbefore sleeping ahead of each retry
onRequestEndhooks.RequestEndonce 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.

EventOther fields
RequestStartmodel (?[]const u8, null for list_models), question_count (0 for list_models)
RetryEventattempt (the attempt that failed, starting at 1), err, status (null for a transport failure), delay (std.Io.Duration)
RequestEndmodel, 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 url and request_id, are valid only during the callback. Copy what you keep.
  • Whatever context and user_data point 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 Io cannot 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:

WhereWhen
result.request_idafter a successful ask or askDynamic
models.request_idafter a successful listModels
diagnostics.request_idafter any call, from the final response
RequestEnd.request_idin 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 std already 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, hooks and testing are 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.

FieldRequestResponse
statestring, object or array; the content to judgenot echoed
modelstring, default jev-lateststring, model that answered
questionsmap of caller-chosen id to Question; ids are not sent to the modelanswers: same ids to Answer
usagen/ainput_tokens, output_tokens (integers, may be absent)

The three question types and their answers (primitives):

TypeinstructionscriteriaAnswer fields
noulstring, object or arrayoptional {true, false} descriptionsnoul float 0 to 1
choicestring, object or arrayrequired map of option to description or nullchoice (top option), probabilities map summing to 1, confidence 0 to 1
scorestring, object or arrayrequired ordered array of at least 2 levelsscore 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 instructions value, Choice option description, Score level and Noul true/false entry 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, a std.json.Value, a typesafe.RawJson — plus null. 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 carries x-typesafe-request-id, which the vendor SDKs surface on every response and error (exceptions). This client exposes it as Result.request_id, Models.request_id and Diagnostics.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, and Accept: application/json. A request with a body also sends Content-Type: application/json.
  • Defaults (constants): base URL https://api.typesafe.ai, model jev-latest, 10 s per HTTP operation, env vars TYPESAFE_API_KEY, TYPESAFE_BASE_URL, TYPESAFE_DEFAULT_MODEL. Explicit options beat env vars, which beat defaults; blank env values are ignored. TYPESAFE_LOG_LEVEL is not mirrored: Zig programs set std.options.log_level and log_scope_levels instead.
  • 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-After and retry-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) and retry-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.Client speaks 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).

ComponentWhat it provides hereNotes
Zig 0.16.0std.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.ClientHTTP/1.1 over TLS via std.crypto.tls, keep-alive connection pool (32 free connections by default), proxiesThread-safe for opening connections; individual Requests are not, one per task. Exposed as client.http for tuning
std.jsonStringify for the encode side, Value plus a strict path-tracking decoder for the response side, std.json.Value for structured entriesThe request body is built by the package’s own validating Encoder, which wraps Stringify
std.IoIo.Threaded for real programs, std.testing.io in tests, io.concurrent, Io.Select, Io.Timeout, std.Random.IoSource for retry jitterCancelation works through the same interface (error.Canceled)
std.crypto.Certificate.BundleSystem root certificates, rescanned on the client’s first HTTPS request and hourly thereafterVerified: the std TLS client completes the handshake with api.typesafe.ai
Dev toolingzig build test, zig build test --test-timeout 60s, zig build fmt, autodoc via Compile.getEmittedDocsCI 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:

DeclarationSignature and rules
Client.init(gpa, io, Options) InitError!ClientValidates 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!ClientSame, 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.ResultQuestions built at run time; validated before sending, answered by id
Client.listModels(client, ListModelsOptions) Error!ModelsGET /v1/models
noul(instructions, criteria) Noulcriteria 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.RawJsonPre-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()
Modelsmodels: []const Model, request_id, attempts, body, an owning arena, find(name), deinit()
RetryPolicy struct with the vendor defaults, plus isRetryable, retriesStatus, isRetryableStatusByDefault, backoffMs, delayMs, nextDelayMs, parseRetryAfter and disabled; see Client and transport
Error, InitError, Diagnostics, errorFromStatusSee Errors
hooks.Hookscontext plus onRequestStart, onRetry, onRequestEnd function pointers
dynamicQuestion, Options, Levels, Json, Result; see README
testing.MockServerLoopback 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, a std.json.Stringify wrapper that tracks the path to the value it is writing and validates as it goes. Each question type writes type, instructions and criteria in the documented shape; Noul omits criteria when it has none; Choice writes one key per enum tag with its description or null; 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. The type discriminator 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 as Result.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 with error.InvalidResponse. The first problem in document order is the one reported.
  • Score legend and probabilities arrive as objects keyed "0", "1", …; they are read by key, so no map allocation is needed and a missing level is reported at answers.<id>.probabilities.<n>.
  • Choice probabilities must carry an entry for every option: a probability the server omitted is error.InvalidResponse with answers.<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 == .technical and 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 .yes or .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 @setEvalBranchQuota themselves, 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.Value number_string, RawJson that is not one valid value or nests deeper than 256 levels, a selection from a non-exhaustive enum whose value has no name, a null Score level, a Noul whose instructions and criteria are all empty, and a state that is not a string, object or array. Each fails with error.InvalidRequest before anything is sent, with the path in Diagnostics.path.
  • Memory: Result and Models own 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_bytes while 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.
  • gzip and deflate response bodies are decoded; any other content encoding, and a compressed body that does not decompress, is error.InvalidResponse.
  • Certificate verification uses the system roots. std.http.Client reads 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 reads std.http.Client fields std does not promise to keep, so it sits behind a build option, -Dtls-trust-refresh (.tls_trust_refresh = false through b.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.Client in Zig 0.16 does not run TLS inside a proxy tunnel, so an HTTPS base URL with https_proxy set is refused with error.InvalidRequest rather 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:

OptionEnv varDefault
api_keyTYPESAFE_API_KEYnone; error.MissingApiKey
base_urlTYPESAFE_BASE_URLhttps://api.typesafe.ai
modelTYPESAFE_DEFAULT_MODELjev-latest
timeoutnone10 s
retrynoneRetry{}
max_response_bytesnone16 MiB; exceeding it is error.ResponseTooLarge
extra_headersnonenone; authorization, accept, content-type, connection, host, user-agent, x-typesafe-* and friends are reserved and rejected with error.ReservedHeader
hooksnone.{}

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
};
ErrorTriggerRetried by default
InvalidRequestThe 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 arrayno
InvalidOptionA 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 optionno
BadRequestHTTP 400, such as an unknown modelno
UnauthorizedHTTP 401, a missing or invalid API keyno
PermissionDeniedHTTP 403no
NotFoundHTTP 404no
RequestTimeoutHTTP 408yes
UnprocessableHTTP 422; Diagnostics.message carries the server’s field detailno
RateLimitedHTTP 429yes
OverloadedHTTP 529yes
ServerErrorAny other 5xxyes
UnexpectedStatusAny other non-2xx, including redirects, which are never followedno
ConnectionFailedDNS, connect, reset, a malformed HTTP response, or a body cut short by a closed connectionyes
TlsFailureTLS handshake failure or unreadable system certificatesyes
TimeoutAn attempt did not complete within the configured timeoutyes
InvalidResponseA 2xx whose body did not parse into Response(Q), or an unsupported content encodingno
ResponseTooLargeThe body exceeded max_response_bytesno, unless the status is retryable
CanceledThe Io canceled the calling taskno
OutOfMemoryAn allocation failedno

Behaviour:

  • ask returns the error; when AskOptions.diagnostics is set, it is filled before returning, including after retries (the final attempt’s status, attempts in 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 in Diagnostics.body and quoted in message.
  • std.http.Client error sets are wide; the client maps them to the four transport errors above and keeps the original name in Diagnostics.cause, so the public error set stays small and stable across Zig versions. The retry log at .debug names 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.

LayerWhat is coveredHow
Wire encodingEach constructor’s JSON equals the shared request fixture byte-for-byte; every entry kind (string, object, tuple, std.json.Value, RawJson) encodes as documentedstd.testing.expectEqualStrings against src/testdata/requests/*
Answer decodingEvery 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 pathFixtures in src/testdata/responses/, plus inline bodies
Encoder and decoderInvalid 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 pathssrc/wire_test.zig, src/json.zig tests
Comptime checksFewer than two levels, empty enum, non-exhaustive enum, no questions, boolean or number entries through optionals and pointersDocumented in doc comments; Zig has no negative-compilation test in std.testing, so these are checked by hand at review
ClientAuth and identification headers, content-length, no redirect following, x-typesafe-retry-count only on retries, per-call overrides, base-URL and option validationMockServer records request heads and bodies for assertions
Retries429 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 retriedMockServer scripted with a status sequence; tests pass a Retry with zero backoff
TimeoutsA server that sleeps past the deadline yields error.Timeout and cancels cleanly; Io.Duration.max disables; a value above max_timeout is rejectedMockServer with a delay; CI passes --test-timeout 60s, so a hang fails the job
ErrorsEach 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 attemptMockServer returning the live-observed error body shape, and the FastAPI validation shape
HooksStart, retry and end hooks fire once per call with attempts, duration and usageA test hook counting calls
MemoryEvery error path frees what it allocated; a leak-checking allocator is used throughout, plus explicit allocation-failure testsstd.testing.allocator, src/oom_test.zig
LivePOST /v1/systemone, GET /v1/models, structured state, non-identifier option names, dynamic questions, concurrency, hooks, TLS trust refresh and connection reuse with a real keytests/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:

JobCommand
Test`zig build test -Doptimize=Debug
Formatzig build fmt
Examples and docszig build examples --summary all, zig build docs
Cross-compilezig 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:

  1. Client.askMany: one question set over a slice of states, fanned out through an Io.Group with a concurrency limit, results returned in input order with per-item errors.
  2. A typesafe CLI (examples/cli.zig): pipe state in, questions from a .zon file, JSON out, for trying questions from the shell.
  3. Response streaming into a caller-provided Io.Writer for 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:

DecisionEvidence
Answer types generated with @Struct from the questions structPrototype compiled and its encode/decode tests passed on Zig 0.16.0
sendBodyComplete (or an explicit connection.flush()) for every requestsendBody + 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.concurrentio.async deadlocked the loopback test; io.concurrent passed
Error bodies parsed as detail.error_type / detail.messageLive 401 response; the FastAPI validation array shape was added after a live 422
std TLS is sufficient; no C TLS libraryclient.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 entriesThe 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 .debugVendor SDKs log retries at info under a default warn level
No HTTP/2std.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 errorZig errors are integers; std.json.Diagnostics is the established shape
A retryable status with an oversized body is retriedA 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 firstThe 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 InvalidRequestA 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 entryMeasured: 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-refreshIt 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 sRFC 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.Client with init and initFromEnv. Options resolve from the explicit option, then TYPESAFE_API_KEY, TYPESAFE_BASE_URL or TYPESAFE_DEFAULT_MODEL (blank values ignored), then the default. Invalid configuration is an InitError at init, never at request time. The base URL’s scheme is lowercased; IPv6 literal hosts, which std.http.Client 0.16 cannot connect to, and hosts over 255 bytes are InvalidBaseUrl.
  • Client.ask for POST /v1/systemone with typed questions built by typesafe.noul, typesafe.choice and typesafe.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]f64 of 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 with null instructions and no non-null criteria are compile errors. The comptime 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 can still reach the comptime branch limit, which the compiler reports and names @setEvalBranchQuota for.
  • Client.askDynamic and typesafe.dynamic for questions defined at run time, validated before sending. dynamic.Json values report their JSON type with kind() and absence with isEmpty().
  • Client.listModels for GET /v1/models.
  • JSON structure for state, instructions, descriptions, levels and criteria: struct literals, tuples, slices, std.json.Value and typesafe.RawJson. A strict encoder, which walks std.json.Value too, rejects invalid UTF-8, non-finite numbers, invalid number_string values, nesting over 256 levels, invalid raw JSON, a Noul whose instructions and criteria are all empty at run time and a null Score level with error.InvalidRequest and a path to the value. Types with their own jsonStringify are written by it unchecked; a writeTypesafeJson method has the encoder check a type’s contents.
  • Strict, forward-compatible decoding in one pass over the response bytes (no intermediate std.json.Value tree), with field paths in errors, such as answers.tone.confidence: expected a number from 0 to 1. Answer strings point into the response body, which the result keeps as body. 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 is error.InvalidResponse. gzip and deflate response bodies are decoded; any other content encoding is error.InvalidResponse.
  • Answer helpers: NoulAnswer.isYes, ChoiceAnswer.probability, ranked and margin, and ScoreAnswer.probability, expectedLevel, maxLevel and ranked. Dynamic answers have probability, ranked and margin (Choice) and expectedLevel and maxLevel (Score). margin is rounded to 10 decimal places, as in the Elixir client.
  • typesafe.Error, one error set for every call, and typesafe.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-ms and Retry-After (seconds or any RFC 9110 HTTP date), and a 30 s budget per call. isRetryableStatus replaces the default retryable statuses, and retriesStatus and isRetryableStatusByDefault expose the status decision.
  • A per-attempt timeout (10 s by default, at most one year, disabled with null or Io.Duration.max) enforced with std.Io.Select, and cancelation through std.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 exactly max_response_bytes is 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 = false through b.dependency leaves it out). HTTPS through a proxy is refused with error.InvalidOption, because std.http.Client 0.16 would not encrypt the proxied connection. Per-call option and header mistakes are error.InvalidOption too, with the option in Diagnostics.path; error.InvalidRequest is 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 as typesafe-zig/<version>. Redirects are never followed.
  • typesafe.Hooks for 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 with zig build docs. The guides, this changelog, the release checklist and the README are published as one site by book/build.sh and 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 master and create a branch such as release/vX.Y.Z.
  • Bump .version in build.zig.zon. The user agent (typesafe-zig/X.Y.Z) and typesafe.version read it from there. Never change .fingerprint.
  • If the minimum Zig version changes, update .minimum_zig_version in build.zig.zon, ZIG_VERSION in .github/workflows/ci.yml and live.yml, and the requirement in README.md.
  • Update the install snippet to #vX.Y.Z in README.md. zig build test checks 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-DD heading 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 at https://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, then zig 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 .version in build.zig.zon, which typesafe.version reads, is X.Y.Z. (The generated reference does not print the version; check the manifest.)
  • The Docs workflow ran on master and 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.md and 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 run prints X.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 on master and 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.