Typed questions

Declare questions as a struct literal. The compiler derives a result struct with one typed field per question.

Shape of a call

client.ask(state, questions, options) takes:

  • state: what the questions are about. Pass a string, or any value std.json can serialize.
  • questions: a struct literal whose fields are built with tai.noul, tai.choice or tai.score. Each field name becomes the question id sent to the API, so ids are unique by construction.
  • options: per-call settings, covered below.

It returns a tai.Result with the typed answers, token usage and model(), the versioned id that answered. The result owns no heap memory.

tai.noul: yes/no

The answer is an f64 from 0 (no) to 1 (yes). Use .criteria(yes, no) to say what each end of the scale means.

const r = try client.ask("I was charged twice for my March invoice.", .{
    .is_billing = tai.noul("Is this about billing?"),
    .is_urgent = tai.noul("Does this convey urgency?")
        .criteria("Explicitly time-sensitive", "No urgency expressed"),
}, .{});

const route_to_billing = r.answers.is_billing > 0.5;

tai.choice: pick one option

Pass a struct literal that maps each option to its description. Use null for an option that needs no description. tai generates an enum from the field names, so the switch below must handle every option.

const r = try client.ask("The app crashes every time I upload a PDF.", .{
    .department = tai.choice("Which team should handle this?", .{
        .billing = "Payments, invoicing, refunds",
        .technical = "Bugs, outages, integrations",
        .sales = null,
    }),
}, .{});

const department = r.answers.department;
const assignee = switch (department.choice) {
    .billing => "finance-oncall",
    .technical => "eng-oncall",
    .sales => "account-manager",
};
const unsure = department.confidence < 0.5 or department.probability(.sales) > 0.2;

The answer is a tai.ChoiceResult of that enum:

Field / methodTypeMeaning
choiceenumThe highest-probability option
probabilitiesstd.EnumArray(E, f64)Every option’s probability
confidencef64How certain the model is (Confidence)
probability(option)f64Shorthand for probabilities.get(option)

If you already have an enum, pass the type itself. Each option is sent with a null description.

const Severity = enum { low, medium, high, critical };

const r = try client.ask("Production database is down for all customers.", .{
    .severity = tai.choice("How severe is this incident?", Severity),
}, .{});

const severity: Severity = r.answers.severity.choice;

A choice must have between 1 and 255 options, and the compiler enforces that.

tai.score: rate against levels

Pass a tuple of ordered level descriptions (2 to 10 levels), and level() returns an index:

const by_index = try client.ask(message, .{
    .frustration = tai.score("How frustrated is the customer?", .{ "Calm", "Frustrated", "Very angry" }),
}, .{});

const level: usize = by_index.answers.frustration.level(); // 0, 1 or 2

Or name the levels with a struct literal, and level() returns an enum:

const by_name = try client.ask(message, .{
    .frustration = tai.score("How frustrated is the customer?", .{
        .calm = "Calm",
        .frustrated = "Frustrated",
        .angry = "Very angry",
    }),
}, .{});

const frustration = by_name.answers.frustration;
const offer_refund = frustration.level() == .angry or frustration.probability(.angry) > 0.4;

The answer is a tai.ScoreResult:

Field / methodTypeMeaning
scoref64Probability-weighted level; can land between levels (e.g. 1.05)
probabilities[N]f64Probability of each level, by index
confidencef64How certain the model is
level()usize or enumThe single most probable level
probability(level)f64Probability of one level

An enum type works here too: its tag names are sent as the level descriptions.

State and instructions

state, instructions and descriptions accept any value that std.json can serialize, so you can pass records straight in. Refer to fields by name in backticks:

const ticket = .{
    .customer = .{ .plan = "pro", .months_subscribed = 18 },
    .message = "I'm cancelling unless the CSV export bug is fixed this week.",
};

const r = try client.ask(ticket, .{
    .churn_risk = tai.noul("Is `customer` likely to cancel based on `message`?"),
    .same_issue = tai.noul(.{
        .question = "Is `message` about the same issue as `previous`?",
        .previous = "CSV export produces an empty file.",
    }),
}, .{});

A plain string is sent as text. Everything else is serialized as JSON. Values can be comptime or runtime; only the shape (option names, number of levels) has to be known at compile time.

Reusing a set of questions

A question set is an ordinary value, so you can declare it once and ask it about many states. tai.Result(@TypeOf(set)) names the result type, and tai.Answers(@TypeOf(set)) names just the answers.

const triage = .{
    .is_urgent = tai.noul("Does this convey urgency?"),
    .department = tai.choice("Which team?", .{ .billing = null, .technical = null }),
};

const tickets = [_][]const u8{
    "I was double charged, please refund me today.",
    "Minor typo on the pricing page.",
};

var results: [tickets.len]tai.Result(@TypeOf(triage)) = undefined;
for (tickets, &results) |body, *result| {
    result.* = try client.ask(body, triage, .{});
}

Per-call options

The last argument to ask is a tai.Client.CallOptions:

var diag: tai.Client.Diagnostics = .{};
defer diag.deinit();

const r = try client.ask("Refund request for order #1234.", .{
    .refund = tai.noul("Is this a refund request?"),
}, .{
    .model = "jev-latest", // or pin a versioned id such as the one r.model() reports
    .retry = .none, // or a custom tai.RetryPolicy
    .diagnostics = &diag,
});

Pin a versioned model if you’ve tuned confidence thresholds against it: the jev-latest alias moves when a new release ships.