Skip to content
Shiny.NET

Delegates & In-App Calls

IAppFunctionDelegate handles what cuts across every function: sign-in checks, feature flags, telemetry. Declare as many as you like in the app project - AddAppFunctions() finds and registers them, so don’t register them yourself.

public class SignInDelegate(SignInState signIn) : IAppFunctionDelegate
{
public Task<AppFunctionGate> OnInvoking(AppFunctionContext context, CancellationToken cancellationToken)
=> Task.FromResult(context.FunctionId != "cancel_order" || signIn.IsSignedIn
? AppFunctionGate.Allow
: context.IsForeground
? AppFunctionGate.Deny("Sign in to cancel orders.")
: AppFunctionGate.OpenApp("Sign in to cancel orders."));
}
public class TelemetryDelegate(ILogger<TelemetryDelegate> logger) : IAppFunctionDelegate
{
public Task OnInvoked(AppFunctionContext context, object? result, Exception? exception)
{
logger.LogInformation("{Function} from {Platform}: {Outcome}", context.FunctionId, context.Platform, exception?.Message ?? "ok");
return Task.CompletedTask;
}
}

Both methods have default implementations, so implement only what you need.

  • OnInvoking runs after the arguments are bound and before the handler. The first delegate that doesn’t return AppFunctionGate.Allow decides the call.
    • AppFunctionGate.Deny(message) refuses it with Denied.
    • AppFunctionGate.OpenApp(message) asks for the app. If the user is already in it (context.IsForeground), the gate passes with no prompt. Otherwise, on iOS, the user is asked to continue in the app, and the function then runs again in the foreground. On Android there is no equivalent, and the call is refused with the message.

OpenApp means “needs the app on screen”, not “needs sign-in”. The run in the foreground passes the gate before the user has had a chance to sign in, as does a call made over the app or from the in-app AI tools. That’s why the sign-in check above asks for the app only from the background, and refuses the call with Deny once the app is open.

  • OnInvoked runs after the handler with its result or exception. A refusal arrives as an AppFunctionException with Denied. Exceptions thrown here are logged and ignored.

Delegates are resolved from the call’s scope and run in the order AddAppFunctions() registers them. Entity lookups go through them as well, because they expose app data.

FunctionId, Function the function’s id and its descriptor
Platform Apple, Android or Other (an in-process call)
IsForeground the app is on screen for this run - the user asked Siri or Gemini from inside it, the call came from the in-app AI tools, or (iOS) OpensApp / OpenApp brought it forward
CallerPackage Android: the calling agent’s package name (empty from the shell)
Request the bound request record (null for entity lookups)
Services the call’s DI scope
Items state shared between delegates and the handler for this call
Say(text) / Dialog the text for Siri, or returned with the Android result

Mark a function that only makes sense on screen - it navigates, shows something, or needs the user - with [AppFunction(OpensApp = true)]. It works like an OpenApp gate that the function declares itself, checked before any delegate:

  • iOS brings the app to the foreground before the handler runs.
  • Android can’t bring an app forward from the background, so the function runs only while the app is visible (including under Gemini’s overlay). Otherwise the call is refused with Denied and “Open the app to continue”.
  • An in-process call through the dispatcher that isn’t marked foreground is refused the same way. Calls from the AI tools are in the foreground, so they run.

The handler still runs off the main thread, so dispatch UI work to it.

Siri and Gemini can be invoked over your app. The function runs in the same process as the UI - the same container, the same singletons - so a handler can update what’s on screen. Check context.IsForeground first, and move to the main thread: handlers never run on it.

public async Task<Order> Handle(OpenOrder request, AppFunctionContext context, CancellationToken cancellationToken)
{
var order = await orders.Get(request.OrderId, cancellationToken);
if (context.IsForeground)
await MainThread.InvokeOnMainThreadAsync(() => Shell.Current.GoToAsync($"order?id={order.Id}"));
return order;
}
  • iOS counts any application state but background - a Siri or Spotlight overlay leaves the app inactive.
  • Android counts any started activity, so an app paused under Gemini’s overlay still counts.

Every call - from Siri, from Gemini or from your own code - goes through AppFunctionDispatcher: a new DI scope, binding, delegates, the handler and the JSON result. You can call it directly, for example from a test, a debug page or your own in-app assistant:

public class OrdersViewModel(AppFunctionDispatcher dispatcher)
{
async Task CreateTestOrder()
{
var outcome = await dispatcher.Execute(
new AppFunctionInvocation("create_order"),
"""{"customer":"acme","quantity":2,"priority":"Normal"}""",
CancellationToken.None
);
if (outcome.Status == AppFunctionStatus.Success)
Console.WriteLine(outcome.Dialog ?? outcome.ResultJson);
else
Console.WriteLine($"{outcome.ErrorCode}: {outcome.Message}");
}
}

Execute never throws: every failure comes back as an AppFunctionOutcome. Arguments are a JSON object keyed by the parameter names in camelCase; enums are their member names, entities their ids, and DateTimeOffset values ISO 8601 strings. AppFunctionInvocation defaults to AppFunctionPlatform.Other and not in the foreground, so an OpenApp gate or an OpensApp function refuses the call the way Android does. Pass IsForeground: true when the call comes from the app’s own screen.

dispatcher.Registry.Functions describes every function - the declared ones, followed by the generated search_{entity} functions - with its title, description, parameters and result type. GetParametersJsonSchema() returns a JSON schema (draft 2020-12) for the parameters, which is what an MCP adapter needs to offer the same functions to a model. For an IChatClient, Shiny.AppFunctions.Extensions.AI already does this.

foreach (var function in dispatcher.Registry.Functions)
Console.WriteLine($"{function.Id}: {function.Description}\n{function.GetParametersJsonSchema()}");