Skip to content

Ask the docs

Answers come only from these docs and are written by an AI model (Gemini), so check the linked pages. Your question is not kept. Do not include personal details or keys.

Build a product CLI over your MCP server, with custom commands, authentication and terminal conversations.

Expose your MCP tools as commands for people, scripts and coding agents. Tool commands need no model key: they call the server directly. The optional chat command talks to a messaging agent through a separate terminal endpoint.

Start npm run mcp in one terminal. In another terminal in the same project:

npm run cli -- --help
npm run cli -- menu --json
npm run cli -- order --help

Check it worked: menu --json prints the café’s menu as JSON. order --help lists the commands for placing and tracking orders.

Install the optional peers when adding a CLI to an existing project:

npm i @modelcontextprotocol/client@2.3.1 @stricli/core@1.3.0

src/cli.ts defines your product’s command names, groups, arguments and output:

import { fileURLToPath } from "node:url";
import { defineCli, runCli } from "@flow-engineer/flow/cli";
export const cli = defineCli({
name: "cafe",
server: process.env.CAFE_SERVER ?? "http://127.0.0.1:8790/mcp",
description: "Menu and pickup orders",
groups: { order: "Place and track orders" },
sections: { Orders: ["order"], Catalog: ["menu"] },
commands: {
menu: { tool: "get_menu", columns: ["item", "milks", "allergens"] },
"order status": { tool: "order_status", args: ["order_id"] },
},
});
if (process.argv[1] === fileURLToPath(import.meta.url)) {
process.exit(await runCli(cli, process.argv.slice(2)));
}

Tools without an explicit command use their tool name with underscores changed to dashes. Commands support --json and --fields; writes support --dry-run, and actions that can delete or overwrite require --yes without an interactive terminal. Add your own Stricli commands alongside the generated tool commands. Handwritten commands execute locally without discovery, including their help and argument parsing; an explicit this.cli.call(...) calls MCP. A handwritten command takes precedence over a derived command with the same name. flow check checks that your CLI configuration still matches the tools src/mcp.ts’s server lists, asking it in the same process.

exclude lists tools that get no command of their own: they are left out of help, completion and the CLI’s skill, but stay on the server, so a handwritten command can still call them. Use it when your command wraps a tool, for example one that copies an API key without printing it:

const cli = defineCli({
name: "acme",
commands: { "keys reveal": { command: keyRevealCommand } }, // calls this.cli.call("api_keys_reveal", ...)
exclude: ["api_keys_reveal"],
});

Expected result: acme --help lists keys reveal and no api-keys-reveal. flow check rejects an excluded name the server has no tool for, and a { tool } command that names an excluded tool.

defaultCommands names the subcommand a group runs when the words after it name none of its subcommands:

defineCli({
name: "acme",
defaultCommands: { scrape: "create" },
commands: {
"scrape create": command("scrape_create").arg("url"),
"scrape get": command("scrape_get").arg("scrape_id"),
},
});

Expected result: acme scrape https://example.com and acme scrape create https://example.com both run scrape_create; acme scrape get scrape_123 runs scrape_get, since a named subcommand wins. Bare acme scrape shows the group’s help, which says that acme scrape <url> runs acme scrape create. flow check rejects a default for a name that is not a group, or one that is not a command of that group.

command(tool) writes a tool’s command as code: how its inputs are typed, what is fixed, and what people see. It lives in the CLI file, or a file the CLI file imports, so tools never carry CLI choices:

// src/cli/scrape.ts
import { readFileSync } from "node:fs";
import { command } from "@flow-engineer/flow/cli";
export const scrape = command("scrape_urls")
.arg("urls") // cafe scrape a.com b.com
.short("f", "format") // -f markdown
.flag("all", "include_subpages") // --all; --include-subpages still works
.default("format", "markdown") // sent when left out
.hide("source").default("source", "cli") // no flag: always "cli"
.help("format", "markdown, html or text") // this flag's help line
.option("from-file", { help: "Read URLs from a file", short: "F" }) // not a tool input
.map((input, options) => {
if (options["from-file"]) input.urls = readFileSync(String(options["from-file"]), "utf8").split("\n").filter(Boolean);
if (!(input.urls as string[] | undefined)?.length) throw new Error("give at least one URL");
return input;
})
.print((result) => `${(result as { pages: unknown[] }).pages.length} pages scraped`);
// src/cli.ts
commands: { scrape },
StepWhat it does
.arg(...fields)Inputs taken positionally, in order. A list input as the only argument takes every word (<urls...>).
.short(letter, field)A one-letter flag. -h and -H are help.
.flag(name, field)The flag’s name in help; the input’s own flag still works.
.default(field, value)Sent when the person leaves the input out; shown in help.
.hide(...fields)No flag; set the value with .default or .map.
.help(field, line)One flag’s or argument’s help line.
.option(name, { help, kind, short })A flag that is not a tool input (kind: string, number or boolean); .map gets its value.
.map((input, options) => input)Your own code over what is sent, run last. An error it throws is printed as wrong arguments (exit 2).
.print((result) => text)What people see; --json and --fields still print the data.

.columns(), .view(), .alias(), .summary() and .examples() match the object keys. The object form takes the same as args, shorthands (letters and words), defaults, hidden, help, options, input and print. Help, completion, the CLI’s skill and flow check read both.

Expected result: cafe scrape a.com b.com -f html scrapes both pages as HTML and prints “2 pages scraped”; cafe scrape --help shows -f [--format], --all and -F [--from-file] and no --source, and cafe scrape with no URL exits 2 with “give at least one URL”. flow check rejects a shorthand, default, hidden input or help line naming an input the tool lacks, a shorthand for a positional argument or one of the CLI’s own flags, two flags of one name, a required hidden input with neither a default nor .map, options with no .map, and a list argument beside other positional arguments.

sections groups top-level commands and groups under headings in the order declared; remaining commands appear under Other. chat, when configured, comes first; built-in commands appear under This CLI. flow check rejects unknown names and a name listed in more than one section. Run cafe help order to find commands whose names or descriptions contain every supplied word.

Add output and format to a tool to share readable Markdown across MCP and the CLI. A command without columns or view prints that text by default; --json and --fields keep using the structured result.

For terminal rendering, install these optional peers:

npm i marked@12.0.2 marked-terminal@7.3.0

For a tool returning { order_id: "BL-1040", ready_by: "10:30" }, the example formatter prints Order BL-1040 is ready by 10:30. Piped output, NO_COLOR, and installations without these peers print the Markdown as written. A formatter error falls back to JSON and records formatError on tool.end; it does not turn a successful operation into a failure.

Every color the CLI shows comes from one palette: the help, the welcome, the sign-in question, the chat (line and full screen) and the Markdown tools and the agent write. Set brand.accent and it colors command names, the banner, the chat’s names, links, inline code, JSON keys and keywords. Red is used only for failures: a failed tool, a server out of reach, wrong arguments. A sign-in or --yes that is needed is shown in yellow. A code block is highlighted only when it names its language (```json); one that names none is shown as written.

colors in the CLI file replaces any part with a color name, a six-digit hex color, or false for the terminal’s own color:

defineCli({
name: "cafe",
brand: { accent: "#b85c00" },
colors: { muted: "gray", error: "magenta", string: "cyan", comment: false },
});
PartDefaultUsed for
accentbrand.accent, else cyanCommand names, the banner, chat names, the selected option
muteddimHints and secondary lines
successgreenSigned in
warningyellowNot signed in, sign-in or --yes needed
errorredFailures
headingthe terminal’s own, boldMarkdown headings
link, code, key, keywordthe accentLinks, inline code, and keys and keywords in code blocks
string, number, commentgreen, yellow, grayCode blocks that name a language

flow check rejects other parts and colors. Pipes, NO_COLOR, FORCE_COLOR=0 and TERM=dumb print no color.

For an account-protected server, run cafe login. It signs the person in with OAuth on the organization’s own login page; automation can use the configured token environment variable instead. cafe logout forgets this computer’s sign-in. cafe --version prints the version with no server and no sign-in, even with signInFirst. The account and permissions are verified by the same MCP server.

login and logout are top-level commands. To put them in a group, set signIn:

defineCli({ name: "cafe", signIn: { login: "auth login", logout: "auth logout" } });

Every hint the CLI prints then says cafe auth login: the sign-in question, “Sign in first” after a 401, the welcome, help, the generated skill and the browser page a sign-in ends on. signInFirst never asks before the sign-in commands. defineCli and flow check reject a command, alias or tool that uses their words.

Flow has no status command. To show the account, write a command of your own and name it freely:

import { buildCommand } from "@stricli/core";
defineCli({
name: "cafe",
commands: {
whoami: { command: buildCommand({ docs: { brief: "Your account" }, parameters: {}, async func() {
this.cli.print(await this.cli.call("account_info", {}));
} }) },
},
});

To end the server’s session before the local sign-in is removed, add beforeLogout. It gets the server and the stored access token. If it throws, the CLI prints its message and still signs out on this computer:

defineCli({
name: "cafe",
signIn: { async beforeLogout({ token }) { await revokeSession(token); } },
});

Before 0.14 the commands were auth login, auth logout and auth status. They still work for one release, hidden from help, and each prints the command that replaces it. They are removed in 0.15.

Set the brand once in the CLI file. The chat, the welcome banner and the page a browser sign-in ends on all read it:

defineCli({
name: "acme",
brand: { name: "Acme", logo: "https://acme.com/logo.svg", accent: "#e8833a" },
});

name is how people see your brand written (default the command’s name), logo is an https URL or a data:image URL, and accent is a six-digit hex color. chat.accent is the older name for brand.accent and still works. npx @flow-engineer/flow check refuses an accent or logo a browser or terminal cannot use.

Expected result: after acme auth login, the browser shows a page with your logo and accent that says “Head back to your terminal”, in light or dark to match the browser. It appears only once the sign-in code is exchanged and the tokens are saved; if either fails, the page says the sign-in didn’t finish and shows acme auth login to try again. The page tries to close its tab, and stays up where the browser does not allow that.

To use your own page instead, return its HTML from signInPage:

defineCli({
name: "acme",
brand: { name: "Acme", accent: "#e8833a" },
signInPage: ({ status, brand, cli, message }) =>
`<!doctype html><title>${brand.name}</title><h1>${status === "signed-in" ? "Welcome back" : "Try again"}</h1><p>${message}</p>`,
});

signInPage gets only what the page shows: status ("signed-in" or "failed"), the brand with its defaults filled in, the command’s name cli, and a one-sentence message. It never gets the authorization code, token or state. Escape anything you put into HTML. Flow sends the page with cache-control: no-store and referrer-policy: no-referrer; if signInPage throws, Flow’s own page is shown.

Run npx @flow-engineer/flow cli --server <url> --help for a generic CLI over an existing MCP server. Always use the full package name with npx; npx flow fetches a different package.

Welcome people, and ask them to sign in first

Section titled “Welcome people, and ask them to sign in first”

Run with no command at a terminal, the CLI shows a welcome: its name in block letters, the version, a greeting, whether the person is signed in, and how to start. Coding agents (Claude Code, Codex, Cursor), scripts and pipes get the help instead, so they spend no tokens on it. Replace any part in the CLI file:

defineCli({
name: "acme",
version: "2.4.0",
description: "Order coffee and track it from your terminal.",
brand: { accent: "#e8833a" },
welcome: {
title: "Hey, coffee person",
tips: ["acme order list Your orders", "acme order place Your usual, now"],
},
});

Expected result: acme at a terminal prints ACME in orange block letters, acme v2.4.0, “Hey, coffee person”, the description, “Not signed in” (or who is signed in), and the two tips. banner replaces the block letters with your own ASCII art (false for none), text replaces the description line, and welcome: false shows the help instead. The banner is left out when it is wider than the terminal.

When nearly every command needs an account, set signInFirst: true. A person who is not signed in is offered Sign in or Exit before any command; after signing in on your login page, the command they typed runs.

defineCli({ name: "acme", signInFirst: { except: ["docs"], message: "Sign in to Acme to continue." } });

Expected result: signed out, acme order list at a terminal shows the menu, then the orders once the person signs in; its output is the command’s own, so --json stays clean. Without a person (a coding agent, CI or a pipe) it exits 3 with Run \acme auth login` (or set ACME_TOKEN). auth, helpand—helpnever ask, nor do the commands inexcept. A token in ACME_TOKENor—token` counts as signed in.

Terminal window
npm run cli -- --help
npm run cli -- order --help

The order status result depends on whether that order exists in the running café server. --dry-run previews the operation without invoking the write tool.

The CLI has a feedback command, from the MCP server’s built-in feedback tool:

acme feedback --about tooling --message "search-orders has no date filter" --tool search_orders

Coding agents are told to use it in three places: the top-level help (a FEEDBACK block), the CLI’s skill (a “Sharing feedback” section), and the error of every failed command, which ends with If this looks wrong, tell the team: acme feedback --about tooling --message "<what happened>". Feedback appears in the Flow dashboard under Insights → Feedback. mcpServer({ feedback: false }) removes the command and all three notes.

Use skill as a catalog and reader, without loading every skill body:

npm run cli -- skill list
npm run cli -- skill get allergens
npm run cli -- skill get cafe-cli allergens --json

Expected result: list shows names and descriptions, including the generated cafe-cli guide and the café’s allergens workflow. get allergens prints that skill’s full instructions. The last command returns two named records, each with name, description and content; no other skill bodies are read. Replace cafe-cli with <your-cli-name>-cli in your own project.

Bare skill is the same as skill list. skill list --json returns only names and descriptions; skill get <name> [name...] --json returns an array even for one selected skill. The generated CLI guide includes handwritten commands and maps MCP tool names to configured CLI command paths. Workflow instructions come from the same agent/skills/ source used by MCP and messaging.

To install selected skills:

npm run cli -- skill get cafe-cli allergens --write ~/.claude/skills

This writes <dir>/<skill-name>/SKILL.md for each selected skill. Additional source assets are not copied; use native plugin packaging to distribute those. The existing skill --write <dir> command continues to export only the generated CLI guide. A handwritten workflow with the same name takes precedence in the catalog; the legacy export still produces the generated guide.

Use the standalone MCP endpoint (/mcp) for workflow skills. Its existing read_skill descriptor advertises names and descriptions in _meta["flow.engineer/skills"]; bodies are fetched only by get. Older MCP servers without this metadata still expose names through the reader’s schema, with empty descriptions. Unknown names trigger one discovery refresh before the CLI reports an error. All requested names are checked before a batch is read or exported.

MCP-backed commands execute on your deployed MCP server; ship that server alongside the CLI. The CLI caches tool definitions and the server’s name/version in ~/.config/<name>/discovery/ (XDG_CONFIG_HOME or CliConfig.directory overrides the location). Entries are scoped to the endpoint, client and credentials. Discovery files have mode 600 and contain no credentials or tool results.

Cached definitions load immediately, including older snapshots. Help, tools, skill and dry runs do not wait for a server check. Once a snapshot is five minutes old, a background refresh checks the server and fetches current definitions. Installed CLIs use a separate process that survives the command exiting, with a 15-second limit. Concurrent commands share one refresh; background failures keep the previous definitions and do not add output.

Tool calls still connect and authenticate. If the server’s name or version differs, the CLI fetches definitions and rebuilds the generated command’s parser and approval flags before sending the call. Bump the MCP server’s version when changing tool contracts or safety annotations. Periodic discovery also picks up changes when a server does not bump its version.

If an unknown command or invalid argument could be caused by an old cache, the CLI fetches current definitions and reparses once before execution. If the server rejects arguments after a request has been sent, the CLI refreshes definitions and reports the error with a help hint. It never silently resubmits that request or reruns a handwritten command.

To force current discovery:

npm run cli -- tools --refresh-tools --json

Expected result: the CLI fetches and prints the server’s current tool definitions and updates the cache. A new tool can also be invoked directly; an unknown command triggers foreground discovery. Missing or malformed cache files require discovery. An unwritable cache directory does not stop commands working, but each invocation must discover again.

Set discoveryCache: false in defineCli to disable disk caching, or discoveryCache: { maxAgeMs: 60_000 } for a one-minute background interval. Handwritten local commands need no MCP server unless they explicitly call it; --refresh-tools opts into discovery. If a handwritten command declares its own --refresh-tools flag, that flag keeps its local meaning; use tools --refresh-tools for framework discovery.

Run the actual CLI inside a local playground

A local playground can run the team’s actual CLI, including custom commands, OAuth, interactive chat, confirmations, stdout/stderr and exit codes. Opt in on the server that runs on your computer:

import { cli } from "./cli.ts"; // the team's defineCli configuration
await play.listen(agent, 8790, { localCli: { channel: "terminal", config: cli } });
// For a local agent mounting its playground under /playground:
const handler = play.handler(agent, { token, base: "/playground", localCli: { channel: "terminal", config: cli } });

The named channel must be declared on the agent. Its tab opens the runner instead of simulated chat. Each browser gets an independent temporary sign-in and conversation directory; the test person picker does not grant it an account. Run login, open the sign-in link, then return to the runner. chat keeps reading input until /exit; reload reconnects to that session. Clear output clears the viewport; New CLI session ends the session and removes its local credentials, without clearing the account’s agent history. Sessions expire after 30 minutes without browser activity, and a server restart starts fresh. Ending a session does not undo work already accepted by the agent.

This runner accepts only direct loopback connections with a loopback Host and same-origin API requests, even if explicitly configured on a mounted handler. It is absent by default and is not enabled in the hosted test cloud. It invokes runCli in-process: no shell, arbitrary program execution, expansion or pipelines. Quoted arguments work. Its configured endpoints stay fixed; use the installed CLI for --server, --token or skill --write.

The browser CLI runner is available locally only. Hosted browser execution is not supported.

Embedded clients can set CliConfig.directory to isolate credential/conversation/discovery files and CliIO.env to avoid inheriting process tokens; CliIO.signal cancels sign-in and terminal HTTP requests. CliIO.background(work) lets an embedded host schedule discovery refreshes without delaying the response; invoke work while the session is alive. The local playground supplies this scheduler and cancels refreshes when the session ends. Defaults retain the installed CLI’s existing environment and config directory.

chat talks directly to the agent, using the same bearer as MCP when signed in; it is not an MCP tool. An agent without accounts lets anyone chat without login (see guests below). defineCli({ name: "acme", server, agent: "https://agent.example.com/agent/terminal" }) overrides the terminal URL; the default is the MCP server’s origin plus /agent/terminal. acme chat "Which orders are ready?" --json prints one response; acme chat reads a TTY until Ctrl-D or /exit. Without a TTY pass a message to chat (an interactive chat exits 2); sign-in failures exit 3.

A coding agent (Claude Code, Codex, Cursor) should always pass the message: acme chat "Which orders are ready?". The reply prints as written, as plain Markdown, with no styling or wrapping. When the CLI sees that a coding agent runs it (CLAUDECODE, CURSOR_AGENT, CODEX_CI or AI_AGENT is set), chat acts as it does without a terminal even when the agent’s shell is one: the interactive view never opens, and acme chat with no message exits 2 instead of waiting for input. --plain does the same for any other tool.

Expected result: from Claude Code, acme chat "hello" prints only the agent’s reply.

acme chat opens a dedicated full-screen session: conversation above, editable composer below, Markdown replies, and tool activity that updates while the agent works. The UI inherits the terminal profile’s foreground and background, so light and dark terminals keep their own appearance. Sent messages use a subtle gray background when the terminal reports its background color, with spacing as a fallback. The welcome message sits directly above the composer. The optional @mariozechner/pi-tui@0.73.1 package provides the interface (flow new includes it):

npm i @mariozechner/pi-tui@0.73.1
acme chat

Expected result: a chat session opens with a composer at the bottom. Send a request to see its tool steps and replies as they arrive. Press Enter to send; the footer only shows the Ctrl+O details hint. Ctrl+O expands the tools’ inputs and results; leaving chat restores the ordinary terminal. Older terminal servers still return completed replies but cannot show live activity until updated.

To apply a brand accent, set the brand’s six-digit hex color in the CLI configuration:

const cli = defineCli({
name: "acme",
server: "https://agent.example.com/mcp",
brand: { accent: "#b85c00" },
});

The accent colors the title, the Send action, the rail beside tool activity and Markdown highlights; it does not override body text or backgrounds. Colors are approximated with the portable 256-color palette. Without an accent, the terminal profile’s cyan is used. When the terminal reports 24-bit color (COLORTERM=truecolor) and its background, sent messages sit on a faint wash of the accent instead of gray. NO_COLOR disables these color accents.

Enter sends. Shift+Enter or Alt+Enter adds a line; \ followed by Enter is a fallback for terminals that do not distinguish modified Enter. Multiline paste stays in the draft. Ctrl+P/Ctrl+N recall previous messages. The mouse wheel or trackpad scrolls the conversation while the composer stays fixed (the terminal sends it as Up/Down, which scroll unless a multi-line draft needs them); PageUp/PageDown also scroll. Ctrl+Home jumps to the beginning and Ctrl+End returns to the latest reply. While reading older messages, incoming replies and expanded tool details keep your current place. Scrolling back to the bottom resumes following replies. The header indicates when you are reading earlier messages. Chat leaves the mouse to the terminal, so you select and copy text as anywhere else. Tab completes these local commands:

CommandResult
/helpShow chat shortcuts (F1 also toggles help).
/detailsExpand or collapse tool inputs and results (also Ctrl+O).
/newClear this view and forget the terminal reference; shared person history remains.
/exitLeave chat.

Ctrl-C clears a draft, stops waiting for a request, or leaves on an empty prompt. Ctrl-D leaves an empty prompt. Escape stops waiting for the current request: work already accepted by the agent may still finish. A failed request leaves its message in the composer for editing.

Single-message calls and --json keep their command-line behavior; they do not open the session. Dumb terminals and embedded clients using CliIO.lines retain basic line input. An embedded host can replace the full-screen presentation with CliIO.chat(session). The session supplies name, color, accent, signal, send(text, update, signal) and reset(), with ChatSession and ChatUpdate types exported from @flow-engineer/flow/cli.

chat.json is mode 600 and scoped by terminal endpoint. auth login and auth logout forget the signed-in reference, and if the agent refuses a saved reference as another person’s (a token for a different account), the CLI forgets it and sends the message once more, starting that account’s own conversation. --new forgets the terminal’s saved reference; the agent keeps its one-person history across channels. It does not erase a person’s WhatsApp history. Teams can replace chat with their own Stricli command in commands.

Serve the optional terminal handler as one of the agent’s routes (no model or extra dependency in the CLI). Flow’s hosting and the local playground (npm start) both serve an agent’s routes:

import { terminalServer } from "@flow-engineer/flow/mcp";
const terminal = terminalServer({ agent: () => agent, auth }); // omit auth: everyone chats as a guest
const agent = createAgent({
...options,
channels: [...channels, terminal.channel],
routes: { [terminal.path]: (request) => terminal.fetch(request) },
});

Locally the agent (npm start) and the MCP server (npm run mcp) listen on different ports, so the starter’s CLI file points agent at the running agent with runningAgent(dir); a deployed agent answers MCP and chat on one origin.

Without auth, or with guests: true beside it, a CLI that is not signed in chats as a guest. It sends a random id it keeps in chat.json, and the agent keeps that guest’s conversation on the terminal channel only. A guest is never joined to an account (ctx.key is null), so a tool that needs sign-in still asks for it. With auth and no guests, chat answers 401 and the CLI says to run its login command, which keeps strangers from spending the agent’s model tokens. Guests run turns on the agent’s model key; put a rate limit in front of the route if strangers can reach it.

terminalServer({ agent: () => agent, auth, guests: true }); // signed-in people are themselves; others are guests

Expected result: with the café starter running (npm start), npm run cli -- chat opens chat with no sign-in.

terminalServer accepts path and channel overrides. It verifies auth on every POST that carries a token, serializes concurrent requests per person, and returns { conversationId, replies } after the turn settles. The authenticated key becomes the same person that resolve returns on other channels. A body cannot choose its own person; a conversation reference from another person is refused before any turn starts, with 409 { "error": "conversation_mismatch" } (when streaming, an error event with code: "conversation_mismatch"). Its custom UI uses Markdown and exports turns as channel cli. For your own HTTP adapters, agent.request({ channel, key, text, conversationId? }) exposes this without the handler. Supply a verified key, or guest (an id the client keeps) instead of key, and serialize calls for that channel and person. Optional onReply(reply) and onActivity(event) callbacks observe delivery and tool, skill and turn activity while agent.request runs; it still returns the complete result. Activity is scoped to the authenticated person’s conversation and excludes model context and hidden model text. Observers are removed when the request finishes; a callback failure cannot interrupt a delivery.

The terminal handler streams when the request accepts application/x-ndjson: reply records contain text, activity records contain event, and a final done record contains the complete result. A failed turn sends an error record. Requests without that Accept header retain the original JSON response. Disconnecting a stream does not cancel work already accepted by the agent.