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.
Add custom commands to a resource
Section titled “Add custom commands to a resource”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:
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.Hostingnamespace so that the extension is visible from the AppHost. - Is a
static classcontaining extension methods onIResourceBuilder<RedisResource>. - Defines a single extension method named
WithClearCommandthat registers aclear-cachecommand on the Redis resource, plus theexecuteCommandandupdateStatecallbacks the dashboard uses to run the command and decide when it’s enabled. - Returns the
IResourceBuilder<RedisResource>to keep the call site chainable.
In a TypeScript AppHost, register the command inline on the resource builder. Because TypeScript doesn’t have extension methods, the closure captures any state the command needs:
import { createBuilder, type ExecuteCommandContext, type ExecuteCommandResult,} from './.modules/aspire.js';
const builder = await createBuilder();
const cache = await builder .addNodeApp("cache", "./cache-service", "src/server.ts") .withHttpEndpoint();
await cache.withCommand( "clear-cache", "Clear Cache", async (_context: ExecuteCommandContext): Promise<ExecuteCommandResult> => { const endpoint = await cache.getEndpoint("http"); const url = await endpoint.url.get(); const res = await fetch(`${url}/admin/cache/clear`, { method: "POST" });
if (!res.ok) { return { success: false, message: `Cache service returned ${res.status} ${res.statusText}.`, }; }
return { success: true, message: "Cache cleared." }; }, { commandOptions: { description: "Drops all entries in the cache service.", confirmationMessage: "Clear all cached entries?", iconName: "AnimalRabbitOff", }, });
await builder.build().run();The preceding code:
- Adds a Node app named
cacheand registers aclear-cachecommand on it. - Implements the
executeCommandcallback as an async arrow that calls the resource’s HTTP endpoint to perform the actual cache invalidation. The pattern works for any resource that exposes an admin operation over HTTP; for resources whose clients you’d rather use directly (databases, message brokers, …), call those clients from the same callback. - Passes a fourth argument with
{ commandOptions: { ... } }to set the dashboard description, confirmation prompt, and icon.
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 isFunc<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’sFunc<UpdateCommandStateContext, ResourceCommandState>and supplied viaCommandOptions.UpdateState; in TypeScript it’s theupdateStatefield onCommandOptions(currently typed asany).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 areRegular(default) orFilled.
Execute command logic
Section titled “Execute command logic”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 orResourceCommandService.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 theaspire logs <resource>command.
The earlier example delegates to a private static method named OnRunClearCacheCommandAsync to clear the cache:
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
FLUSHALLcommand to clear the cache. - Returns a
CommandResults.Success()instance to indicate that the command was successful.
In TypeScript the callback signature is (context: ExecuteCommandContext) => Promise<ExecuteCommandResult>. ExecuteCommandContext exposes the same surface in camelCase, plus a logger accessor as a shortcut:
serviceProvider— async accessor for the dependency-injection container.resourceName— async accessor for the resource instance name.cancellationToken— async accessor returning aCancellationTokenthat you canawaitand forward to APIs that accept anAbortSignal.logger— async accessor for the resource logger; equivalent to resolving a logger fromserviceProvider.
The TypeScript callback is typically defined inline as an async arrow function. The example below pulls the resource’s HTTP endpoint and forwards the cancellation token to fetch:
async (context: ExecuteCommandContext): Promise<ExecuteCommandResult> => { const endpoint = await cache.getEndpoint("http"); const url = await endpoint.url.get(); const cancellation = await context.cancellationToken.get();
const res = await fetch(`${url}/admin/cache/clear`, { method: "POST", signal: cancellation as unknown as AbortSignal, });
if (!res.ok) { return { success: false, message: `Cache service returned ${res.status} ${res.statusText}.`, }; }
return { success: true, message: "Cache cleared." };}The preceding code:
- Reads the resource’s HTTP endpoint URL.
- Calls the resource’s admin endpoint with the cancellation token forwarded as an abort signal so the request stops when the dashboard or CLI cancels the command.
- Returns
{ success: false, message: ... }if the HTTP call fails, otherwise{ success: true, message: "Cache cleared." }.
Update command state logic
Section titled “Update command state logic”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:
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.Enabledif the resource is healthy; otherwise, it returnsResourceCommandState.Disabled.
In TypeScript the updateState field on CommandOptions is typed loosely (any), and the SDK does not yet export a ResourceCommandState enum. The simplest path for TypeScript AppHosts today is to omit updateState entirely — commands default to enabled — and only register the command after the resource is reachable:
await cache.withCommand( "clear-cache", "Clear Cache", executeClearCache, { commandOptions: { iconName: "AnimalRabbitOff", }, });When you need lifecycle-aware gating in a TypeScript AppHost, drive it from outside the callback by registering the command only after the resource exposes the data your command depends on (for example, after subscribing to a connection-string or endpoint event).
Test the custom command
Section titled “Test the custom command”To test the custom command, update your AppHost to wire up the resource and the command:
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.
import { createBuilder } from './.modules/aspire.js';
const builder = await createBuilder();
const cache = await builder.addNodeApp("cache", "../cache/server.js");await cache.withHttpEndpoint();await cache.withCommand( "clear-cache", "Clear Cache", async (context) => { const endpoint = await cache.getEndpoint("http"); const url = await endpoint.url.get(); const cancellation = await context.cancellationToken.get();
const res = await fetch(`${url}/admin/cache/clear`, { method: "POST", signal: cancellation as unknown as AbortSignal, });
if (!res.ok) { return { success: false, message: `Cache service returned ${res.status} ${res.statusText}.`, }; }
return { success: true, message: "Cache cleared." }; }, { commandOptions: { iconName: "AnimalRabbitOff", description: "Clears the application cache.", confirmationMessage: "Are you sure you want to clear the cache?", }, });
const apiService = await builder.addProject("apiservice", "../ApiService/ApiService.csproj");
const web = await builder.addProject("webfrontend", "../Web/Web.csproj");await web.withExternalHttpEndpoints();await web.withReference(cache);await web.waitFor(cache);await web.withReference(apiService);await web.waitFor(apiService);
await builder.build().run();The preceding code registers the cache service as a Node app with an HTTP endpoint and attaches the clear-cache command directly to it.
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:

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:

Programmatically execute commands
Section titled “Programmatically execute commands”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.
Execute commands from a custom command
Section titled “Execute commands from a custom command”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:
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
contextparameter provides access toServiceProviderandLogger context.Loggerwrites log messages directly to the resource’s log stream in the Aspire dashboard — no need to resolve a logger from the service provider- The
ResourceCommandServiceis retrieved from the service provider - Commands are executed on resource instances using
cache.Resourceanddatabase.Resource - Each command result is checked for success before proceeding
Execute a command by resource ID
Section titled “Execute a command by resource ID”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-abcdwxyzfor 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 instancesvar 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
Handle command execution results
Section titled “Handle command execution results”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 payloadreturn CommandResults.Success();
// Failure with a status messagereturn 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);In TypeScript there is no helper class — return a plain object that conforms to ExecuteCommandResult:
// Success with no payloadreturn { success: true };
// Failure with a status messagereturn { success: false, message: "Error occurred during execution" };
// Cancellation (typically when the user backs out of a confirmation prompt)return { canceled: true };
// Failure derived from a caught exceptionreturn { success: false, message: err instanceof Error ? err.message : String(err) };Return structured output from a command
Section titled “Return structured output from a command”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.
Jsonresults lock the visualizer to JSON syntax highlighting;Markdownresults lock it to Markdown rendering. SetDisplayImmediatelyto open the dialog as soon as the command completes (no click required). -
CLI: Status messages route to
stderrand the payload routes tostdout, so the output is safe to pipe to tools such asjq:Terminal aspire resource cache issue-access-token | jq -r .token -
MCP tools: The payload is appended to the tool response as a second
TextContentBlockafter the status message.
Return text or JSON
Section titled “Return text or JSON”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.
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:
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();In TypeScript, return an ExecuteCommandResult object literal with a data property of type CommandResultData:
import { createBuilder, CommandResultFormat, type ExecuteCommandContext, type ExecuteCommandResult,} from './.modules/aspire.js';
const builder = await createBuilder();
await builder .addNodeApp("myservice", "./myservice", "src/server.ts") .withCommand("issue-access-token", "Issue Access Token", async (_context: ExecuteCommandContext): Promise<ExecuteCommandResult> => { const token = crypto.randomUUID().replace(/-/g, "");
return { success: true, message: "Access token issued.", data: { value: token, format: CommandResultFormat.Text, }, }; });
await builder.build().run();To return JSON with the expiry and scopes, switch the format to CommandResultFormat.Json:
import { createBuilder, CommandResultFormat, type ExecuteCommandContext, type ExecuteCommandResult,} from './.modules/aspire.js';
const builder = await createBuilder();
await builder .addNodeApp("myservice", "./myservice", "src/server.ts") .withCommand("issue-access-token", "Issue Access Token", async (_context: ExecuteCommandContext): Promise<ExecuteCommandResult> => { const token = crypto.randomUUID().replace(/-/g, ""); const json = JSON.stringify({ token, expiresAt: new Date(Date.now() + 3_600_000).toISOString(), scopes: ["read", "write"], });
return { success: true, message: "Access token issued.", data: { value: json, format: CommandResultFormat.Json, }, }; });
await builder.build().run();Choose a result format
Section titled “Choose a result format”CommandResultFormat has three values:
| Value | Dashboard behavior |
|---|---|
Text | Plain text. Opens in the text visualizer dialog with no fixed format — the user can switch to a different syntax for inspection. |
Json | JSON. The visualizer locks to JSON mode for syntax highlighting and pretty-printing. |
Markdown | Markdown. The visualizer locks to Markdown rendering. |
Omit Data (return CommandResults.Success() or { success: true }) when the command has no payload to display.
Return data on a failure
Section titled “Return data on a failure”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:
return CommandResults.Failure( errorMessage: "Migration failed.", result: errorJson, resultFormat: CommandResultFormat.Json);return { success: false, message: "Migration failed.", data: { value: errorJson, format: 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:
return CommandResults.Success( message: "Access token issued.", value: new CommandResultData { Value = json, Format = CommandResultFormat.Json, DisplayImmediately = true, });return { success: true, message: "Access token issued.", data: { value: json, format: CommandResultFormat.Json, displayImmediately: true, },};Replica aggregation
Section titled “Replica aggregation”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.
Invoke commands from the CLI
Section titled “Invoke commands from the CLI”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:
aspire resource <resource-name> <command-name> [--apphost <path>]The CLI splits status output from payload output so the result is friendly to scripting:
- The
Messageand any progress text is written to stderr. - The
Data.Value(when present) is written to stdout verbatim.JsonandTextpayloads are emitted as-is;Markdownis rendered with terminal-styled headings, lists, and code blocks. - The exit code is
0on 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:
$ aspire resource cache issue-access-token | jq -r .tokeney7WqGxk2vL...The status message Access token issued. was written to stderr, so stdout stays clean for the pipe.
if aspire resource db migrate > migrations.json; then echo "Applied $(jq length migrations.json) migrations."else echo "Migration failed — see error above." exit 1fi