From shiny-extensions
Generate code using Shiny.Extensions.Push, a server-side push notification dispatch library for .NET with a provider-agnostic core and transports for APNs (.p8/ES256), FCM (HTTP v1), Web Push (VAPID + RFC 8291) and WNS (Windows App SDK / Entra auth), plus Shiny.DocumentDb persistence, structured targeting, topics, interceptors, dead-token pruning, multi-app keyed registration, runtime static/dynamic (multi-tenant) configuration via IPushConfigurationProvider, and System.Diagnostics.Metrics + tracing — fully AOT/trim-safe.
How this skill is triggered — by the user, by Claude, or both
Slash command
/shiny-extensions:shiny-extensions-pushThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
You are an expert in **Shiny.Extensions.Push**, a server-side push notification dispatch library for
You are an expert in Shiny.Extensions.Push, a server-side push notification dispatch library for
.NET. It has a provider-agnostic core, a direct APNs transport (token-based .p8 / ES256 over
HTTP/2 — no FCM dependency for Apple), a Shiny.DocumentDb persistence option, structured targeting,
an interceptor pipeline, automatic dead-token pruning, multi-app keyed registration, and
System.Diagnostics.Metrics telemetry. The whole library is AOT/trim-safe.
Invoke this skill when the user wants to:
.p8 auth key (iOS/macOS), FCM (HTTP v1) for Android, Web Push (VAPID) for browsers, or WNS (Windows App SDK / Entra auth) for WindowsThis is the server counterpart. For the on-device client (registering for push, receiving), use
Shiny.Push(the MAUI client library) — a different package.
Shiny.Extensions.PushShiny.Extensions.Push — core plus all four transports: models,
IPushManager/IPushProvider/IPushRepository/IPushInterceptor, builder/DI, and the APNs, FCM,
Web Push and WNS (Windows) providers. The implementation types (PushManager, InMemoryPushRepository,
DebugPushProvider, PushMetrics) live in the Shiny.Extensions.Push.Infrastructure namespace; each
transport keeps its own namespace (Shiny.Extensions.Push.Apns / .Fcm / .WebPush / .Wns).Shiny.Extensions.Push.DocumentDb — IPushRepository backed by Shiny.DocumentDb (any provider)Shiny.Extensions.Push (IPushManager, etc.). Add a
transport with using Shiny.Extensions.Push.Apns; (or .Fcm / .WebPush / .Wns) to surface its
AddApns extension. You only need using Shiny.Extensions.Push.Infrastructure; to name a concrete impl
directly (e.g. AddProvider<DebugPushProvider>() or casting a repo in tests) — normal app code shouldn't.dotnet add package Shiny.Extensions.Push.Apns
(or .Fcm / .WebPush / .Wns) — they ship inside Shiny.Extensions.Push.net10.0. AOT/trim-safe.using Shiny.Extensions.Push;
using Shiny.Extensions.Push.Apns;
using Shiny.Extensions.Push.DocumentDb;
using Shiny.DocumentDb.Sqlite;
services.AddPushNotifications(push =>
{
// Direct APNs (single app)
push.AddApns(o =>
{
o.TeamId = "ABCDE12345";
o.KeyId = "KEY1234567";
o.BundleId = "com.example.app";
o.PrivateKeyPath = "AuthKey_KEY1234567.p8"; // or o.PrivateKey = "<PEM contents>"
// o.ForceEnvironment = PushEnvironment.Sandbox; // default: honour each registration
});
// FCM (Android) — Google service-account JSON
push.AddFcm(o => o.ServiceAccountJsonPath = "firebase-service-account.json");
// Web Push (browsers) — VAPID keys in base64url (web-push format)
push.AddWebPush(o =>
{
o.PublicKey = "<vapid-public-key>";
o.PrivateKey = "<vapid-private-key>";
o.Subject = "mailto:[email protected]";
});
// WNS (Windows) — modern Windows App SDK / Entra (Azure AD) app registration
push.AddWns(o =>
{
o.TenantId = "<entra-tenant-id>";
o.ClientId = "<app-registration-client-id>";
o.ClientSecret = "<client-secret>";
});
// Persistence (defaults to in-memory if omitted)
push.UseDocumentDb(o => o.DatabaseProvider = new SqliteDatabaseProvider("Data Source=push.db"));
// Optional
// push.AddInterceptor<LocalizationInterceptor>();
// push.Configure(m => { m.MaxDegreeOfParallelism = 25; m.AutoPruneDeadTokens = true; });
});
Defaults when not configured: an in-memory IPushRepository and the built-in PushManager.
IMeterFactory/metrics are wired automatically.
Resolve IPushManager and register the device when your client app reports its token:
await pushManager.RegisterDevice(new DeviceRegistration
{
DeviceToken = "<apns-device-token>",
Platform = DevicePlatform.iOS, // iOS, MacOS, Android, Windows, WebBrowser
DeviceId = "install-guid", // stable identity across token rotation (recommended)
UserIdentifier = "user-42", // enables "send to user across all devices"
Tags = ["beta", "sports"],
Locale = "en-US",
Environment = PushEnvironment.Production, // APNs sandbox vs production (tokens are env-specific!)
AppId = null // set when using multi-app keyed providers
});
await pushManager.UnregisterDevice("<apns-device-token>", DevicePlatform.iOS);
IPushManager returns a PushSendResult (BatchId, Sent, Failed, TokensRemoved, Skipped, Results).
One device failing never aborts the batch.
var result = await pushManager.SendToUser("user-42", new PushNotification
{
Title = "Goal!",
Message = "Your team just scored",
Badge = 1,
Sound = "default",
DeepLink = "app://match/123",
Priority = PushPriority.High,
Data = new Dictionary<string, string> { ["matchId"] = "123" }
});
await pushManager.SendToTags(["sports"], notification, TagMatch.Any);
await pushManager.SendToTokens(["tokenA", "tokenB"], notification);
await pushManager.Broadcast(notification);
// Full structured targeting (all set clauses are AND-combined):
await pushManager.Send(notification, new PushFilter
{
Platforms = [DevicePlatform.iOS],
Tags = ["beta"],
TagMatch = TagMatch.All,
Environment = PushEnvironment.Production
});
new PushNotification
{
Title = "Title",
Apple = new ApplePushOptions
{
Subtitle = "Subtitle",
Category = "MESSAGE_CATEGORY",
ThreadId = "thread-1",
MutableContent = true, // for a Notification Service Extension
// ContentAvailable = true, // background push
// TopicOverride / PushTypeOverride for VoIP/complication topics
},
CollapseId = "score-update", // apns-collapse-id
TimeToLive = TimeSpan.FromHours(1) // apns-expiration
};
// Silent / background push (no visible alert):
new PushNotification
{
Apple = new ApplePushOptions { ContentAvailable = true },
Data = new Dictionary<string, string> { ["sync"] = "inbox" }
};
Implement IPushInterceptor and register with push.AddInterceptor<T>() (additive; run in order).
Mutate by replacing context.Notification; return InterceptorResult.Skip to drop a device.
public sealed class LocalizationInterceptor : IPushInterceptor
{
public Task<InterceptorResult> BeforeSend(PushSendContext context, CancellationToken ct = default)
{
if (context.Registration.Tags.Contains("opted-out"))
return Task.FromResult(InterceptorResult.Skip);
var title = Localize(context.Notification.Title, context.Registration.Locale);
context.Notification = context.Notification with { Title = title };
return Task.FromResult(InterceptorResult.Continue);
}
public Task OnSent(PushSendContext c, PushDeliveryResult r, CancellationToken ct = default) => Task.CompletedTask;
public Task OnFailed(PushSendContext c, PushDeliveryResult r, CancellationToken ct = default) => Task.CompletedTask;
}
When you just want to watch sends — not mutate them — implement IPushEventReceiver and register with
push.AddEventReceiver<T>() (additive; register zero or more). Unlike interceptors, receivers can't skip
or mutate; they're pure observers with batch lifecycle hooks (OnBatchStarted/OnBatchFinished) plus
per-device OnSent/OnFailed. A receiver that throws is logged and swallowed — it never breaks a batch.
OnFailed fires for every failed device, including the normalized failures that never throw
(TokenExpired, InvalidToken, RateLimited, Error) — inspect result.Status/result.Reason.
(Interceptor Skip and no-provider outcomes aren't failures; read them from the OnBatchFinished
PushSendResult counts instead.)
public sealed class PushTelemetry : IPushEventReceiver
{
public Task OnBatchStarted(Guid batchId, PushFilter filter, PushNotification n, CancellationToken ct = default) => Task.CompletedTask;
public Task OnSent(Guid batchId, DeviceRegistration reg, PushNotification n, PushDeliveryResult r, CancellationToken ct = default) => Task.CompletedTask;
public Task OnFailed(Guid batchId, DeviceRegistration reg, PushNotification n, PushDeliveryResult r, CancellationToken ct = default)
{
// capture dead-letters, alert on RateLimited, etc.
return Task.CompletedTask;
}
public Task OnBatchFinished(Guid batchId, PushNotification n, PushSendResult result, CancellationToken ct = default)
=> Task.CompletedTask; // result.Sent / .Failed / .TokensRemoved / .Skipped
}
Providers return a normalized PushDeliveryStatus. The manager auto-removes tokens reported
TokenExpired/InvalidToken (APNs 410 Unregistered / BadDeviceToken) and applies rotated tokens
(PushDeliveryResult.UpdatedToken) to the repository. Disable via PushManagerOptions.AutoPruneDeadTokens = false.
RateLimited is surfaced on the result but not retried — backoff is the caller's responsibility.
On success, providers set PushDeliveryResult.ProviderMessageId (APNs apns-id, FCM message name,
WebPush Location).
Topics are server-side subscriptions (work across every provider, independent of FCM-native topics):
await pushManager.SubscribeToTopic(token, DevicePlatform.iOS, "sports");
await pushManager.SendToTopic("sports", new PushNotification { Title = "Goal!" });
await pushManager.UnsubscribeFromTopic(token, DevicePlatform.iOS, "sports");
The manager routes a registration to the provider that claims its Platform: APNs → iOS/MacOS,
FCM → Android, Web Push → WebBrowser, WNS → Windows. For WNS, the registration's DeviceToken is
the WNS channel URI. For Web Push, the DeviceToken is the subscription endpoint and the
p256dh/auth keys go in Data:
await pushManager.RegisterDevice(new DeviceRegistration
{
DeviceToken = subscription.Endpoint,
Platform = DevicePlatform.WebBrowser,
Data = new Dictionary<string, string>
{
["p256dh"] = subscription.Keys.P256dh,
["auth"] = subscription.Keys.Auth
}
});
FCM uses AndroidPushOptions on the notification for android.notification fields (channel id, icon,
color, image); Web Push uses WebPushOptions (icon, urgency); WNS uses WindowsPushOptions
(Type = Toast/Tile/Badge/Raw, a verbatim Payload for tile/badge, and a toast Launch arg — default is
a ToastGeneric toast built from title/body + DeepLink). All providers honour the cross-cutting fields
(title/body, badge, sound, data, deep link, collapse id, TTL, priority).
FcmProvider implements IPushBatchProvider (MaxBatchSize 500): when PushManagerOptions.EnableBatching
is on (the default), the manager packs devices that share the same notification into one multipart /batch
request instead of one request per device — far fewer round trips for broadcasts and topic fan-out. This is
automatic; no API change at the call site. Notes:
OnSent/OnFailed still happen per device.push.Configure(m => m.EnableBatching = false) to force per-device delivery everywhere.IPushBatchProvider (MaxBatchSize + SendBatch
returning one result per registration, in input order). Web Push has no multicast endpoint, so it stays
one request per device (fanned out concurrently by the manager).Register one keyed provider per app (works for AddApns/AddFcm/AddWebPush/AddWns); devices carry the
matching AppId; the manager routes by it.
services.AddPushNotifications(push =>
{
push.AddApns("consumer", o => { o.BundleId = "com.example.consumer"; /* … */ });
push.AddApns("driver", o => { o.BundleId = "com.example.driver"; /* … */ });
});
await pushManager.RegisterDevice(new DeviceRegistration
{
DeviceToken = "<token>", Platform = DevicePlatform.iOS, AppId = "driver"
});
await pushManager.Send(notification, new PushFilter { AppId = "driver", Tags = ["on-shift"] });
The static path is the plain AddApns(o => …) / AddApns("key", o => …) registration (config baked in).
For a dynamic setup — many apps, or tenants/keys that change without a restart — supply credentials at
send time via IPushConfigurationProvider.GetConfiguration(appId) → PushConfiguration? (one record
bundling optional Apns/Fcm/WebPush/Wns option objects), keyed by DeviceRegistration.AppId. Register
the provider once with UsePushConfiguration<T>(), then opt each transport in with its no-argument
overload (AddApns() / AddFcm() / AddWebPush() / AddWns()).
Rules to follow when generating code:
UsePushConfiguration<T>() registers the provider scoped — so it may inject scoped services (e.g. an EF
DbContext). Don't make it a singleton. Cache inside it if the lookup is expensive; it's on the hot path.AddApns("key", …) for a given transport, not both
(both would claim the platform).AppId yields a failed PushDeliveryResult (Error, reason
"app not configured for …") — it does not prune the token.public sealed class MyTenantConfig(MyDbContext db) : IPushConfigurationProvider // scoped
{
public async ValueTask<PushConfiguration?> GetConfiguration(string appId, CancellationToken ct = default)
{
var t = await db.Tenants.FindAsync([appId], ct);
return t is null ? null : new PushConfiguration
{
AppId = appId,
Apns = t.HasApns ? new ApnsOptions { TeamId = t.TeamId, KeyId = t.KeyId, BundleId = t.BundleId, PrivateKey = t.P8 } : null
};
}
}
services.AddPushNotifications(push => push
.UsePushConfiguration<MyTenantConfig>()
.AddApns()
.AddFcm());
Telemetry is emitted via System.Diagnostics.Metrics under the meter Shiny.Extensions.Push
(PushMetrics.MeterName): counters push.notifications.sent / .failed / .skipped,
push.tokens.pruned, and histogram push.send.duration (ms) — tagged by platform, provider,
status (never by BatchId, which is high-cardinality).
Distributed tracing uses the ActivitySource PushDiagnostics.ActivitySourceName (same name): a
push.send span per batch and push.deliver per device (batched sends emit one push.deliver.batch
span with a push.batch_size tag instead).
services.AddOpenTelemetry()
.WithMetrics(m => m.AddMeter(PushMetrics.MeterName))
.WithTracing(t => t.AddSource(PushDiagnostics.ActivitySourceName));
Shiny.Extensions.Push.DocumentDb implements IPushRepository over any Shiny.DocumentDb backend
(SQLite, Postgres, SQL Server, Cosmos, Mongo, …). UseDocumentDb(Action<DocumentStoreOptions>)
registers the store + repository; UseDocumentDb() assumes an IDocumentStore is already registered.
Token-keyed operations (save/remove/rotate) are O(1) point lookups; targeted sends currently scan and
filter in-process. To replace persistence entirely, implement IPushRepository and call
push.UseRepository<T>().
Implement IPushProvider (Identifier, CanDeliver(registration), Send(...) returning a
PushDeliveryResult) and register additively via push.AddProvider<T>(). Optionally also implement
IPushBatchProvider to deliver groups of devices in one call (see Batching above). For local dev/tests,
the built-in DebugPushProvider logs instead of sending and claims every platform.
Title/Message are nullable — silent/background pushes carry only Data + ContentAvailable.DeviceRegistration.Environment.DeviceId so re-registration upserts instead of duplicating.Data is string-keyed/valued to stay AOT-safe.npx claudepluginhub shinyorg/skills --plugin shiny-extensionsGuides completion of development work by verifying tests, detecting environment, and presenting structured options for merge, PR, or cleanup.
Guides creation and editing of skills using test-driven development with pressure scenarios and subagents to verify agent compliance.
Dispatches multiple subagents concurrently for independent tasks without shared state. Use when facing 2+ unrelated failures or subsystems that can be investigated in parallel.