Skip to content

Best Practices & FAQ

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-verb
app.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).

The moment you write the same Map(...) in two places, extract it into a module. See Modules.


Constraints give you validation for free:

// Validated at parse time — handler only runs for valid integers
app.Map("user {id:int}", (int id) => ...);
// Not validated — handler gets whatever the user typed
app.Map("user {id}", (string id) => int.Parse(id)); // parse error at runtime instead

Full constraint table: Routes & Parameters.

Static lambdas prevent accidental captures:

app.Map("list", static (IStore store) => store.All()); // good
app.Map("list", () => store.All()); // captures — avoid

Return objects and let Repl format them. Use Results.* for control flow:

// Good
app.Map("show {id:int}", static (int id, IStore store) => store.Get(id)); // renders as table or JSON
app.Map("delete {id:int}", static (int id, IStore store) =>
{
store.Delete(id);
return Results.Success($"Deleted {id}.");
});
// Avoid
app.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 surfaces

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");

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.


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 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.

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",
});

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.

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 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 subsystem
app.Map("sync", async (IMyService svc, ILogger<Program> log) =>
{
log.LogInformation("Starting sync");
return Results.Success($"Synced {await svc.SyncAsync()} records.");
});
// Bad — diagnostic contaminates command output
app.Map("sync", async (IMyService svc, IReplIoContext io) =>
{
await io.Output.WriteLineAsync("Starting sync"); // appears in --json
return Results.Success($"Synced {await svc.SyncAsync()} records.");
});

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:

Terminal window
dotnet add package Serilog.Extensions.Hosting
dotnet add package Serilog.Sinks.File
using 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.

ReplLoggingMiddleware (active by default) pushes a structured scope for every command execution. Every log statement emitted from a handler automatically carries:

PropertyValue
ReplSessionIdUnique session identifier
ReplSessionActivetrue while a session is active
ReplHostedSessiontrue for remote transport sessions
ReplProgrammatictrue when called non-interactively (CI, scripts)
ReplProtocolPassthroughtrue in MCP stdio mode
ReplTransportwebsocket, telnet, local, …
ReplRemotePeerClient address for hosted sessions
ReplTerminalIdentityTerminal 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.