baml_language/sdks/csharp/bridge_csharp/src/README.md
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.
baml-bridge package version exactly matching the BAML CLI that generated
the clientThe 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.
Add the exact runtime package to an existing .NET project:
<ItemGroup>
<PackageReference Include="baml-bridge" Version="[0.15.0]" />
</ItemGroup>
Configure the C# generator in baml.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:
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>14.0</LangVersion>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
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:
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.
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?):
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.
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:
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:
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.
A compiler-declared stream companion is one cold synchronous factory:
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.
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:
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.
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 / FunctionRenderPromptAsyncFunctionBuildRequest / FunctionBuildRequestAsyncFunctionBuildStreamRequest / FunctionBuildStreamRequestAsyncFunctionParseResponse / FunctionParseResponseAsyncFunctionParseStreamResponse, 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.
Catch the narrowest useful type:
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.
Generated entry points are static by design. Keep them at the composition boundary and wrap the calls your application uses:
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.
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.