diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
new file mode 100644
index 0000000..d855c98
--- /dev/null
+++ b/.github/workflows/docs.yml
@@ -0,0 +1,51 @@
+name: Deploy Docs
+
+on:
+ push:
+ branches: [ "main" ]
+ paths:
+ - 'docs/**'
+ - 'src/**'
+ - '.github/workflows/docs.yml'
+ workflow_dispatch:
+
+permissions:
+ actions: read
+ pages: write
+ id-token: write
+
+concurrency:
+ group: "pages"
+ cancel-in-progress: false
+
+jobs:
+ build-docs:
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ global-json-file: global.json
+
+ - name: Install docfx
+ run: dotnet tool install -g docfx
+
+ - name: Build docs
+ run: docfx docs/docfx.json
+
+ - name: Upload artifact
+ uses: actions/upload-pages-artifact@v3
+ with:
+ path: 'docs/_site'
+
+ - name: Deploy to GitHub Pages
+ id: deployment
+ uses: actions/deploy-pages@v4
diff --git a/.gitignore b/.gitignore
index 7802160..10e12b3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -481,4 +481,8 @@ $RECYCLE.BIN/
# Vim temporary swap files
*.swp
-.serena/
\ No newline at end of file
+.serena/
+
+# DocFX generated site
+docs/_site/
+docs/api/
\ No newline at end of file
diff --git a/docs/carpanet/public/main.css b/docs/carpanet/public/main.css
new file mode 100644
index 0000000..6e1ed72
--- /dev/null
+++ b/docs/carpanet/public/main.css
@@ -0,0 +1,5 @@
+#logo
+{
+ margin-right: 10px;
+ border-radius: 5px;
+}
diff --git a/docs/docfx.json b/docs/docfx.json
new file mode 100644
index 0000000..1bcecce
--- /dev/null
+++ b/docs/docfx.json
@@ -0,0 +1,52 @@
+{
+ "metadata": [
+ {
+ "src": [
+ {
+ "src": "..",
+ "files": [
+ "src/CarpaNet/*.csproj",
+ "src/CarpaNet.OAuth/*.csproj",
+ "src/CarpaNet.Jetstream/*.csproj",
+ "src/CarpaNet.AspNetCore/*.csproj"
+ ]
+ }
+ ],
+ "dest": "api",
+ "outputFormat": "apiPage"
+ }
+ ],
+ "build": {
+ "content": [
+ {
+ "files": [
+ "**/*.{md,yml}"
+ ],
+ "exclude": [
+ "_site/**"
+ ]
+ }
+ ],
+ "resource": [
+ {
+ "files": [
+ "images/**",
+ "favicon.png"
+ ]
+ }
+ ],
+ "output": "_site",
+ "template": [
+ "default",
+ "modern",
+ "carpanet"
+ ],
+ "postProcessors": [ "ExtractSearchIndex" ],
+ "globalMetadata": {
+ "_appName": "CarpaNet",
+ "_appTitle": "CarpaNet",
+ "_enableSearch": true,
+ "_appFaviconPath": "favicon.png"
+ }
+ }
+}
diff --git a/docs/docs/authentication.md b/docs/docs/authentication.md
new file mode 100644
index 0000000..0a148e2
--- /dev/null
+++ b/docs/docs/authentication.md
@@ -0,0 +1,44 @@
+# Authentication
+
+CarpaNet supports two authentication methods: app passwords (for scripts and bots) and [OAuth 2.0](oauth.md) (for user-facing apps).
+
+## App Password
+
+The simplest way to authenticate. Create an app password in your Bluesky account settings, then:
+
+```csharp
+var client = await ATProtoClient.CreateWithSessionAsync(
+ identifier: "alice.bsky.social",
+ password: "xxxx-xxxx-xxxx-xxxx",
+ options: new ATProtoClientOptions
+ {
+ JsonOptions = ATProtoJsonContext.DefaultOptions,
+ CborContext = ATProtoCborContext.Default,
+ });
+```
+
+## Session Persistence
+
+Implement `ISessionStore` to persist sessions across app restarts:
+
+```csharp
+var client = ATProtoClientFactory.Create(new ATProtoClientOptions
+{
+ SessionStore = new MySessionStore(),
+});
+bool restored = await client.RestoreSessionAsync(userDid);
+```
+
+## Token Refresh
+
+Token refresh is handled automatically when `AutoRetryOnAuthFailure` is enabled (the default). Listen for refresh events to persist updated tokens:
+
+```csharp
+if (client.TokenProvider is { } provider)
+{
+ provider.TokenRefreshed += (sender, args) =>
+ {
+ SaveTokens(args.Did, args.AccessToken, args.RefreshToken);
+ };
+}
+```
diff --git a/docs/docs/common-operations.md b/docs/docs/common-operations.md
new file mode 100644
index 0000000..ac107d5
--- /dev/null
+++ b/docs/docs/common-operations.md
@@ -0,0 +1,99 @@
+# Common Operations
+
+## Get a Profile
+
+```csharp
+var profile = await client.AppBskyActorGetProfileAsync(
+ new AppBsky.Actor.GetProfileParameters { Actor = new ATHandle("alice.bsky.social") });
+```
+
+## Create a Post
+
+```csharp
+var post = new AppBsky.Feed.Post
+{
+ Text = "Hello from CarpaNet!",
+ CreatedAt = DateTimeOffset.UtcNow,
+};
+
+var result = await client.ComAtprotoRepoCreateRecordAsync(
+ new ComAtproto.Repo.CreateRecordInput
+ {
+ Repo = new ATIdentifier(client.AuthenticatedDid!),
+ Collection = AppBsky.Feed.Post.RecordType,
+ Record = post.ToJson(),
+ });
+
+Console.WriteLine($"Posted: {result.Uri}");
+```
+
+## Delete a Record
+
+```csharp
+var atUri = new ATUri(result.Uri.Value);
+
+await client.ComAtprotoRepoDeleteRecordAsync(
+ new ComAtproto.Repo.DeleteRecordInput
+ {
+ Repo = new ATIdentifier(client.AuthenticatedDid!),
+ Collection = AppBsky.Feed.Post.RecordType,
+ Rkey = atUri.RecordKey!,
+ });
+```
+
+## List Records
+
+```csharp
+var records = await client.ComAtprotoRepoListRecordsAsync(
+ new ComAtproto.Repo.ListRecordsParameters
+ {
+ Repo = "did:plc:example",
+ Collection = AppBsky.Feed.Post.RecordType,
+ Limit = 50,
+ });
+
+foreach (var record in records.Records)
+{
+ var post = AppBsky.Feed.Post.FromJson(record.Value);
+ Console.WriteLine(post?.Text);
+}
+```
+
+## Get a Timeline
+
+```csharp
+var timeline = await client.AppBskyFeedGetTimelineAsync(
+ new AppBsky.Feed.GetTimelineParameters { Limit = 25 });
+
+foreach (var item in timeline.Feed)
+{
+ Console.WriteLine($"{item.Post.Author.Handle}: {item.Post.Record}");
+}
+```
+
+## AT Protocol Types
+
+CarpaNet provides strongly-typed wrappers for AT Protocol identifiers:
+
+```csharp
+// DID
+var did = new ATDid("did:plc:z72i7hdynmk6r22z27h6tvur");
+Console.WriteLine(did.Method); // "plc"
+
+// Handle
+var handle = new ATHandle("alice.bsky.social");
+
+// AT URI
+var uri = ATUri.Create("did:plc:example", "app.bsky.feed.post", "3k2la7k");
+Console.WriteLine(uri.Collection); // "app.bsky.feed.post"
+Console.WriteLine(uri.RecordKey); // "3k2la7k"
+
+// AT Identifier (accepts either DID or Handle)
+var id = new ATIdentifier("alice.bsky.social");
+Console.WriteLine(id.IsHandle); // true
+
+// CID
+var cid = ATCid.FromSha256Hash(sha256Bytes);
+```
+
+All identifier types support equality, implicit string conversion, and JSON serialization.
diff --git a/docs/docs/creating-clients.md b/docs/docs/creating-clients.md
new file mode 100644
index 0000000..1e3c55e
--- /dev/null
+++ b/docs/docs/creating-clients.md
@@ -0,0 +1,80 @@
+# Creating Clients
+
+## Public (Unauthenticated) Client
+
+Uses the Bluesky public AppView — can only make GET requests:
+
+```csharp
+using CarpaNet;
+
+// ATProtoClientFactory is source-generated with your JSON/CBOR contexts preconfigured
+var client = ATProtoClientFactory.Create();
+
+var profile = await client.AppBskyActorGetProfileAsync(
+ new AppBsky.Actor.GetProfileParameters { Actor = new ATHandle("alice.bsky.social") });
+
+Console.WriteLine($"{profile.DisplayName} (@{profile.Handle})");
+```
+
+## Authenticated Client (App Password)
+
+```csharp
+var client = await ATProtoClient.CreateWithSessionAsync(
+ identifier: "alice.bsky.social", // handle, email, or DID
+ password: "xxxx-xxxx-xxxx-xxxx", // app password
+ options: new ATProtoClientOptions
+ {
+ JsonOptions = ATProtoJsonContext.DefaultOptions,
+ CborContext = ATProtoCborContext.Default,
+ });
+
+// Now you can make POST requests
+var timeline = await client.AppBskyFeedGetTimelineAsync(
+ new AppBsky.Feed.GetTimelineParameters { Limit = 10 });
+```
+
+## Authenticated Client with Custom Options
+
+```csharp
+var client = ATProtoClientFactory.Create(new ATProtoClientOptions
+{
+ SessionStore = new MySessionStore(), // persist sessions across restarts
+ EnableRateLimitHandler = true, // automatic 429 retry (default: true)
+ AutoRetryOnAuthFailure = true, // retry on 401 with token refresh (default: true)
+ RateLimitMaxRetries = 3,
+ UserAgent = "MyApp/1.0",
+ LoggerFactory = loggerFactory,
+});
+```
+
+## Restoring a Session
+
+```csharp
+// From explicit tokens
+var client = ATProtoClient.CreateWithRestoredSession(
+ accessJwt: savedAccessJwt,
+ refreshJwt: savedRefreshJwt,
+ did: savedDid,
+ handle: savedHandle,
+ pdsUrl: new Uri(savedPdsUrl));
+
+// Or from a session store
+var client = ATProtoClientFactory.Create(new ATProtoClientOptions
+{
+ SessionStore = new MySessionStore(),
+});
+bool restored = await client.RestoreSessionAsync(userDid);
+```
+
+## Listening for Token Refreshes
+
+```csharp
+if (client.TokenProvider is { } provider)
+{
+ provider.TokenRefreshed += (sender, args) =>
+ {
+ // Persist new tokens
+ SaveTokens(args.Did, args.AccessToken, args.RefreshToken);
+ };
+}
+```
diff --git a/docs/docs/custom-lexicons.md b/docs/docs/custom-lexicons.md
new file mode 100644
index 0000000..73f3df1
--- /dev/null
+++ b/docs/docs/custom-lexicons.md
@@ -0,0 +1,30 @@
+# Working with Custom Lexicons
+
+You can use your own or third-party lexicons beyond Bluesky's. Place JSON files in your project and reference them:
+
+```xml
+
+
+
+
+
+
+
+```
+
+The source generator produces the same type-safe bindings for custom lexicons. Use the generated `FromJson()` method to parse records:
+
+```csharp
+var records = await client.ComAtprotoRepoListRecordsAsync(
+ new ComAtproto.Repo.ListRecordsParameters
+ {
+ Repo = "did:plc:example",
+ Collection = MyCustom.Namespace.MyRecord.RecordType,
+ });
+
+foreach (var record in records.Records)
+{
+ var parsed = MyCustom.Namespace.MyRecord.FromJson(record.Value);
+ Console.WriteLine(parsed?.SomeField);
+}
+```
diff --git a/docs/docs/firehose.md b/docs/docs/firehose.md
new file mode 100644
index 0000000..02fac7c
--- /dev/null
+++ b/docs/docs/firehose.md
@@ -0,0 +1,32 @@
+# Firehose
+
+Subscribe to the ATProtocol firehose for CBOR-encoded, batched commit events. Uses the core CarpaNet package — add `com.atproto.sync.subscribeRepos` to your lexicon resolves.
+
+```csharp
+var client = ATProtoClientFactory.Create(new ATProtoClientOptions
+{
+ BaseUrl = new Uri("https://bsky.network"), // relay URL
+});
+
+await foreach (var message in client.ComAtprotoSyncSubscribeReposAsync(cancellationToken: cts.Token))
+{
+ switch (message)
+ {
+ case ComAtproto.Sync.SubscribeReposCommit commit:
+ Console.WriteLine($"[Commit] seq={commit.Seq} repo={commit.Repo}");
+ foreach (var op in commit.Ops ?? [])
+ {
+ Console.WriteLine($" {op.Action} {op.Path}");
+ }
+ break;
+
+ case ComAtproto.Sync.SubscribeReposIdentity identity:
+ Console.WriteLine($"[Identity] {identity.Did} → {identity.Handle}");
+ break;
+
+ case ComAtproto.Sync.SubscribeReposAccount account:
+ Console.WriteLine($"[Account] {account.Did} active={account.Active}");
+ break;
+ }
+}
+```
diff --git a/docs/docs/identity-resolution.md b/docs/docs/identity-resolution.md
new file mode 100644
index 0000000..026a265
--- /dev/null
+++ b/docs/docs/identity-resolution.md
@@ -0,0 +1,21 @@
+# Identity Resolution
+
+Resolve handles to DIDs and DID documents:
+
+```csharp
+using CarpaNet.Identity;
+
+// Create with in-memory caching
+var resolver = IdentityResolver.CreateWithCache();
+
+// Handle → DID document
+var didDoc = await resolver.ResolveAsync("alice.bsky.social");
+Console.WriteLine($"DID: {didDoc.Id}");
+Console.WriteLine($"PDS: {didDoc.PdsEndpoint}");
+Console.WriteLine($"Handle: {didDoc.Handle}");
+
+// DID → DID document
+var didDoc2 = await resolver.ResolveAsync("did:plc:z72i7hdynmk6r22z27h6tvur");
+```
+
+The `ATProtoClient` creates an `IdentityResolver` automatically (configurable via `ATProtoClientOptions.CreateIdentityResolver`).
diff --git a/docs/docs/jetstream.md b/docs/docs/jetstream.md
new file mode 100644
index 0000000..b9d4023
--- /dev/null
+++ b/docs/docs/jetstream.md
@@ -0,0 +1,52 @@
+# Jetstream
+
+Jetstream provides a lightweight, JSON-based WebSocket event stream. Requires the `CarpaNet.Jetstream` package.
+This is useful when you only care for a type of collection that you want to respond to. It also uses far less data than the full Firehose.
+
+```csharp
+using CarpaNet.Jetstream;
+
+using var client = new JetstreamClient(
+ new Uri("https://jetstream1.us-east.bsky.network"));
+
+var options = new JetstreamSubscribeOptions
+{
+ WantedCollections = new[] { "app.bsky.feed.post", "app.bsky.feed.like" },
+ WantedDids = new[] { "did:plc:z72i7hdynmk6r22z27h6tvur" }, // optional, max 10,000
+ Cursor = 1725911162329308, // optional, resume from Unix microsecond timestamp
+ Compress = true, // enable zstd compression
+};
+
+await foreach (var evt in client.SubscribeAsync(options))
+{
+ switch (evt.Kind)
+ {
+ case "commit" when evt.Commit is { } commit:
+ Console.WriteLine($"[{commit.Operation}] {commit.Collection}/{commit.Rkey}");
+ if (commit.Record is { } record)
+ {
+ // record is a JsonElement — parse with your generated types
+ var type = record.TryGetProperty("$type", out var t) ? t.GetString() : null;
+ Console.WriteLine($" $type={type}");
+ }
+ break;
+
+ case "identity" when evt.Identity is { } identity:
+ Console.WriteLine($"[Identity] {evt.Did} → {identity.Handle}");
+ break;
+
+ case "account" when evt.Account is { } account:
+ Console.WriteLine($"[Account] {evt.Did} active={account.Active} status={account.Status}");
+ break;
+ }
+}
+```
+
+## Dynamic Filter Updates
+
+```csharp
+await client.SendOptionsUpdateAsync(new JetstreamOptionsUpdate
+{
+ WantedCollections = new[] { "app.bsky.graph.follow" },
+});
+```
diff --git a/docs/docs/migrating-from-fishyflip.md b/docs/docs/migrating-from-fishyflip.md
new file mode 100644
index 0000000..57287cd
--- /dev/null
+++ b/docs/docs/migrating-from-fishyflip.md
@@ -0,0 +1,302 @@
+# Migrating from FishyFlip
+
+This guide helps users transition from [FishyFlip](https://github.com/drasticactions/FishyFlip) to CarpaNet. Both libraries target ATProtocol but differ significantly in architecture and API design.
+
+## Package Mapping
+
+| FishyFlip | CarpaNet | Notes |
+|-----------|----------|-------|
+| `FishyFlip` | `CarpaNet` | Core library |
+| `FishyFlip` (built-in) | `CarpaNet.Jetstream` | Jetstream is a separate package |
+| `FishyFlip` (built-in) | `CarpaNet.OAuth` | OAuth is a separate package |
+| `FishyFlip.AspNetCore` | `CarpaNet.AspNetCore` | ASP.NET Core integration |
+
+## Declaring Lexicons (New Concept)
+
+FishyFlip bundles all Bluesky lexicons — every API method is available immediately. CarpaNet requires you to **declare which lexicons you need** in your `.csproj`. The source generator then produces only the types and methods you use:
+
+```xml
+
+
+
+
+
+
+
+
+```
+
+To pull in all lexicons from Bluesky at once, use handle resolution:
+
+```xml
+
+
+
+
+```
+
+## Client Creation
+
+FishyFlip uses a builder pattern that creates an `ATProtocol` instance:
+
+```csharp
+// FishyFlip
+var protocol = new ATProtocolBuilder()
+ .WithInstanceUrl(new Uri("https://bsky.social"))
+ .WithUserAgent("MyApp/1.0")
+ .WithLogger(logger)
+ .EnableAutoRenewSession(true)
+ .Build();
+```
+
+CarpaNet uses static factory methods or a constructor with options. A source-generated `ATProtoClientFactory` provides pre-configured JSON/CBOR contexts:
+
+```csharp
+// CarpaNet — unauthenticated (public AppView)
+var client = ATProtoClientFactory.Create();
+
+// CarpaNet — with app password
+var client = await ATProtoClient.CreateWithSessionAsync(
+ identifier: "alice.bsky.social",
+ password: "xxxx-xxxx-xxxx-xxxx",
+ options: new ATProtoClientOptions
+ {
+ JsonOptions = ATProtoJsonContext.DefaultOptions,
+ CborContext = ATProtoCborContext.Default,
+ });
+
+// CarpaNet — full options
+var client = ATProtoClientFactory.Create(new ATProtoClientOptions
+{
+ UserAgent = "MyApp/1.0",
+ LoggerFactory = loggerFactory,
+ EnableRateLimitHandler = true,
+ AutoRetryOnAuthFailure = true,
+});
+```
+
+## API Call Patterns
+
+FishyFlip organizes methods into endpoint groups (`protocol.Actor`, `protocol.Feed`, etc.):
+
+```csharp
+// FishyFlip — endpoint groups
+var (profile, error) = await protocol.Actor.GetProfileAsync(
+ ATHandle.Create("alice.bsky.social"));
+
+var (timeline, error) = await protocol.Feed.GetTimelineAsync(limit: 25);
+
+var (result, error) = await protocol.Feed.CreatePostAsync(post);
+```
+
+CarpaNet uses flat extension methods on `IATProtoClient`, named after the NSID:
+
+```csharp
+// CarpaNet — extension methods
+var profile = await client.AppBskyActorGetProfileAsync(
+ new AppBsky.Actor.GetProfileParameters { Actor = new ATHandle("alice.bsky.social") });
+
+var timeline = await client.AppBskyFeedGetTimelineAsync(
+ new AppBsky.Feed.GetTimelineParameters { Limit = 25 });
+
+var result = await client.ComAtprotoRepoCreateRecordAsync(
+ new ComAtproto.Repo.CreateRecordInput
+ {
+ Repo = new ATIdentifier(client.AuthenticatedDid!),
+ Collection = AppBsky.Feed.Post.RecordType,
+ Record = post.ToJson(),
+ });
+```
+
+Key differences:
+
+- CarpaNet uses **parameter/input record objects** instead of method parameters
+- Method names follow the NSID pattern: `AppBskyActorGetProfileAsync` = `app.bsky.actor.getProfile`
+- Creating records is done via the generic `ComAtprotoRepoCreateRecordAsync` with `Record = post.ToJson()`; FishyFlip provides typed helpers like `CreatePostAsync`
+
+## Error Handling
+
+FishyFlip uses a `Result` type (OneOf-based) with tuple deconstruction:
+
+```csharp
+// FishyFlip
+var (profile, error) = await protocol.Actor.GetProfileAsync(handle);
+if (error is not null)
+{
+ Console.WriteLine($"Error: {error.Detail?.Message}");
+ return;
+}
+Console.WriteLine(profile!.DisplayName);
+```
+
+CarpaNet throws exceptions on failure — use standard try/catch:
+
+```csharp
+// CarpaNet
+try
+{
+ var profile = await client.AppBskyActorGetProfileAsync(
+ new AppBsky.Actor.GetProfileParameters { Actor = handle });
+ Console.WriteLine(profile.DisplayName);
+}
+catch (ATProtoException ex)
+{
+ Console.WriteLine($"Error: {ex.Message}");
+}
+```
+
+## Authentication
+
+### App Password
+
+```csharp
+// FishyFlip
+var protocol = new ATProtocolBuilder().Build();
+var (session, error) = await protocol.AuthenticateWithPasswordResultAsync(
+ "alice.bsky.social", "xxxx-xxxx-xxxx-xxxx");
+
+// CarpaNet
+var client = await ATProtoClient.CreateWithSessionAsync(
+ identifier: "alice.bsky.social",
+ password: "xxxx-xxxx-xxxx-xxxx",
+ options: new ATProtoClientOptions
+ {
+ JsonOptions = ATProtoJsonContext.DefaultOptions,
+ CborContext = ATProtoCborContext.Default,
+ });
+```
+
+### OAuth 2.0
+
+```csharp
+// FishyFlip — OAuth is on ATProtocol directly
+var protocol = new ATProtocolBuilder().Build();
+var (authUrl, error) = await protocol.GenerateOAuth2AuthenticationUrlResultAsync(
+ clientId: "http://localhost",
+ redirectUrl: "http://localhost:3000/callback",
+ scopes: new[] { "atproto" },
+ instanceUrl: "https://bsky.social");
+// ... user completes browser login ...
+var (session, error) = await protocol.AuthenticateWithOAuth2CallbackResultAsync(callbackUrl);
+
+// CarpaNet — OAuth is a separate OAuthSession class
+using var oauthSession = new OAuthSession(new OAuthClientConfig
+{
+ ClientId = OAuthClientConfig.CreateLoopbackClientId(8080),
+ RedirectUri = OAuthClientConfig.CreateLoopbackRedirectUri(8080),
+ Scope = "atproto transition:generic",
+ JsonOptions = ATProtoJsonContext.DefaultOptions,
+ SessionStore = new MemoryOAuthSessionStore(),
+});
+var authUrl = await oauthSession.AuthorizeAsync("alice.bsky.social");
+// ... user completes browser login ...
+ATProtoOAuthClient atClient = await oauthSession.CallbackAsync(callbackUrl);
+```
+
+### Session Persistence
+
+```csharp
+// FishyFlip — serialize AuthSession to string
+var saved = await protocol.RefreshAuthSessionResultAsync();
+File.WriteAllText("session.json", saved.ToString());
+// Restore:
+var restored = AuthSession.FromString(File.ReadAllText("session.json"));
+await protocol.AuthenticateWithOAuth2SessionResultAsync(restored, clientId);
+
+// CarpaNet — implement ISessionStore (password) or IOAuthSessionStore (OAuth)
+var client = ATProtoClientFactory.Create(new ATProtoClientOptions
+{
+ SessionStore = new MyFileSessionStore(),
+});
+bool restored = await client.RestoreSessionAsync(userDid);
+```
+
+## Identity Types
+
+Both libraries use similar strongly-typed identifiers but with different creation patterns:
+
+```csharp
+// FishyFlip
+var did = ATDid.Create("did:plc:abc123");
+var handle = ATHandle.Create("alice.bsky.social");
+var uri = ATUri.Create("at://did:plc:abc123/app.bsky.feed.post/rkey");
+var identifier = ATIdentifier.Create("alice.bsky.social");
+
+// CarpaNet — constructors and implicit conversion
+var did = new ATDid("did:plc:abc123");
+var handle = new ATHandle("alice.bsky.social");
+var uri = ATUri.Create("did:plc:abc123", "app.bsky.feed.post", "rkey");
+var identifier = new ATIdentifier("alice.bsky.social");
+```
+
+## Jetstream
+
+FishyFlip uses an event-based callback model:
+
+```csharp
+// FishyFlip
+var jetStream = new ATJetStream(new ATJetStreamOptions
+{
+ Url = new Uri("wss://jetstream.atproto.tools"),
+ WantedCollections = new[] { "app.bsky.feed.post" },
+});
+jetStream.OnRecordReceived += (s, e) => { /* handle event */ };
+await jetStream.ConnectAsync();
+```
+
+CarpaNet uses `IAsyncEnumerable` for a cleaner streaming pattern:
+
+```csharp
+// CarpaNet
+using var client = new JetstreamClient(
+ new Uri("https://jetstream1.us-east.bsky.network"));
+
+await foreach (var evt in client.SubscribeAsync(new JetstreamSubscribeOptions
+{
+ WantedCollections = new[] { "app.bsky.feed.post" },
+ Compress = true,
+}))
+{
+ if (evt.Kind == "commit" && evt.Commit is { } commit)
+ Console.WriteLine($"{commit.Operation} {commit.Collection}/{commit.Rkey}");
+}
+```
+
+## Serialization Context
+
+FishyFlip manages its `SourceGenerationContext` internally — you don't need to think about it. CarpaNet requires you to pass source-generated contexts explicitly (they are generated per-project based on your declared lexicons):
+
+```csharp
+// CarpaNet — contexts are auto-generated, used via ATProtoClientFactory or manually
+var client = ATProtoClientFactory.Create(); // contexts pre-wired
+
+// Or manually:
+var client = ATProtoClient.Create(new ATProtoClientOptions
+{
+ JsonOptions = ATProtoJsonContext.DefaultOptions,
+ CborContext = ATProtoCborContext.Default,
+});
+```
+
+## Quick Reference
+
+| Operation | FishyFlip | CarpaNet |
+|-----------|-----------|----------|
+| Get profile | `protocol.Actor.GetProfileAsync(handle)` | `client.AppBskyActorGetProfileAsync(new ... { Actor = handle })` |
+| Create post | `protocol.Feed.CreatePostAsync(post)` | `client.ComAtprotoRepoCreateRecordAsync(new ... { Record = post.ToJson() })` |
+| Delete post | `protocol.Feed.DeletePostAsync(repo, rkey)` | `client.ComAtprotoRepoDeleteRecordAsync(new ... { Rkey = rkey })` |
+| Get timeline | `protocol.Feed.GetTimelineAsync(limit)` | `client.AppBskyFeedGetTimelineAsync(new ... { Limit = limit })` |
+| Resolve handle | `protocol.ResolveATIdentifierAsync(handle)` | `resolver.ResolveAsync("alice.bsky.social")` |
+| Check auth | `protocol.IsAuthenticated` | `client.IsAuthenticated` |
+| Get current DID | `protocol.Session?.Did` | `client.AuthenticatedDid` |
+
+## Summary of Key Differences (It's a numbered list so you know an LLM did this)
+
+1. **Lexicon-driven**: CarpaNet generates only the APIs you declare — add lexicons to `.csproj`
+2. **No endpoint groups**: Flat extension methods named after NSIDs instead of `protocol.Actor.*`
+3. **No Result type**: CarpaNet throws exceptions instead of returning `Result`
+4. **Explicit serialization contexts**: You pass `JsonOptions`/`CborContext` (auto-generated per project)
+5. **Modular packages**: OAuth and Jetstream are separate NuGet packages
+6. **IAsyncEnumerable streams**: Jetstream and firehose use `await foreach` instead of event callbacks
+7. **Constructor-based types**: `new ATHandle(...)` instead of `ATHandle.Create(...)`
+8. **Generic record operations**: Use `ComAtprotoRepoCreateRecordAsync` with `Record = obj.ToJson()` instead of typed `CreatePostAsync` helpers
diff --git a/docs/docs/oauth.md b/docs/docs/oauth.md
new file mode 100644
index 0000000..81cbb83
--- /dev/null
+++ b/docs/docs/oauth.md
@@ -0,0 +1,112 @@
+# OAuth Authentication
+
+OAuth is the recommended auth method for user-facing apps. Requires the `CarpaNet.OAuth` package.
+
+## Desktop/Console App Flow
+
+```csharp
+using CarpaNet.OAuth;
+
+// 1. Configure with loopback URI for desktop apps
+var port = 8080;
+var config = new OAuthClientConfig
+{
+ ClientId = OAuthClientConfig.CreateLoopbackClientId(port),
+ RedirectUri = OAuthClientConfig.CreateLoopbackRedirectUri(port),
+ Scope = "atproto transition:generic",
+ JsonOptions = ATProtoJsonContext.DefaultOptions,
+ SessionStore = new MemoryOAuthSessionStore(),
+};
+
+// 2. Start the OAuth flow
+using var oauthSession = new OAuthSession(config);
+var authUrl = await oauthSession.AuthorizeAsync("alice.bsky.social");
+
+// 3. Open browser and listen for callback
+Console.WriteLine($"Open: {authUrl}");
+// ... start HTTP listener on port, capture callback URL ...
+
+// 4. Exchange code for tokens
+ATProtoOAuthClient atClient = await oauthSession.CallbackAsync(callbackUrl);
+
+// 5. Use the authenticated client
+var profile = await atClient.AppBskyActorGetProfileAsync(
+ new AppBsky.Actor.GetProfileParameters { Actor = new ATHandle("alice.bsky.social") });
+
+// 6. Sign out when done
+await atClient.SignOutAsync();
+```
+
+## Web App Flow
+
+```csharp
+var config = new OAuthClientConfig
+{
+ ClientId = "https://myapp.example.com/client-metadata.json",
+ RedirectUri = "https://myapp.example.com/callback",
+ Scope = "atproto transition:generic",
+ JsonOptions = ATProtoJsonContext.DefaultOptions,
+ SessionStore = myPersistentSessionStore,
+ StateStore = myPersistentStateStore,
+};
+
+using var oauthSession = new OAuthSession(config);
+var authUrl = await oauthSession.AuthorizeAsync(userHandle);
+// Redirect user to authUrl...
+
+// In callback handler:
+var atClient = await oauthSession.CallbackAsync(Request.Url.ToString());
+```
+
+## Restoring an OAuth Session
+
+```csharp
+var config = new OAuthClientConfig
+{
+ ClientId = savedClientId,
+ RedirectUri = savedRedirectUri,
+ Scope = "atproto",
+ JsonOptions = ATProtoJsonContext.DefaultOptions,
+ SessionStore = mySessionStore,
+};
+
+using var oauthSession = new OAuthSession(config);
+ATProtoOAuthClient atClient = await oauthSession.RestoreSessionAsync(userDid);
+```
+
+## Custom Session Storage
+
+Implement `IOAuthSessionStore` to persist OAuth sessions (DPoP keys, tokens) across app restarts:
+
+```csharp
+public sealed class FileOAuthSessionStore : IOAuthSessionStore
+{
+ private readonly string _directory;
+
+ public FileOAuthSessionStore(string directory) => _directory = directory;
+
+ public Task StoreAsync(string sub, OAuthSessionData data, CancellationToken ct)
+ {
+ var json = JsonSerializer.Serialize(data);
+ File.WriteAllText(GetPath(sub), json);
+ return Task.CompletedTask;
+ }
+
+ public Task GetAsync(string sub, CancellationToken ct)
+ {
+ var path = GetPath(sub);
+ if (!File.Exists(path)) return Task.FromResult(null);
+ var data = JsonSerializer.Deserialize(File.ReadAllText(path));
+ return Task.FromResult(data);
+ }
+
+ public Task DeleteAsync(string sub, CancellationToken ct)
+ {
+ File.Delete(GetPath(sub));
+ return Task.CompletedTask;
+ }
+
+ private string GetPath(string sub) =>
+ Path.Combine(_directory, $"oauth-{sub.Replace(":", "_")}.json");
+}
+```
diff --git a/docs/docs/project-setup.md b/docs/docs/project-setup.md
new file mode 100644
index 0000000..a8c8bd8
--- /dev/null
+++ b/docs/docs/project-setup.md
@@ -0,0 +1,100 @@
+# Project Setup
+
+## Prerequisites
+
+- .NET 8 SDK or above
+- `dotnet add package CarpaNet`
+
+## Minimal .csproj
+
+```xml
+
+
+ Exe
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+When you build, the source generator resolves lexicons via DNS, caches them locally, and generates:
+
+- Data model classes with JSON attributes
+- Extension methods on `IATProtoClient` for each query/procedure
+- A `JsonSerializerContext` and `CborSerializerContext` for AOT-compatible serialization
+- `ToJson()`/`FromJson()` helpers on each generated type
+- An `ATProtoClientFactory` with preconfigured JSON/CBOR contexts
+
+## Lexicon Sources
+
+You can combine four ways to supply lexicons:
+
+```xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+## Auto-Resolve Transitive Dependencies
+
+Enable automatic discovery of referenced lexicons you haven't explicitly listed:
+
+```xml
+
+ true
+
+```
+
+This scans your lexicons for `ref` fields pointing to external NSIDs, resolves them via DNS, and repeats until all dependencies are satisfied.
+
+## MSBuild Properties
+
+| Property | Default | Description |
+|----------|---------|-------------|
+| `CarpaNet_JsonContextName` | `ATProtoJsonContext` | Name of the generated JSON serializer context |
+| `CarpaNet_CborContextName` | `ATProtoCborContext` | Name of the generated CBOR serializer context |
+| `CarpaNet_SourceGen_RootNamespace` | Project namespace | Root namespace for generated code |
+| `CarpaNet_SourceGen_EmitValidationAttributes` | `false` | Emit `[ATStringLength]`, `[Range]` attributes |
+| `CarpaNet_LexiconAutoResolve` | `false` | Auto-resolve transitive lexicon dependencies |
+| `CarpaNet_LexiconAutoResolveMaxDepth` | `10` | Max iterations for transitive resolution |
+| `CarpaNet_LexiconCacheDir` | `obj/lexicon-cache/` | Cache directory for resolved lexicons |
+| `CarpaNet_LexiconCacheTtlHours` | `24` | Cache TTL in hours; `0` forces refresh |
+| `CarpaNet_LexiconFailOnError` | `true` | Fail build on resolution errors |
+| `CarpaNet_PlcDirectoryUrl` | `https://plc.directory` | PLC directory URL |
+| `CarpaNet_DnsServers` | (empty) | Semicolon-separated DNS server IPs |
+
+## Inspecting Generated Code
+
+Roslyn allows for emiting the compiler generated files. This makes it easy to debug (and for LLMs to inspect, as it were.)
+
+```xml
+
+ true
+
+```
+
+Generated files appear at `obj/Debug/{TFM}/generated/CarpaNet.SourceGen/CarpaNet.LexiconGenerator/`.
diff --git a/docs/docs/repository-car.md b/docs/docs/repository-car.md
new file mode 100644
index 0000000..76a5860
--- /dev/null
+++ b/docs/docs/repository-car.md
@@ -0,0 +1,26 @@
+# Repository & CAR File Reading
+
+Read ATProtocol repositories from CAR (Content Addressable aRchive) files:
+
+```csharp
+using CarpaNet.Repo;
+
+// Load from file
+var repo = Repository.LoadFromFile("repository.car");
+
+// Or from stream/bytes
+var repo = Repository.Load(carStream);
+var repo = Repository.Load(carBytes);
+
+// Inspect
+Console.WriteLine($"Owner: {repo.Did}");
+Console.WriteLine($"Revision: {repo.Rev}");
+Console.WriteLine($"Root CID: {repo.RootCid}");
+
+// Low-level CAR block reading
+using var reader = new CarReader(stream);
+foreach (var block in reader.ReadBlocks())
+{
+ Console.WriteLine($"CID: {block.Cid}, Size: {block.Data.Length}");
+}
+```
diff --git a/docs/docs/toc.yml b/docs/docs/toc.yml
new file mode 100644
index 0000000..e6a3030
--- /dev/null
+++ b/docs/docs/toc.yml
@@ -0,0 +1,14 @@
+- name: Getting Started
+- name: Introduction
+ href: ../index.md
+- href: project-setup.md
+- href: creating-clients.md
+- href: authentication.md
+- href: common-operations.md
+- href: oauth.md
+- href: jetstream.md
+- href: firehose.md
+- href: identity-resolution.md
+- href: repository-car.md
+- href: custom-lexicons.md
+- href: migrating-from-fishyflip.md
diff --git a/docs/favicon.png b/docs/favicon.png
new file mode 100644
index 0000000..8ed0742
Binary files /dev/null and b/docs/favicon.png differ
diff --git a/docs/index.md b/docs/index.md
new file mode 100644
index 0000000..9ba2c3d
--- /dev/null
+++ b/docs/index.md
@@ -0,0 +1,51 @@
+# CarpaNet - a .NET ATProtocol Library
+
+[](https://www.nuget.org/packages/CarpaNet/) 
+
+CarpaNet is a .NET library for [ATProtocol](https://atproto.com/) that uses Roslyn source generators to produce type-safe API bindings from Lexicon JSON schema files. It's the replacement for [FishyFlip](https://github.com/drasticactions/FishyFlip).
+
+
+
+This site is under construction.
+
+## Quick Start
+
+Add the CarpaNet NuGet package and declare which lexicons you need:
+
+```xml
+
+
+
+
+
+
+
+
+
+```
+
+Then use the generated client:
+
+```csharp
+using CarpaNet;
+
+var client = ATProtoClientFactory.Create();
+
+var profile = await client.AppBskyActorGetProfileAsync(
+ new AppBsky.Actor.GetProfileParameters { Actor = new ATHandle("alice.bsky.social") });
+
+Console.WriteLine($"{profile.DisplayName} (@{profile.Handle})");
+```
+
+## Packages
+
+| Package | Description |
+|---------|-------------|
+| [CarpaNet](https://www.nuget.org/packages/CarpaNet/) | Core runtime, source generator, XRPC protocol, identity resolution, DAG-CBOR, CAR files |
+| [CarpaNet.OAuth](https://www.nuget.org/packages/CarpaNet.OAuth/) | OAuth 2.0 with PAR, PKCE, and DPoP support |
+| [CarpaNet.Jetstream](https://www.nuget.org/packages/CarpaNet.Jetstream/) | Real-time event subscription via Bluesky Jetstream |
+| [CarpaNet.AspNetCore](https://www.nuget.org/packages/CarpaNet.AspNetCore/) | ASP.NET Core integration |
+
+## Third-Party Libraries
+
+- [ZstdSharp.Port](https://github.com/oleg-st/ZstdSharp) — Zstandard compression
diff --git a/docs/toc.yml b/docs/toc.yml
new file mode 100644
index 0000000..abd17b8
--- /dev/null
+++ b/docs/toc.yml
@@ -0,0 +1,4 @@
+- name: Docs
+ href: docs/
+- name: API
+ href: api/
diff --git a/skills/carpanet/SKILL.md b/skills/carpanet/SKILL.md
index 769e85c..81df525 100644
--- a/skills/carpanet/SKILL.md
+++ b/skills/carpanet/SKILL.md
@@ -609,3 +609,306 @@ dotnet publish -c Release
```
No additional configuration needed — the generated `ATProtoJsonContext` and `ATProtoCborContext` handle all serialization at compile time.
+
+---
+
+## Migrating from FishyFlip
+
+This section helps users transition from [FishyFlip](https://github.com/drasticactions/FishyFlip) to CarpaNet. Both libraries target ATProtocol but differ significantly in architecture and API design.
+
+### Package Mapping
+
+| FishyFlip | CarpaNet | Notes |
+|-----------|----------|-------|
+| `FishyFlip` | `CarpaNet` | Core library |
+| `FishyFlip` (built-in) | `CarpaNet.Jetstream` | Jetstream is a separate package in CarpaNet |
+| `FishyFlip` (built-in) | `CarpaNet.OAuth` | OAuth is a separate package in CarpaNet |
+| `FishyFlip.AspNetCore` | *(not yet available)* | ASP.NET Core integration |
+
+### Lexicon Declaration (New Concept)
+
+FishyFlip bundles all Bluesky lexicons — every API method is available immediately. CarpaNet requires you to **declare which lexicons you need** in your `.csproj`. The source generator then produces only the types and methods you use:
+
+```xml
+
+
+
+
+
+
+
+
+```
+
+To pull in all lexicons from Bluesky at once, use authority resolution:
+
+```xml
+
+
+
+```
+
+### Client Creation
+
+**FishyFlip** uses a builder pattern that creates an `ATProtocol` instance:
+
+```csharp
+// FishyFlip
+var protocol = new ATProtocolBuilder()
+ .WithInstanceUrl(new Uri("https://bsky.social"))
+ .WithUserAgent("MyApp/1.0")
+ .WithLogger(logger)
+ .EnableAutoRenewSession(true)
+ .Build();
+```
+
+**CarpaNet** uses static factory methods or constructor with options. A source-generated `ATProtoClientFactory` provides pre-configured JSON/CBOR contexts:
+
+```csharp
+// CarpaNet — unauthenticated (public AppView)
+var client = ATProtoClientFactory.Create();
+
+// CarpaNet — with app password
+var client = await ATProtoClient.CreateWithSessionAsync(
+ identifier: "alice.bsky.social",
+ password: "xxxx-xxxx-xxxx-xxxx",
+ options: new ATProtoClientOptions
+ {
+ JsonOptions = ATProtoJsonContext.DefaultOptions,
+ CborContext = ATProtoCborContext.Default,
+ });
+
+// CarpaNet — full options
+var client = ATProtoClientFactory.Create(new ATProtoClientOptions
+{
+ UserAgent = "MyApp/1.0",
+ LoggerFactory = loggerFactory,
+ EnableRateLimitHandler = true,
+ AutoRetryOnAuthFailure = true,
+});
+```
+
+### API Call Patterns
+
+**FishyFlip** organizes methods into endpoint groups (`protocol.Actor`, `protocol.Feed`, etc.):
+
+```csharp
+// FishyFlip — endpoint groups
+var (profile, error) = await protocol.Actor.GetProfileAsync(
+ ATHandle.Create("alice.bsky.social"));
+
+var (timeline, error) = await protocol.Feed.GetTimelineAsync(limit: 25);
+
+var (result, error) = await protocol.Feed.CreatePostAsync(post);
+```
+
+**CarpaNet** uses flat extension methods on `IATProtoClient`, named after the NSID:
+
+```csharp
+// CarpaNet — extension methods
+var profile = await client.AppBskyActorGetProfileAsync(
+ new AppBsky.Actor.GetProfileParameters { Actor = new ATHandle("alice.bsky.social") });
+
+var timeline = await client.AppBskyFeedGetTimelineAsync(
+ new AppBsky.Feed.GetTimelineParameters { Limit = 25 });
+
+var result = await client.ComAtprotoRepoCreateRecordAsync(
+ new ComAtproto.Repo.CreateRecordInput
+ {
+ Repo = new ATIdentifier(client.AuthenticatedDid!),
+ Collection = AppBsky.Feed.Post.RecordType,
+ Record = post.ToJson(),
+ });
+```
+
+Key differences:
+- CarpaNet uses **parameter/input record objects** instead of method parameters
+- Method names follow the NSID pattern: `AppBskyActorGetProfileAsync` = `app.bsky.actor.getProfile`
+- Creating records is done via the generic `ComAtprotoRepoCreateRecordAsync` with `Record = post.ToJson()`; FishyFlip provides typed helpers like `CreatePostAsync`
+
+### Error Handling
+
+**FishyFlip** uses a `Result` type (OneOf-based) with tuple deconstruction:
+
+```csharp
+// FishyFlip
+var (profile, error) = await protocol.Actor.GetProfileAsync(handle);
+if (error is not null)
+{
+ Console.WriteLine($"Error: {error.Detail?.Message}");
+ return;
+}
+Console.WriteLine(profile!.DisplayName);
+```
+
+**CarpaNet** throws exceptions on failure — use standard try/catch:
+
+```csharp
+// CarpaNet
+try
+{
+ var profile = await client.AppBskyActorGetProfileAsync(
+ new AppBsky.Actor.GetProfileParameters { Actor = handle });
+ Console.WriteLine(profile.DisplayName);
+}
+catch (ATProtoException ex)
+{
+ Console.WriteLine($"Error: {ex.Message}");
+}
+```
+
+### Authentication
+
+#### App Password
+
+```csharp
+// FishyFlip
+var protocol = new ATProtocolBuilder().Build();
+var (session, error) = await protocol.AuthenticateWithPasswordResultAsync(
+ "alice.bsky.social", "xxxx-xxxx-xxxx-xxxx");
+
+// CarpaNet
+var client = await ATProtoClient.CreateWithSessionAsync(
+ identifier: "alice.bsky.social",
+ password: "xxxx-xxxx-xxxx-xxxx",
+ options: new ATProtoClientOptions
+ {
+ JsonOptions = ATProtoJsonContext.DefaultOptions,
+ CborContext = ATProtoCborContext.Default,
+ });
+```
+
+#### OAuth 2.0
+
+```csharp
+// FishyFlip — OAuth is on ATProtocol directly
+var protocol = new ATProtocolBuilder().Build();
+var (authUrl, error) = await protocol.GenerateOAuth2AuthenticationUrlResultAsync(
+ clientId: "http://localhost",
+ redirectUrl: "http://localhost:3000/callback",
+ scopes: new[] { "atproto" },
+ instanceUrl: "https://bsky.social");
+// ... user completes browser login ...
+var (session, error) = await protocol.AuthenticateWithOAuth2CallbackResultAsync(callbackUrl);
+
+// CarpaNet — OAuth is a separate OAuthSession class
+using var oauthSession = new OAuthSession(new OAuthClientConfig
+{
+ ClientId = OAuthClientConfig.CreateLoopbackClientId(8080),
+ RedirectUri = OAuthClientConfig.CreateLoopbackRedirectUri(8080),
+ Scope = "atproto transition:generic",
+ JsonOptions = ATProtoJsonContext.DefaultOptions,
+ SessionStore = new MemoryOAuthSessionStore(),
+});
+var authUrl = await oauthSession.AuthorizeAsync("alice.bsky.social");
+// ... user completes browser login ...
+ATProtoOAuthClient atClient = await oauthSession.CallbackAsync(callbackUrl);
+```
+
+#### Session Persistence
+
+```csharp
+// FishyFlip — serialize AuthSession to string
+var saved = await protocol.RefreshAuthSessionResultAsync();
+File.WriteAllText("session.json", saved.ToString());
+// Restore:
+var restored = AuthSession.FromString(File.ReadAllText("session.json"));
+await protocol.AuthenticateWithOAuth2SessionResultAsync(restored, clientId);
+
+// CarpaNet — implement ISessionStore (password) or IOAuthSessionStore (OAuth)
+var client = ATProtoClientFactory.Create(new ATProtoClientOptions
+{
+ SessionStore = new MyFileSessionStore(),
+});
+bool restored = await client.RestoreSessionAsync(userDid);
+```
+
+### Identity Types
+
+Both libraries use similar strongly-typed identifiers but with different creation patterns:
+
+```csharp
+// FishyFlip
+var did = ATDid.Create("did:plc:abc123");
+var handle = ATHandle.Create("alice.bsky.social");
+var uri = ATUri.Create("at://did:plc:abc123/app.bsky.feed.post/rkey");
+var identifier = ATIdentifier.Create("alice.bsky.social");
+
+// CarpaNet — constructors and implicit conversion
+var did = new ATDid("did:plc:abc123");
+var handle = new ATHandle("alice.bsky.social");
+var uri = ATUri.Create("did:plc:abc123", "app.bsky.feed.post", "rkey");
+var identifier = new ATIdentifier("alice.bsky.social");
+```
+
+### Jetstream (Real-Time Events)
+
+**FishyFlip** uses an event-based callback model:
+
+```csharp
+// FishyFlip
+var jetStream = new ATJetStream(new ATJetStreamOptions
+{
+ Url = new Uri("wss://jetstream.atproto.tools"),
+ WantedCollections = new[] { "app.bsky.feed.post" },
+});
+jetStream.OnRecordReceived += (s, e) => { /* handle event */ };
+await jetStream.ConnectAsync();
+```
+
+**CarpaNet** uses `IAsyncEnumerable` for a cleaner streaming pattern:
+
+```csharp
+// CarpaNet
+using var client = new JetstreamClient(
+ new Uri("https://jetstream1.us-east.bsky.network"));
+
+await foreach (var evt in client.SubscribeAsync(new JetstreamSubscribeOptions
+{
+ WantedCollections = new[] { "app.bsky.feed.post" },
+ Compress = true,
+}))
+{
+ if (evt.Kind == "commit" && evt.Commit is { } commit)
+ Console.WriteLine($"{commit.Operation} {commit.Collection}/{commit.Rkey}");
+}
+```
+
+### Serialization Context
+
+FishyFlip manages its `SourceGenerationContext` internally — you don't need to think about it. CarpaNet requires you to pass source-generated contexts explicitly (they are generated per-project based on your declared lexicons):
+
+```csharp
+// CarpaNet — contexts are auto-generated, used via ATProtoClientFactory or manually
+var client = ATProtoClientFactory.Create(); // contexts pre-wired
+
+// Or manually:
+var client = ATProtoClient.Create(new ATProtoClientOptions
+{
+ JsonOptions = ATProtoJsonContext.DefaultOptions,
+ CborContext = ATProtoCborContext.Default,
+});
+```
+
+### Quick Reference: Common Operations
+
+| Operation | FishyFlip | CarpaNet |
+|-----------|-----------|----------|
+| Get profile | `protocol.Actor.GetProfileAsync(handle)` | `client.AppBskyActorGetProfileAsync(new ... { Actor = handle })` |
+| Create post | `protocol.Feed.CreatePostAsync(post)` | `client.ComAtprotoRepoCreateRecordAsync(new ... { Record = post.ToJson() })` |
+| Delete post | `protocol.Feed.DeletePostAsync(repo, rkey)` | `client.ComAtprotoRepoDeleteRecordAsync(new ... { Rkey = rkey })` |
+| Get timeline | `protocol.Feed.GetTimelineAsync(limit)` | `client.AppBskyFeedGetTimelineAsync(new ... { Limit = limit })` |
+| Resolve handle | `protocol.ResolveATIdentifierAsync(handle)` | `resolver.ResolveAsync("alice.bsky.social")` |
+| Check auth | `protocol.IsAuthenticated` | `client.IsAuthenticated` |
+| Get current DID | `protocol.Session?.Did` | `client.AuthenticatedDid` |
+
+### Summary of Key Differences
+
+1. **Lexicon-driven**: CarpaNet generates only the APIs you declare — add lexicons to `.csproj`
+2. **No endpoint groups**: Flat extension methods named after NSIDs instead of `protocol.Actor.*`
+3. **No Result type**: CarpaNet throws exceptions instead of returning `Result`
+4. **Explicit serialization contexts**: You pass `JsonOptions`/`CborContext` (auto-generated per project)
+5. **Modular packages**: OAuth and Jetstream are separate NuGet packages
+6. **IAsyncEnumerable streams**: Jetstream and firehose use `await foreach` instead of event callbacks
+7. **Constructor-based types**: `new ATHandle(...)` instead of `ATHandle.Create(...)`
+8. **Generic record operations**: Use `ComAtprotoRepoCreateRecordAsync` with `Record = obj.ToJson()` instead of typed `CreatePostAsync` helpers
diff --git a/src/CarpaNet.SourceGen/Generation/CborContextGenerator.cs b/src/CarpaNet.SourceGen/Generation/CborContextGenerator.cs
index ef1b672..d35c3ef 100644
--- a/src/CarpaNet.SourceGen/Generation/CborContextGenerator.cs
+++ b/src/CarpaNet.SourceGen/Generation/CborContextGenerator.cs
@@ -101,6 +101,13 @@ public static void GenerateCborTypeInfo(
propName = propName + "Value";
}
+ // Records auto-generate a "Type" property for the $type discriminator,
+ // so rename any lexicon property that would also become "Type"
+ if (isRecord && propName == "Type")
+ {
+ propName = "TypeValue";
+ }
+
propName = NsidHelper.EscapeIdentifier(propName);
var propType = GetPropertyCSharpType(prop.Value, currentNsid, registry, shortClassName, prop.Key, typeNamespace);
diff --git a/src/CarpaNet.SourceGen/Generation/JsonContextGenerator.cs b/src/CarpaNet.SourceGen/Generation/JsonContextGenerator.cs
index daa1a55..c3b4a98 100644
--- a/src/CarpaNet.SourceGen/Generation/JsonContextGenerator.cs
+++ b/src/CarpaNet.SourceGen/Generation/JsonContextGenerator.cs
@@ -86,7 +86,7 @@ public static void GenerateJsonTypeInfo(
if (isRecord)
{
collectedPropertyTypes?.Add("string");
- sb.AppendLine($"var prop_type = global::System.Text.Json.Serialization.Metadata.JsonMetadataServices.CreatePropertyInfo(options, new global::System.Text.Json.Serialization.Metadata.JsonPropertyInfoValues()");
+ sb.AppendLine($"var prop__dtype = global::System.Text.Json.Serialization.Metadata.JsonMetadataServices.CreatePropertyInfo(options, new global::System.Text.Json.Serialization.Metadata.JsonPropertyInfoValues()");
sb.OpenBrace();
sb.AppendLine("IsProperty = true,");
sb.AppendLine("IsPublic = true,");
@@ -124,6 +124,13 @@ public static void GenerateJsonTypeInfo(
propName = propName + "Value";
}
+ // Records auto-generate a "Type" property for the $type discriminator,
+ // so rename any lexicon property that would also become "Type"
+ if (isRecord && propName == "Type")
+ {
+ propName = "TypeValue";
+ }
+
propName = NsidHelper.EscapeIdentifier(propName);
// Resolve property type to fully qualified form
@@ -175,7 +182,7 @@ public static void GenerateJsonTypeInfo(
sb.Append("return new global::System.Text.Json.Serialization.Metadata.JsonPropertyInfo[] { ");
if (isRecord)
{
- sb.Append("prop_type, ");
+ sb.Append("prop__dtype, ");
}
for (int i = 0; i < propList.Count; i++)
diff --git a/src/CarpaNet.SourceGen/Generation/ObjectGenerator.cs b/src/CarpaNet.SourceGen/Generation/ObjectGenerator.cs
index db3e2ad..6520314 100644
--- a/src/CarpaNet.SourceGen/Generation/ObjectGenerator.cs
+++ b/src/CarpaNet.SourceGen/Generation/ObjectGenerator.cs
@@ -63,7 +63,7 @@ public static void GenerateClass(
foreach (var prop in properties)
{
- GenerateProperty(sb, className, prop.Key, prop.Value, currentNsid, registry, requiredProps, nullableProps);
+ GenerateProperty(sb, className, prop.Key, prop.Value, currentNsid, registry, requiredProps, nullableProps, isRecord);
}
sb.CloseBrace();
@@ -111,7 +111,8 @@ public static void GenerateProperty(
string currentNsid,
TypeRegistry registry,
List requiredProps,
- List nullableProps)
+ List nullableProps,
+ bool isRecord = false)
{
var isRequired = requiredProps.Contains(propertyName) || def.IsRequired;
var isNullable = nullableProps.Contains(propertyName) || !isRequired;
@@ -125,6 +126,13 @@ public static void GenerateProperty(
csharpName = csharpName + "Value";
}
+ // Records auto-generate a "Type" property for the $type discriminator,
+ // so rename any lexicon property that would also become "Type"
+ if (isRecord && csharpName == "Type")
+ {
+ csharpName = "TypeValue";
+ }
+
csharpName = NsidHelper.EscapeIdentifier(csharpName);
sb.WriteSummary(def.Description);
diff --git a/tests/CarpaNet.UnitTests/Generation/ObjectGeneratorTests.cs b/tests/CarpaNet.UnitTests/Generation/ObjectGeneratorTests.cs
new file mode 100644
index 0000000..35762d0
--- /dev/null
+++ b/tests/CarpaNet.UnitTests/Generation/ObjectGeneratorTests.cs
@@ -0,0 +1,162 @@
+using CarpaNet.Generation;
+using CarpaNet.Models;
+
+using Xunit;
+
+namespace CarpaNet.UnitTests.Generation;
+
+public class ObjectGeneratorTests
+{
+ [Fact]
+ public void RecordWithTypeProperty_RenamesPropertyToTypeValue()
+ {
+ // Arrange: a record type with a lexicon-defined "type" property
+ // This conflicts with the auto-generated "Type" property for the $type discriminator
+ var registry = new TypeRegistry();
+ var def = new LexiconDefinition
+ {
+ Type = "object",
+ Properties = new Dictionary
+ {
+ ["type"] = new LexiconDefinition { Type = "string" },
+ ["subject"] = new LexiconDefinition { Type = "string" },
+ },
+ RequiredRaw = CreateJsonArray("type", "subject"),
+ };
+
+ var sb = new SourceBuilder();
+
+ // Act
+ ObjectGenerator.GenerateClass(
+ sb, "Reaction", def, "tech.tokimeki.kaku.reaction", registry,
+ isRecord: true, recordType: "tech.tokimeki.kaku.reaction",
+ typeId: "tech.tokimeki.kaku.reaction");
+
+ var result = sb.ToString();
+
+ // Assert: the $type discriminator property exists
+ Assert.Contains("public string Type => RecordType;", result);
+
+ // Assert: the lexicon "type" property is renamed to TypeValue
+ Assert.Contains("JsonPropertyName(\"type\")", result);
+ Assert.Contains("TypeValue", result);
+
+ // Assert: there is no duplicate "Type" settable property (only the computed "Type =>" should exist)
+ Assert.DoesNotContain("public required string Type { get; set; }", result);
+ Assert.Contains("public required string TypeValue { get; set; }", result);
+ }
+
+ [Fact]
+ public void NonRecordWithTypeProperty_KeepsPropertyAsType()
+ {
+ // Arrange: a non-record type with a "type" property — no conflict
+ var registry = new TypeRegistry();
+ var def = new LexiconDefinition
+ {
+ Type = "object",
+ Properties = new Dictionary
+ {
+ ["type"] = new LexiconDefinition { Type = "string" },
+ },
+ RequiredRaw = CreateJsonArray("type"),
+ };
+
+ var sb = new SourceBuilder();
+
+ // Act
+ ObjectGenerator.GenerateClass(
+ sb, "PostEntity", def, "app.bsky.feed.post#entity", registry,
+ isRecord: false);
+
+ var result = sb.ToString();
+
+ // Assert: the property keeps its original name since there's no $type discriminator
+ Assert.Contains("JsonPropertyName(\"type\")", result);
+ Assert.Contains("public required string Type { get; set; }", result);
+ Assert.DoesNotContain("TypeValue", result);
+ }
+
+ [Fact]
+ public void RecordWithoutTypeProperty_GeneratesNormalProperties()
+ {
+ // Arrange: a record without a conflicting "type" property
+ var registry = new TypeRegistry();
+ var def = new LexiconDefinition
+ {
+ Type = "object",
+ Properties = new Dictionary
+ {
+ ["text"] = new LexiconDefinition { Type = "string" },
+ ["createdAt"] = new LexiconDefinition { Type = "string", Format = "datetime" },
+ },
+ RequiredRaw = CreateJsonArray("text", "createdAt"),
+ };
+
+ var sb = new SourceBuilder();
+
+ // Act
+ ObjectGenerator.GenerateClass(
+ sb, "Post", def, "app.bsky.feed.post", registry,
+ isRecord: true, recordType: "app.bsky.feed.post",
+ typeId: "app.bsky.feed.post");
+
+ var result = sb.ToString();
+
+ // Assert: $type discriminator exists
+ Assert.Contains("public string Type => RecordType;", result);
+ // Assert: normal properties are not renamed
+ Assert.Contains("public required string Text { get; set; }", result);
+ Assert.Contains("public required System.DateTimeOffset CreatedAt { get; set; }", result);
+ Assert.DoesNotContain("TypeValue", result);
+ }
+
+ [Fact]
+ public void PropertyMatchingClassName_GetsSuffixed()
+ {
+ // Arrange: a property that matches the class name
+ var registry = new TypeRegistry();
+ var def = new LexiconDefinition
+ {
+ Type = "object",
+ Properties = new Dictionary
+ {
+ ["reaction"] = new LexiconDefinition { Type = "string" },
+ },
+ RequiredRaw = CreateJsonArray("reaction"),
+ };
+
+ var sb = new SourceBuilder();
+
+ // Act
+ ObjectGenerator.GenerateClass(
+ sb, "Reaction", def, "test.ns.reaction", registry, isRecord: false);
+
+ var result = sb.ToString();
+
+ // Assert: property is renamed to avoid matching class name
+ Assert.Contains("ReactionValue", result);
+ Assert.Contains("JsonPropertyName(\"reaction\")", result);
+ }
+
+ #region Test Helpers
+
+ private static System.Text.Json.JsonElement CreateJsonArray(params string[] values)
+ {
+ var json = System.Text.Json.JsonSerializer.Serialize(values);
+ return System.Text.Json.JsonDocument.Parse(json).RootElement.Clone();
+ }
+
+ private static int CountOccurrences(string source, string substring)
+ {
+ int count = 0;
+ int index = 0;
+ while ((index = source.IndexOf(substring, index, StringComparison.Ordinal)) != -1)
+ {
+ count++;
+ index += substring.Length;
+ }
+ return count;
+ }
+
+ #endregion
+}