Skip to content

Pipelines & Integration

Every command invocation goes through a 12-stage pipeline. Understanding it lets you hook in at the right stage and reason about where errors come from.

1. Global Option Parsing — --help, --output:format, --answer:*, custom globals
2. Prefix Resolution — abbreviated command names
3. Pre-Execution Handling — shell completion, help rendering, ambient commands
4. Route Resolution — matches tokens to registered routes
5. Banner Rendering — suppressed in CI/non-interactive
6. Command Option Parsing — --name value, @responsefile, --no-*
7. Argument Binding — CancellationToken → attributes → groups → route → DI → positional → defaults
8. Context Validation — optional validation delegates on contexts
9. Middleware & Handler — your Use() chain + handler invocation
10. Result Processing — unwrap IExitResult, EnterInteractiveResult
11. Output Transformation — format selection and rendering
12. Exit Code — 0 success, 1 all other results, n via Results.Exit(n)

Register middleware with app.Use(...). The delegate receives a ReplExecutionContext (with Services, Items, and CancellationToken) and a ReplNext delegate to invoke the next stage:

app.Use(async (context, next) =>
{
var logger = context.Services.GetRequiredService<ILogger<Program>>();
var sw = Stopwatch.StartNew();
await next();
logger.LogInformation("Command completed in {Elapsed}ms", sw.ElapsedMilliseconds);
});

Middleware runs between argument binding (step 7) and your handler (step 9). Chain multiple Use(...) calls — they execute in registration order.

Resolve your auth service from context.Services. If the check fails, short-circuit by not calling next():

app.Use(async (context, next) =>
{
var auth = context.Services.GetRequiredService<IAuthService>();
if (!auth.IsAuthenticated)
throw new UnauthorizedAccessException("Not authenticated. Run 'auth login' first.");
await next();
});
app.Use(async (context, next) =>
{
var logger = context.Services.GetRequiredService<ILogger<Program>>();
logger.LogDebug("Executing command");
try
{
await next();
}
catch (Exception ex)
{
logger.LogError(ex, "Command threw");
throw;
}
});

Every command works as a one-liner. Combine with shell pipes for scripting:

Terminal window
# Export contacts and pipe to jq
myapp contacts list --json | jq '.[] | select(.email | endswith("@example.com"))'
# Import from a file
myapp contacts import contacts.csv
# Chain with standard Unix tools
myapp orders list --json | jq '.[].id' | xargs -I{} myapp orders {} archive

Group complex invocations in a file:

Terminal window
myapp @deploy.rsp

deploy.rsp:

deploy
--environment
production
--region
us-east-1
--confirm

Response files are space-transparent (one token per line) and non-recursive.

Use --answer:* to supply interactive prompts from environment variables or scripts:

Terminal window
# In CI — no user present
myapp release publish \
--answer:confirm=yes \
--answer:version=$VERSION
Terminal window
# Discover available commands
myapp --help --json | jq '.commands[].name'
# Get parameter schema for a command
myapp contacts add --help --json | jq '.parameters'

Repl integrates natively with WebApplicationBuilder. The command graph and HTTP endpoints share the same DI container:

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<IContactStore, ContactStore>();
var webApp = builder.Build();
webApp.UseWebSockets();
webApp.MapReplWebSocket("/repl"); // REPL sessions
webApp.MapGet("/api/contacts", (IContactStore store) => store.All()); // REST API
// Repl command graph — same services; register all routes before RunAsync
var repl = webApp.Services.GetRequiredService<IReplApp>();
repl.Map("contacts list", static (IContactStore store) => store.All());
await webApp.RunAsync();

The REPL command contacts list and the REST endpoint /api/contacts share the same IContactStore instance.


A Repl app can host IHostedService / BackgroundService implementations alongside its command surface. Register them as you would in any .NET host:

var app = ReplApp.Create(services =>
{
services.AddSingleton<IContactStore, ContactStore>();
services.AddHostedService<CacheWarmupService>(); // runs on startup
services.AddHostedService<SessionCleanupService>(); // runs in background
});

Enable hosted service lifecycle so Repl starts and stops them:

return await app.RunAsync(args, options: new ReplRunOptions
{
HostedServiceLifecycle = HostedServiceLifecycleMode.Head,
});

This is most useful for REPL and MCP server scenarios where the process runs indefinitely — background services have a long lifetime alongside the interactive session. In CLI mode the process exits after the command completes, so hosted services run only for the duration of that invocation.

public class SessionCleanupService(IContactStore store) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
await Task.Delay(TimeSpan.FromHours(1), ct);
store.PurgeExpiredSessions();
}
}
}

Repl handles SIGINT (Ctrl+C) and propagates a CancellationToken to all handlers. Long-running commands should honor it:

app.Map("process {file}", async (string file, CancellationToken ct) =>
{
await foreach (var record in ReadRecords(file, ct))
{
ct.ThrowIfCancellationRequested();
await ProcessRecord(record, ct);
}
return Results.Success("Done.");
});

In hosted sessions, graceful shutdown sends a disconnect signal to all active sessions before the server exits.


Errors flow through the pipeline as typed Results:

app.Map("pay {amount:double}", async (double amount, IPaymentService payments) =>
{
if (amount <= 0)
return Results.Validation("Amount must be positive.");
var result = await payments.ChargeAsync(amount);
return result.IsSuccess
? Results.Success($"Paid {amount:C}.")
: Results.Error("payment_failed", $"Payment failed: {result.Message}");
});

Unhandled exceptions are caught at stage 9, rendered to stderr with a stack trace in debug mode, and produce exit code 5.

return Results.Exit(42); // explicit exit code