Back to Baml

BAML for C#

baml_language/sdks/csharp/bridge_csharp/src/README.md

0.226.114.0 KB
Original Source

BAML for C#

baml-bridge is the .NET 10 runtime for C# source generated by the BAML CLI. The generated baml_client/ directory contains the compiled BAML program and strongly typed call surfaces; the NuGet package contains the managed runtime and native assets.

Requirements

  • .NET SDK 10 and C# 14
  • nullable reference types enabled
  • a baml-bridge package version exactly matching the BAML CLI that generated the client

The package supports osx-arm64, osx-x64, linux-arm64, linux-musl-arm64, linux-x64, linux-musl-x64, win-x64, and win-arm64. It supports normal and trimmed JIT deployment, including single-file apps. NativeAOT is deliberately unsupported and fails the build with BAML0019.

Install and generate

Add the exact runtime package to an existing .NET project:

xml
<ItemGroup>
  <PackageReference Include="baml-bridge" Version="[0.15.0]" />
</ItemGroup>

Configure the C# generator in baml.toml:

toml
[package]
name = "my-application"

[generator.csharp]
output_type = "csharp"
output_dir = "."
naming_convention = "language"

Run baml generate from the project. The CLI atomically replaces only its owned baml_client/ directory. Commit that directory when generated clients are committed in your repository. Do not hand-edit generated files.

The consumer project should use the product baseline:

xml
<PropertyGroup>
  <TargetFramework>net10.0</TargetFramework>
  <LangVersion>14.0</LangVersion>
  <Nullable>enable</Nullable>
  <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>

Functions and generated values

Free BAML functions are static members of a namespace-local Functions class. The unsuffixed method is synchronous; the Async method returns a Task<T>. Both use the same native operation and accept a final cancellation token:

csharp
using CsharpSlice;

string result = Functions.PrimitiveSlice(
    flag: true,
    count: 42,
    ratio: 1.25,
    text: "hello",
    nullable: null,
    cancellationToken);
string asyncResult = await Functions.PrimitiveSliceAsync(
    flag: false,
    count: -17,
    ratio: -2.5,
    text: "hello",
    nullable: "present",
    cancellationToken);

Generated classes are sealed partial classes with required init-only properties. Generated enums use explicit stable long values while their wire names remain independent. Lists and maps decode to owned read-only snapshots. BAML int is long and is checked against the BAML range [-2^62, 2^62-1]; bigint is System.Numerics.BigInteger, and uint8array is ReadOnlyMemory<byte>.

Static and instance BAML methods project to static and instance C# methods. The instance receiver is encoded by generated code; it never appears as a public self argument.

Optionality, nullability, unions, and generics

A BAML argument with a default is emitted as BamlOptional<T> argument = default. Omit it to let BAML evaluate the declared default, pass a value directly through the implicit conversion, or use BamlOptional<T>.FromValue. For a nullable defaulted argument, an omitted value and an explicitly supplied null remain distinct.

Ordinary nullable value positions use C# nullable types when they are unambiguous. Generic nullable-reference bindings use BamlNullable<T> because the CLR cannot distinguish typeof(string) from typeof(string?):

csharp
BamlOptional<string?> omitted = default;
BamlOptional<string?> explicitNull = BamlOptional<string?>.FromValue(null);
BamlNullable<string> explicitValue = BamlNullable.FromValue("value");
BamlNullable<string> genericNull = BamlNullable.Null<string>();

Generated generic APIs use ordinary C# type parameters. Supply explicit type arguments for result-only generics, bare nulls, or other calls where C# cannot infer T. Only canonical BAML mappings are accepted; the bridge rejects host-only types and noncanonical substitutes before native dispatch.

BAML unions project to BamlUnion<T0,...,TN> (arity 2 through 32). Generated user unions must have distinct CLR projections: for example, int | "fixed" is supported, while string | "fixed" and aliases that erase to the same CLR type are rejected because Canary cannot preserve that occurrence identity through execution. Use the explicit FromTn factory and match on the active case rather than inferring it from the payload type.

Callbacks

A BAML callable argument is a Func<...,CancellationToken,Task<TResult>> (or the Task form for void). The injected token is always last. Optional BAML callback parameters are declaration-ordered BamlOptional<T> arguments:

csharp
Func<long, CancellationToken, Task<long>> callback =
    async (value, cancellationToken) =>
    {
        await Task.Yield();
        cancellationToken.ThrowIfCancellationRequested();
        return checked(value * 2);
    };

long result = await callback(21, cancellationToken);

Pass that delegate to the corresponding generated callable parameter; the generated signature supplies its exact argument types.

For a tokenless synchronous callback, use BamlCallback.FromSync. It accepts value-returning Func and BAML-void Action callbacks with zero through fifteen BAML parameters and returns the same canonical Task-based delegate:

csharp
long result = Functions.InvokeDeferred(
    BamlCallback.FromSync<long, long>(value => checked(value * 2)),
    21);

Constructing the adapter does not run the callback. The injected token is ignored by this tokenless parity form; callbacks that need cancellation should use the canonical asynchronous delegate directly. Synchronous exceptions use the same exact managed-exception restoration path.

Callbacks may suspend. The runtime restores the captured execution context, does not run application code inline on the native callback stack, and returns the exact original managed exception (including its preserved stack) when a callback fails. A cancellation using the supplied linked token is classified as cancellation; an unrelated OperationCanceledException remains a fault.

Streams and semantic partials

A compiler-declared stream companion is one cold synchronous factory:

csharp
static async Task<TFinal> ConsumeStreamAsync<TPartial, TFinal>(
    BamlStream<TPartial, TFinal> stream,
    Func<TPartial, Task> onPartial,
    CancellationToken cancellationToken)
{
    await using (stream)
    {
        await foreach (TPartial partial in
            stream.WithCancellation(cancellationToken).ConfigureAwait(false))
        {
            await onPartial(partial).ConfigureAwait(false);
        }

        return await stream.GetFinalResponseAsync(cancellationToken)
            .ConfigureAwait(false);
    }
}

The native stream starts once when partial enumeration or final retrieval first demands it. There is one partial consumer, while multiple callers may await the cached final result. Final-only use performs no partial pulls. Canceling one final waiter does not cancel the shared operation. Disposing a running stream ends it with BamlCancellationOrigin.StreamDisposed and releases native state exactly once.

Compiler semantic-partial annotations determine the generated partial class. Pending fields are nullable and not required, must-exist fields remain required, and @stream.with_state projects to BamlStreamState<T> with Pending, Incomplete, and Complete states.

Media, dynamic values, and opaque resources

BamlImage, BamlAudio, BamlVideo, and BamlPdf are immutable URL-or-bytes values. Byte factories and decoded values own a snapshot. Their default string representations redact byte contents and sensitive URL query/fragment data. They retain no native handle. Treat application-supplied media URLs as SSRF-sensitive input and enforce the application's URL policy before passing them to provider-backed BAML calls.

BamlValue is the explicit dynamic carrier. Use BamlValue.From(value) and value.As<T>() only for registered generated or built-in types. Inspect Kind, Type, and the typed TryGet... methods instead of reflection or serialization conventions. Dynamic lists/maps are owned read-only snapshots; limits and cycles fail deterministically.

Native resources such as files, sockets, CSV readers, HTTP responses, glob objects, and task groups project to generated nominal resource classes. The class owns an internal BamlHandle, implements IDisposable, exposes a typed Clone, and carries the standard-library methods that have a supported public C# signature. Generated standard-library Functions classes expose resource factories and related free functions. For example:

csharp
using Baml.Fs.File file = Baml.Fs.Functions.Open(path, "r");
string first = file.Read(3);
string second = await file.ReadAsync(3);
file.SeekFrom("start", 0);

The two reads use the same native object, so second starts at the cursor left by first. Applications cannot inspect a raw native key or manufacture a resource. Clone a resource when independent managed ownership is required and dispose every owned wrapper. Passing a closed resource or mismatched generic metadata fails before or at the typed boundary; distinct resource identities are distinct CLR types and therefore usually fail at compile time.

Clients and modular companions

An LLM callable with a client option accepts BamlClient; use BamlClient.FromShorthand("provider/model") for a shorthand override. The bridge does not read provider defaults or credentials while constructing that managed value.

Compiler-declared modular operations use the same owner and typed arguments:

  • FunctionRenderPrompt / FunctionRenderPromptAsync
  • FunctionBuildRequest / FunctionBuildRequestAsync
  • FunctionBuildStreamRequest / FunctionBuildStreamRequestAsync
  • FunctionParseResponse / FunctionParseResponseAsync
  • FunctionParseStreamResponse, returning BamlStream<TPartial,TFinal>

The current BAML baml.http.Request value contains only method, URL, a single-value header map, and a UTF-8 string body. It does not carry the request ID, duplicate-header order, content type, or raw body bytes required by the frozen BamlHttpRequest contract. Build-request decoding therefore fails closed with BamlProtocolException; it never fabricates those fields or sends the request. This limitation must be removed in the core request carrier before application-owned exact HTTP transport can be enabled.

The current outbound bridge serializes PromptAst as a structural prompt tree, while C# v1 intentionally defines neither a public prompt-tree model nor a reflection-based reconstruction fallback. Render-prompt decoding therefore also fails closed with BamlProtocolException; it is not an opaque BamlHandle in the current carrier.

Errors and cancellation

Catch the narrowest useful type:

csharp
try
{
    _ = await Functions.PrimitiveSliceAsync(
        true, 1, 1.0, text, null, cancellationToken);
}
catch (BamlTypeMismatchException error)
{
    // The exact typed thrown value is retained.
    _ = error.ThrownValue;
    throw;
}
catch (BamlErrorException error)
{
    // User-thrown BAML error; ThrownValue and Trace retain structured identity.
    _ = error.ThrownValue;
    throw;
}
catch (BamlPanicException error)
{
    // Catchable non-exit BAML panic.
    throw;
}
catch (BamlOperationCanceledException error)
    when (error.Origin == BamlCancellationOrigin.Caller)
{
    throw;
}

BamlOperationCanceledException derives from OperationCanceledException and preserves its winning token and origin (Caller, Engine, or StreamDisposed). Hard BAML process exit is not catchable; its BAML long code is clamped to the CLR int range and passed to Environment.Exit.

Default exception messages and security-sensitive carrier formatting (BamlValue, media, and requests) do not render secret payloads, media contents, request URLs, or bodies. Ordinary wrappers such as BamlOptional<T> format their contained value, so do not log them when that value is sensitive. Inspect structured properties deliberately.

Dependency injection and tests

Generated entry points are static by design. Keep them at the composition boundary and wrap the calls your application uses:

csharp
public interface IPrimitiveService
{
    Task<string> EchoAsync(string text, CancellationToken cancellationToken);
}

public sealed class PrimitiveService : IPrimitiveService
{
    public Task<string> EchoAsync(
        string text,
        CancellationToken cancellationToken) =>
        CsharpSlice.Functions.PrimitiveSliceAsync(
            flag: true,
            count: 1,
            ratio: 1.0,
            text,
            nullable: null,
            cancellationToken);
}

Tests can replace IPrimitiveService without mocking the native bridge. Keep generated nominal types as DTOs; their partial declarations may add application-owned helpers but must not replace generated required properties or codecs.

The repository's Baml.Bridge.DocumentationConsumer compiles these public patterns with nullable analysis and warnings as errors. Product verification runs that consumer from the exact assembled package, and the release workflow runs it again from a clean public NuGet cache after publication.

Publishing and trimming

Use ordinary dotnet publish for framework-dependent, self-contained, trimmed, and single-file JIT applications. RID-specific publication selects exactly one package native asset. Do not copy native binaries by hand or set a production library-path override.

Generated field codecs and factories never discover model members through reflection and are trim-safe. Canonical generic closures use a narrowly annotated JIT factory only for generator-registered open types and validated canonical type arguments; NativeAOT remains unsupported. If your own code discovers public models only through reflection, your application owns the corresponding linker roots (for example through DynamicallyAccessedMembers). The bridge does not keep arbitrary application types alive on the assumption that they might be reflected over.

The package and generated client versions must match exactly. Do not use floating package ranges: generated/runtime/native skew fails closed during program registration.