CLI
An Api’s typed client works from Effect code. A person at a terminal, a shell script or a coding agent wants a command line instead. toCli from @alchemy.run/domain/Cli builds an Effect CLI command from the Api you already have. It calls the deployed Api over its HTTP protocol, so the server runs the same middleware and checks as for every other client.
Build the command
Section titled “Build the command”import * as Domain from "@alchemy.run/domain";import { toCli } from "@alchemy.run/domain/Cli";import { BunRuntime, BunServices } from "@effect/platform-bun";import * as Command from "effect/cli/Command";import * as Config from "effect/Config";import * as Effect from "effect/Effect";import * as FetchHttpClient from "effect/http/FetchHttpClient";import * as Layer from "effect/Layer";import * as Redacted from "effect/Redacted";import { CustomerApi } from "@bank/domain/CustomerApi.api";
const bank = toCli(CustomerApi, { name: "bank", // Only these calls get a subcommand. expose: ["DepositMoney", "WithdrawMoney", "AccountLedger", "AccountSummary.get", "Statement.list"], // The session middleware runs on the server; the CLI sends its token. headers: Config.redacted("BANK_TOKEN").pipe( Effect.map((token) => ({ authorization: `Bearer ${Redacted.value(token)}` })), ),});
Command.run(bank, { version: "1.0.0" }).pipe( Effect.provide( Layer.mergeAll( Domain.protocolHttp("https://bank.example.com/api").pipe(Layer.provide(FetchHttpClient.layer)), BunServices.layer, ), ), BunRuntime.runMain,);toCli is its own module, so an app that builds no CLI never loads effect/cli.
Describe its help
Section titled “Describe its help”Each subcommand’s help is the description in the annotations of the declaration it calls. The root help is the Api’s own description, unless you pass toCli a description of its own.
export class CustomerApi extends Domain.Api( "CustomerApi", { model: Bank, commands: ["DepositMoney", "WithdrawMoney"], views: ["AccountSummary", "Statement"] }, { description: "The bank, as a customer reaches it." },) {}$ bank --helpThe bank, as a customer reaches it. deposit-money Put money into an account. The amount is in cents. withdraw-money Take money out of an account. The amount is in cents. account-summary An account's balance and owner. statement Read the Statement view.A declaration without a description gets a generated sentence, like statement here.
Send a command
Section titled “Send a command”Each command is a subcommand in kebab-case, and its payload and input fields are flags. It prints the receipt as JSON:
$ bank deposit-money --target a-1 --amount 1000{ "reply": { "balance": 1000 }, "stream": "Account/a-1", "version": 1}--json takes the same fields as one object, and any flag you also give wins over it:
$ bank deposit-money --json '{"target":"a-1","amount":1000}' --amount 50It also takes the call’s own wire payload, with the input nested, as the HTTP API and MCP tools take it:
$ bank deposit-money --json '{"target":"a-1","input":{"amount":1000}}'Read a view
Section titled “Read a view”A view, a stream or an aggregate is a subcommand with one subcommand per read, get, peek, list or snapshot:
$ bank account-summary get --key a-1{ "balance": 1000 }$ bank statement list --key a-1 --order desc --take 20watch and events stream, so they aren’t commands.
Ask a query
Section titled “Ask a query”A query is a subcommand, like a command. Its input fields are flags, next to --after, --take and --at-least:
$ bank account-ledger --account a-1 --take 20{ "entries": [{ "id": "…", "amount": 1000 }], "cursor": null}Pass the cursor back as --after for the next page.
Pass structured values
Section titled “Pass structured values”A string, a number, a boolean or a set of string literals takes a typed flag. Anything else, such as a struct, an array or a date, takes its JSON:
$ bank account-summary get --key a-1 --at-least '{"stream":"Account/a-1","version":1}'A command whose input has a field named like a payload field, such as its own target, takes its whole input as --input JSON instead.
Read failures
Section titled “Read failures”The CLI decodes the input against the call’s schema before it sends anything, so a bad flag fails without a request:
$ bank deposit-money --amount 1000 # exit code 1, nothing sent# stderr: Invalid input for deposit-money, naming the missing `target`A rejection, or an error of the call’s middleware, goes to stderr as its wire JSON, and the run fails with CallRejected, exiting with code 1:
$ bank withdraw-money --target a-1 --amount 5000 # exit code 1{ "_tag": "InsufficientFunds", "balance": 1000, "requested": 5000 }Names the CLI refuses
Section titled “Names the CLI refuses”Every run dies with a DefinitionError when two things the Api serves get one command name, or when a field would take a flag the CLI reserves (--json, --help, --version, --wizard, --completions, --log-level):
class Reserved extends Domain.Command("Reserved", { target: AccountId, input: { json: Schema.String },}) {}// bank reserved ... -> DefinitionError: field `json` would be the flag --json, which the CLI reservesA call that expose leaves out has no subcommand.