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

BAML 0.20.1

Simplified streaming, bigints in JSON, the TypeSafe JEV client, lower memory use, rename in the editor, and the Collector API removal.

Thanks to everyone who contributed!

Special thanks to sbguangha for contributing the BAML_HOME fix, and to Ben for reporting the inactive Collector API and the bigint JSON round-trip.

This canary updates the language, SDKs, editor integration, and developer documentation since 0.19.0. Read the breaking changes before upgrading. Update your toolchain and bridge packages together, run baml generate, and rebuild applications and packed executables. Reinstall the matching agent skill with baml agent install and update the editor integration with baml ide install.

0.20.1 replaces 0.20.0, which was published for two days. On 0.20.0 the aarch64 Linux toolchain, the linux-arm64-gnu Node package, and every Linux Python wheel shipped without their build fingerprint, so a generated SDK failed to load. Upgrade rather than staying on 0.20.0; the bug fixes below have the details.

Simplified streaming

Streaming no longer uses a separate partial type. Fn@stream returns ai.stream.Stream<T>, where T is the function's declared return type. next() returns T | ai.stream.Done. final() returns T. A partial value is the declared type parsed from the text received so far. Generated Foo$stream companion classes no longer exist. This implements BEP-075. (#4935)

Stream attributes now belong to declarations, not types. @stream.done and @stream.must_exist follow a class field. @@stream.done applies to a whole class. Attributes on type expressions are rejected. @stream.with_state is removed, and this release has no replacement for per-field streaming state.

class Receipt {
    vendor: string @stream.done,
    total: float?,
    items: string[],
}

function extract(text: string) -> Receipt {
    client: "openai/gpt-5.6-luna"
    prompt: `${text} ${ctx.output_format()}`
}

function vendors_seen(text: string) -> string[] {
    let stream = extract@stream(text);
    let seen: string[] = [];
    while (true) {
        match (stream.next()) {
            ai.stream.Done => {
                break;
            },
            let partial: Receipt => {
                seen.push(partial.vendor);
            },
        }
    }
    seen
}

While a class streams, each field is complete, incomplete, or still pending. A complete field is used as-is. An incomplete field uses its partial parse when one exists. Otherwise the field takes its default:

Field typeDefault while pending or incomplete
string""
Arrays and maps[] and {}
Nullable typesnull
Literals and one-variant enumsThe value
ClassesIts fields' defaults, or none if any field has none
int, bigint, float, bool, media, other enums, non-nullable unionsNone

A class with a required field that has no default has no partial value until that field is complete. Declare such a field as optional if you want earlier partials. @stream.done on a field means an incomplete value is never used; the field keeps its default until it completes. @stream.must_exist removes the field's default, so the class has no partial until the field appears. @@stream.done on a class means the class is never parsed from an incomplete object.

Before, on 0.19.0:

class Receipt {
    vendor: string,
    total: float? @stream.with_state,
    items: (string @stream.done)[],
}

function receipt_stream(text: string) -> ai.stream.Stream<Receipt$stream | null, Receipt> {
    extract@stream(text)
}

After:

class Receipt {
    vendor: string,
    total: float?,
    items: string[] @stream.done,
}

function receipt_stream(text: string) -> ai.stream.Stream<Receipt> {
    extract@stream(text)
}

Replace ai.stream.Stream<Partial, Final> with ai.stream.Stream<Final>. Replace references to Foo$stream with Foo. Move an attribute from a type expression to the field that declares it; an element-level @stream.done becomes a field-level one. Delete @stream.with_state. ai.stream.from_spec<T> also takes one type argument. The generated SDK changes are listed under breaking changes.

Features

Bigints in JSON

A bigint now survives JSON. It serializes as an arbitrary-precision JSON number, and typed decoding reads it back exactly, so timestamps and ids no longer lose digits on the way out and back. baml.json.parse reads an integral number too large for int as a bigint instead of rounding it into a float. Bigint literal types such as 1n are supported, in aliases, class fields, and unions. (#4934)

class Reading {
    sensor: string,
    nanoseconds: bigint,
}

type Ack = 1n;

function round_trip(text: string) -> string {
    let reading = baml.json.from_string<Reading>(text);
    baml.json.to_string(reading)
}

Given {"sensor":"probe","nanoseconds":123456789012345678901234567890}, that returns the same digits. On 0.19.0 the same value came back as "1.2345678901234568e+29".

Typed decoding also accepts a bigint written as a decimal string, so {"nanoseconds": "42"} still decodes. The Go, Swift, Java, and C++ bridges keep bigint values inside recursive json aliases. baml.toml conversion rejects a bigint explicitly rather than silently narrowing it. The wire format changed, so read the breaking changes before upgrading.

TypeSafe JEV client

BAML can call TypeSafe's JEV classifier with client: "typesafeai/jev-latest", or typesafeai.Client.new(...) to set the model, credential, base URL, or timeout. The credential defaults to env.TYPESAFE_API_KEY. (#4938)

enum Sentiment {
    Positive,
    Negative,
}

function classify(text: string) -> Sentiment {
    client: "typesafeai/jev-latest"
    prompt: `Classify the sentiment of: ${text}`
}

The client is built entirely from BAML reflection, with no JEV-specific compiler support. Bool and float outputs map to JEV's Noul; enums, enum subsets, literal unions, and nullable finite choices map to Choice; a class becomes one question per leaf field. Output types JEV cannot answer, media, tools, and empty instructions are rejected before any HTTP request. JEV does not stream.

Lower memory use under load

Programs that make many short calls or spawn many tasks now use far less memory, and garbage collection itself is faster. There is nothing to configure and no code to change. (#4831, #4840, #4847)

On a spawn-heavy workload, peak memory fell by more than half. A parent spawns 10,000 children, each retaining 256 three-element arrays, then awaits them all. Peak resident memory was 1,519 / 1,556 / 1,553 MB on the published 0.19.0 toolchain and 694 / 715 / 703 MB on this release. We measured three runs each on macOS arm64 with /usr/bin/time -l. The binary for this release was an unoptimized debug build, so wall-clock time is not comparable and only memory is reported. This is one workload on one machine, not a general guarantee.

Collection is also 1.1× to 2.1× faster on the engine's allocation benchmarks:

WorkloadBeforeAfterMedian paired speedup
Tiny object-producing calls1.164 s0.620 s1.88×
100k tiny calls5.008 s2.383 s2.10×
Retained arrays, minor/full collections8.340 s4.375 s1.83×
Retained string payloads0.130 s0.060 s2.13×
Permanent cache plus transient work1.334 s1.203 s1.11×
Concurrent async callers0.418 s0.369 s1.13×

These numbers come from #4831. The author ran the engine experiment harness on macOS ARM64 in the optimized fasttest profile, with three randomized pairs per workload. The baseline was the same source with the two optimized functions reverted. Speedups are medians of paired ratios, and a no-allocation control measured 0.96–0.99×. They measure the engine, not end-to-end SDK latency. Both optimizations remain in this release.

How it works: the runtime now tracks how much memory each heap has reserved since its last collection, and collects when that passes a budget that grows with the amount of live data. Previously, short calls could reserve memory without ever triggering a collection. The runtime also checks that pressure in more places, including inside long loops and after every spawn, so a burst of short-lived tasks can no longer outrun the collector. Short calls reserve less memory up front, and the collector takes fewer locks while scanning.

One limit to know about: the trigger counts objects, not the bytes behind large strings, images, or byte arrays. A service that continuously produces large payloads can hold dead buffers until it goes idle.

Idle processes release memory

After a burst of calls, an idle process now frees that burst's garbage after about 100 ms of quiet instead of holding it until the next call. Dropping the last handle to a result after the final call triggers the same cleanup. This applies to the CLI and every native SDK. (#4844)

A call that crosses the allocation budget also returns its result immediately rather than collecting first. The next call, or the idle cleanup, pays instead.

The WebAssembly bridge, used in browsers and Cloudflare Workers, has no background timer, so overdue cleanup runs at the start of the next call. Freeing BAML objects does not guarantee the allocator returns pages to the operating system, so resident memory may not drop right away.

Rename symbols in the editor

The language server supports rename. Press F2 in VS Code to rename a class, enum, function, interface, interface method, class field, or local binding. Renaming an interface method renames every implementation across files. Renaming a class field also updates the interface link that names it. The edit is all-or-nothing: the server either rewrites every reference or refuses with a reason. It refuses names declared outside the workspace, such as the standard library, class fields that satisfy an interface field by spelling alone, and symbol kinds whose references it cannot yet find completely. The editor greys out F2 where rename is unavailable. Update the toolchain to get this; the extension does not need a separate change. (#4873)

Standard-library internals stay out of the way

Completions and baml describe listings hide standard-library items whose names start with _ unless you type the _ yourself. baml describe baml.sap now lists parse and parse_type; baml describe baml.sap._ suggests the internal helpers. (#4873)

Implementations in baml describe and hover

baml describe, hover, and completions list a class's or enum's implement blocks with their methods and locations, the way rustdoc does. Interface members are no longer rendered as inherent members. (#4801)

interface Named {
    function label(self) -> string throws never;
}

enum Color {
    Red,
    Green,
}

implements Named for Color {
    function label(self) -> string throws never { "color" }
}
$ baml describe Color
enum Color  baml_src/main.baml:5-8

enum Color {
    Red,
    Green,
}

implementations (2):
  implement Named for Color  baml_src/main.baml:10
    function label(self) -> string throws never  baml_src/main.baml:11-11
  implement<T> Concrete for T  <builtin>/baml/core.baml:20

references (0):

Structured compiler diagnostics in reflection

reflect.Diagnostic now carries severity ("error", "warning", or "info"), phase ("parse", "hir", "validation", "type", or null for runtime-generated diagnostics), headline, primary_label, message_highlights, annotations, and related_info. message keeps the flattened headline-plus-label form. The new DiagnosticHighlight, DiagnosticAnnotation, and DiagnosticRelatedInfo classes describe the parts. (#4866)

function first_problem(source: string) -> string {
    let pkg = reflect.Package.compile({ "main.baml": source }) catch (e) {
        let err: reflect.errors.CompilationError => {
            let d = err.diagnostics[0];
            return d.code + " " + d.severity + " " + (d.phase ?? "-") + " :: " + d.headline;
        },
    };
    "compiled"
}

For function f() -> int { "oops" } this returns E0001 error type :: mismatched types.

baml generate leaves unchanged output untouched

When the generated tree already matches byte for byte, baml generate installs nothing and every file keeps its inode and modification time. Cargo, uv, pnpm, Gradle, and MSBuild caches keyed on those times are no longer invalidated by a no-op regeneration. Hand-edited generated files are still restored. (#4852)

Images and vision models example

The developer documentation gains a runnable images and vision models guide. It covers asking questions about an image, mixing text and labeled image arrays in a prompt, loading URL, file, and base64 inputs, extracting a typed Receipt, switching between OpenAI, Anthropic, and Google, and writing a custom ai.Client for Cloudflare's vision API. (#4885)

Common programming concepts, two ways to read it

The book gains a chapter on common programming concepts, which you can read from the basics or as someone coming from TypeScript. The TypeScript version keeps familiar material collapsed and puts each TypeScript idiom beside its BAML equivalent. Your choice follows you across chapters and is part of the link you share. (#4946)

Breaking changes

Generated streaming bindings

Regenerate every host client with baml generate. Partial values now have the same type as final values, and the separate partial types are gone. (#4935)

Python: the baml_sdk.stream_types package is removed. Fn_stream(...).next() returns T | Done and never None. Fn_stream and Fn_stream_async keep their names.

Before:

from baml_sdk import extract_stream
from baml_sdk.ai.stream import Done
from baml_sdk.stream_types import Receipt as PartialReceipt

stream = extract_stream("Total: 42")
while True:
    partial = stream.next()
    if isinstance(partial, Done):
        break
    if partial is not None:
        assert isinstance(partial, PartialReceipt)
receipt = stream.final()

After:

from baml_sdk import extract_stream, Receipt
from baml_sdk.ai.stream import Done

stream = extract_stream("Total: 42")
while True:
    partial = stream.next()
    if isinstance(partial, Done):
        break
    assert isinstance(partial, Receipt)
receipt = stream.final()

TypeScript: Foo$stream classes are removed from every leaf and from the type map. Fn$stream and Fn$stream_async keep their names and now return ai.stream.Stream<Foo>. The 0.19.0 note to keep Receipt$stream type names no longer applies.

Before:

import { ai, extract$stream_async, Receipt$stream } from "./baml_sdk/index.js";

const stream = await extract$stream_async("Total: 42");
while (true) {
  const partial: unknown = await stream.nextAsync();
  if (partial instanceof ai.stream.Done) break;
  if (partial !== null) console.log((partial as Receipt$stream).vendor);
}
const receipt = await stream.finalAsync();

After:

import { ai, extract$stream_async, Receipt } from "./baml_sdk/index.js";

const stream = await extract$stream_async("Total: 42");
while (true) {
  const partial: unknown = await stream.nextAsync();
  if (partial instanceof ai.stream.Done) break;
  console.log((partial as Receipt).vendor);
}
const receipt = await stream.finalAsync();

Every bridge's stream type takes one type parameter: Rust baml_bridge::Stream<T>, Go baml_go.Stream[T] with a single decoder in DecodeStream, Java BamlStream<T>, Kotlin BamlStream<T>.asFlow() and awaitFinal(), Swift BamlStream<Value> and BamlStreamNext<Value>, C# BamlStream<T> implementing IAsyncEnumerable<T>, and C++ baml::stream<T>. Replace the two-parameter spellings and remove any handling of null partials that existed only because of the old partial type.

Collector API removed

The Python, Node, and web bridges no longer export Collector, FunctionLog, Timing, Usage, or LLMCall. The collectors argument is removed from call_function, call_function_sync, callFunction, and callFunctionSync. The API had reported empty logs and zero usage since tracing was removed. (#4833)

Remove Collector imports and arguments. In TypeScript, an explicit call context moves from the sixth argument to the fifth:

// Before
callFunction(runtime, name, args, undefined, undefined, callContext);
// After
callFunction(runtime, name, args, undefined, callContext);

In BAML v1, ai.Journal is the supported interface for retrieving events. A journal is the complete record of one run: every message, model call, usage report, tool call, and repair attempt. Run a function's spec through ai.Agent to get its journal back with the result:

function answer(question: string) -> string {
    client: "openai/gpt-5.6-luna"
    prompt: `${question}`
}

class Answered {
    text: string,
    events: ai.events.Event[],
    usage: ai.events.Usage,
}

function answer_with_record(question: string) -> Answered {
    let result = ai.Agent.new().run(answer@spec(question));
    Answered { text: result.value, events: result.journal.entries(), usage: result.usage }
}

result.journal.entries() returns the run's ai.events.Event values in order. For a single successful call these are RunStarted, AssistantMessage, LLMCall, Usage, and FinalProduced. result.usage is the run's total. ai.events.Usage carries input_tokens, output_tokens, cached_input_tokens, and reasoning_tokens. ai.events.LLMCall carries the client name, provider, timing, and, with capture_wire, the request and response. To receive the same events while the run is in progress, pass an on_event listener to ai.Agent.new or to a generated function binding. testing.TestCollector, host span-depth APIs, and the profiling store are unchanged.

JSON bigint representation

baml.json.to_string renders a bigint as a JSON number; it used to render a decimal string. baml.json.json gained a bigint arm, so an exhaustive match over it needs one more arm. (#4934)

Before:

function kind(value: baml.json.json) -> string {
    match (value) {
        null => "null",
        let b: bool => "bool",
        let i: int => "int",
        let f: float => "float",
        let s: string => "string",
        let a: baml.json.json[] => "array",
        let m: map<string, baml.json.json> => "map",
    }
}

After:

function kind(value: baml.json.json) -> string {
    match (value) {
        null => "null",
        let b: bool => "bool",
        let i: int => "int",
        let g: bigint => "bigint",
        let f: float => "float",
        let s: string => "string",
        let a: baml.json.json[] => "array",
        let m: map<string, baml.json.json> => "map",
    }
}

Without the new arm the compiler reports E0062 non-exhaustive match on type baml.json.json; missing: bigint. Update anything that consumes BAML-produced JSON and expected a bigint as "12", because it now reads 12. Decoding accepts both spellings, so a producer can move before its consumers do.

Reflection metadata and literal values

reflect.Meta gained a skip field, which is true for a class field or enum variant marked @skip. Every reflect.Meta literal must now set it. reflect.literal.Type.value() returns unknown and throws never, instead of returning string | int | bigint | bool and throwing InvalidArgument; it can now return an enum variant, which belongs to no fixed union. Matches on the result need a wildcard arm. (#4938)

function label(literal: reflect.literal.Type) -> string {
    match (literal.value()) {
        let text: string => text,
        let number: int => number.to_string(),
        _ => "other",
    }
}

Bug fixes

  • A bigint survives a JSON round trip. baml.json.from_string<T>, to<T>, from_json<T>, and deserialize<T> decode one from an integral JSON number or a decimal string, in nested classes, containers, and unions, instead of throwing bigint JSON decoding not yet implemented. An integral JSON number too large for the runtime's bigint now fails explicitly rather than silently becoming a float. (#4934, reported in #4815)
  • 0.20.0's aarch64 Linux toolchain, @boundaryml/baml-bridge-linux-arm64-gnu package, and all four Linux Python wheels were built without their build fingerprint, so a bridge refused the bytecode a toolchain generated with baml_sdk ... could not be loaded. Every 0.20.1 artifact carries the commit it was built from, a canary build now refuses to compile without one, and the release checks every artifact before publishing. If you used 0.20.0 on those platforms, update the toolchain and bridge package together and re-run baml generate. (#4945)
  • Closures see the values they captured. A captured variable read before a closure, call, branch, or spawned task writes it keeps the earlier value. Each loop iteration's closures capture that iteration's let, C-style for header, match, or catch binding instead of sharing one cell; previously every closure reported the last iteration's value. A baml.time.Instant.now() or other system-operation result assigned to a field or a captured variable is now stored. Implementations whose generic parameters are bound only by interface arguments dispatch correctly. (#4891)
  • Calling a function-typed interface field through an interface-typed receiver, including self.field(...) inside a default method, works instead of failing with virtual call could not resolve interface method. Detected invariant violations in interface dispatch report a readable internal error instead of crashing or returning a wrong result. Implementation blocks are cached under a coherence key, so a stale bytecode cache is no longer served. (#4801)
  • Lambdas and callbacks receive the same throws validation as named functions. throws unknown on a lambda that does not throw, or that throws a narrower type, is an E0097 error; remove it. Declared error members a lambda never throws produce an E0097 warning. A nested lambda's throw no longer satisfies the enclosing function's contract. Contextual callback expectations and open clauses such as throws string | _ are not treated as closed contracts. (#4832)
  • Runtime compilation with mounted packages validates a local implements block for a foreign receiver like a static one, lowers associated-type chains in order, and no longer lets a package-interface stub shadow a mounted export during type resolution. (#4866)
  • An empty or whitespace-only BAML_HOME or HOME is treated as unset. On Windows the CLI uses USERPROFILE only. Project discovery stops at the user home instead of walking to the drive root, and ~\ path selectors expand like ~/. (#4865)
  • The getting-started, TypeScript bridge, classifier, receipt, and concurrency guides use current APIs and include installation and PATH steps for macOS, Linux, Windows, and Arch. $stream reference URLs no longer return 404, and section anchors no longer collide with member anchors. (#4886)

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