Skip to content

Routes & Parameters

A route template is the string you pass to app.Map(...). It defines both the command name and how input tokens are bound to handler parameters.

app.Map("user {id:int}", handler); // static + one dynamic segment
app.Map("file {path:uri}", handler); // typed constraint
app.Map("search {query}", handler); // untyped, defaults to string
app.Map("archive {date?}", handler); // optional trailing segment
app.Map("client {id} show", handler); // dynamic segment in the middle

Segments are separated by spaces. Static segments must match literally. Dynamic segments ({name}) capture an input token.

A route can contain any number of dynamic segments, at any position. Each one is bound independently by name to the matching handler parameter:

// Two consecutive parameters
app.Map("settings set {key} {value}", static (string key, string value) => ...);
// Typed parameters at different positions
app.Map("log {from:date} {to:date}", static (DateOnly from, DateOnly to) => ...);
// Dynamic between two literals
app.Map("order {id:int} tag {label}", static (int id, string label) => ...);
// Multiple optional trailing parameters
app.Map("client add {name?} {email?:email}", static (string? name, string? email) => ...);

There is no limit on the number of dynamic segments. Optional segments are the only ones that must be at the end — required segments can appear anywhere.

Constraints enforce the type of a segment at parse time. An invalid value is rejected before your handler runs.

Constraint.NET TypeExamples accepted
string (default)stringany token
alphastringletters only
intint42, 1_000
longlong1000000
boolbooltrue, false
doubledouble3.14, -0.5
guidGuid123e4567-e89b-...
emailstringuser@example.com
uriUrihttps://example.com
urlUrihttps:// with valid host
urnUriurn:isbn:0451450523
date / dateonlyDateOnly2024-01-15
datetime / date-timeDateTime2024-01-15T10:30
datetimeoffset / date-time-offsetDateTimeOffset2024-01-15T10:30+05:00
time / timeonlyTimeOnly14:30, 14:30:45
timespan / time-spanTimeSpan2h30m, 30d, PT8H

Write the constraint after a colon: {id:int}, {email:email}, {when:date}.

Full format details — including all accepted timestamp strings and duration syntax — are in Built-in Types & Formats.

If you omit the constraint, Repl infers it from the handler parameter type:

app.Map("user {id}", (int id) => ...); // :int inferred
app.Map("open {path}", (Uri path) => ...); // :uri inferred
app.Map("since {d}", (DateOnly d) => ...); // :date inferred

Implicit types not available as constraint names: FileInfo, DirectoryInfo, ReplDateRange, ReplDateTimeRange, ReplDateTimeOffsetRange.

Optional segments must be at the end:

app.Map("list {page?}", (int? page) => ...);
app.Options(o => o.Parsing.AddRouteConstraint("ipv4", value =>
System.Net.IPAddress.TryParse(value, out var addr) &&
addr.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork));
app.Map("connect {host:ipv4}", (string host) => ...);

ReplDateRange, ReplDateTimeRange, and ReplDateTimeOffsetRange capture an interval from a single token. The type is inferred from the handler parameter — no constraint name needed.

app.Map("report {period}", (ReplDateRange period) =>
GetReport(period.From, period.To));
app.Map("logs {range}", (ReplDateTimeRange range) =>
GetLogs(range.From, range.To));

Both start..end and start@duration forms are accepted:

myapp report 2024-01-01..2024-03-31 # start..end
myapp report 2024-01-01@90d # start + duration (whole days for ReplDateRange)
myapp logs 2024-03-15T08:00..2024-03-15T18:00
myapp logs 2024-03-15T08:00@8h

See Built-in Types & Formats for all accepted timestamp and duration formats.


app.Context(...) defines a navigable scope. Every app.Map(...) inside it produces a route prefixed by the context template.

app.Context("client", client =>
{
client.Map("list", static (IClientStore store) => store.All());
client.Map("{id:int} show", static (int id, IClientStore store) => store.Get(id));
client.Map("{id:int} remove", static (int id, IClientStore store) =>
{
store.Remove(id);
return Results.Success($"Removed client {id}.");
});
});

This registers three complete routes: client list, client {id:int} show, client {id:int} remove.

CLI — type the full path every time:

myapp client list
myapp client 42 show
myapp client 42 remove

REPL — navigate into the context once; subsequent commands omit the prefix:

> client
[client]> list
[client]> 42 show
[client]> 42 remove
[client]> ..
>

Both modes resolve the same underlying routes — the REPL just holds the prefix in the session state.

A context template can itself contain dynamic segments. The captured value is visible to every handler inside the scope:

app.Context("client {id:int}", client =>
{
client.Map("show", static (int id, IClientStore store) => store.Get(id));
client.Map("remove", static (int id, IClientStore store) => store.Remove(id));
});

Full routes: client {id:int} show and client {id:int} remove. The id value is bound once (at context entry) and available to all inner handlers.

In the REPL, client 42 enters [client/42]> and binds id = 42 for the rest of the session at that depth.

Contexts can nest freely. Flat routes and nested routes are equivalent — the two forms register the same paths:

// Nested form
app.Context("client", client =>
{
client.Map("list", ...);
client.Context("{id:int}", c =>
{
c.Map("show", ...);
c.Map("remove", ...);
});
});
// Flat form — identical routes
app.Map("client list", ...);
app.Map("client {id:int} show", ...);
app.Map("client {id:int} remove", ...);

Choose the nested form when you want REPL navigation depth; use the flat form when a shallow command graph is sufficient.


Handler parameters are resolved in this order:

  1. CancellationToken — injected from the execution context
  2. Explicit attributes[FromServices], [FromContext]
  3. Options groups — classes decorated with [ReplOptionsGroup]
  4. Route values — captured from dynamic segments
  5. Named options--option value syntax
  6. DI services — resolved from the container
  7. Positional arguments — remaining tokens consumed left-to-right
  8. Parameter defaults — C# default values
  9. null — for nullable types with no other source

Options can be written as --name value, --name=value, or --name:value. Option parsing is case-sensitive by default.

myapp report --format json --limit 50
app.Map("report", (string format = "text", int limit = 20) => ...);

Options groups — reusable parameter sets

Section titled “Options groups — reusable parameter sets”

When multiple commands share the same options, extract them into a class:

[ReplOptionsGroup]
public class PagingOptions
{
[ReplOption(Aliases = ["-n"])]
public int Limit { get; init; } = 20;
public int Page { get; init; } = 1;
}
app.Map("list", (PagingOptions paging, IStore store) => store.Page(paging.Page, paging.Limit));
app.Map("search {q}", (string q, PagingOptions paging, IStore store) => store.Search(q, paging));

Both commands now share --limit, -n, and --page without duplicating them.

Any argument starting with @ is treated as a response file. Tokens are read from the file, one per line:

Terminal window
myapp @opts.rsp
# opts.rsp contains:
# report
# --format
# json
# --limit
# 50

Useful for long or repeatable invocations. Response files are non-recursive.

ModeAccepts
OptionAndPositional (default)--name value or positional
OptionOnly--name value only
ArgumentOnlypositional only

Override with [ReplOption(Mode = ReplParameterMode.OptionOnly)] or [ReplArgument(Mode = ...)].


You never have to type full command names. Before routing, the framework expands each segment to its unique prefix match. This works identically in CLI and REPL modes.

myapp cl li # → client list
myapp cl 42 sh # → client 42 show

In the REPL:

> cl # enters [client] context
[client]> li # runs list
[client]> 42 sh # runs 42 show

Prefix expansion is position-aware: li is matched against the candidates reachable at that position in the command tree, not against every route globally. This means the same abbreviation can mean different things at different depths.

If two candidates share the same prefix at the same position, the framework reports an error listing all matches:

> client l
error: ambiguous prefix "l" — matches: list, load

Type enough characters to make the prefix unique — li for list, lo for load.

Literal segment matching is case-insensitive. Client, client, and CLIENT all resolve to the same route.

When multiple routes could match the same input, the framework picks the most specific one. Specificity is determined by a scoring system:

  • Literal segments score highest — list beats {id} for the token "list"
  • Typed dynamic segments score by constraint specificity: int > long > guid > timespan > datetimeoffset > datetime > date > time > urn > url > uri > email > bool > alpha > string
  • Routes that match more segments score higher overall
app.Map("client list", static (...) => ...); // literal "list"
app.Map("client {id:int} show", static (...) => ...); // int param + literal "show"
app.Map("client {id:int} remove", static (...) => ...); // int param + literal "remove"
InputRoute matchedReason
client listclient listlist is a literal — scores higher than {id:int} for that token
client 42 showclient {id:int} show42 satisfies :int; literal show confirms the route
cl liclient listclclient, lilist via prefix expansion
cl 42 shclient {id:int} showclclient, 42:int match, shshow

Ambiguity at the route level — two registered routes that could match the same input — is detected at startup, not at runtime. The framework throws if the route graph is ambiguous, so it can never occur in production.

When input matches nothing and no prefix can be resolved, the framework suggests similar commands using edit distance:

> clien lis
error: no command found. Did you mean: client list?