The SDK ships a low-level HTTP client for the Braintrust REST API.
If you just want to run evals or trace AI calls, prefer
Braintrust.Sdk.Eval.EvalandBraintrust.Get(). Reach for the API client only when you need raw REST access.
The client is generated code. Every operation and model comes from Braintrust's public OpenAPI spec:
- Spec repo: https://github.com/braintrustdata/braintrust-openapi
- The exact commit we generate against is pinned as
BraintrustOpenApiRefinsrc/Braintrust.Sdk.Api.Generated/Braintrust.Sdk.Api.Generated.csproj.
Nothing is committed: the build downloads the pinned spec, normalizes it, runs NSwag, and
compiles the result. The generated assembly ships inside the Braintrust.Sdk package, so
there is no second package to install. (Working in this repo is the exception: a project that
touches generated types needs its own ProjectReference to Braintrust.Sdk.Api.Generated,
because Braintrust.Sdk references it with PrivateAssets="all". See
examples/ApiClientExample.)
BraintrustOpenApiClient is the SDK's own client; its Api property is the generated
client, already wired up with the base URL, bearer auth and timeout from your config.
using Braintrust.Sdk.Api;
using Braintrust.Sdk.Config;
using Generated = Braintrust.Sdk.Api.Generated;
using var client = BraintrustOpenApiClient.Of(BraintrustConfig.FromEnvironment());
Generated.IBraintrustGeneratedApiClient api = client.Api;
// Create a project (POST /v1/project upserts by name).
Generated.Project project = await api.PostProjectAsync(new Generated.CreateProject
{
Name = "my-project",
Description = "created from dotnet",
});
Console.WriteLine($"{project.Id} {project.Name}");The generated client borrows the HttpClient owned by BraintrustOpenApiClient, so keep
that instance alive for as long as you use Api.
A complete, runnable example is in examples/ApiClientExample.
It reads a project's experiments, prompts, datasets and automations, and includes - but does
not invoke - two full create/read/update/delete round trips: one for an automation, one for
an experiment (which also logs a row and reads it back).
BRAINTRUST_API_KEY=sk-... dotnet run --project examples/ApiClientExampleIt is shaped by the spec rather than by the SDK, so it does not follow .NET naming conventions:
- Properties are spec-cased:
project.Org_id,experiment.Base_exp_id,config.Btql_filter. - Identifiers are
Guid, notstring. - List endpoints take every filter positionally, with no defaults. Pass
nullfor the ones you don't need; named arguments keep the call readable. - List endpoints return a page wrapper whose
Objectsproperty holds the results.
var page = await api.GetExperimentAsync(
limit: 5,
starting_after: null,
ending_before: null,
ids: null,
experiment_name: null,
project_name: null,
project_id: project.Id,
org_name: null);
foreach (var experiment in page.Objects)
{
Console.WriteLine($"{experiment.Name} ({experiment.Id})");
}Pass the last id you saw as starting_after to walk further pages.
Most Braintrust unions are tagged with a discriminator field (type, event_type, role,
provider). Those generate as a base class plus one subclass per variant, so you write them
by picking a subclass and read them with a type switch:
// Writing: pick the variant. The event_type/type discriminators are written for you.
var config = new Generated.LogsProjectAutomationConfig
{
Btql_filter = "scores.Factuality < 0.5",
Interval_seconds = 300,
Action = new Generated.WebhookProjectAutomationConfigAction
{
Url = "https://example.com/braintrust-hook",
},
};
var automation = await api.PostProjectAutomationAsync(new Generated.CreateProjectAutomation
{
Project_id = project.Id,
Name = "low-factuality-alert",
Config = config,
});
// Reading: switch on the concrete type.
string description = automation.Config switch
{
Generated.LogsProjectAutomationConfig logs => $"logs: {logs.Btql_filter}",
Generated.RetentionProjectAutomationConfig r => $"retention: {r.Retention_days} days",
_ => "some other kind",
};Two details worth knowing:
- A variant the pinned spec has never heard of does not throw. It deserializes as the
base class with its fields in
AdditionalProperties, so one unrecognized row cannot fail a whole page. BumpBraintrustOpenApiRefto get a typed subclass for it. - The discriminator is not a declared property. It belongs to the base schema, so it
arrives in the inherited
AdditionalPropertiesbag:config.AdditionalProperties["event_type"].
A union whose arms have distinct JSON kinds (string | object, string | array) generates
as a wrapper class with one property per arm - AsString, AsObject, AsArray, AsNumber,
AsBoolean. Only the arm you set is written to the wire, and the wrapper itself never
appears there. Wrappers with a string arm also convert implicitly from string:
Generated.ChatCompletionContentPartContent content = "hello"; // fills AsString
var parts = new Generated.ChatCompletionContentPartContent
{
AsArray = [ /* ... */ ],
};Some schemas are genuinely untypeable - free-form JSON, or a union of same-shaped object
arms with nothing to tell them apart. Those land as object, which System.Text.Json
materializes as JsonElement. Row payloads (input, expected, output) and a few
function fields (params, options) are the ones you are most likely to hit:
var input = (JsonElement)row.Input;
Console.WriteLine(input.GetProperty("question").GetString());String enum members are named in C# style but carry their spec value, so
AutomationStatus.Paused goes out as "paused". Use the enum; the casing difference is
handled for you.
Unset optional members are omitted from request bodies rather than written as
null - the API validates nullability strictly and rejects an explicit null for a field
you never set. The flip side is that assigning null cannot clear a field on a PATCH;
serialize that field yourself if you need to.
Calls handled by the generated OpenAPI client throw Generated.ApiException (or
Generated.ApiException<T>), including calls made by the SDK's wrapper methods. The server's
message is in ex.Response:
try
{
await api.GetProjectIdAsync(id);
}
catch (Generated.ApiException ex)
{
Console.WriteLine($"{ex.StatusCode}: {ex.Response}");
}The spec declares error bodies as JSON strings, but the API returns plain text, so a 4xx
usually surfaces as ApiException: Could not deserialize the response body string as System.String. That message is about the error body, not your request - the real
diagnostic is still in ex.Response.
The spec is generated from the API's own types, but a few declared filters are rejected at
runtime - project_automation_name on GET /v1/project_automation, for instance, comes back
400 Extraneous key. Filter client-side when that happens.
POST /btql is absent from the spec, so BraintrustOpenApiClient implements that operation
itself. Project and organization lookup use the generated endpoints.
The generated surface tracks whatever spec ref the build pinned, so it is not covered by the SDK's own compatibility promises: bumping the ref can rename a class or change a signature.
Edit BraintrustOpenApiRef in
Braintrust.Sdk.Api.Generated.csproj
and rebuild - the ref is part of the spec cache path, so a bump is a cache miss. To generate
against a local checkout of the spec instead:
BRAINTRUST_OPENAPI_ROOT=/path/to/braintrust-openapi dotnet buildThen run the tests. tests/Braintrust.Sdk.Api.Generated.Tests is the regression guard:
GeneratedShapeInvariantTests asserts the shape of the output by reflection (no empty
pass-through classes, no Name/Name2 pairs, every union base memberless with variants),
UnionRoundTripTests covers the discriminator handling described above, and
RequestSerializationTests covers what goes out on the wire - enum values, and optional
members being omitted rather than nulled.
Normalization of the spec happens in
build/SpecNormalizer.cs; every
rule there matches on schema shape, never on a schema name, so new unions in the spec are
handled without editing it. The NSwag template patches that make unions round-trip are
documented in
build/templates/PATCHES.md.