Sessions and providers

The common client is summarized below. The remaining sections describe the lower-level provider and session APIs.

Common client

instructor.Client has resource fields responses and messages. Use them without resource parentheses:

Zig
const Product = struct {
    name: []const u8,
    price: f64,
    in_stock: bool,
};

var product = try client.responses.parse(Product, .{
    .input = "Extract the product: Coffee mug costs $12.50 and is in stock.",
});
defer product.deinit();

std.debug.print("{s}: {d:.2}, in stock={}\n", .{
    product.value.name,
    product.value.price,
    product.value.in_stock,
});

const messages = [_]instructor.Message{
    .{ .role = .user, .content = "Extract the product: Coffee mug costs $12.50 and is in stock." },
};
var parsed = try client.messages.parse(Product, .{ .messages = &messages });
defer parsed.deinit();

Use create for ordinary text without an output schema:

Zig
var response = try client.responses.create(.{ .input = "Say hello." });
defer response.deinit();

var reply = try client.messages.create(.{ .messages = &.{
    .{ .role = .user, .content = "Say hello." },
} });
defer reply.deinit();

The resource fields borrow stable heap state owned by the client. Moving the outer client does not invalidate resource handles. Do not copy an active client owner or use a handle after client.deinit(). Results may outlive the client if their backing allocator is still alive.

ResponseCreateParams requires a text input and has optional model, max_output_tokens, and temperature fields. MessageCreateParams requires messages and has optional model, max_tokens, and temperature fields. Both also accept:

Zig
extra_body: ?std.json.Value = null,
extra_headers: []const std.http.Header = &.{},
extra_query: []const instructor.QueryParam = &.{},

QueryParam has name: []const u8 and value: []const u8. ClientConfig and EnvConfig accept extra_body and extra_query with the same defaults. Their existing headers field supplies default headers. Initialization deeply copies the defaults. Request values are borrowed only during the call.

Client and request JSON objects merge recursively. Request arrays and scalars replace existing values. Unknown body keys are passed through. Request query entries replace defaults by raw name, preserve repeated request names, and use RFC 3986 component encoding. Request headers override defaults, authentication, and content type case-insensitively without duplicate names.

A non-object body returns error.InvalidExtraBody. Extras cannot override model, input, or messages. Parsing also protects text.format, response_format, output_config.format, tools, and tool_choice. These conflicts return error.ReservedBodyField. Siblings such as text.verbosity and output_config.effort remain allowed. An extra stream value of true or a non-boolean returns error.UnsupportedStreaming. false is allowed. Invalid headers return error.InvalidHeader.

Both resources also offer parseWith, parseInto, and parseIntoWith. Parsing retains local validation and retries. See the client guide for ownership and parsing options, or request extras for examples and full merge rules.

Retained lower-level API

The following sections cover OpenAI, Anthropic, and Session. Request extras work across all three HTTP adapters for ordinary and typed requests, including provider SDK paths without an injected transport.

Parsed values are validated by default with jsonschema.validateValue for supported metadata.

Core design

instructor.zig uses caller-owned lifetime scopes.

  • Client owns provider config and transport defaults.
  • Session owns result memory for one task/batch.
  • create() returns T directly.
  • Session.deinit() frees all parsed result allocations together.
  • Provider adapter owns request mutation/retry formatting.

This matches Zig style and std.Io: caller controls I/O, allocation, and lifetime.

Goals

  • Generate JSON Schema from comptime Zig type.
  • Send schema to provider through adapter.
  • Parse provider JSON text into Zig type with std.json.
  • Retry on parse or validation errors by appending provider-specific error feedback.
  • Return T directly for ergonomic code.
  • Free all result memory through Session.deinit().
  • Validate parsed values against supported jsonschema.zig metadata by default.

Limits

  • Full JSON Schema spec validation.
  • T.validate() hooks.
  • Streaming.
  • Arbitrary root unions without an object wrapper.
  • Multi-provider parity with instructor-go.
  • Rich error payloads.
  • Full OpenAI generated SDK correctness.

Primary user API

Zig
var client = instructor.OpenAI.init(.{
    .allocator = gpa,
    .io = init.io,
    .api_key = api_key,
});
defer client.deinit();

var session = instructor.session(gpa, &client);
defer session.deinit();

const request: instructor.OpenAI.Request = .{
    .model = "gpt-6-luna",
    .messages = &.{
        .{
            .role = .user,
            .content = "Extract the product: Coffee mug costs $12.50 and is in stock.",
        },
    },
};

const product = try session.create(Product, request, .{});

product is valid until session.deinit().

Type-level API

Zig
pub fn Session(comptime Provider: type) type;

Returned type:

Zig
pub fn Session(comptime Provider: type) type {
    return struct {
        allocator: std.mem.Allocator,
        arena: std.heap.ArenaAllocator,
        provider: *Provider,
        usage: Usage = .{},
        last_usage: Usage = .{},
        last_text: ?[]const u8 = null,
        last_raw_response: ?[]const u8 = null,
        hooks: Hooks = .{},

        pub fn init(allocator: std.mem.Allocator, provider: *Provider) @This();
        pub fn deinit(self: *@This()) void;

        pub fn create(
            self: *@This(),
            comptime T: type,
            request: anytype,
            comptime options: Options,
        ) !T;

        pub fn createDetailed(
            self: *@This(),
            comptime T: type,
            request: anytype,
            comptime options: Options,
        ) !CreateResult(T);

        pub fn setHooks(self: *@This(), hooks: Hooks) void;
        pub fn reset(self: *@This()) void;
    };
}

Convenience inference helper:

Zig
pub fn session(allocator: std.mem.Allocator, provider: anytype) Session(ProviderType(provider));

Possible use:

Zig
var s = instructor.session(gpa, &client);
defer s.deinit();

const product = try s.create(Product, req, .{});

Why not return wrapper

Returning wrapper is safe but noisy:

Zig
var result = try instructor.create(Product, ...);
defer result.deinit();
const product = result.value;

Session makes lifetime explicit once and returns T directly:

Zig
var session = instructor.session(gpa, &client);
defer session.deinit();

const product = try session.create(Product, req, .{});

This is better for app code and still memory-safe.

Lifetime rule

All allocations needed by returned T live in Session arena.

Zig
const product = try session.create(Product, req, .{});
// product.name, price, and in_stock are valid here

session.deinit();
// product invalid after this point

Session.reset() frees all prior results and usage/raw debug memory. Any previously returned T becomes invalid after reset.

Client ownership

Client stores provider config and request-time defaults:

  • GPA/temp allocator
  • std.Io
  • API key
  • base URL override for OpenAI-compatible providers
  • HTTP config

Client should not own parsed result structs. Long-lived clients must not accumulate result memory.

Example:

Zig
var client = instructor.OpenAI.init(.{
    .allocator = gpa,
    .io = init.io,
    .api_key = api_key,
    .base_url = "https://api.openai.com/v1", // override for compatible endpoints
});
defer client.deinit();

Session ownership

Session owns parsed values and optional retained debug data.

Zig
var session = instructor.session(gpa, &client);
defer session.deinit();

const user = try session.create(User, user_req, .{});
const ticket = try session.create(Ticket, ticket_req, .{});

Both user and ticket are freed by one session.deinit().

Use one session per task/batch/request scope. Do not use one global session for whole application unless intentionally retaining all results.

Low-level one-shot API

Core functions powering Session.create and Session.createDetailed:

Zig
pub fn createWithArena(
    comptime T: type,
    temp_allocator: std.mem.Allocator,
    result_allocator: std.mem.Allocator,
    provider: anytype,
    request: anytype,
    usage: ?*Usage,
    comptime options: Options,
) !T;

pub fn createDetailedWithArena(
    comptime T: type,
    temp_allocator: std.mem.Allocator,
    result_allocator: std.mem.Allocator,
    provider: anytype,
    request: anytype,
    usage: ?*Usage,
    hooks: ?Hooks,
    comptime options: Options,
) !CreateResult(T);

These return data allocated in caller-provided arena. Caller must ensure arena outlives returned values and slices.

Example:

Zig
var arena = std.heap.ArenaAllocator.init(gpa);
defer arena.deinit();

const product = try instructor.createWithArena(
    Product,
    gpa,
    arena.allocator(),
    &client,
    req,
    null,
    .{},
);

Object roots

Some provider structured-output modes require an object root. instructor.zig does not hide this with schema transforms. Define the object root as a Zig type and call create normally.

Root array:

Zig
const Item = struct {
    task: []const u8,
};

const Items = struct {
    items: []const Item,

    pub const jsonschema = .{
        .name = "Items",
        .fields = .{
            .items = .{ .description = "Extracted items." },
        },
    };
};

const result = try session.create(Items, req, .{});
const items = result.items;

Root union:

Zig
const Next = struct {
    action: Action,
};

const next = try session.create(Next, req, .{});

This mirrors Zig's explicit-type style and avoids dynamic wrapper/unwrapper state.

Options

Zig
pub const Options = struct {
    mode: Mode = .json_schema,
    max_retries: u8 = 3,
    validate: bool = true,
    schema_options: jsonschema.Options = jsonschema.strict_options,
    parse_options: std.json.ParseOptions = .{
        .allocate = .alloc_always,
    },
};

pub const Mode = enum {
    json_schema,
    json_object,
    tool_call,
    tool_call_required,
    responses_tool_call,
    responses_tool_call_required,
};

parse_options.allocate = .alloc_always recommended so returned T does not borrow provider response text.

Modes:

Mode Endpoint Behavior
.json_schema Responses or Chat Completions Provider-native structured outputs.
.json_object Responses or Chat Completions JSON mode fallback.
.tool_call Chat Completions Sends T as function tool and parses first tool-call arguments.
.tool_call_required Chat Completions Same, with forced function choice.
.responses_tool_call Responses Sends T as Responses function tool and parses first function-call arguments.
.responses_tool_call_required Responses Same, with forced function choice.

Provider adapters may reject unsupported endpoint/mode combinations with error.UnsupportedMode.

Validation policy

After a provider response parses into T, instructor.zig validates the value with jsonschema.validateValue by default. Validation failures are retried just like parse failures, but the retry message includes concrete validation diagnostics.

Disable validation per call when needed:

Zig
const value = try session.create(T, req, .{ .validate = false });

Layering:

  • jsonschema.zig: emit schemas and validate parsed Zig values for supported metadata.
  • openai.zig: HTTP/API types and client.
  • instructor.zig: schema -> provider -> parse -> validate -> retry.

Validation coverage follows jsonschema.zig; unsupported JSON Schema vocabulary remains provider-guidance-only.

Usage

Zig
pub const Usage = struct {
    input_tokens: u64 = 0,
    output_tokens: u64 = 0,
    total_tokens: u64 = 0,

    pub fn add(self: *Usage, other: Usage) void;
};

Session accumulates usage across calls:

Zig
const user = try session.create(User, req1, .{});
const ticket = try session.create(Ticket, req2, .{});

std.debug.print("tokens: {}\n", .{session.usage.total_tokens});

Per-call usage is available through createDetailed() and session.last_usage:

Zig
const result = try session.createDetailed(User, req, .{});
std.debug.print("call tokens: {}\n", .{result.usage.total_tokens});

session.last_text and session.last_raw_response point into the session arena and are replaced by the next successful call.

Hooks

Hooks use plain function pointers and optional context.

Zig
pub const HookEvent = enum {
    request_start,
    response_received,
    parse_error,
    validation_error,
    retry,
    completion_done,
};

pub const HookInfo = struct {
    attempt: usize = 0,
    schema_name: ?[]const u8 = null,
    response_text: ?[]const u8 = null,
    raw_response: ?[]const u8 = null,
    error_name: ?[]const u8 = null,
    validation_errors: ?[]const u8 = null,
    usage: Usage = .{},
};

pub const HookFn = *const fn (?*anyopaque, HookEvent, HookInfo) void;

pub const Hooks = struct {
    ctx: ?*anyopaque = null,
    on_event: ?HookFn = null,
};

Example:

Zig
const State = struct {
    fn onEvent(ctx: ?*anyopaque, event: instructor.HookEvent, info: instructor.HookInfo) void {
        _ = ctx;
        std.debug.print("{s} attempt={}\n", .{ @tagName(event), info.attempt });
    }
};

session.setHooks(.{ .on_event = State.onEvent });

Detailed result

Zig
pub fn CreateResult(comptime T: type) type {
    return struct {
        value: T,
        text: []const u8,
        raw_response: []const u8,
        usage: Usage = .{},
    };
}

text and raw_response are copied into the result arena. With Session, they live until reset() or deinit().

Provider adapter pattern

Provider adapters are duck-typed. No virtual table or interface object required.

Provider must implement:

Zig
pub fn completeStructured(
    self: *Provider,
    allocator: std.mem.Allocator,
    request: Request,
    schema: instructor.StructuredSchema,
    comptime options: instructor.Options,
) !instructor.Completion;

pub fn appendRetry(
    self: *Provider,
    allocator: std.mem.Allocator,
    request: *Request,
    retry: instructor.RetryMessage,
) !void;

Provider may optionally implement:

Zig
pub fn deinitRequest(
    self: *Provider,
    allocator: std.mem.Allocator,
    request: *Request,
) void;

createWithArena() copies user request into mutable local req. Retry hooks mutate local request. If deinitRequest exists, core calls it before returning.

Provider receives allocator for temp/request-copy allocations, not parsed result allocations unless adapter intentionally writes into session arena. Returned Completion buffers are freed by core after parse.

appendRetry receives borrowed slices. If provider keeps retry.failed_response or retry.error_message after returning, it must copy them with the provided allocator and release them from deinitRequest.

Structured schema passed to providers

Zig
pub const StructuredSchema = struct {
    name: []const u8,
    description: ?[]const u8 = null,
    schema_json: []const u8,
    strict: bool = true,
};

schema_json is borrowed and valid only during completeStructured call.

Provider completion returned to core

Zig
pub const Completion = struct {
    text: []u8,
    raw_response: []u8,
    usage: Usage = .{},

    pub fn deinit(self: *Completion, allocator: std.mem.Allocator) void;
};

text and raw_response are owned by caller after return and allocated by allocator passed to completeStructured.

Core first parses text into a temporary per-attempt arena to avoid retaining failed-attempt allocations. On success, it parses into the result allocator using options.parse_options. Detailed results keep their own copies of the response text.

Retry message

Zig
pub const RetryMessage = struct {
    failed_response: []const u8,
    error_message: []const u8,
};

Default parse retry text:

Text
JSON parsing failed: <error>. Return only valid JSON matching the schema.

Provider adapters decide how to append this. OpenAI-style adapters should append:

  1. assistant message containing failed model text
  2. user message containing error feedback

OpenAI provider

The adapter uses generated OpenAI request types. It constructs request headers and uses the injected HTTP transport or the SDK's raw request dispatch.

Zig
pub const OpenAI = struct {
    pub const Config = struct {
        allocator: std.mem.Allocator,
        io: std.Io,
        api_key: []const u8,
        /// Override for OpenAI-compatible endpoints.
        base_url: []const u8 = "https://api.openai.com/v1",
        /// `.chat_completions` works with OpenRouter and many OpenAI-compatible APIs.
        endpoint: Endpoint = .responses,
        /// Optional OpenRouter ranking header.
        http_referer: ?[]const u8 = null,
        /// Optional OpenRouter ranking header.
        app_name: ?[]const u8 = null,
    };

    pub const Request = struct {
        model: []const u8,
        messages: []const Message,
        temperature: ?f64 = null,
        max_output_tokens: ?u32 = null,
        extra_body: ?std.json.Value = null,
        extra_headers: []const std.http.Header = &.{},
        extra_query: []const instructor.QueryParam = &.{},
    };

    pub const Message = struct {
        role: Role,
        content: []const u8,
        images: []const Image = &.{},
    };

    pub const Image = struct {
        url: []const u8,
        detail: ?[]const u8 = null,
    };

    pub const Role = enum {
        system,
        user,
        assistant,
    };
};

Provider responsibilities:

  • Convert StructuredSchema to OpenAI text.format = { type: "json_schema", name, schema, strict } for Responses API.
  • Convert StructuredSchema to Chat Completions response_format = { type: "json_schema", json_schema: { name, schema, strict } } for OpenRouter-compatible APIs.
  • Convert StructuredSchema to Chat Completions function tools for .tool_call and .tool_call_required modes.
  • Convert StructuredSchema to Responses function tools for .responses_tool_call and .responses_tool_call_required modes.
  • Convert .json_object to provider JSON object format.
  • Emit endpoint-specific token fields: max_output_tokens for Responses, max_tokens for Chat Completions.
  • Add auth headers.
  • Extract output text.
  • Extract usage.
  • Preserve provider HTTP/shape error status/body for diagnostics via lastStatus() and lastErrorBody().
  • Implement appendRetry by appending messages and copying borrowed retry slices.

OpenRouter-compatible config

Zig
var client = instructor.OpenAI.init(.{
    .allocator = gpa,
    .io = init.io,
    .api_key = init.environ_map.get("OPENROUTER_API_KEY") orelse return error.MissingApiKey,
    .base_url = "https://openrouter.ai/api/v1",
    .endpoint = .chat_completions,
    .http_referer = "https://github.com/h0rv/instructor.zig",
    .app_name = "instructor.zig",
});

Free OpenRouter models can be used by setting .model in the request to the model slug.

Core flow

Text
session.create(T, request, options)
  session.createDetailed(T, request, options)
  return result.value

session.createDetailed(T, request, options)
  createDetailedWithArena(T, temp_allocator, session.arena.allocator(), provider, request, &session.usage, session.hooks, options)
  save result.usage/text/raw_response as session.last_*

createDetailedWithArena(T, temp_allocator, result_allocator, provider, request, usage_out, hooks, options)
  req = mutable copy(request)
  schema = jsonschema.toolSchemaAlloc(T, temp_allocator, options.schema_options)
  schema = { name, description, schema_json, strict = true }
  emit request_start

  for attempt in 0..max_retries:
    completion = provider.completeStructured(temp_allocator, req, schema, options)
    usage_out.add(completion.usage)
    emit response_received

    parse_check = std.json.parseFromSliceLeaky(T, temp_attempt_arena, completion.text, options.parse_options)
    if parse ok and validation ok:
      copy completion.text/raw_response into result_allocator
      value = std.json.parseFromSliceLeaky(T, result_allocator, copied_text, options.parse_options)
      emit completion_done
      completion.deinit(temp_allocator)
      return .{ value, text, raw_response, usage }

    emit parse_error or validation_error
    completion.deinit(temp_allocator)
    if no attempts remain:
      return error.MaxRetriesExceeded

    provider.appendRetry(temp_allocator, &req, .{ failed_response, parse_or_validation_message })
    emit retry

createWithArena() follows the same parse/retry flow but returns only T and does not retain response text/raw response.

Error model and diagnostics

Zig
pub const Error = error{
    MaxRetriesExceeded,
    UnsupportedMode,
    MissingOutputText,
    InvalidProviderResponse,
};

Provider adapters can return transport/provider-specific errors too. Request extras may return InvalidExtraBody, ReservedBodyField, UnsupportedStreaming, or InvalidHeader before sending a request. See request extras for the validation rules.

Zig errors have no payload, so providers expose diagnostics separately:

Zig
const value = session.create(T, req, .{}) catch |err| {
    instructor.printError(err, &client);
    return err;
};

Low-level writer API uses std.Io.Writer:

Zig
try instructor.writeError(writer, err, &client);

Provider diagnostics include optional status/body through:

Zig
pub fn diagnostic(self: *const Provider) instructor.Diagnostic;

Improvements over Go implementation

  • No reflection-based request mutation. Provider adapters own retry mutation.
  • No runtime schema option branching for compile-time schema shape.
  • Direct T return with session-owned lifetime.
  • Client does not leak parsed results.
  • Provider request types stay concrete.
  • Validation not mixed into core orchestration.

Current scope

  • Clean Zig 0.17.0 library build.
  • Session-owned direct T API.
  • Detailed result API for raw response, response text, and per-call usage.
  • Hook API for request/response/parse/retry/completion events.
  • Fake provider tests for success, retry, detailed result, and hooks.
  • OpenAI-compatible HTTP provider for Responses and Chat Completions.
  • Local validation of supported JSON Schema constraints, enabled by default.