ATProtoNet.Blazor 0.3.0

dotnet add package ATProtoNet.Blazor --version 0.3.0
                    
NuGet\Install-Package ATProtoNet.Blazor -Version 0.3.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="ATProtoNet.Blazor" Version="0.3.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="ATProtoNet.Blazor" Version="0.3.0" />
                    
Directory.Packages.props
<PackageReference Include="ATProtoNet.Blazor" />
                    
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 ATProtoNet.Blazor --version 0.3.0
                    
#r "nuget: ATProtoNet.Blazor, 0.3.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 ATProtoNet.Blazor@0.3.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=ATProtoNet.Blazor&version=0.3.0
                    
Install as a Cake Addin
#tool nuget:?package=ATProtoNet.Blazor&version=0.3.0
                    
Install as a Cake Tool

ATProto.NET

CI

A comprehensive .NET SDK for the AT Protocol. Build custom AT Protocol applications with your own Lexicon schemas, or interact with Bluesky — all with clean, modern .NET 10 APIs.

Source: Forgejo (canonical) · GitHub (mirror — issues & PRs welcome here)

⚠️ Disclosure: This repository was mainly created by a coding agent. Thorough testing has been conducted. The maintainer is a human though (me 😃, Grandiras)

Why ATProto.NET?

The AT Protocol isn't just Bluesky — it's an open protocol where one account works across many apps. Each app defines its own Lexicon schemas and stores records in the user's Personal Data Server (PDS). ATProto.NET makes it easy to build these custom applications in .NET.

Features

  • Custom Lexicon supportRecordCollection<T> for typed CRUD on your own record schemas
  • Full AT Protocol — authentication, repositories, identity, sync, admin, labels, moderation
  • OAuth authentication — DPoP, PAR, PKCE with dynamic PDS selection
  • Custom XRPC endpoints — call your own query/procedure methods
  • Bluesky APIs — actors, feeds, posts, social graph, notifications, rich text
  • ASP.NET Core integration — dependency injection, JWT authentication handler
  • Blazor components — login forms (with OAuth), profile cards, post cards, feed views, composers
  • Rich text builder — fluent API with automatic UTF-8 byte offset calculation
  • Firehose client — real-time WebSocket streaming
  • Type-safe identityDid, Handle, AtUri, Nsid, Tid, RecordKey, Cid
  • Automatic session management — token refresh, persistence, resume
  • Dynamic PDS — connect to any PDS at runtime, resolve from user identity

Quick Start

Install

dotnet add package ATProtoNet

Connect & Authenticate

using ATProtoNet;

var client = new AtProtoClientBuilder()
    .WithInstanceUrl("https://your-pds.example.com")
    .Build();

await client.LoginAsync("alice.example.com", "app-password");

For user-facing applications, use AT Protocol OAuth instead of handling passwords:

using ATProtoNet.Auth.OAuth;

// Configure OAuth client
var oauthOptions = new OAuthOptions
{
    ClientMetadata = new OAuthClientMetadata
    {
        ClientId = "https://myapp.example.com/client-metadata.json",
        ClientName = "My App",
        RedirectUris = ["https://myapp.example.com/oauth/callback"],
        Scope = "atproto transition:generic",
        TokenEndpointAuthMethod = "none",
        DpopBoundAccessTokens = true,
    },
};

var oauthClient = new OAuthClient(oauthOptions, httpClient, logger);

// Step 1: Start authorization — works with any handle, DID, or PDS URL
var (authUrl, state) = await oauthClient.StartAuthorizationAsync(
    "alice.bsky.social",
    "https://myapp.example.com/oauth/callback");

// Step 2: Redirect user to authUrl...
// Step 3: Handle callback
var session = await oauthClient.CompleteAuthorizationAsync(code, state, issuer);

// Step 4: Use the session
await client.ApplyOAuthSessionAsync(session);
await client.PostAsync("Hello from OAuth!");

OAuth handles DPoP proof-of-possession, PKCE, server discovery, and identity verification automatically. See the OAuth guide for full details.

Building Custom AT Protocol Apps

The core value of ATProto.NET is enabling you to build your own applications on the AT Protocol. Define your Lexicon record types as C# classes, then use the typed RecordCollection<T> API for full CRUD.

1. Define Your Record Types

using System.Text.Json.Serialization;
using ATProtoNet;

// A simple todo item stored in the user's PDS
public class TodoItem : AtProtoRecord
{
    public override string Type => "com.example.todo.item";

    [JsonPropertyName("title")]
    public string Title { get; set; } = "";

    [JsonPropertyName("completed")]
    public bool Completed { get; set; }

    [JsonPropertyName("priority")]
    public int Priority { get; set; } = 0;

    [JsonPropertyName("dueDate")]
    public string? DueDate { get; set; }
}

AtProtoRecord provides $type and createdAt fields automatically. You can also use any plain C# class — the collection API works with any serializable type.

2. Get a Typed Collection

// Get a strongly-typed collection for your record type
var todos = client.GetCollection<TodoItem>("com.example.todo.item");

3. CRUD Operations

// Create
var created = await todos.CreateAsync(new TodoItem
{
    Title = "Buy groceries",
    Priority = 2,
});
Console.WriteLine($"Created: {created.Uri} (key: {created.RecordKey})");

// Read
var item = await todos.GetAsync(created.RecordKey);
Console.WriteLine($"Title: {item.Value.Title}");

// Update (upsert)
await todos.PutAsync(created.RecordKey, new TodoItem
{
    Title = "Buy groceries",
    Completed = true,
    Priority = 2,
});

// Delete
await todos.DeleteAsync(created.RecordKey);

// Check existence
bool exists = await todos.ExistsAsync(created.RecordKey);

4. List & Paginate

// List with pagination
var page = await todos.ListAsync(limit: 25);
foreach (var record in page.Records)
    Console.WriteLine($"[{(record.Value.Completed ? "x" : " ")}] {record.Value.Title}");

if (page.HasMore)
{
    var nextPage = await todos.ListAsync(limit: 25, cursor: page.Cursor);
}

// Enumerate all records (automatic pagination)
await foreach (var record in todos.EnumerateAsync())
{
    Console.WriteLine($"{record.RecordKey}: {record.Value.Title}");
}

5. Read From Other Users

// Read records from any user's repository
var theirTodos = await todos.ListFromAsync("did:plc:otherperson");

await foreach (var record in todos.EnumerateFromAsync("did:plc:otherperson"))
{
    Console.WriteLine(record.Value.Title);
}

var specificItem = await todos.GetFromAsync("did:plc:otherperson", "3abc");

6. Custom XRPC Endpoints

If your app defines custom query or procedure Lexicon methods (not just record types), call them directly:

// Custom query (HTTP GET)
var result = await client.QueryAsync<SearchResult>(
    "com.example.todo.search",
    new { q = "groceries", limit = 10 });

// Custom procedure (HTTP POST)
var status = await client.ProcedureAsync<BatchResult>(
    "com.example.todo.markAllComplete",
    new { before = "2024-01-01" });

// Fire-and-forget procedure
await client.ProcedureAsync("com.example.todo.cleanup");

Multi-App Example

One AT Protocol account can power many apps — each with its own Lexicons:

await client.LoginAsync("alice.example.com", "app-password");

// Todo app
var todos = client.GetCollection<TodoItem>("com.example.todo.item");

// Bookmark manager
var bookmarks = client.GetCollection<Bookmark>("com.example.bookmarks.bookmark");

// Recipe collection
var recipes = client.GetCollection<Recipe>("com.example.recipes.recipe");

// All stored in the same user's PDS, in separate collections
await todos.CreateAsync(new TodoItem { Title = "Cook dinner" });
await bookmarks.CreateAsync(new Bookmark { Url = "https://example.com", Title = "Example" });
await recipes.CreateAsync(new Recipe { Name = "Pasta", Ingredients = ["pasta", "sauce"] });

Bluesky Integration

ATProto.NET also provides full Bluesky application support:

Create a Post

await client.PostAsync("Hello from ATProto.NET!");

Rich Text

using ATProtoNet.Lexicon.App.Bsky.RichText;

var (text, facets) = new RichTextBuilder()
    .Text("Check out ")
    .Link("ATProto.NET", "https://github.com/example/ATProto.NET")
    .Text(" — built with ")
    .Tag("atproto")
    .Text("!")
    .Build();

await client.PostAsync(text, facets: facets);

Profiles & Feeds

var profile = await client.Bsky.Actor.GetProfileAsync("alice.bsky.social");
Console.WriteLine($"{profile.DisplayName} — {profile.Description}");

var timeline = await client.Bsky.Feed.GetTimelineAsync(limit: 25);
foreach (var item in timeline.Feed!)
    Console.WriteLine($"@{item.Post!.Author!.Handle}: {item.Post.Record}");

Social Actions

await client.FollowAsync("did:plc:abc123");
await client.LikeAsync("at://did:plc:abc/app.bsky.feed.post/3k2la", "bafyreib...");
await client.RepostAsync("at://did:plc:abc/app.bsky.feed.post/3k2la", "bafyreib...");

ASP.NET Core Integration

Standalone Client (Server-to-Server)

For bot or service scenarios with app-password authentication:

builder.Services.AddAtProto(options =>
{
    options.InstanceUrl = "https://bsky.social";
});

User-Authenticated Access (with Blazor OAuth)

For apps where users log in via OAuth and the backend accesses AT Proto on their behalf:

// Program.cs
builder.Services.AddAuthentication("Cookies").AddCookie();
builder.Services.AddAtProtoAuthentication();  // Blazor OAuth login
builder.Services.AddAtProtoServer();           // Backend AT Proto access

app.MapAtProtoOAuth();

Then use IAtProtoClientFactory in API endpoints or Blazor components:

// Minimal API endpoint
app.MapGet("/api/profile", async (ClaimsPrincipal user, IAtProtoClientFactory factory) =>
{
    await using var client = await factory.CreateClientForUserAsync(user);
    if (client is null) return Results.Unauthorized();
    var profile = await client.Bsky.Actor.GetProfileAsync(client.Session!.Did);
    return Results.Ok(new { profile.DisplayName, profile.Handle });
}).RequireAuthorization();
@* Or in a Blazor component *@
@inject IAtProtoClientFactory ClientFactory

@code {
    [CascadingParameter] Task<AuthenticationState> AuthState { get; set; } = null!;

    protected override async Task OnInitializedAsync()
    {
        var auth = await AuthState;
        await using var client = await ClientFactory.CreateClientForUserAsync(auth.User);
        // Use client to call AT Proto APIs...
    }
}

See the ServerIntegrationSample for a complete example.

Blazor Integration

Setup

// Program.cs
builder.Services.AddAuthentication("Cookies").AddCookie("Cookies");
builder.Services.AddAtProtoAuthentication();
builder.Services.AddCascadingAuthenticationState();
builder.Services.AddAuthorizationCore();

var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapAtProtoOAuth();   // Maps /atproto/login, /atproto/callback, /atproto/logout

Login Component

@using ATProtoNet.Blazor.Components

<LoginForm ReturnUrl="/" />

Auth State

<AuthorizeView>
    <Authorized>
        Signed in as @context.User.FindFirst("handle")?.Value
        <form action="/atproto/logout" method="post">
            <button type="submit">Logout</button>
        </form>
    </Authorized>
    <NotAuthorized>
        <a href="/login">Sign in</a>
    </NotAuthorized>
</AuthorizeView>

See docs/blazor.md for full documentation including custom claims, configuration, and production setup.

Architecture

ATProto.NET/
├── src/
│   ├── ATProtoNet/                    # Core SDK
│   │   ├── Identity/                  # Did, Handle, AtUri, Nsid, Tid, etc.
│   │   ├── Auth/                      # Session, ISessionStore
│   │   │   └── OAuth/                 # OAuth client, DPoP, PKCE, discovery
│   │   ├── Http/                      # XrpcClient, AtProtoHttpException
│   │   ├── Models/                    # BlobRef, StrongRef, Label, etc.
│   │   ├── Serialization/             # JSON converters, defaults
│   │   ├── Streaming/                 # FirehoseClient
│   │   ├── RecordCollection.cs        # Typed collection CRUD for custom records
│   │   ├── AtProtoClient.cs           # Main client facade
│   │   └── Lexicon/
│   │       ├── Com/AtProto/           # Protocol-level APIs
│   │       │   ├── Server/            # Authentication, session management
│   │       │   ├── Repo/              # Record CRUD, blob upload
│   │       │   ├── Identity/          # Handle/DID resolution
│   │       │   ├── Sync/              # Repo sync, blob download
│   │       │   ├── Admin/             # Admin operations
│   │       │   ├── Label/             # Content labels
│   │       │   └── Moderation/        # Moderation reports
│   │       └── App/Bsky/              # Bluesky app APIs
│   │           ├── Actor/             # Profiles, preferences
│   │           ├── Feed/              # Posts, timeline, feeds
│   │           ├── Graph/             # Follows, blocks, mutes, lists
│   │           ├── Notification/      # Notifications
│   │           ├── RichText/          # Rich text builder, facets
│   │           └── Embed/             # Images, links, quotes, video
│   ├── ATProtoNet.Server/            # ASP.NET Core integration
│   │   ├── Extensions/               # DI registration (AddAtProtoServer, AddAtProto)
│   │   ├── Authentication/           # JWT auth handler
│   │   ├── Services/                 # IAtProtoClientFactory
│   │   └── TokenStore/               # IAtProtoTokenStore, InMemoryAtProtoTokenStore
│   └── ATProtoNet.Blazor/            # Blazor components
│       ├── Components/                # Razor components (LoginForm)
│       ├── Authentication/            # OAuth service, options
│       └── Extensions/               # DI registration (AddAtProtoAuthentication)
├── samples/
│   ├── BlazorOAuthSample/            # Blazor Server OAuth example
│   └── ServerIntegrationSample/      # Blazor + server-side AT Proto access
└── tests/
    ├── ATProtoNet.Tests/              # Unit tests (268 tests)
    └── ATProtoNet.IntegrationTests/   # Integration tests (requires PDS)

API Reference

Client Namespaces

Property Namespace Description
client.GetCollection<T>() Typed CRUD for custom records
client.QueryAsync<T>() Custom XRPC queries
client.ProcedureAsync<T>() Custom XRPC procedures
client.Server com.atproto.server.* Authentication, sessions, app passwords
client.Repo com.atproto.repo.* Low-level record CRUD, blob upload, batch writes
client.Identity com.atproto.identity.* Handle/DID resolution
client.Sync com.atproto.sync.* Repo sync, blob download
client.Admin com.atproto.admin.* Admin operations
client.Label com.atproto.label.* Content label queries
client.Moderation com.atproto.moderation.* Moderation reports
client.Bsky.Actor app.bsky.actor.* Profile read/write, search
client.Bsky.Feed app.bsky.feed.* Posts, timeline, feeds, likes
client.Bsky.Graph app.bsky.graph.* Follows, blocks, mutes, lists
client.Bsky.Notification app.bsky.notification.* Notification management

Identity Types

var did = Did.Parse("did:plc:z72i7hdynmk6r22z27h6tvur");
var handle = Handle.Parse("alice.example.com");
var uri = AtUri.Parse("at://did:plc:abc/com.example.todo.item/3k2la");
var nsid = Nsid.Parse("com.example.todo.item");
var tid = Tid.Next();
var rkey = RecordKey.Parse("self");
var id = AtIdentifier.Parse("did:plc:abc"); // or "alice.example.com"

Custom Session Persistence

public class FileSessionStore : ISessionStore
{
    private readonly string _path;
    public FileSessionStore(string path) => _path = path;

    public async Task SaveAsync(Session session, CancellationToken ct = default)
    {
        var json = JsonSerializer.Serialize(session);
        await File.WriteAllTextAsync(_path, json, ct);
    }

    public async Task<Session?> LoadAsync(CancellationToken ct = default)
    {
        if (!File.Exists(_path)) return null;
        var json = await File.ReadAllTextAsync(_path, ct);
        return JsonSerializer.Deserialize<Session>(json);
    }

    public Task ClearAsync(CancellationToken ct = default)
    {
        if (File.Exists(_path)) File.Delete(_path);
        return Task.CompletedTask;
    }
}

var client = new AtProtoClientBuilder()
    .WithInstanceUrl("https://your-pds.example.com")
    .WithSessionStore(new FileSessionStore("session.json"))
    .Build();

Firehose (Real-time Streaming)

using ATProtoNet.Streaming;

var firehose = new FirehoseClient("wss://bsky.network");

await foreach (var message in firehose.SubscribeAsync())
{
    Console.WriteLine($"Seq: {message.Seq}, Repo: {message.Repo}");
}

Running Tests

Unit Tests

dotnet test tests/ATProtoNet.Tests

Integration Tests

Integration tests require a running PDS. Set environment variables and run:

export ATPROTO_PDS_URL=http://localhost:2583
export ATPROTO_TEST_HANDLE=test.handle
export ATPROTO_TEST_PASSWORD=your-password

dotnet test tests/ATProtoNet.IntegrationTests
Local PDS with Podman/Docker
podman run -d \
  --name pds \
  -p 2583:3000 \
  -e PDS_HOSTNAME=localhost \
  -e PDS_JWT_SECRET=$(openssl rand -hex 16) \
  -e PDS_ADMIN_PASSWORD=admin \
  -e PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX=$(openssl rand -hex 32) \
  -e PDS_DATA_DIRECTORY=/pds \
  -e PDS_BLOBSTORE_DISK_LOCATION=/pds/blocks \
  -e PDS_DID_PLC_URL=https://plc.directory \
  ghcr.io/bluesky-social/pds:latest

Requirements

  • .NET 10.0 SDK or later
  • For ASP.NET Core: Microsoft.AspNetCore.App framework reference
  • For Blazor: Microsoft.AspNetCore.Components.Web 10.0+

License

MIT

Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  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.3.0 77 2/21/2026
0.2.0 76 2/20/2026