Ensemble.Client 0.5.0

dotnet add package Ensemble.Client --version 0.5.0
                    
NuGet\Install-Package Ensemble.Client -Version 0.5.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Ensemble.Client" Version="0.5.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Ensemble.Client" Version="0.5.0" />
                    
Directory.Packages.props
<PackageReference Include="Ensemble.Client" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Ensemble.Client --version 0.5.0
                    
#r "nuget: Ensemble.Client, 0.5.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Ensemble.Client@0.5.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Ensemble.Client&version=0.5.0
                    
Install as a Cake Addin
#tool nuget:?package=Ensemble.Client&version=0.5.0
                    
Install as a Cake Tool

Ensemble.Client

.NET 8 client for the Ensemble peer-to-peer messaging daemon. Sibling to the Python SDK at clients/python. Wraps the daemon's gRPC API with idiomatic async C#: typed records, IAsyncDisposable lifetimes, CancellationToken propagation, typed exceptions for structured daemon errors.

Install

dotnet add package Ensemble.Client

Targets net8.0. NuGet pulls Google.Protobuf, Grpc.Net.Client, and Microsoft.Extensions.Logging.Abstractions transitively.

Quickstart — identity

using Ensemble.Client;

await using var client = new EnsembleClient("127.0.0.1:9099");
var identity = await client.GetIdentityAsync();
Console.WriteLine($"address: {identity.Address}");

EnsembleClient picks plaintext HTTP/2 (http://) for loopback / unix-socket endpoints and TLS (https://) otherwise. Override by passing a fully-qualified URI (http://..., https://..., or unix:///path).

Quickstart — register a service

using Ensemble.Client;

await using var client = new EnsembleClient("127.0.0.1:9099");

var manifest = ServiceManifest.NewBuilder("my-bot")
    .Description("a friendly bot")
    .Acl(ServiceAcl.Contacts)
    .Transport(ServiceTransport.Chat)
    .Build();

await using var svc = await client.RegisterServiceAsync(
    manifest,
    onEvent: async ev =>
    {
        if (ev is ServiceEvent.ChatMessage cm)
            Console.WriteLine($"[{cm.FromAddr}] {cm.Text}");
        else if (ev is ServiceEvent.ConnectionRequest cr)
            await svc.AcceptConnectionAsync(cr.RequestId);
    },
    onError: err =>
    {
        // Daemon-side structured errors. err.AsException() returns a typed
        // exception for known codes (payload_too_large, rate_limited) and
        // null for unstructured stream errors.
        Console.Error.WriteLine($"service error: {err.Message} (code={err.Code})");
        return ValueTask.CompletedTask;
    });

Console.WriteLine($"my service address: {svc.ServiceAddress}");
Console.WriteLine($"reachable at:        {svc.Onion}");

await Task.Delay(TimeSpan.FromMinutes(5));

Quickstart — RPC transport + introductions

For services that host strangers (matchmaking, marketplaces, group chat rooms), register under SERVICE_TRANSPORT_RPC to exchange raw protobuf bytes instead of chat envelopes, and use IntroducePeersAsync to wire two peers together with a daemon-attested provenance stamp:

var manifest = ServiceManifest.NewBuilder("pong-matchmaker")
    .Acl(ServiceAcl.Public)
    .Transport(ServiceTransport.Rpc)
    .MaxPayloadBytes(256 * 1024)
    .RateLimit(requestsPerMinute: 600, burst: 60)
    .Build();

await using var svc = await client.RegisterServiceAsync(manifest, onEvent, onError);

// Pair two peers and introduce them.
await svc.IntroducePeersAsync(
    toAddr:        playerA,
    otherAddr:     playerB,
    sessionId:     Guid.NewGuid().ToString(),
    expiresAtMs:   DateTimeOffset.UtcNow.AddMinutes(2).ToUnixTimeMilliseconds(),
    roleHint:      "host",
    payload:       sessionTicket);

Both peers receive a ServiceEvent.PeerIntroduction carrying FromServiceAddr (daemon-attested — receivers trust the local daemon's word for provenance, no client-side verification needed), the other peer's E-address, the session id, and the optional payload.

Typed exceptions

Daemon-side errors with a stable code map to typed exceptions you can catch (or pattern-match on) without string-matching message:

err => {
    if (err.AsException() is PayloadTooLargeException pe)
        Console.Error.WriteLine($"too big — service cap is {pe.LimitBytes} bytes");
    else if (err.AsException() is RateLimitedException rl)
        Console.Error.WriteLine($"throttled — retry after {rl.RetryAfter}");
    else
        Console.Error.WriteLine($"service error: {err.Message}");
    return ValueTask.CompletedTask;
}

Unknown future codes surface as a plain ServiceProtocolException with the code preserved so callers can branch on it without code changes here.

Stream lifecycle

RegisteredService : IAsyncDisposable. Dispose to deregister:

  • DisposeAsync half-closes the bidi request stream → the daemon sees a clean EOF → the service is dropped from the registry.
  • A reader task runs for the lifetime of the registration. Mid-flight transport errors (daemon restart, network blip) synthesise an unstructured ServiceError (Code="") to onError so the host process doesn't crash on a connectivity loss.
  • The dispose path waits up to 5 seconds for the reader to drain, then aborts. The bidi stream is always closed exactly once.

EnsembleClient : IAsyncDisposable owns one GrpcChannel. One client can host many registered services and many independent Subscribe streams; dispose the client to release the channel.


Repo-side notes (for contributors)

Source: clients/dotnet/ in the Ensemble repo. The proto at api/proto/ensemble.proto is included directly via <Protobuf Include=...> in the csproj — no vendoring; codegen runs every build.

Layout

clients/dotnet/
├── Ensemble.Client.sln
├── global.json                  pins .NET 8 SDK
├── Ensemble.Client/
│   ├── EnsembleClient.cs        basic RPCs (identity, contacts, peer dial)
│   ├── EnsembleClient.Services.cs  partial — RegisterServiceAsync
│   ├── RegisteredService.cs     bidi-stream handle (IAsyncDisposable)
│   ├── ServiceManifest.cs       fluent ManifestBuilder + immutable manifest
│   ├── ServiceTypes.cs          enums, ServiceError, ServiceEvent variants
│   ├── Records.cs               Identity, Status, Contact, ConnectResult, DaemonEvent
│   └── Errors.cs                exception hierarchy
└── tests/Ensemble.Client.Tests/
    ├── DaemonFixture.cs         shared headless daemon per test class
    ├── IsolatedDaemon.cs        one-off daemon for kill scenarios
    ├── ManifestBuilderTests.cs  pure-CLR validation tests
    ├── BasicRpcTests.cs         (SmokeTests.cs) — identity/contacts/subscribe
    ├── ServiceRegistrationTests.cs  chat-transport bidi + lifecycle
    ├── Phase4PrimitiveTests.cs  RPC bytes / introductions / typed errors
    └── DaemonLifecycleTests.cs  daemon-killed onError pathway

Prerequisites

  • .NET 8 SDKglobal.json pins this. Newer SDKs side-by-side are fine.
  • Go 1.22 — needed to build the ensemble daemon binary integration tests spawn.
  • Tor binary — system /usr/bin/tor (Fedora: dnf install tor) or set $ENSEMBLE_TOR_PATH to an alternative. The daemon's auto-download path is currently 404ing on dist.torproject.org; tests bypass it by passing --tor-path explicitly.

Build & test

make dotnet-build              # restore + build the sln
make dotnet-test               # pure-CLR unit tests (no daemon)
make dotnet-test-integration   # builds bin/ensemble (build-dev) then runs
                               # the integration suite against a real daemon

Integration tests spawn ensemble --headless --data-dir <tmp> --api-addr 127.0.0.1:<ephemeral> --tor-path <found> per test session and wait for Tor bootstrap (~10s on a healthy network) before exercising RegisterService.

Generated code

Grpc.Tools writes protobuf and gRPC client stubs into Ensemble.Client/obj/Debug/net8.0/ under the proto's default namespace (Ensemble.Api). Consumers don't normally need to touch this — the public SDK surface re-exports the relevant shapes as typed records.

Versioning

Ensemble.Client versions independently from the daemon. Semver. Releases are cut from tags namespaced client-dotnet/v* so daemon and sibling-SDK tags don't collide.

Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 was computed.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 was computed.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.5.0 0 6/3/2026
0.4.0 81 6/2/2026
0.3.0 95 6/1/2026
0.2.1 93 5/29/2026
0.2.0 93 5/27/2026
0.1.1 88 5/21/2026