BAML
QuickstartBlogPodcastTeam
DiscordGitHub8,423Learn BAML
Learn BAMLQuickstartBlogPodcastTeam
DiscordGitHub8,423
Release1 day ago12 min read

BAML 0.23.0

Global variables, clearer AI traces, OpenAI structured outputs, Rust SDK improvements, and migrations for reflection, JSON, strings, and error handling.

Thanks to everyone who contributed! Special thanks to johnhkchen, gauravch-code, and harivansh-afk for their contributions, and to BenSpex, oreoorbitz, ViperJuice, and robertdevore for their reports and requests.

This canary covers changes since 0.22.2. Update the toolchain and bridge packages together, run baml generate, and rebuild applications and packed executables. Run baml ide install to refresh the editor integration. Read the migrations below before upgrading.

Global variables

A file-scope const initializes once at package startup. Initializers can call functions and depend on other globals. The binding cannot be reassigned, but an array, map, or class held by it can still be mutated and is shared across calls.

const MAX_RETRIES: int = 3;
const NAMES: string[] = ["ada", "grace"];

function retries_left(used: int) -> int {
    MAX_RETRIES - used
}

function configured_retries() -> int {
    reflect.Package.current().get_global<int>("MAX_RETRIES")
}

Use const for globals and let for locals. A top-level let now produces an error. Package.globals() lists globals and client declarations; get_global<T> throws on a missing name or wrong type, while find_global<T> returns null for a missing name. Initialization follows dependencies; reflection cannot read a global before its initializer runs.

(#5325), (#5361), (#5364)

One trace for an AI function's full execution

Direct calls, function pointers, function specs, and streams record the original AI function as one operation. A stream's operation includes consumption and final parsing. Its output is the parsed function result. Cloud uploads preserve this operation and its children. Functions with a client expression also keep their own names in traces.

You can create an operation explicitly:

function work() -> int {
    let span = trace.start_span("work", inputs = { "count": 3 });
    let result = span.run(() -> { 3 * 2 });
    span.finish(result);
    result
}

run activates the span without closing it. Finish with finish, fail, fail_with_context, or close; the first terminal action wins. Trace queries use the existing span_type and span_name columns. Stream/spec helper spans are suppressed, so dashboards that relied on those wrapper names should group by the original function instead. Self-hosted collectors must support owned spans before accepting these recordings.

(#5282), (#5310), (#5335)

Features

OpenAI Decisions

Use openai-decisions/<model> or openai.DecisionsClient.new. Boolean and float results map to predicates; enums and finite unions map to choices. Classes become named questions for their supported leaves. Float results represent predicate probabilities, not ordered-rubric scores.

function relevant(text: string) -> bool {
    client: "openai-decisions/gpt-6-luna"
    prompt: `Is this text about programming? ${text}`
}

The client supports text and images and reads OPENAI_API_KEY lazily. (#5200)

Images and PDFs through Claude Code

Claude Code prompts travel through standard input as stream JSON. Images and PDFs reach the CLI as content blocks, and long prompts no longer depend on command-line argument limits. Unsupported audio/video content produces an error.

function describe_picture(picture: image) -> string {
    client: "claude-code/haiku"
    prompt: `Describe this picture. ${picture}`
}

(#5225)

OpenAI native structured outputs

ChatClient and ResponsesClient accept structured_output = true; the default remains false. This sends a JSON schema to OpenAI and decodes the structured response. Schema-aligned parsing remains the default path.

class Answer { summary: string }

function summarize(text: string) -> Answer {
    client: openai.ChatClient.new(model = "gpt-4o-mini", structured_output = true)
    prompt: `Summarize: ${text}. ${ctx.output_format()}`
}

LLM outputs, SAP, and tool arguments now drop union members that cannot come from JSON when another member remains usable. The compiler warns about impossible output members and optional fields that can only be null. Entirely impossible outputs still fail. Schema compatibility changes are covered in the migrations below. bigint schemas use numeric strings.

(#5371), (#5398)

Build runtime types with less boilerplate

Named concrete types have a static type() method. Use reflect.Type.of<T>() for type parameters and compound spellings such as unions or arrays. reflect.Class.extends<I> copies an interface's fields and implementations. fields_from copies a class's fields and their metadata, including @skip.

interface Named { name: string }

function extended_type() -> reflect.Class {
    let base = reflect.Class.extends<Named>("NamedItem", { "count": int.type() });
    reflect.Class.builder("DetailedItem")
        .fields_from(base)
        .field("detail", string.type())
        .build()
}

fields_from copies fields only; pass implementations to build when needed. (#5272), (#5279), (#5269)

Pass Rust class arguments by reference

Generated required class parameters accept impl Borrow<Class>. Existing owned calls work, and callers can reuse a class without cloning it:

// For a generated function taking an ApiSettings class:
let first = baml_sdk::get_async(&settings, first_id).await?;
let second = baml_sdk::get_async(&settings, second_id).await?;

Nullable and defaulted parameters retain their existing forms. A borrowed async argument must outlive the future; pass an owned value or Arc when spawning work. (#5372)

Free-form JSON in Rust SDKs

Rust generation maps BAML json to baml_bridge::Json, an alias for serde_json::Value. Classes and functions using it are emitted instead of being skipped. json? becomes Option<Json>. A bare json union arm overlapping another arm remains unsupported.

let payload: baml_bridge::Json = baml_bridge::serde_json::json!({ "count": 3 });

(#5287)

Optional Serde support for generated Rust types

Enable the generated crate's serde feature:

baml_sdk = { path = "baml_sdk", features = ["serde"] }

Supported classes, enums, and unions gain serialization and deserialization. Field names follow BAML declarations. Custom BAML ToJson overrides are not applied. Unsupported fields such as bigint, bytes, functions, and handles prevent an implementation; generated documentation identifies the blocker.

Rust Serde union decoding tries declaration order. BAML's JSON decoder now uses best match, so ambiguous unions can decode differently; use discriminants where identity matters. (#5375)

Explicit SDK shutdown

Rust registers an exit hook and exposes explicit shutdown. Node exposes shutdownRuntime at the package root for exits that skip beforeExit.

To call Rust shutdown directly, add baml_bridge to your application's dependencies, matching the generated SDK's bridge version. Generated SDK crates do not re-export runtime::shutdown.

[dependencies]
baml_bridge = "=0.23.0"
baml_bridge::runtime::shutdown()?;
import { shutdownRuntime } from '@boundaryml/baml-bridge';
shutdownRuntime();

Shutdown waits for in-flight BAML work and flushes telemetry. Repeated shutdown is safe; subsequent BAML calls fail. Call it before exits that bypass normal hooks, such as Node's process.exit(). (#5293)

Launch context for packed executables

Packed executables read BAML_CONTEXT as inline JSON, @file, or - for stdin. baml run also reads it; --context takes precedence. Context and JSON arguments cannot both consume stdin.

BAML_CONTEXT='{"distinct_id":"job-123","metadata":{"queue":"imports"}}' ./my-app
BAML_CONTEXT=@context.json baml run main

(#5319)

Omit HTTP bodies from artifact recordings

Publishers can disable network-span request bodies, response bodies, streamed payloads, and network error text:

[bridge]
http_bodies = false

[pack]
http_bodies = false

Regenerate or repack to bake in the policy. Method, sanitized URL and headers, status, timing, and usage remain available. This setting does not suppress function arguments, parsed results, or function errors that contain provider response bodies. Review those capture policies separately when handling sensitive data. (#5389)

Recover runtime types from recordings

Captured classes and enums include their definitions. The SQL helper baml_code emits an expression that rebuilds a recorded runtime type; declared types are referenced by name:

SELECT baml_code(type_args['T'])
FROM spans
WHERE span_name = 'user.Pick';

Enable input/type-argument capture for the target call to populate type_args. Older recordings without definitions cannot reconstruct them. (#5169)

Faster JSON conversion

JSON conversion no longer builds an intermediate serde_json tree. The benchmarks in #5402 compare implementation c9399b85 with parent b6579e81 plus the same workloads. They use release builds on Linux, pinned to one CPU, and the median of five trials:

WorkloadBeforeAfter
Provider-shaped request serialization0.91 ms0.11 ms
Selector reads into a 1 MB JSON tree42.0 ms6.82 ms
Typed NDJSON event decoding29.4 ms10.3 ms
JSON aggregation, 2k268 ms120 ms

These are the PR's workload measurements, not an application-wide latency claim. The measured implementation remains in this release. The later union-decoding fixes measured within noise for most workloads; the union-heavy aggregation case was 1–5% slower than this intermediate revision. Override lookups are cached within a call, not across calls.

Faster ASCII indexing and safer large strings

String ropes cache character lengths and ASCII prefixes. The benchmarks in #5253 compare implementation 155e9024 with canary 5a99dfa0 in release builds on an Apple M3 Max, using nine alternating rounds for the existing string microbenchmarks. A 20k-character flat ASCII scan fell from 11.9 ms to 138 µs. These measurements exclude BAML VM dispatch. Non-ASCII scans were essentially unchanged; some append-only and short-string workloads regressed. This is a workload-specific improvement.

A separate deep-rope run could build and flatten one million appends where the baseline aborted with a stack overflow. The cached-length implementation remains in the release.

Breaking changes

Reflection names, lookups, and type views

Reflection kind views move to reflect.Class, reflect.Enum, reflect.Union, reflect.Map, reflect.Array, reflect.Literal, reflect.Primitive, and reflect.Function. Constructors become static methods. Lowercase namespaces still contain helpers such as reflect.class.Builder and reflect.class.Field.

Before:

function make_type() -> reflect.class.Type {
    reflect.class.new("Row", { "n": reflect.Type.of<int>() })
}

After:

function make_type() -> reflect.Class {
    reflect.Class.new("Row", { "n": int.type() })
}

Update generated host calls too, such as Python's reflect.class_.new to reflect.Class.new and TypeScript's reflect.class.new to reflect.Class.new.

Named get_* lookups and reflected value get/get_field now throw reflect.errors.NotFound on a miss. Replace null checks with find_*, find, or find_field, or catch NotFound.

Before:

function has_row() -> bool {
    reflect.Package.current().get_class("Row") != null
}

After:

function has_row() -> bool {
    reflect.Package.current().find_class("Row") != null
}

Type-taking APIs accept reflect.TypeView; variables passed as collections must use that element type because arrays/maps are invariant. Class and enum metadata readers move from meta() to metadata(). Keep .as_type() for equality against reflect.Type or a variable requiring that exact type. Reflection kind to_string() now returns the type name.

(#5268), (#5276), (#5277)

Runtime type bindings use let type

Replace body-level type T = unreflect(value) with let type T = value. Top-level static aliases are unchanged.

Before:

function bind_type() -> int {
    let row = reflect.class.new("Row", { "n": reflect.Type.of<int>() });
    type Row = unreflect(row);
    1
}

After:

interface Named { name: string }

function bind_type() -> int {
    let row = reflect.Class.extends<Named>("Row", {});
    let type Row extends Named = row else { return 0 };
    1
}

The bounded form requires a diverging else block. A bound mismatch takes that branch; it does not throw. Build a class before binding it: use builder.build(), not builder.type(). User-defined TypeView implementations work too. reflect.class.Field.type is non-null; remove its fallback/null check. Invalid reflection-native arguments now consistently use reflect.errors.InvalidArgument.

(#5278), (#5297), (#5270)

Qualify package-local names through root

Partially qualified cross-namespace references and a package's own configured name no longer stand in for root.

Before, from another namespace:

function item() -> models.Item { models.Item { value: 1 } }

After:

function item() -> root.models.Item { root.models.Item { value: 1 } }

Use an unqualified name within its own namespace, root.ns.Item elsewhere in the same package, and the dependency's configured name for another package. Source paths are package-relative; update tooling that treated embedded source paths as absolute. Regenerate SDKs and rebuild packed artifacts after these package-identity changes. (#5254)

Schemas require JSON-compatible types

Schema generation rejects unknown instead of treating it as an unrestricted JSON schema. Use a concrete JSON-compatible type. For an arbitrary JSON payload, use json:

Before:

function payload_schema() -> json {
    baml.json.schema(reflect.Type.of<unknown>())
}

After:

function payload_schema() -> json {
    baml.json.schema(reflect.Type.of<json>())
}

Review model outputs and tool schemas too. Impossible union alternatives can now be pruned, but an unknown value a model must fill remains unsupported. The bigint schema uses numeric strings; update custom consumers that expected a numeric schema. (#5371, #5398)

JSON and YAML conversion names

The redundant JSON helpers are removed without a deprecation period.

RemovedReplacement
from_string<T>, deserialize<T>decode<T>
to_string, serializestringify
to_json, from<T>from
from_json<T>to<T>
stringify_pretty(j)stringify(j, indent = 2)
baml.yaml.deserialize<T>baml.yaml.decode<T>

Before:

class User { name: string }
function round_trip(text: string) -> string {
    let user = baml.json.from_string<User>(text);
    baml.json.to_string(user)
}

After:

class User { name: string }
function round_trip(text: string) -> string {
    let user = baml.json.decode<User>(text);
    baml.json.stringify(user, indent = 2)
}

Instance v.to_json() and static T.from_json(j) methods stay. decode now honors FromJson overrides. Missing-field errors identify the containing object in path and the missing field in message; update consumers of error paths.

stringify accepts any value and can throw baml.json.SerializationError, including when its input is already json. Remove an incompatible throws never declaration or handle the error.

from/stringify accept naming and omit_nulls; to/decode accept naming. Aliased naming honors @alias and @skip. Null omission applies to class fields, preserving null data in maps, arrays, and JSON values.

class Request { name: string @alias("display_name"), note: string? }
function request_body() -> string {
    baml.json.stringify(
        Request { name: "Ada", note: null },
        naming = baml.json.Naming.Aliased,
        omit_nulls = true,
    )
}

(#5399)

JSON chooses the best union match and rejects non-finite text

Union decoding chooses the class consuming the most object keys. Numeric preference is int, bigint, then float; string preference is literal, enum variant, then string. Broad JSON/unknown/map alternatives come last. Declaration order breaks ties. Audit persisted unions whose earlier alternatives intentionally swallowed later ones; add discriminants when shapes are indistinguishable.

Before, this returned Empty:

class Empty {}
class Count { n: int }
function decode_count() -> Empty | Count {
    baml.json.from_string<Empty | Count>("{\"n\":1}")
}

After, this returns Count:

class Empty {}
class Count { n: int }
function decode_count() -> Empty | Count {
    baml.json.decode<Empty | Count>("{\"n\":1}")
}

stringify now throws for NaN/infinity instead of writing null. parse rejects overflowing numeric text. Validate those values or explicitly map them to null before serialization. from and in-memory to<float> can still preserve non-finite values. Inspect the more specific SerializationError.reason values instead of matching only unserializable.

Fields whose types admit null, including json and unknown, may be omitted and become null. JSON schemas and class literals follow that rule. Update validators that assumed those keys were required. (#5404)

Strings decode escapes strictly

Quoted and backtick strings decode hex, Unicode, and supported control escapes. Unknown escapes are errors. Byte strings accept byte escapes and reject Unicode escapes/non-ASCII source characters. Raw quoted strings preserve their contents; there is no raw backtick form.

Before, a regex backslash could survive as written:

function digits() -> baml.regex.Regex { baml.regex.new("\d+") }

After, use a raw string or escape the backslash:

function digits() -> baml.regex.Regex { baml.regex.new(r"\d+") }
function unicode_text() -> string { "caf\u00E9" }

Audit regexes, paths, prompts, and pasted JSON. Use r"..." or \\ where a backslash is data. Existing \xHH/\u... text now decodes; double the backslash if literal escape text is intended. Lone Unicode surrogates are errors. (#5345)

Newlines can end expressions

In blocks and match/catch arms, a newline ends an expression when the next token can start another. A next-line -, (, or [ can therefore start a new expression. Inside call parentheses, brackets, and map/object field lists, newlines remain whitespace.

Before, this meant subtraction:

function difference(a: int, b: int) -> int {
    a
    - b
}

After, preserve subtraction with parentheses:

function difference(a: int, b: int) -> int {
    (a
     - b)
}

Audit previously formatted multiline subtraction before running the new formatter: existing code may already have changed meaning when parsed. Parenthesize intended multiline expressions or keep the operator/call/index on the preceding line. The formatter now protects subtraction chains with parentheses. (#5357)

Exhaustive catches must cover their inputs

catch remains partial. catch_all must cover every ordinary error; catch_all_panics must cover ordinary errors and panics. Ordinary wildcard arms do not swallow panics, but explicitly named panic arms can handle them. Chained catches run successively, so a later clause must also cover errors from earlier handlers.

Before, an incomplete exhaustive handler was accepted:

function fail() -> int throws string | int { throw "failed" }
function recover() -> int {
    fail() catch_all (e) { int => 0 }
}

After, cover the missing case:

function fail() -> int throws string | int { throw "failed" }
function recover() -> int {
    fail() catch_all (e) { int => 0, string => -1 }
}

Use catch when propagation is intended. throws never does not promise freedom from panics. (#5317)

Nullable TypeScript keys are optional

Generated nullable fields and constructor keys now accept omission. Reads therefore include undefined under strictNullChecks.

Before:

const workspace: string | null = client.workspace;
if (client.workspace !== null) client.workspace.toUpperCase();

After:

const workspace: string | null = client.workspace ?? null;
if (client.workspace != null) client.workspace.toUpperCase();

Regenerate and update strict consumers and structural assignments. Values decoded from BAML still carry null keys. An inbound object omitting a nullable key must not also contain undeclared keys; remove the extra key or supply the nullable key explicitly. (#5377)

Model turns, usage, and query fields

ai.events.LLMCall becomes ModelCall. Requested and response-reported model identities are separate. Rename Client.id() and FunctionSpec.client_id() to requested_model().

Before:

function model_name(client: ai.Client) -> string { client.id() }

After:

function model_name(client: ai.Client) -> string { client.requested_model() }
function calls(turn: ai.ModelTurn) -> ai.events.ModelCall[] { turn.all_calls() }

ModelTurn.calls contains only its own calls; traverse all_calls() to include retry/fallback child turns. Failures live on the child turn. Replace ModelTurn.model/usage, AssistantMessage.client_id, and custom TurnMeta token fields with the new requested/resolved-model and usage structures. RunResult.usage is ModelUsage[]; token counts are nullable when unreported. The journal's separate Usage event is removed; consume usage from ModelCall events.

Update SQL integrations to read spans.self_usage and spans.total_usage. The model_usage table, temporary projections, and recording-derived cost estimates are removed. Recordings predating format minor 11 have no new usage data. Regenerate SDKs and rebuild consumers of these event types. (#5261)

Bug fixes

  • Generic LLM return types retain their realized type arguments during schema-aligned parsing. (#5252)
  • Generic receiver inference handles previously crashing arithmetic/method chains. Unresolved members and inference failures produce diagnostics instead of silently reaching emission; legitimate unknown values remain usable. Replace map dot access with indexing if diagnosed. (#5038), (#5243), (#5401)
  • Optional chaining handles nested nullable unions, and complex function types keep their callable structure inside unions. (#5405), (#5387)
  • Instance methods resolve through aliases. Invalid assignment targets such as [x, y] = [1, 2] are rejected; assign to variables or indexed/field locations instead. (#5358), (#5020)
  • Host-call arguments are checked recursively against declared types. Invalid scalar, list, map, or class arguments report type errors instead of reaching unsafe VM operations. This does not claim to close every opaque-handle or host-return validation gap. (#5333)
  • Omitted nullable host class fields become null. Missing required fields still fail, and undeclared keys beside an omitted nullable field are reported. (#5291)
  • Swift generated wrappers no longer shadow an argument named args. Regenerate affected SDKs. (#5261)
  • Rust union variants gain namespace qualifiers when short names collide, restoring affected generated functions. Match the regenerated variant names. Rust class cycles through aliases are boxed; use Box::new when constructing newly boxed fields. (#5320), (#5374)
  • Generated SDK bytecode excludes tests/testsets and avoids embedding their machine-specific paths. Regenerate to receive the deterministic payload. SDK distributions include the Apache license. (#5273), (#5275)
  • Runtime initialization accepts directory names ending in forward slashes. (#5223)
  • Native HTTP operations reuse clients and connections for matching timeout/proxy/TLS settings instead of building a client per request. (#5373)
  • Boundary telemetry failures warn and continue by default. Explicit abort policies still apply. Cloud delivery honors proxy environment variables, and final delivery has a five-second budget; remaining data can be dropped when the budget expires. (#5280), (#5380), (#5379)
  • Generated SDKs and packed artifacts tolerate an empty or invalid telemetry level by recording nothing. Empty destination settings mean unset; an invalid cloud URL falls back to local recording. CLI and source-runtime validation retain their actionable errors, and other credential failures can still reject loading. (#5388)
  • Telemetry preparation/uploads proceed concurrently, with memory budgets released as captures advance. Upload acknowledgments do not skip unstored recording files. (#5390)
  • JSON cycles produce serialization errors; deeply nested values no longer rely on the intermediate recursive conversion tree. (#5402)
  • VM stacks have a 10 MiB budget per BAML thread, counting frame and value slots, instead of a fixed 256-frame limit. Overflow raises catchable baml.panics.StackOverflow; use a named panic arm or catch_all_panics to recover. (#5386)
  • Editor grammars highlight runtime let type bindings. (#5316)
  • Shipped Rust networking/TLS dependencies and trust stores are updated. Upgrade toolchains and bridge packages to pick up the changes. (#5271), (#5356)

Boundary

Basically a made up language.

  • Company
  • About Us
  • Why BAML?
  • Privacy Policy
  • Terms of Service
  • Resources
  • Docs
  • Jobs
  • Blog
  • Pricing
  • Social
  • GitHub
  • Twitter
  • Discord
  • LinkedIn
  • YouTube