Best Practices & FAQ
Command graph design
Section titled “Command graph design”Prefer a noun-verb shape
Section titled “Prefer a noun-verb shape”Model your graph as <noun> <verb> (or <noun> <id> <verb> for scoped resources). This mirrors how users think and makes help output self-explaining.
// Good — noun-verbapp.Context("contact", contact =>{ contact.Map("list", ...); contact.Context("{id:int}", scoped => { scoped.Map("show", ...); scoped.Map("edit {name}", ...); scoped.Map("delete", ...); });});
// Avoid — verb-noun (harder to navigate in REPL)app.Map("list-contacts", ...);app.Map("show-contact {id:int}", ...);Keep routes flat where depth isn’t meaningful
Section titled “Keep routes flat where depth isn’t meaningful”Don’t introduce a context just to group commands — only do it when the nesting represents a real navigation boundary (entering a specific entity’s scope, changing the “current” object).
Use modules for anything reused
Section titled “Use modules for anything reused”The moment you write the same Map(...) in two places, extract it into a module. See Modules.
Mapping
Section titled “Mapping”Use typed constraints
Section titled “Use typed constraints”Constraints give you validation for free:
// Validated at parse time — handler only runs for valid integersapp.Map("user {id:int}", (int id) => ...);
// Not validated — handler gets whatever the user typedapp.Map("user {id}", (string id) => int.Parse(id)); // parse error at runtime insteadFull constraint table: Routes & Parameters.
Use static lambdas
Section titled “Use static lambdas”Static lambdas prevent accidental captures:
app.Map("list", static (IStore store) => store.All()); // goodapp.Map("list", () => store.All()); // captures — avoidReturn semantic types, not strings
Section titled “Return semantic types, not strings”Return objects and let Repl format them. Use Results.* for control flow:
// Goodapp.Map("show {id:int}", static (int id, IStore store) => store.Get(id)); // renders as table or JSONapp.Map("delete {id:int}", static (int id, IStore store) =>{ store.Delete(id); return Results.Success($"Deleted {id}.");});
// Avoidapp.Map("show {id:int}", (int id, IStore store) => store.Get(id)?.ToString() ?? "not found");Don’t map administrative commands as the default route
Section titled “Don’t map administrative commands as the default route”The empty route ("") or very short routes are easy to trigger accidentally in REPL mode. Put admin/destructive commands behind a context or prefix:
app.Context("admin", admin =>{ admin.Map("purge", ...); // user must type 'admin purge', not just 'purge'}).AutomationHidden(); // hidden from MCP and automation surfacesDocumenting commands
Section titled “Documenting commands”Use [Description] on handlers and parameters
Section titled “Use [Description] on handlers and parameters”It’s the only work you need to do to get good --help output and MCP tool descriptions:
[Description("List all contacts, optionally filtered by name")]public IEnumerable<Contact> List( [Description("Name to filter by (substring match)")] string? filter = null){ return filter is null ? _store.All() : _store.Where(c => c.Name.Contains(filter));}Use .WithDescription(...) on lambda handlers
Section titled “Use .WithDescription(...) on lambda handlers”app.Map("list", static (IStore store) => store.All()) .WithDescription("List all contacts");Write the description for both humans and agents
Section titled “Write the description for both humans and agents”The same description is shown in --help and sent to MCP clients as the tool description. Write it as a complete sentence that explains what the command does, not just its name.
Use .WithBanner(...) for the interactive startup message
Section titled “Use .WithBanner(...) for the interactive startup message”Give users a quick hint at what commands are available when they enter REPL mode:
app.WithBanner("Try: contacts list, contacts add, contacts 1 show");DI and services
Section titled “DI and services”Inject, don’t capture
Section titled “Inject, don’t capture”Never close over a service instance — inject it as a handler parameter. See Dependency Injection.
Use AddScoped for per-session state in hosted sessions
Section titled “Use AddScoped for per-session state in hosted sessions”If you’re building a hosted session app, per-user state (cart, auth context, cursor) should be Scoped:
builder.Services.AddScoped<IUserSession, UserSession>();In CLI mode, “scoped” means per invocation — it’s still correct, just not significant.
Testing
Section titled “Testing”Write tests first
Section titled “Write tests first”Repl.Testing makes test-first practical for CLI commands. Write the test that asserts the behavior, then implement it.
[TestMethod][Description("Verifies that the add route binds both arguments, delegates to the store, and returns a zero exit code — catches routing and binding regressions that unit-testing the handler alone would miss.")]public async Task When_AddContact_Then_ContactPersistedAndIdReturned(){ await using var session = await host.OpenSessionAsync();
var result = await session.RunCommandAsync("contacts add Alice alice@example.com");
result.Output.Should().Contain("Alice");
var list = await session.RunCommandAsync("contacts list --json"); list.Output.Should().Contain("alice@example.com");}Test the interaction, not the handler
Section titled “Test the interaction, not the handler”Test at the command level (full route → output), not at the handler method level. This catches routing bugs, binding bugs, and output formatting issues that unit-testing the handler method alone won’t catch.
Supply answers for interactive commands
Section titled “Supply answers for interactive commands”Pass prompt answers as a dictionary keyed by prompt name. These are injected as --answer:name=value flags:
await session.RunCommandAsync( "contacts add", new Dictionary<string, string> { ["name"] = "Alice", ["email"] = "alice@example.com", });Output — never use Console directly
Section titled “Output — never use Console directly”Console.Write and Console.WriteLine bypass Repl’s session-routing layer. In hosted sessions, Console.Out doesn’t correspond to any connected client. In protocol passthrough mode (MCP stdio), Console.Out is the wire — writing arbitrary text to it corrupts the protocol. In tests, you can’t capture it.
Inject IReplIoContext for raw text output
Section titled “Inject IReplIoContext for raw text output”IReplIoContext is injectable as a handler parameter. io.Output is always the correct writer for the active session, regardless of execution mode:
app.Map("dump {file}", async (string file, IReplIoContext io, CancellationToken ct) =>{ await io.Output.WriteLineAsync($"Reading {file}…"); // …});In CLI mode io.Output is stdout. In a hosted session it routes to that client’s stream. In protocol passthrough mode the framework redirects io.Output to the protocol stream and uses stderr for its own diagnostics — all automatically.
Inject IAnsiConsole for Spectre richness (Repl.Spectre)
Section titled “Inject IAnsiConsole for Spectre richness (Repl.Spectre)”Repl.Spectre registers IAnsiConsole in DI backed by a delegating writer that resolves to ReplSessionIO at call time. Inject it in any handler — it routes to the right client even across concurrent hosted sessions:
app.Map("status", (IAnsiConsole console) =>{ console.Write(new Panel("[green]All systems go[/]")); return Results.Success("Status OK.");});Works in CLI mode, hosted sessions, and tests. Never use AnsiConsole (the static class) directly — it writes to the process stdout regardless of session context.
Logging
Section titled “Logging”Use ILogger<T>, not output writes
Section titled “Use ILogger<T>, not output writes”Logging is observability. Output is what the user or agent sees. Mixing them pollutes command results and breaks structured consumers — --json output with embedded log lines is unparseable, and MCP agents receive diagnostic noise in tool results:
// Good — diagnostic stays in the logging subsystemapp.Map("sync", async (IMyService svc, ILogger<Program> log) =>{ log.LogInformation("Starting sync"); return Results.Success($"Synced {await svc.SyncAsync()} records.");});
// Bad — diagnostic contaminates command outputapp.Map("sync", async (IMyService svc, IReplIoContext io) =>{ await io.Output.WriteLineAsync("Starting sync"); // appears in --json return Results.Success($"Synced {await svc.SyncAsync()} records.");});Logging is silent by default
Section titled “Logging is silent by default”ReplApp.Create() wires Microsoft.Extensions.Logging but registers no provider. Handlers can freely inject ILogger<T> — add the provider you want when you need it.
Do not use AddConsole() in a Repl app. The console logger writes to stdout/stderr — the same streams as command output. In REPL mode it interrupts the interactive experience; in MCP passthrough mode it corrupts the protocol wire.
Use a file logger instead:
dotnet add package Serilog.Extensions.Hostingdotnet add package Serilog.Sinks.Fileusing Serilog;
Log.Logger = new LoggerConfiguration() .WriteTo.File("logs/myapp-.log", rollingInterval: RollingInterval.Day) .CreateLogger();
var app = ReplApp.Create(services =>{ services.AddLogging(l => l.AddSerilog());});For local development only, AddDebug() writes to the IDE debug output window — not to the terminal, so it doesn’t contaminate command output.
Without any provider, log calls are no-ops. This is intentional — the default stays clean.
Structured scope is pre-wired
Section titled “Structured scope is pre-wired”ReplLoggingMiddleware (active by default) pushes a structured scope for every command execution. Every log statement emitted from a handler automatically carries:
| Property | Value |
|---|---|
ReplSessionId | Unique session identifier |
ReplSessionActive | true while a session is active |
ReplHostedSession | true for remote transport sessions |
ReplProgrammatic | true when called non-interactively (CI, scripts) |
ReplProtocolPassthrough | true in MCP stdio mode |
ReplTransport | websocket, telnet, local, … |
ReplRemotePeer | Client address for hosted sessions |
ReplTerminalIdentity | Terminal identity string (e.g. xterm-256color) |
No opt-in needed — the scope appears automatically in any structured provider (OpenTelemetry, Serilog, etc.).
ReplTerminalIdentity and ReplRemotePeer originate from the remote client — treat them as untrusted strings in your log queries and dashboards. Do not pipe them into a raw terminal or console that interprets ANSI escape sequences.
Q: Should I use ReplApp or CoreReplApp?
Use ReplApp (from the Repl meta-package) unless you’re in an environment where DI is unavailable or you want zero dependencies. CoreReplApp (from Repl.Core) is the dependency-free base — great for embedding in a larger host.
Q: Can I mix CLI and REPL mode in tests?
Yes. Repl.Testing runs commands in a session context, which is equivalent to REPL mode. For CLI-mode-specific behavior (no prompts, plain output), pass ChannelType.Cli when creating the test session.
Q: How do I hide a command from help but keep it runnable?
Decorate the handler with [Browsable(false)]. The command still runs if invoked directly but doesn’t appear in --help or discovery.
Q: How do I hide a command from MCP agents but keep it in the interactive REPL?
.AutomationHidden()Q: My MCP client doesn’t see my commands. Why?
Make sure app.UseMcpServer() is called before app.Run(args), and that the MCP server is started with myapp mcp serve. Use myapp mcp list-tools to verify which commands are exposed.
Q: How do I version my command surface?
Route under a version prefix: app.Context("v2", v2 => { ... }). For backward compatibility in MCP, keep old tools alive alongside new ones and use .WithDescription(...) to signal that a tool is superseded.
Q: Can I use Repl in an ASP.NET Core app alongside HTTP endpoints?
Yes. Register your shared services in WebApplicationBuilder, then use app.Services.GetRequiredService<IReplApp>() to get the Repl command graph. HTTP endpoints and REPL commands share the same DI container and IServiceProvider.