App Functions (Siri, Shortcuts & Gemini)
Shiny.AppFunctions turns one C# declaration into an Apple App Intent (Siri, Spotlight, the Shortcuts app,
Apple Intelligence) and an Android 16+ AppFunction (Gemini and other agents). Shiny.Mediator.AppFunctions makes your
mediator contracts those declarations. Every call from the assistant then runs through the full mediator pipeline:
middleware, validation, caching, resilience, exception handlers and logging, the same way an ASP.NET endpoint does.
Siri / Gemini ──► Shiny.AppFunctions dispatcher ──► IMediator ──► middleware ──► your handler-
Install
in your app project (the MAUI head). It brings
Shiny.AppFunctionsand its source generator with it. -
Register both the mediator and the generated app functions:
builder.UseMauiApp<App>().UseShiny(); // Shiny.AppFunctions starts from a Shiny startup taskbuilder.Services.AddShinyMediator(x => x.UseMaui());builder.Services.AddAppFunctions(); // generated by Shiny.AppFunctions -
Mark a contract with
[AppFunction]and implementIAppFunctionRequest<TResult>(orIAppFunctionCommandfor no result):using Shiny.AppFunctions;[AppFunction("create_order", Description = "Creates an order for a customer")][AppShortcut("Create an order in ${applicationName}", ShortTitle = "New Order", SystemImage = "cart.badge.plus")]public record CreateOrder([property: AppParameter(Title = "Customer")] Customer Customer,int Quantity,string? Note) : IAppFunctionRequest<OrderResult>; -
Implement
IAppFunctionRequestHandler<TRequest, TResult>(orIAppFunctionCommandHandler<TCommand>) instead of the plain mediator handler interface. You write the normal mediatorHandlemethod and nothing else:[MediatorScoped]public class CreateOrderHandler(IOrders orders) : IAppFunctionRequestHandler<CreateOrder, OrderResult>{public async Task<OrderResult> Handle(CreateOrder request, IMediatorContext context, CancellationToken ct){var order = await orders.Create(request.Customer.Id, request.Quantity, request.Note, ct);context.SayToAssistant($"Order {order.Number} is in."); // Siri dialog / Android response textreturn new OrderResult(order.Number, order.Total);}}
IAppFunctionRequestHandler<,> is both a mediator IRequestHandler<,> and a Shiny.AppFunctions IAppFunctionHandler<,>. The
app function side is a default interface implementation that forwards to IMediator.Request(...), so the Shiny.AppFunctions
generator finds your handler and emits the Swift App Intent and the Android schema. The mediator generator registers the same
class as a normal handler. Calling mediator.Request(new CreateOrder(...)) from your own code works exactly as before.
| Contract | Handler | Mediator call |
|---|---|---|
IAppFunctionRequest<TResult> (IRequest<TResult> + IAppFunction<TResult>) |
IAppFunctionRequestHandler<TRequest, TResult> |
IMediator.Request |
IAppFunctionCommand (ICommand + IAppFunction) |
IAppFunctionCommandHandler<TCommand> |
IMediator.Send |
Parameter and result types follow the Shiny.AppFunctions rules: string, int, long, double, bool,
DateTimeOffset, enums and [AppEntity] records as parameters; any of those, plus records and lists of them, as
results. Stream requests and events are not supported, because the platforms have no equivalent.
Inside handlers & middleware
Section titled “Inside handlers & middleware”The AppFunctionContext of the call is attached to the mediator context:
Extension on IMediatorContext |
|
|---|---|
GetAppFunctionContext() |
The call’s AppFunctionContext (Platform, FunctionId, IsForeground, CallerPackage, Items, …), or null when the mediator was called normally. Walks up parent contexts, so it works from nested requests and events too. |
IsAppFunctionCall() |
true when Siri, Shortcuts, an agent or AppFunctionDispatcher started this execution |
SayToAssistant(dialog) |
Sets the text Siri shows/speaks (Android returns it with the result). No-op outside an app function call. |
Use them from middleware to treat assistant calls differently, for example to skip a cache or to log the caller:
public class AssistantAuditMiddleware<TRequest, TResult>(ILogger<AssistantAuditMiddleware<TRequest, TResult>> logger) : IRequestMiddleware<TRequest, TResult> where TRequest : IRequest<TResult>{ public Task<TResult> Process(IMediatorContext context, RequestHandlerDelegate<TResult> next, CancellationToken ct) { var app = context.GetAppFunctionContext(); if (app != null) logger.LogInformation("{Fn} called from {Platform} ({Caller})", app.FunctionId, app.Platform, app.CallerPackage);
return next(); }}Errors
Section titled “Errors”- Throw
AppFunctionException(code, message)from a handler to fail with a specific code. It passes straight through the mediator, and the message is shown or spoken to the user. - A
ValidateExceptionfrom the validation middleware becomesAppFunctionErrorCode.InvalidArgument, with the validation messages joined as the user-facing message. - Anything else becomes
AppError, unless a mediatorIExceptionHandlerhandles it first.
Contracts in a library
Section titled “Contracts in a library”If your contracts live in a shared library, declare a thin [AppFunction] record in the app project and forward it
with MediatorAppFunctions:
[AppFunction("get_weather", Description = "Gets the forecast for a city")]public record GetWeatherFunction(string City) : IAppFunction<string>;
public class GetWeatherFunctionHandler : IAppFunctionHandler<GetWeatherFunction, string>{ public async Task<string> Handle(GetWeatherFunction request, AppFunctionContext context, CancellationToken ct) { var result = await MediatorAppFunctions.Request<GetWeatherRequest, WeatherResult>(new GetWeatherRequest(request.City), context, ct); return result.Summary; }}MediatorAppFunctions.Request / Send attach the AppFunctionContext and map ValidateException, so handlers and
middleware behave the same as with the bridge interfaces.
Testing
Section titled “Testing”AppFunctionDispatcher runs the same pipeline the platforms use, so you can test an app function end to end without a device:
var outcome = await dispatcher.Execute( new AppFunctionInvocation("create_order", AppFunctionPlatform.Apple), """{"customer":"acme","quantity":2}""", CancellationToken.None);// outcome.Status, outcome.ResultJson, outcome.Dialog, outcome.ErrorCode, outcome.Message

