Runtime questions

When the set of questions is only known at runtime, build a tai.Request and read a tai.Response.

When to use this

The typed DSL needs question names and choice options at compile time. For questions built at runtime (labels loaded from a config file, one question per retrieved passage, speculative fan-out), use Client.systemOne instead. It maps one-to-one onto the HTTP API.

Here, labels and document come from somewhere at runtime, and arena is an allocator that outlives the call:

const questions = try arena.alloc(tai.NamedQuestion, labels.len);
for (labels, questions) |label, *q| q.* = .{
    .name = label,
    .question = .{ .noul = .{
        .instructions = .{ .text = try std.fmt.allocPrint(arena, "Is this about {s}?", .{label}) },
    } },
};

var result = try client.systemOne(.{
    .state = .{ .text = document },
    .questions = questions,
}, .{});
defer result.deinit();

var matches: std.ArrayList([]const u8) = .empty;
for (result.value.answers) |named| switch (named.answer) {
    .noul => |n| if (n.noul > 0.5) try matches.append(arena, named.name),
    else => {},
};

Requests

A tai.Request holds the state, a slice of tai.NamedQuestion, and an optional model that overrides the client’s default. Each tai.Question is a union of .noul, .choice or .score, mirroring the three API question types:

const options = try arena.alloc(tai.Choice.Option, team_names.len);
for (team_names, options) |name, *option| option.* = .{ .name = name };

const request: tai.Request = .{
    .state = .{ .text = "Stripe webhooks are failing with 500s." },
    .questions = &.{
        .{ .name = "team", .question = .{ .choice = .{
            .instructions = .{ .text = "Which team owns this?" },
            .options = options,
        } } },
        .{ .name = "impact", .question = .{ .score = .{
            .instructions = .{ .text = "How much customer impact?" },
            .levels = &.{ .{ .text = "None" }, .{ .text = "Some" }, .{ .text = "Severe" } },
        } } },
    },
};
try tai.validate(request); // systemOne also validates before sending

tai.Content is anything the API accepts as string | object | array:

VariantSent as
.textA JSON string
.jsonA std.json.Value, as is
.rawPre-serialized JSON, written verbatim

Content.fromValue(allocator, value) serializes any Zig value into a .raw.

Before anything is sent, systemOne checks the documented limits: at least one question, unique names, 1 to 255 choice options with unique names, and 2 to 10 score levels.

Responses

systemOne returns a std.json.Parsed(tai.Response); call deinit to free it. response.answers lists every answer in API order as a tai.Answer union. The noul, choice and score getters look one up by name, returning null if it’s missing or of another type:

const response = result.value;
const team = response.choice("team").?; // null if missing or not a choice
const owner = team.choice; // one of team_names
const p_payments = team.probabilityOf("payments") orelse 0;

const impact = response.score("impact").?;
const worst = impact.levels[impact.mostLikelyLevel().?].description; // "None", "Some" or "Severe"

  • ChoiceAnswer has choice, probabilities (option and probability pairs) and confidence, plus probabilityOf(name), and as(Enum) to convert the chosen option into one of your enums.
  • ScoreAnswer has score, levels (description and probability, by index) and confidence, plus mostLikelyLevel().
  • Answer types this SDK version doesn’t know about are kept as .unknown: std.json.Value, so a new API feature won’t break your parsing.