Skip to content
Docs Try Aspire
Docs Try

Custom resource commands

Each resource in the Aspire app model is represented as an IResource and when added to the distributed application builder, it’s the generic-type parameter of the IResourceBuilder<T> interface. You use the resource builder API to chain calls, configuring the underlying resource, and in some situations, you might want to add custom commands to the resource. Some common scenario for creating a custom command might be running database migrations or seeding/resetting a database. In this article, you learn how to add a custom command to a Redis resource that clears the cache.

Start with an Aspire AppHost project. The walkthrough below registers a clear-cache command on a Redis resource. The C# version uses an extension method that wraps the registration; the TypeScript version registers the command inline against a Node service that exposes an admin endpoint. Both flows produce the same dashboard/CLI experience.

Add a new class named RedisResourceBuilderExtensions.cs to the AppHost project and replace its contents with the following code:

RedisResourceBuilderExtensions.cs
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Logging;
using StackExchange.Redis;
namespace Aspire.Hosting;
internal static class RedisResourceBuilderExtensions
{
public static IResourceBuilder<RedisResource> WithClearCommand(
this IResourceBuilder<RedisResource> builder)
{
var commandOptions = new CommandOptions
{
UpdateState = OnUpdateResourceState,
IconName = "AnimalRabbitOff",
IconVariant = IconVariant.Filled
};
builder.WithCommand(
name: "clear-cache",
displayName: "Clear Cache",
executeCommand: context => OnRunClearCacheCommandAsync(builder, context),
commandOptions: commandOptions);
return builder;
}
private static async Task<ExecuteCommandResult> OnRunClearCacheCommandAsync(
IResourceBuilder<RedisResource> builder,
ExecuteCommandContext context)
{
var connectionString = await builder.Resource.GetConnectionStringAsync() ??
throw new InvalidOperationException(
$"Unable to get the '{context.ResourceName}' connection string.");
await using var connection = ConnectionMultiplexer.Connect(connectionString);
var database = connection.GetDatabase();
await database.ExecuteAsync("FLUSHALL");
return CommandResults.Success();
}
private static ResourceCommandState OnUpdateResourceState(
UpdateCommandStateContext context)
{
var logger = context.ServiceProvider.GetRequiredService<ILogger<Program>>();
if (logger.IsEnabled(LogLevel.Information))
{
logger.LogInformation(
"Updating resource state: {ResourceSnapshot}",
context.ResourceSnapshot);
}
return context.ResourceSnapshot.HealthStatus is HealthStatus.Healthy
? ResourceCommandState.Enabled
: ResourceCommandState.Disabled;
}
}

The preceding code:

  • Shares the Aspire.Hosting namespace so that the extension is visible from the AppHost.
  • Is a static class containing extension methods on IResourceBuilder<RedisResource>.
  • Defines a single extension method named WithClearCommand that registers a clear-cache command on the Redis resource, plus the executeCommand and updateState callbacks the dashboard uses to run the command and decide when it’s enabled.
  • Returns the IResourceBuilder<RedisResource> to keep the call site chainable.

The WithCommand API adds the appropriate annotations to the resource, which are consumed in the Aspire dashboard. The dashboard uses these annotations to render the command in the UI. Before getting too far into those details, let’s ensure that you first understand the parameters of the WithCommand method:

  • name: The name of the command to invoke.
  • displayName: The name of the command to display in the dashboard.
  • executeCommand: The callback that runs when the command is invoked. In C# the type is Func<ExecuteCommandContext, Task<ExecuteCommandResult>>; in TypeScript it’s (context: ExecuteCommandContext) => Promise<ExecuteCommandResult>.
  • updateState: A callback that decides the command’s enabled state in the dashboard. In C# it’s Func<UpdateCommandStateContext, ResourceCommandState> and supplied via CommandOptions.UpdateState; in TypeScript it’s the updateState field on CommandOptions (currently typed as any).
  • iconName: The name of the icon to display in the dashboard. The icon is optional, but when you do provide it, it should be a valid Fluent UI Blazor icon name.
  • iconVariant: The variant of the icon to display in the dashboard, valid options are Regular (default) or Filled.

The executeCommand callback is where the command logic is implemented. The ExecuteCommandContext it receives gives you access to the service provider, the resource being acted on, and a cancellation token.

In C# the parameter is typed as Func<ExecuteCommandContext, Task<ExecuteCommandResult>>. ExecuteCommandContext exposes:

  • ServiceProvider (IServiceProvider) — used to resolve services such as loggers or ResourceCommandService.
  • ResourceName (string) — the name of the resource instance that the command is being executed on.
  • CancellationToken (CancellationToken) — used to cancel command execution.
  • Logger (ILogger) — used to write log messages directly to the resource’s logs. These logs can be viewed in the Aspire dashboard or with the aspire logs <resource> command.

The earlier example delegates to a private static method named OnRunClearCacheCommandAsync to clear the cache:

RedisResourceBuilderExtensions.cs
private static async Task<ExecuteCommandResult> OnRunClearCacheCommandAsync(
IResourceBuilder<RedisResource> builder,
ExecuteCommandContext context)
{
var connectionString = await builder.Resource.GetConnectionStringAsync() ??
throw new InvalidOperationException(
$"Unable to get the '{context.ResourceName}' connection string.");
await using var connection = ConnectionMultiplexer.Connect(connectionString);
var database = connection.GetDatabase();
await database.ExecuteAsync("FLUSHALL");
return CommandResults.Success();
}

The preceding code:

  • Retrieves the connection string from the Redis resource.
  • Connects to the Redis instance.
  • Gets the database instance.
  • Executes the FLUSHALL command to clear the cache.
  • Returns a CommandResults.Success() instance to indicate that the command was successful.

The updateState callback decides whether a command is enabled in the dashboard. The dashboard invokes it whenever the resource’s state changes — typical use cases are gating destructive commands behind a healthy resource, or only enabling a command after a startup probe has succeeded.

In C# the parameter is typed as Func<UpdateCommandStateContext, ResourceCommandState>. UpdateCommandStateContext exposes:

  • ServiceProvider (IServiceProvider) — used to resolve services.
  • ResourceSnapshot (CustomResourceSnapshot) — an immutable snapshot of the resource instance, including health status, lifecycle state, properties, and URLs.

The earlier example gates the clear-cache command on the Redis resource being healthy:

RedisResourceBuilderExtensions.cs
private static ResourceCommandState OnUpdateResourceState(
UpdateCommandStateContext context)
{
var logger = context.ServiceProvider.GetRequiredService<ILogger<Program>>();
if (logger.IsEnabled(LogLevel.Information))
{
logger.LogInformation(
"Updating resource state: {ResourceSnapshot}",
context.ResourceSnapshot);
}
return context.ResourceSnapshot.HealthStatus is HealthStatus.Healthy
? ResourceCommandState.Enabled
: ResourceCommandState.Disabled;
}

The preceding code:

  • Retrieves the logger instance from the service provider.
  • Logs the resource snapshot details.
  • Returns ResourceCommandState.Enabled if the resource is healthy; otherwise, it returns ResourceCommandState.Disabled.

To test the custom command, update your AppHost to wire up the resource and the command:

AppHost.cs
var builder = DistributedApplication.CreateBuilder(args);
var cache = builder.AddRedis("cache")
.WithClearCommand();
var apiService = builder.AddProject<Projects.AspireApp_ApiService>("apiservice");
builder.AddProject<Projects.AspireApp_Web>("webfrontend")
.WithExternalHttpEndpoints()
.WithReference(cache)
.WaitFor(cache)
.WithReference(apiService)
.WaitFor(apiService);
builder.Build().Run();

The preceding code calls the WithClearCommand extension method to add the custom command to the Redis resource.

Run the app and navigate to the Aspire dashboard. You should see the custom command listed under the cache resource. On the Resources page of the dashboard, select the ellipsis button under the Actions column:

Aspire dashboard: Redis cache resource with custom command displayed.

The preceding image shows the Clear cache command that was added to the Redis resource. The icon displays as a rabbit crossed out to indicate that the speed of the dependent resource is being cleared.

Select the Clear cache command to clear the cache of the Redis resource. The command should execute successfully, and the cache should be cleared. The result of the command is available in the notification center:

Aspire dashboard: Redis cache resource with custom command executed.

In addition to executing commands through the Aspire dashboard, you can also execute commands programmatically using the ResourceCommandService. This service is useful when you need to:

  • Execute commands from within your application code
  • Automate command execution as part of a workflow
  • Build custom tooling that needs to control resources

The most common scenario is to call commands from within other custom command implementations. The ExecuteCommandContext provides access to the IServiceProvider, which you can use to retrieve the ResourceCommandService.

The following example shows how to create a custom command that executes other commands. In this case, a “reset-all” command clears the cache and restarts a database:

AppHost.cs
using Aspire.Hosting.ApplicationModel;
using Microsoft.Extensions.DependencyInjection;
var builder = DistributedApplication.CreateBuilder(args);
var cache = builder.AddRedis("cache")
.WithClearCommand(); // Defines a "clear-cache" command as shown earlier
var database = builder.AddPostgres("postgres")
.WithCommand("restart", "Restart Database", async (context, ct) =>
{
// Restart database implementation
return CommandResults.Success();
});
var api = builder.AddProject<Projects.Api>("api")
.WithReference(cache)
.WithReference(database)
.WithCommand("reset-all", "Reset Everything", async (context, ct) =>
{
var commandService = context.ServiceProvider.GetRequiredService<ResourceCommandService>();
context.Logger.LogInformation("Starting full system reset...");
var clearResult = await commandService.ExecuteCommandAsync(
resource: cache.Resource,
commandName: "clear-cache",
cancellationToken: ct);
var restartResult = await commandService.ExecuteCommandAsync(
resource: database.Resource,
commandName: "restart",
cancellationToken: ct);
if (!clearResult.Success || !restartResult.Success)
{
return CommandResults.Failure("System reset failed");
}
context.Logger.LogInformation("System reset completed successfully");
return CommandResults.Success();
});
builder.Build().Run();

In this example:

  • The context parameter provides access to ServiceProvider and Logger
  • context.Logger writes log messages directly to the resource’s log stream in the Aspire dashboard — no need to resolve a logger from the service provider
  • The ResourceCommandService is retrieved from the service provider
  • Commands are executed on resource instances using cache.Resource and database.Resource
  • Each command result is checked for success before proceeding

You can also execute a command using the resource ID or resource name. The resource ID is the unique identifier for a resource instance, while the resource name is the display name (which must be unique to use this approach):

var result = await commandService.ExecuteCommandAsync(
resourceId: "cache",
commandName: "clear-cache",
cancellationToken: cancellationToken);
if (result.Success)
{
logger.LogInformation("Command executed successfully");
}
else if (result.Canceled)
{
logger.LogWarning("Command was canceled");
}
else
{
logger.LogError("Command failed: {Message}", result.Message);
}

The resourceId parameter can be either:

  • The unique ID of the resource (e.g., cache-abcdwxyz for a resource with replicas)
  • The display name (e.g., cache) if there are no duplicate names

Execute commands on resources with replicas

Section titled “Execute commands on resources with replicas”

When executing a command on a resource with multiple replicas using the IResource overload, the command runs in parallel on all instances:

// For a resource with replicas, the command executes on all instances
var result = await commandService.ExecuteCommandAsync(
resource: cache.Resource,
commandName: "clear-cache",
cancellationToken: cancellationToken);

The behavior when executing on multiple replicas:

  • The command runs in parallel on all instances
  • If all commands succeed, the result indicates success
  • If any commands fail, the result includes details about the failures
  • If all non-successful commands were canceled, the result indicates cancellation

The ExecuteCommandResult type carries the outcome of a command. It exposes the same conceptual fields in both languages:

  • Success / success (boolean) — Whether the command completed successfully.
  • Canceled / canceled (boolean) — Whether the command was canceled by the user.
  • Message / message (string?) — A human-readable message that surfaces in the dashboard notification center and CLI output. Used for both success messages and error messages.
  • Data / data (CommandResultData?) — Optional structured output (plain text, JSON, or Markdown). See Return structured output from a command.

The CommandResults helper class provides factory methods for the common shapes:

// Success with no payload
return CommandResults.Success();
// Failure with a status message
return CommandResults.Failure("Error occurred during execution");
// Cancellation (typically when the user backs out of a confirmation prompt)
return CommandResults.Canceled();
// Failure derived from an exception (uses Exception.Message)
return CommandResults.Failure(ex);

Resource commands can return a structured payload — plain text, JSON, or Markdown — alongside the success/failure signal. The payload flows automatically through the entire Aspire pipeline:

  • Dashboard: The success notification gains a View response action that opens a text visualizer dialog with the payload. Json results lock the visualizer to JSON syntax highlighting; Markdown results lock it to Markdown rendering. Set DisplayImmediately to open the dialog as soon as the command completes (no click required).

  • CLI: Status messages route to stderr and the payload routes to stdout, so the output is safe to pipe to tools such as jq:

    Terminal
    aspire resource cache issue-access-token | jq -r .token
  • MCP tools: The payload is appended to the tool response as a second TextContentBlock after the status message.

A common scenario is issuing an access token from a custom command — developers can copy it straight from the dashboard or pipe it into a Bearer header from the CLI. In Text mode, the result is just the token; in Json mode, the result includes the expiry and scopes.

Use the CommandResults.Success(message, result, resultFormat) overload to attach a payload to a successful result. The message is the notification center/CLI status; result is the payload string; resultFormat controls how the dashboard visualizer interprets it.

C# — AppHost.cs
using System.Security.Cryptography;
using Aspire.Hosting.ApplicationModel;
var builder = DistributedApplication.CreateBuilder(args);
builder.AddProject<Projects.MyService>("myservice")
.WithCommand(
name: "issue-access-token",
displayName: "Issue Access Token",
executeCommand: context =>
{
var token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
return Task.FromResult(CommandResults.Success(
message: "Access token issued.",
result: token,
resultFormat: CommandResultFormat.Text));
});
builder.Build().Run();

To return JSON with the expiry and scopes, switch the format to CommandResultFormat.Json:

C# — AppHost.cs
using System.Security.Cryptography;
using System.Text.Json;
using Aspire.Hosting.ApplicationModel;
var builder = DistributedApplication.CreateBuilder(args);
builder.AddProject<Projects.MyService>("myservice")
.WithCommand(
name: "issue-access-token",
displayName: "Issue Access Token",
executeCommand: context =>
{
var token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
var json = JsonSerializer.Serialize(new
{
token,
expiresAt = DateTimeOffset.UtcNow.AddHours(1),
scopes = new[] { "read", "write" },
});
return Task.FromResult(CommandResults.Success(
message: "Access token issued.",
result: json,
resultFormat: CommandResultFormat.Json));
});
builder.Build().Run();

CommandResultFormat has three values:

ValueDashboard behavior
TextPlain text. Opens in the text visualizer dialog with no fixed format — the user can switch to a different syntax for inspection.
JsonJSON. The visualizer locks to JSON mode for syntax highlighting and pretty-printing.
MarkdownMarkdown. The visualizer locks to Markdown rendering.

Omit Data (return CommandResults.Success() or { success: true }) when the command has no payload to display.

The matching CommandResults.Failure(errorMessage, result, resultFormat) overload attaches the same payload shape to a failed result, which is useful for surfacing diagnostic detail that the user (or a downstream tool) can inspect:

C# — AppHost.cs
return CommandResults.Failure(
errorMessage: "Migration failed.",
result: errorJson,
resultFormat: CommandResultFormat.Json);

Auto-open the result dialog in the dashboard

Section titled “Auto-open the result dialog in the dashboard”

By default, the dashboard surfaces the payload as a View response action on the notification in the notification center. Set DisplayImmediately on CommandResultData to open the visualizer the moment the command completes — useful for short commands where the response is the point:

C# — AppHost.cs
return CommandResults.Success(
message: "Access token issued.",
value: new CommandResultData
{
Value = json,
Format = CommandResultFormat.Json,
DisplayImmediately = true,
});

When a resource has multiple replicas, the command runs on all instances in parallel. If more than one replica returns a payload, only the first successful payload is propagated to the caller; the others are discarded.

Any command registered with WithCommand can be invoked from the terminal — including the built-in start, stop, and restart commands — using the aspire resource command:

Terminal
aspire resource <resource-name> <command-name> [--apphost <path>]

The CLI splits status output from payload output so the result is friendly to scripting:

  • The Message and any progress text is written to stderr.
  • The Data.Value (when present) is written to stdout verbatim. Json and Text payloads are emitted as-is; Markdown is rendered with terminal-styled headings, lists, and code blocks.
  • The exit code is 0 on success and non-zero on failure or cancellation.

Combined, this means you can pipe the structured payload directly into jq, redirect it to a file, or branch on the exit code:

Terminal — Pipe a JSON payload through jq
$ aspire resource cache issue-access-token | jq -r .token
ey7WqGxk2vL...

The status message Access token issued. was written to stderr, so stdout stays clean for the pipe.

Terminal — Use the exit code in a script
if aspire resource db migrate > migrations.json; then
echo "Applied $(jq length migrations.json) migrations."
else
echo "Migration failed — see error above."
exit 1
fi