Watch Aspire live streamsDocumentaciónPrueba Aspire
Watch Aspire live streamsDocumentaciónProbar

Create ephemeral terminals from your AppHost

Esta página aún no está disponible en tu idioma.

Ephemeral terminals let AppHost code launch an interactive process for a specific task and display it in the dashboard. Redis’s WithRepl() is one example: it adds a resource command that opens an authenticated redis-cli session inside the running container.

The same building blocks are available for your own commands. Use Aspire.Hosting.ApplicationModel.TerminalService to create a terminal, configure its executable and placement, start its process, and show it in the dock. The custom docked SQL REPL sample demonstrates this with rqlite’s SQL client.

These are ephemeral terminals: the AppHost launches and owns a separate terminal workload for a specific task. They aren’t resources in the app model. They differ from resource terminals, where Aspire runs the resource and a terminal exposes that resource’s existing process.

The sample defines a custom rqlite container resource and an Open SQL REPL command. The excerpts below focus on the terminal code inside that command’s callback. The surrounding implementation supplies context, the resource builder, and the running container’s current containerId.

The callback gets TerminalService from dependency injection. Because this process will run inside a container, it also resolves the configured container runtime:

apphost.cs — resolve services
var runtime = await context.Services.GetRequiredService<IContainerRuntimeResolver>()
.ResolveAsync(context.CancellationToken);
var terminals = context.Services.GetRequiredService<TerminalService>();

CreateTerminal returns a handle to the new terminal. Executable selects the container runtime, and Arguments tells it to launch the SQL client with an interactive TTY. TerminalPlacement.Dock puts the session in the dashboard’s terminal dock:

apphost.cs — create the terminal
var terminal = terminals.CreateTerminal(new TerminalLaunchOptions
{
Title = $"SQL ({builder.Resource.Name})",
Executable = runtime.Name.ToLowerInvariant(),
Arguments = ["exec", "-it", containerId, "/bin/rqlite", "-H", "127.0.0.1", "-p", "4001"],
Placement = TerminalPlacement.Dock
});

The database server continues running as a resource. The SQL client is a separate process inside the container, connecting to its container-local loopback endpoint.

Start() starts the terminal workload. Show() reveals its dock tab. The sample then waits for the client’s prompt before reporting that the command succeeded:

apphost.cs — start the session
try
{
terminal.Start();
terminal.Show();
await terminal.WaitForTextAsync("127.0.0.1:4001>", TimeSpan.FromSeconds(20), context.CancellationToken);
// Keep the session alive for the user; TerminalService cleans up at AppHost shutdown.
return CommandResults.Success();
}
catch (Exception ex) when (ex is TimeoutException or OperationCanceledException or InvalidOperationException)
{
await terminal.DisposeAsync();
context.Services.GetRequiredService<ResourceLoggerService>().GetLogger(builder.Resource)
.LogError(ex, "Could not open the SQL REPL.");
return CommandResults.Failure(ex is OperationCanceledException ? "Opening SQL REPL canceled." : ex.Message);
}

A successful terminal intentionally outlives the command callback. Don’t wrap it in await using here: that would dispose the session when the callback returns. If startup fails or is canceled, the catch block disposes it and reports a failed command instead.

The same mechanism can launch other interactive programs, not just REPLs. For example, open a Bash shell when the container image includes Bash. Change the executable arguments and readiness check to match the program you launch.

A read-eval-print loop (REPL) accepts commands and displays their results interactively. The built-in WithRepl() methods are examples of this command-driven experience: they add an opt-in REPL resource command that opens the integration’s bundled client in the dashboard terminal dock. They don’t replace the database server’s process or require WithTerminal().

For a complete TypeScript or C# Redis example, follow Use the terminal dock. It covers setup, opening the REPL command, checking the connection with PING, and exiting cleanly.

The command is disabled until the container is running and its runtime ID is available. Each invocation creates a new dock terminal and uses the current container ID, including after a container restart. Reopen the REPL after restarting its container; an old session doesn’t reconnect automatically.

REPL commands are opt-in and run-mode-only. They use the resource’s configured credentials and can modify data, so enable them only for trusted dashboard users.

Use WithTerminal() for a resource whose main process is itself interactive. Use WithRepl() for an additional database or cache client in the dock. The REPL dock session isn’t a resource-terminal target for aspire terminal tape play.

Resolve the service and configure a terminal

Section titled “Resolve the service and configure a terminal”

Resolve TerminalService from dependency injection, either through the built AppHost’s app.Services.GetRequiredService<TerminalService>() or a callback’s service provider, such as commandContext.Services. You don’t construct or register the service yourself.

The terminal types are in Aspire.Hosting.ApplicationModel, which AppHost projects import automatically when implicit usings are enabled.

CreateTerminal(new TerminalLaunchOptions { ... }) returns an AspireTerminal handle. Launch options are properties of TerminalLaunchOptions itself; you don’t need a terminal-library builder or a separate process-options object.

For example, configure a local shell with a working directory, a custom prompt, and an initial grid size. This example requires /bin/sh on the AppHost machine:

AppHost.cs — terminal launch options
#pragma warning disable ASPIRETERMINAL001
var options = new TerminalLaunchOptions
{
Title = "Local shell",
Executable = "/bin/sh",
Arguments = ["-i"],
WorkingDirectory = builder.AppHostDirectory,
EnvironmentVariables =
{
["PS1"] = "tools> "
},
Columns = 100,
Rows = 30,
Placement = TerminalPlacement.Dock
};

Title and Executable are required. Pass arguments separately; an executable name without a full path is resolved against PATH. Environment entries add to or override the inherited AppHost environment.

If omitted, the working directory defaults to the AppHost’s working directory, the initial grid is 80 × 24, and placement is Dock. Both dimensions must be positive. Viewers can resize the grid, but placement is fixed when the terminal is created.

Choose the placement independently of the workload:

  • Dock: A tab in the dashboard’s terminal dock.
  • Dialog: A caller-owned terminal that an interaction dialog can borrow. It isn’t listed in the dock.
  • None: A terminal used only by automation, with no dashboard presentation.

TerminalPlacement.ResourceView is reserved for resource-owned terminals. Passing it to CreateTerminal throws.

This complete AppHost uses an Alpine container as the resource on which to display a custom command. Start the AppHost with Docker available, then select Open local shell on the tools resource in the dashboard. The shell runs on the AppHost machine, not inside the container.

AppHost.cs
using Microsoft.Extensions.DependencyInjection;
#pragma warning disable ASPIRETERMINAL001
var builder = DistributedApplication.CreateBuilder(args);
builder.AddContainer("tools", "alpine", "3.22")
.WithArgs("sleep", "infinity")
.WithCommand("local-shell", "Open local shell", commandContext =>
{
var terminals = commandContext.Services
.GetRequiredService<TerminalService>();
// Keep the caller-owned terminal alive after this command returns.
var terminal = terminals.CreateTerminal(new TerminalLaunchOptions
{
Title = "Local shell",
Executable = OperatingSystem.IsWindows() ? "cmd.exe" : "/bin/sh",
Arguments = OperatingSystem.IsWindows() ? [] : ["-i"],
WorkingDirectory = builder.AppHostDirectory,
Placement = TerminalPlacement.Dock
});
terminal.Start();
terminal.Show();
return Task.FromResult(CommandResults.Success());
});
builder.Build().Run();

Start() is idempotent and nonblocking: it schedules the workload, but doesn’t wait for output. Call it explicitly when you want the workload running before a viewer attaches. Show() reveals the dock and selects the terminal’s tab in every connected dashboard, not just the browser that invoked the command. It doesn’t make a dialog terminal visible.

Don’t put this dock terminal in an await using declaration inside the command. That would dispose it as soon as the command returned. It remains available until you dispose it, the user closes its dock tab, or the AppHost shuts down.

To open a Bash shell instead of a SQL client, use the same command pattern with a container image that includes /bin/bash. Replace the terminal creation block with:

AppHost.cs — Bash terminal variant
var terminal = terminals.CreateTerminal(new TerminalLaunchOptions
{
Title = $"Shell ({builder.Resource.Name})",
Executable = runtime.Name.ToLowerInvariant(),
Arguments = ["exec", "-it", containerId, "/bin/bash", "-i"],
Placement = TerminalPlacement.Dock
});

Keep the Start() and Show() calls, but replace the SQL-specific WaitForTextAsync with a readiness check for your shell’s configured prompt. Also update the command label and failure messages. Not every image includes Bash; use an available shell such as /bin/sh when appropriate.

Use the current container ID rather than guessing a name from the Aspire resource name. The sample’s surrounding command resolves that ID on each invocation and disables the action while the resource isn’t running.

The shell is separate from the container’s main process. Type exit before closing its tab: disposing the local runtime command doesn’t guarantee that the shell inside the container stops. Shell access runs with the container user’s permissions, so expose it only to trusted dashboard users.

For input, screen inspection, headless workflows, and resource-terminal handles, see Automate terminals from your AppHost.

For short-lived modal workflows, create a Dialog terminal with await using, start it, and pass it to IInteractionService.PromptTerminalAsync. The interaction borrows the handle and never disposes it. You can reuse the same live terminal across multiple prompts before the owner disposes it.

Keep these lifetimes separate:

LifetimeWhat ends it
AppHost-owned workloadIts process exits, or terminal disposal stops it.
AppHost-owned terminalExplicit disposal, dock-tab close for a dock terminal, or AppHost shutdown.
Terminal interactionSuccessful work completion, explicit interaction completion, or cancellation—not process exit.
Resource-owned producerResource lifecycle, not disposal of an acquired automation handle.