.NET console apps - the CLI and bot interface surface
A console binary is one of two things by its external interface: a one-shot CLI tool (parse arguments, do the work, return an exit code) or a long-running gateway app (a bot or consumer that stays connected and reacts to events). The generic host that runs the long-running kind - lifecycle, BackgroundService, graceful shutdown, and the 24/7 hardening: resilience, rate limiting, reconnect, deployment - is the hosted-worker skill's (the one covering the generic host's BackgroundService lifecycle and a 24/7 worker's outbound-I/O hardening: HttpClient pooling, resilience pipelines, rate limiting, ClientWebSocket reconnect, deployment). This skill assumes that skill is loaded alongside and, where the install lacks it, holds a bot to the floors under 'Bots and gateway consumers'. This skill owns the interface layer on top: how a CLI parses its command surface, and how each bot platform's SDK plugs into that host. Floor is .NET 8 / C# 12.
CLI argument parsing
Three libraries; pick by how much command surface you have.
- System.CommandLine - the parser Microsoft's own
dotnet CLI is built on; reached stable 2.0.0 GA in November 2025. The default for a real command tree (subcommands, options, arguments, shell tab-completion). Migration warning: the API churned hard through the betas - 2.0.0-beta5 (June 2025) landed major breaking changes, and four sub-packages are now deprecated and excluded from all future releases (DragonFruit, Hosting, Rendering, NamingConventionBinder). Treat any tutorial older than beta5 as stale, and do not adopt the deprecated Hosting package to bridge to the generic host - wire the host yourself.
- Spectre.Console.Cli - opinionated, type-safe command/settings model (
[CommandArgument] / [CommandOption]), DI support, and rich rendering (tables, prompts, progress bars). The best default for a polished, interactive CLI.
- Cocona - minimal, attribute/convention-based, ASP.NET-Core-like ergonomics; fastest to stand up a small command surface.
A CLI tool that also needs config, DI, and logging builds the generic host and drives the parser from it - Host.CreateApplicationBuilder, resolve the command handler from DI, return its exit code. Do not reach for a parser's abandoned hosting shim to do it. The GA shape - SetAction on the command, values read from the ParseResult (the beta-era SetHandler surface is gone):
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddSingleton<IngestCommand>();
using var host = builder.Build();
var fileOption = new Option<FileInfo>("--file") { Description = "Input file" };
var root = new RootCommand("Ingests a data file");
root.Options.Add(fileOption);
root.SetAction((parseResult, ct) =>
host.Services.GetRequiredService<IngestCommand>()
.RunAsync(parseResult.GetValue(fileOption)!, ct)); // Task<int> -> exit code
return await root.Parse(args).InvokeAsync();
Signal handling and graceful shutdown for a tool that does real work are the hosted-worker skill's; without it, the floor is Host.CreateApplicationBuilder + RunAsync so Ctrl-C and SIGTERM cancel the handler's token.
Bots and gateway consumers
A bot is a long-running gateway client, so it is a hosted service: run the platform client inside a BackgroundService, keep command handlers in DI, and lean on the hosted-worker skill for the lifecycle plus the reconnect / rate-limit / idempotency hardening its I/O reference carries. Where the install lacks that skill, these floors still hold: the client runs in a BackgroundService whose ExecuteAsync catches and logs per cycle (an escaped exception stops the host); every call threads the stopping token; one HttpClient over a SocketsHttpHandler with PooledConnectionLifetime, never new HttpClient() per call; reconnect with exponential backoff plus jitter and re-subscribe every channel on a fresh socket; honor 429 / Retry-After; persist an idempotency key before each send and reuse it on the retry. The per-platform library choice and integration shape live in references/bot-sdks.md:
| Building... |
Library |
Also load |
| a Telegram bot |
Telegram.Bot (long-polling or webhook) |
references/bot-sdks.md |
| a Discord bot |
Discord.Net or DSharpPlus (gateway) |
references/bot-sdks.md |
| a Slack bot |
SlackNet (Socket Mode or Events API) |
references/bot-sdks.md |
| a trading / exchange bot |
CryptoExchange.Net + a venue client |
references/bot-sdks.md |
| a broker queue worker |
per-broker client |
the broker-messaging skill (the delivery contract - outbox, idempotent consumers), where installed + references/bot-sdks.md |
The one rule that spans all of them: decouple the receive loop from the work. The websocket/poll loop writes to a bounded System.Threading.Channels channel; a consumer drains it at a controlled concurrency. That keeps a slow handler from stalling the gateway and gives you backpressure - the channel-drained-by-a-hosted-service pattern is the hosted-worker skill's; the floor here is a bounded channel (FullMode = Wait) drained by one BackgroundService loop.
Testing and time
A bot's timed logic (poll intervals, backoff windows, rate limits) is tested by injecting TimeProvider and advancing a FakeTimeProvider - never real waits. Integration tests run the host against a fake gateway (a fake client, an in-memory channel), never the live Telegram / Discord / exchange endpoint. The full approach is dotnet-testing.
1---2name: dotnet-console-apps3description: Conventions for the console app's interface surface - what a .NET console binary IS to the outside world, on top of the generic host that runs it. Two shapes: a one-shot CLI tool (System.CommandLine 2.0 / Spectre.Console.Cli / Cocona, subcommands, exit codes) and a long-running gateway bot or consumer (Telegram.Bot, Discord.Net, SlackNet, CryptoExchange.Net, broker queue-workers) run inside a BackgroundService. Floors at .NET 8 / C# 12. Load when building a CLI tool, a chat or trading bot, or a bot's command surface, or when the user names System.CommandLine, Spectre.Console, Telegram.Bot, Discord.Net, or a bot. Companions: dotnet-hosted-services (host lifecycle + 24/7 hardening), dotnet-messaging, csharp, dotnet-testing. Do NOT load for the host lifecycle itself (dotnet-hosted-services), a web API or webhook endpoint (dotnet-web-backend), or a desktop GUI (dotnet-wpf).4---56# .NET console apps - the CLI and bot interface surface78A console binary is one of two things by its external interface: a **one-shot CLI tool** (parse arguments, do the work, return an exit code) or a **long-running gateway app** (a bot or consumer that stays connected and reacts to events). The generic host that runs the long-running kind - lifecycle, `BackgroundService`, graceful shutdown, and the 24/7 hardening: resilience, rate limiting, reconnect, deployment - is the hosted-worker skill's (the one covering the generic host's `BackgroundService` lifecycle and a 24/7 worker's outbound-I/O hardening: `HttpClient` pooling, resilience pipelines, rate limiting, `ClientWebSocket` reconnect, deployment). This skill assumes that skill is loaded alongside and, where the install lacks it, holds a bot to the floors under 'Bots and gateway consumers'. This skill owns the interface layer on top: how a CLI parses its command surface, and how each bot platform's SDK plugs into that host. Floor is .NET 8 / C# 12.910## CLI argument parsing1112Three libraries; pick by how much command surface you have.1314- **System.CommandLine** - the parser Microsoft's own `dotnet` CLI is built on; reached stable **2.0.0 GA in November 2025**. The default for a real command tree (subcommands, options, arguments, shell tab-completion). **Migration warning:** the API churned hard through the betas - `2.0.0-beta5` (June 2025) landed major breaking changes, and four sub-packages are now deprecated and excluded from all future releases (DragonFruit, Hosting, Rendering, NamingConventionBinder). Treat any tutorial older than beta5 as stale, and do not adopt the deprecated `Hosting` package to bridge to the generic host - wire the host yourself.15- **Spectre.Console.Cli** - opinionated, type-safe command/settings model (`[CommandArgument]` / `[CommandOption]`), DI support, and rich rendering (tables, prompts, progress bars). The best default for a polished, interactive CLI.16- **Cocona** - minimal, attribute/convention-based, ASP.NET-Core-like ergonomics; fastest to stand up a small command surface.1718A CLI tool that also needs config, DI, and logging builds the generic host and drives the parser from it - `Host.CreateApplicationBuilder`, resolve the command handler from DI, return its exit code. Do not reach for a parser's abandoned hosting shim to do it. The GA shape - `SetAction` on the command, values read from the `ParseResult` (the beta-era `SetHandler` surface is gone):1920```csharp21var builder = Host.CreateApplicationBuilder(args);22builder.Services.AddSingleton<IngestCommand>();23using var host = builder.Build();2425var fileOption = new Option<FileInfo>("--file") { Description = "Input file" };26var root = new RootCommand("Ingests a data file");27root.Options.Add(fileOption);28root.SetAction((parseResult, ct) =>29 host.Services.GetRequiredService<IngestCommand>()30 .RunAsync(parseResult.GetValue(fileOption)!, ct)); // Task<int> -> exit code3132return await root.Parse(args).InvokeAsync();33```3435Signal handling and graceful shutdown for a tool that does real work are the hosted-worker skill's; without it, the floor is `Host.CreateApplicationBuilder` + `RunAsync` so Ctrl-C and SIGTERM cancel the handler's token.3637## Bots and gateway consumers3839A bot is a long-running gateway client, so it *is* a hosted service: run the platform client inside a `BackgroundService`, keep command handlers in DI, and lean on the hosted-worker skill for the lifecycle plus the reconnect / rate-limit / idempotency hardening its I/O reference carries. Where the install lacks that skill, these floors still hold: the client runs in a `BackgroundService` whose `ExecuteAsync` catches and logs per cycle (an escaped exception stops the host); every call threads the stopping token; one `HttpClient` over a `SocketsHttpHandler` with `PooledConnectionLifetime`, never `new HttpClient()` per call; reconnect with exponential backoff plus jitter and re-subscribe every channel on a fresh socket; honor `429` / `Retry-After`; persist an idempotency key before each send and reuse it on the retry. The per-platform library choice and integration shape live in `references/bot-sdks.md`:4041| Building... | Library | Also load |42|---|---|---|43| a Telegram bot | `Telegram.Bot` (long-polling or webhook) | `references/bot-sdks.md` |44| a Discord bot | `Discord.Net` or `DSharpPlus` (gateway) | `references/bot-sdks.md` |45| a Slack bot | `SlackNet` (Socket Mode or Events API) | `references/bot-sdks.md` |46| a trading / exchange bot | `CryptoExchange.Net` + a venue client | `references/bot-sdks.md` |47| a broker queue worker | per-broker client | the broker-messaging skill (the delivery contract - outbox, idempotent consumers), where installed + `references/bot-sdks.md` |4849The one rule that spans all of them: **decouple the receive loop from the work.** The websocket/poll loop writes to a bounded `System.Threading.Channels` channel; a consumer drains it at a controlled concurrency. That keeps a slow handler from stalling the gateway and gives you backpressure - the channel-drained-by-a-hosted-service pattern is the hosted-worker skill's; the floor here is a bounded channel (`FullMode = Wait`) drained by one `BackgroundService` loop.5051## Testing and time5253A bot's timed logic (poll intervals, backoff windows, rate limits) is tested by injecting `TimeProvider` and advancing a `FakeTimeProvider` - never real waits. Integration tests run the host against a fake gateway (a fake client, an in-memory channel), never the live Telegram / Discord / exchange endpoint. The full approach is `dotnet-testing`.