Skip to content
Shiny.NET

App Functions (Siri, Shortcuts & Gemini)

NuGet package Shiny.Mediator.AppFunctions

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
  1. Install NuGet package Shiny.Mediator.AppFunctions in your app project (the MAUI head). It brings Shiny.AppFunctions and its source generator with it.

  2. Register both the mediator and the generated app functions:

    builder
    .UseMauiApp<App>()
    .UseShiny(); // Shiny.AppFunctions starts from a Shiny startup task
    builder.Services.AddShinyMediator(x => x.UseMaui());
    builder.Services.AddAppFunctions(); // generated by Shiny.AppFunctions
  3. Mark a contract with [AppFunction] and implement IAppFunctionRequest<TResult> (or IAppFunctionCommand for 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>;
  4. Implement IAppFunctionRequestHandler<TRequest, TResult> (or IAppFunctionCommandHandler<TCommand>) instead of the plain mediator handler interface. You write the normal mediator Handle method 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 text
    return 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.

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();
}
}
  • 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 ValidateException from the validation middleware becomes AppFunctionErrorCode.InvalidArgument, with the validation messages joined as the user-facing message.
  • Anything else becomes AppError, unless a mediator IExceptionHandler handles it first.

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.

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