| name | shiny-calendarstore |
| description | Generate code using Shiny.Calendar for cross-platform device calendar & event access with CRUD, a fluent async query builder, and Shiny.Core permissions |
| auto_invoke | true |
| triggers | ["calendar store","calendar","calendars","calendar event","ICalendarStore","CalendarStore","AddCalendarStore","Shiny.Calendar","device calendar","read calendar","write calendar","create event","update event","delete event","calendar query","CalendarEventQuery","CalendarEventSortField","search events","EventKit","CalendarContract","AppointmentStore","calendar AI tools","Shiny.Calendar.Extensions.AI","AddCalendarAITools","CalendarAITools","CalendarAICapabilities","ICalendarAIToolBuilder","AddCalendars","calendar AI filter","per-calendar AI access"] |
Shiny.Calendar Skill
You are an expert in Shiny.Calendar, a cross-platform library for accessing device calendars and
events on iOS, Mac Catalyst, macOS, Android, and Windows.
When to Use This Skill
Invoke this skill when the user wants to:
- Access device calendars and events (read, create, update, delete)
- Query events (filter/sort/page over a date window)
- Request calendar permissions using Shiny's AccessState model
- Register the calendar store in DI
- Work with calendar models (events, attendees, reminders)
Library Overview
GitHub: https://github.com/shinyorg/shiny
NuGet: Shiny.Calendar
Namespace: Shiny.Calendar
Shiny.Calendar provides:
- Full CRUD operations on device calendars and events
- A fluent async query builder with native fetch translation (calendar id + start/end window are
pushed to the native query; other filters, sorting and paging run in-memory)
- Permission handling via Shiny.Core's
AccessState model
- Dependency injection integration
- AOT and trimmer compatible
Platform backends: EventKit (iOS/Mac Catalyst/macOS), CalendarContract (Android),
Windows.ApplicationModel.Appointments.AppointmentStore (Windows).
Setup
1. Install NuGet Package
dotnet add package Shiny.Calendar
2. Register in MauiProgram.cs
The app must call .UseShiny() (from Shiny.Hosting.Maui) so platform services like permissions are wired up.
using Shiny;
builder.UseShiny();
builder.Services.AddCalendarStore();
3. Platform Permissions
Android — Add to AndroidManifest.xml:
<uses-permission android:name="android.permission.READ_CALENDAR" />
<uses-permission android:name="android.permission.WRITE_CALENDAR" />
iOS 17+ / Mac Catalyst / macOS — Add to Info.plist:
<key>NSCalendarsFullAccessUsageDescription</key>
<string>This app needs access to your calendar.</string>
<key>NSCalendarsWriteOnlyAccessUsageDescription</key>
<string>This app needs to add events to your calendar.</string>
iOS < 17 — Add NSCalendarsUsageDescription.
Mac Catalyst / sandboxed macOS — the Info.plist keys are not enough. The App Sandbox (which
Mac Catalyst enables by default) also requires the calendar entitlement, or RequestAccess returns
Denied with no prompt ever appearing:
<CustomEntitlements Include="com.apple.security.personal-information.calendars"
Type="Boolean" Value="true" />
Windows — Add to Package.appxmanifest:
<uap:Capability Name="appointments" />
Permissions
Permissions use Shiny.Core's Shiny.AccessState model. ICalendarStore exposes:
var access = await calendarStore.RequestAccess(CalendarAccessType.ReadWrite);
if (access != AccessState.Available)
return;
var current = calendarStore.GetCurrentAccess();
CalendarAccessType: ReadOnly, WriteOnly (iOS 17+ add-only), ReadWrite (default).
AccessState.Available — access granted
AccessState.Restricted — partial (e.g. iOS write-only, or Android read-only/write-only)
AccessState.Denied — denied
AccessState.Unknown — not yet determined (also the Windows GetCurrentAccess() result — call RequestAccess)
API Reference
ICalendarStore Interface
public interface ICalendarStore
{
AccessState GetCurrentAccess();
Task<AccessState> RequestAccess(CalendarAccessType accessType = CalendarAccessType.ReadWrite, CancellationToken ct = default);
Task<IReadOnlyList<Calendar>> GetAll(CancellationToken ct = default);
Task<Calendar?> GetById(string calendarId, CancellationToken ct = default);
Task<string> Create(string name, string? color = null, CancellationToken ct = default);
Task Update(string calendarId, string newName, string? newColor = null, CancellationToken ct = default);
Task Delete(string calendarId, CancellationToken ct = default);
Task<IReadOnlyList<CalendarEvent>> GetEvents(string? calendarId = null, DateTimeOffset? start = null, DateTimeOffset? end = null, CancellationToken ct = default);
Task<CalendarEvent?> GetEvent(string eventId, CancellationToken ct = default);
CalendarEventQuery Query();
Task<string> CreateEvent(CalendarEvent calendarEvent, CancellationToken ct = default);
Task UpdateEvent(CalendarEvent calendarEvent, CancellationToken ct = default);
Task DeleteEvent(string eventId, bool deleteSeries = false, CancellationToken ct = default);
}
Convenience Extension Methods
Task<string> store.CreateEvent(string? calendarId, string title, string? description, string? location,
DateTimeOffset start, DateTimeOffset end, bool isAllDay = false,
IEnumerable<EventReminder>? reminders = null, CancellationToken ct = default);
Task<string> store.CreateAllDayEvent(string? calendarId, string title, string? description, string? location,
DateTimeOffset startDate, DateTimeOffset endDate, CancellationToken ct = default);
Task<Shiny.Contacts.Contact?> attendee.ResolveContact(IContactStore contactStore, CancellationToken ct = default);
Querying events
Query() returns a CalendarEventQuery builder. It is lazy — nothing runs until ToListAsync /
FirstOrDefaultAsync / CountAsync, and the native calendar read happens off the calling thread, so
awaiting it from a UI/view-model method is correct (do NOT wrap it in Task.Run). There is no
IQueryable — do not write .Where(e => …).ToList() LINQ against Query().
public sealed class CalendarEventQuery
{
CalendarEventQuery ForCalendar(string? calendarId);
CalendarEventQuery From(DateTimeOffset start);
CalendarEventQuery To(DateTimeOffset end);
CalendarEventQuery Between(DateTimeOffset start, DateTimeOffset end);
CalendarEventQuery Where(Func<CalendarEvent, bool> predicate);
CalendarEventQuery TitleContains(string text);
CalendarEventQuery OrderBy(CalendarEventSortField field, bool descending = false);
CalendarEventQuery ThenBy(CalendarEventSortField field, bool descending = false);
CalendarEventQuery Skip(int count);
CalendarEventQuery Take(int count);
Task<IReadOnlyList<CalendarEvent>> ToListAsync(CancellationToken ct = default);
Task<CalendarEvent?> FirstOrDefaultAsync(CancellationToken ct = default);
Task<int> CountAsync(CancellationToken ct = default);
}
var events = await store.Query()
.ForCalendar(calId)
.Between(DateTimeOffset.Now, DateTimeOffset.Now.AddDays(7))
.OrderBy(CalendarEventSortField.Start)
.ToListAsync(ct);
var standups = await store.Query()
.From(DateTimeOffset.Now.AddDays(-30))
.TitleContains("standup")
.ToListAsync(ct);
var withAlice = await store.Query()
.Where(e => e.Attendees.Any(a => a.Email == "alice@example.com"))
.Skip(0).Take(20)
.ToListAsync(ct);
var busy = await store.Query()
.Between(from, to)
.Where(e => e.Availability == EventAvailability.Busy)
.CountAsync(ct);
Native fetch hints: ForCalendar, From, To, Between. Everything else — Where,
TitleContains, sorting, Skip/Take — is applied to the fetched events.
CalendarEventSortField: Start, End, Title. ThenBy throws without a preceding OrderBy.
Create an Event
var evt = new CalendarEvent("Team Sync", DateTimeOffset.Now.AddHours(1), DateTimeOffset.Now.AddHours(2))
{
CalendarId = calId,
Location = "Room 3",
Description = "Weekly sync"
};
evt.Reminders.Add(new EventReminder(TimeSpan.FromMinutes(15)));
evt.Attendees.Add(new EventAttendee("Alice", "alice@example.com"));
string id = await store.CreateEvent(evt);
string id2 = await store.CreateEvent(calId, "Lunch", null, "Cafe",
DateTimeOffset.Now.AddHours(4), DateTimeOffset.Now.AddHours(5));
Update / Delete an Event
var evt = await store.GetEvent(id);
evt.Location = "Room 5";
await store.UpdateEvent(evt);
await store.DeleteEvent(id);
Deleting a recurring event
deleteSeries decides whether a recurring event loses one occurrence or the rest of the series. It
is ignored for non-recurring events, so it is always safe to pass. Never guess on a recurring
event — prompt the user, keyed off CalendarEvent.IsRecurring:
if (evt.IsRecurring)
{
var choice = await dialogs.ActionSheet(
$"Delete \"{evt.Title}\"?", "Cancel", "Delete All Future Events", "Delete This Event");
if (choice is not ("Delete This Event" or "Delete All Future Events"))
return;
await store.DeleteEvent(evt.Id!, choice == "Delete All Future Events");
}
else
{
await store.DeleteEvent(evt.Id!);
}
Platform behaviour:
| Platform | deleteSeries: false | deleteSeries: true |
|---|
| iOS / Mac Catalyst / macOS | EKSpan.ThisEvent | EKSpan.FutureEvents |
| Android | Inserts a cancellation exception for the occurrence | Deletes the Events row (whole series) |
| Windows | Deletes the appointment — no per-instance delete exists in AppointmentStore, so the flag has no effect | Same |
Android reads series masters from the Events table rather than expanded instances, so
deleteSeries: false cancels the series' own DTSTART — i.e. the first occurrence.
Models
Calendar
| Property | Type |
|---|
| Id | string? |
| Name | string |
| Color | string? (hex, e.g. #FF3B30) |
| IsReadOnly | bool |
| Account | string? |
CalendarEvent
| Property | Type |
|---|
| Id | string? |
| CalendarId | string? |
| Title | string |
| Description | string? |
| Location | string? |
| Start / End | DateTimeOffset |
| IsAllDay | bool |
| Availability | EventAvailability |
| Url | string? |
| IsRecurring | bool (read-only) |
| RecurrenceRule | string? (read-only) |
| Reminders | List<EventReminder> |
| Attendees | List<EventAttendee> |
| Organizer | EventAttendee? (read-only) |
EventReminder
Offset (TimeSpan) — how far before the start the reminder fires.
EventAttendee
Name, Email, Role (AttendeeRole), Status (AttendeeStatus), IsOrganizer.
Enums
- EventAvailability: Busy, Free, Tentative, Unavailable
- AttendeeRole: Required, Optional, Resource, Unknown
- AttendeeStatus: Unknown, Pending, Accepted, Declined, Tentative
- CalendarAccessType: ReadOnly, WriteOnly, ReadWrite
Platform Notes & Caveats
- Recurrence is read-only on all platforms (
IsRecurring / RecurrenceRule are surfaced but not written).
- Apple (EventKit): attendees cannot be written —
Attendees you set on create/update are ignored. Reminders use a relative lead-time.
- Android (CalendarContract): event CRUD works with permissions. Creating/modifying calendars goes through sync-adapter semantics and may behave differently across OEMs.
- Windows (AppointmentStore): best-effort. Reads/queries cover all calendars; create/update/delete only work inside an app-owned calendar — writes targeting a system calendar throw
NotSupportedException. Requires the appointments capability.
Best Practices
- Always request access first —
await store.RequestAccess(...) and check AccessState.Available.
- Always set a date window —
Between(from, to) (or From/To) plus ForCalendar(id) are the only hints pushed to the native fetch; unbounded reads default to a ~4-month window.
- Await the query, don't wrap it —
ToListAsync already does the native read off the calling thread. Do not add Task.Run around it.
- Handle
Restricted — iOS write-only and Android partial grants surface as AccessState.Restricted.
- Don't rely on attendee writes on Apple — set attendees where supported (Android), and use
ResolveContact to map an attendee's email back to a device contact.
- Use primary constructors — inject
ICalendarStore via primary constructor.
AI Tool Integration (Shiny.Calendar.Extensions.AI)
The optional Shiny.Calendar.Extensions.AI package exposes ICalendarStore as
Microsoft.Extensions.AI tool functions (AIFunctions) for LLM agents. You opt-in per operation
(read / create / update / delete) and, optionally, per calendar id — an allow-list you control on
behalf of the agent (not an OS permission prompt; the platform calendar permission must already be
granted). AOT-compatible (hand-built schemas, JsonNode results — no reflection).