CLI
Every operation in your API becomes a command with typed flags, JSON output, and exit codes, plus login, config, docs, completion, and webhook tooling including the hosted relay.
The cli platform adds a command-line interface to your TypeScript package. Every operation becomes a command that calls the generated SDK. API commands take flags only, print JSON, and exit with a code, so they run in CI and in scripts without wrappers. The bin is named after your API, so the Acme API ships acme.
acme <resource> <command> [args] [--flags]
acme accounts list --limit 50
acme accounts get acct_123
acme accounts create --name "Operating" --currency usdPath parameters are positional arguments in path order. Everything else is a flag. Subcommands are trimmed of the resource word, so accounts list rather than accounts list-accounts. The full name stays routable, so nothing you script breaks when the short form appears.
Command names come from the spec's operationIds when they say something the path does not, and are derived otherwise: list, get, create, update, delete, or the action segment (charges capture, invoices mark-uncollectible). One item of a sub-collection is singular and the collection plural (customers create-card, customers list-cards, customers get-card <customer> <id>), a singleton is get/update (customers get-cash-balance), and two actions with the same word under one resource are told apart by their parent (billing deactivate-meters, billing deactivate-alerts), never by a number. An operationId's ById/ByName tail that just names the positional argument is dropped, so getPetById is pet get <petId>.
A resource whose name is one of the CLI's own words (config, docs, mcp, webhooks, login, and the rest listed below) is exposed as <name>-api, so acme config is always the CLI's config and acme config-api list is your API's. Generation reports the rename.
Flags from the spec
- Body fields, query params, and header params become typed flags named after their wire names:
--name,--currency,--limit,--idempotency-key,--cursor. - A parameter whose name matches a flag the CLI reads on every API command (
data,all,select,base-url,debug,validate,non-interactive,color,version,help, and the auth flags such astokenorapi-key) is prefixed with where it goes: a body field nameddatais--body-data, so--datais always the raw body. Two parameters sharing a name across path, query, and body get the same treatment. - Boolean flags stand alone or take
true/false:--active,--active false. - Numbers and booleans are validated.
--limit abcis a usage error. Enum values are checked against the spec's allowed list, for scalars and for array elements. - Array fields take a comma list, the flag repeated, or a JSON array, and a single value is always sent as a one-item array:
--tags a,b,--tags a --tags b,--tags '["a","b"]', and--platforms clisends["cli"]. Help shows the element type (string[],available|pending|sold[],object[]). - Object fields take JSON:
--metadata '{"team":"core"}'. Help shows them asobject; a value that is not a JSON object is a usage error. - Loosely typed fields (unions, untyped values) show as
jsonand accept JSON; a value that is not valid JSON passes through as a plain string. --data '<json>'supplies a raw JSON body. When the operation also has field flags,--datais the base object and field flags are merged on top. Flags win.--data @body.jsonreads the body from a file and--data -reads it from stdin, the curl conventions;--file -does the same for a binary body.- For CLIs generated from a GraphQL schema, object-returning operations accept
--select '{ id name }', a raw selection set that replaces the default depth-2 selection. Help anddocsshow each command as its root field (GraphQL query account), since every call is one POST to the endpoint.
acme accounts create \
--data '{"name":"Operating","currency":"usd"}' \
--name "Ops"
# body sent: { "name": "Ops", "currency": "usd" }Mistyped names get a suggestion. acme accountz list answers Unknown command: accountz. Did you mean 'acme accounts'? and exits 2. The same applies to subcommands and flags.
Flags for date and date-time parameters take relative forms as well as ISO 8601: --created-after -7d (or -P7D, "7 days ago", today, now) resolves against the clock before the request, so the API sees an absolute value. An unsigned duration (7d) is a usage error that names the signed forms. The help table shows date-time as the type and names the forms.
Output and exit codes
Success prints the JSON response on stdout, pretty-printed. Paginated commands print one page plus what fetches the next one: { "items": [...], "hasMore": true, "nextPage": { "limit": 2, "cursor": "cur_2" }, "nextCommand": "acme accounts list --limit 2 --cursor cur_2" }. nextPage is the same shape the MCP tool returns; nextCommand is ready to run. --fields id,name,owner.email keeps only those paths of the result (per item for pages and --all streams), which is most of what jq gets used for and a token saver for agents.
Every failure is one JSON envelope on stderr whenever stderr is not a terminal (pipes, CI, agents):
{
"status": "error",
"issues": [{ "code": "NOT_FOUND", "message": "no such account" }],
"docs_url": "https://docs.acme.example",
"next_steps": ["Check the id; list the resource first."],
"detail": { "status": 404, "error": "NotFoundError", "body": { "code": "not_found", "message": "no such account" } }
}status is error when the command failed and action_required when it stopped on purpose and next_steps says what unblocks it (a missing credential, a plan limit, a confirmation). issues[].code is stable; branch on it, never on the message. The message is the API's own words when it sent any; detail carries the API's body, the SDK error class, and the request id, so nothing is lost.
A person at a terminal gets the same envelope as prose: the message, the next steps, and a dim trailer with the code, the HTTP status, the exit code, and how to get the JSON:
$ acme accounts get acc_404
acme: no such account
Check the id; list the resource first.
NOT_FOUND · HTTP 404 · exit 1 · pipe stderr or --mode agent for JSON--mode human forces the prose form and --mode agent (or --format json) the envelope, whatever stderr is.
| Code | Exit | When |
|---|---|---|
NO_AUTH | 1 | 401 with no credential resolved. action_required. |
AUTH_INVALID | 1 | 401 or 403 with a credential sent. |
PLAN_LIMIT | 1 | 402. action_required; next_steps carries the upgrade URL when the API sends one. |
NOT_FOUND | 1 | 404. |
INVALID_REQUEST | 1 | 400, 409, 413, 422: the API rejected the input. |
SPEC_INVALID | 1 | 422 with typeship's spec_error (the typeship CLI). |
RATE_LIMITED | 1 | 429. action_required; next_steps says how long to wait. |
SERVER_ERROR | 1 | 5xx. |
NETWORK_ERROR | 1 | No response: DNS, TLS, refused, timed out. |
VALIDATION_FAILED | 1 | --validate found the body does not match the schema; detail.violations. |
CONFIRMATION_REQUIRED | 2 | A destructive command needs --force. action_required with the exact command. |
TTY_REQUIRED | 2 | A prompt was needed and there is no terminal. |
INVALID_USAGE, UNKNOWN_COMMAND, UNKNOWN_FLAG, MISSING_ARGUMENT | 2 | Wrong flags or arguments. |
Transport errors still name the request and the deepest cause in the message: TransportError: GET https://... failed: fetch failed: connect ECONNREFUSED ....
Bare invocations (acme, acme accounts) print help on stderr and exit 2, so a misassembled command in a script still fails and never pollutes stdout. acme --help, acme accounts --help, -h, and the bare word help print to stdout and exit 0.
When the spec says an operation is authenticated (security at the operation or document level, without an anonymous alternative) and no credential resolves from flags, environment, or login, the command stops before sending anything: NO_AUTH, action_required, next_steps naming the env var and login. Operations with security: [] run without one and show (no auth needed) in help; [{}, {...}] shows (auth optional). A 401 from the API with a credential sent is AUTH_INVALID, as before.
Streaming every page
Paginated commands accept --all, which walks every page and streams one item per line (NDJSON) on stdout:
acme accounts list --all > accounts.ndjson
acme accounts list --all | jq -r '.id'Help at every level
acme # resources and their commands
acme accounts # commands for one resource
acme accounts create --help # flags, types, required markers, an exampleCommand help is a table sized to its content: the flag, its type (string, number, string[], usd|eur, object), a required marker, and the first sentence of the spec's description wrapped to the terminal width; enum lists too long for the type column go on their own line. Pagination and idempotency parameters the engine recognized get a description even when the spec left them blank. Every command ends with an example built from its required inputs. acme docs accounts create has the untruncated descriptions.
Root help lists each resource's commands in CRUD order (list, get, create, update, delete, then the rest as the spec orders them), wrapped to the terminal; an API with more than 120 operations gets a digest per resource (list, get, create, … 24 more (acme accounts)) and the resource's own help has the full list. Root help also lists the auth environment variables, the credentials path, and the setup commands.
Auth and configuration
Credentials come from environment variables named after your package, with flag overrides. For the acme package the prefix is ACME:
| Environment variable | Flag | Used when the spec declares |
|---|---|---|
ACME_TOKEN | --token | HTTP bearer auth, or OAuth2 access tokens |
ACME_API_KEY | --api-key | A single API key header |
ACME_USERNAME / ACME_PASSWORD | --username / --password | HTTP basic auth |
ACME_BASE_URL | --base-url | Always available |
With several API key schemes, each key gets its own variable and flag named after the header or query parameter. Precedence at request time is flags, then environment variables, then credentials saved by login.
The base URL resolves as --base-url, then ACME_BASE_URL, then the stored base-url, then the stored environment, then the spec's default server. If none exists the command exits 2 with No base URL. Pass --base-url, set ACME_BASE_URL, or run 'acme config set base-url <url>'.
Version
--version, -v, or the bare word version prints the package version, the API version it was generated from, and the generator:
$ acme --version
acme 2.3.0 (Acme API 2.3.0, generated by typeship)login, logout, whoami
Instead of exporting variables every session, credentials can be stored once. login saves them to ~/.config/acme/credentials.json with mode 600, honoring XDG_CONFIG_HOME. logout deletes the file.
acme login # hidden prompt on a TTY
acme login --token <value> # any auth flag works
cat token.txt | acme login --with-token # stdin, for scriptsWhen the API offers browser approval (an endpoint pair the project configures; typeship's own API does), acme login with no flags opens a page where the person approves, and the API mints a key for this machine that login stores. Nothing is pasted. Under an agent, or with --no-browser, the CLI prints the approval URL (as a JSON event on stderr too) and polls until the person decides; the key never crosses the conversation. The request is PKCE-shaped: the CLI keeps a verifier and the API hands the key only to the poll that proves it.
login is otherwise the one command that prompts, and only on a TTY. When the spec declares OAuth2 and the CLI knows a client id (set in the console, passed with --client-id, or in ACME_CLIENT_ID), login runs the RFC 8628 device flow. It prints a code and a URL, polls until you approve in the browser, and refreshes the token automatically a minute before it expires. Scopes and an audience can be configured in the console for authorization servers that gate refresh tokens behind offline_access or need an audience for API-valid tokens.
whoami calls your API's identity endpoint with whatever credentials would be used, so it answers "which account is this machine acting as". Generation detects endpoints like GET /me automatically. Project settings can pin a specific resource.method. If the API has no such endpoint, whoami reports where credentials would come from without a network call, and exits 1 when there are none.
config
Stored defaults live next to credentials in ~/.config/acme/config.json, so the base URL does not need repeating on every command. When the spec declares several servers, environments can be pinned by name:
acme config set base-url https://api.staging.example.com
acme config set environment sandbox # names come from the spec's servers
acme config set docs-url https://docs.acme.example
acme config list
acme config get base-url
acme config unset base-url
acme config pathFlags always win over stored config, so CI overrides need no cleanup.
mcp
When the package was generated with the mcp platform, the CLI wires the server into agent clients:
acme mcp # print the server entry as JSON, and which clients were detected
acme mcp install --all # write it into every agent client found on this machine
acme mcp install --claude # Claude Code (./.mcp.json)
acme mcp install --codex # Codex CLI (~/.codex/config.toml)
acme mcp install --vscode # VS Code (./.vscode/mcp.json)
acme mcp install --windsurf | --gemini | --opencode | --zed | --claude-desktop
acme mcp --url https://typeship.dev/mcp/<slug> --claude # a remote endpoint instead
acme mcp install --claude --read-only # a server that cannot write--read-only registers the local server with its --read-only flag, or points a hosted entry at <url>/readonly; either way writes are not callable. Existing entries in the client config are preserved. The entry is the API's hosted MCP endpoint when the project has one (with the auth env var written as a reference, ${ACME_TOKEN}, never a literal key), else the package's local stdio server, which reads the credentials login saves. --all skips Cursor until Cursor speaks MCP 2026-07-28; --cursor writes it on request with a note. See MCP server.
init
One command connects a machine, or a repository, to the API:
acme init --all -k <credential>It stores the credential (or notes the env var already set), installs the API's skills when a repository is configured (npx skills add owner/skills), writes the MCP entry into every detected agent client, and upserts a marked acme block into ./AGENTS.md (or CLAUDE.md when only that exists, or when running under Claude Code) with the auth rules, the discovery commands, and a compact index of every command. Idempotent: run it again and the block is replaced in place. --no-skills, --no-mcp, --no-agents-md skip a part; the report on stdout says what happened to each.
agent-guide, auth check, doctor, help --json
For the agent holding only the binary:
acme agent-guide --format json # conventions, first command, docs index, hosted MCP, skills, next steps
acme help --json # every resource, command, positional, and flag, as data
acme auth check [--live] # which credential the CLI would send (and, with --live, whether the API accepts it)
acme doctor # node, credentials, base URL, docs, MCP client config, with next_stepsAll four print JSON. auth check and doctor exit 1 when something is action_required.
webhooks listen
ProAvailable on Pro and Enterprise
Local webhook development without tunnels. Developers integrating your webhooks need events to reach a handler on their laptop. Tunnels work, but they expose a port, need an account somewhere, and drop events while disconnected. Turn on webhook relay under the CLI platform in project settings, which needs the CLI on and Pro, and the next generation bakes the relay coordinates into your CLI, which gains a webhooks listen command that needs none of that:
$ acme webhooks listen --forward-to localhost:3000/webhooks
Ready! Forwarding relayed events to http://localhost:3000/webhooks
Point this API's webhook endpoint at: https://typeship.dev/relay/rls_... (^C to quit)
200 account.created [1] (43ms)listenmints a session with its own unguessable ingest URL. Two developers on the same project never see each other's events.- The developer registers that URL as a webhook endpoint in your API, the same way they would register any endpoint.
- Events sent there are buffered and replayed to the local handler with their original headers and body. Nothing is re-signed, so signature verification with the generated SDK's
webhooks.unwrapworks unchanged. --events account.created,account.closedfilters by the payload's type field.
If the connection drops, events keep buffering and replay on reconnect within the same run.
Relayed payloads are transient. Events are deleted 24 hours after they arrive, and sessions are removed after 48 hours idle. Bodies are capped at 1MB, sessions at 5,000 events, and the ingest URL is rate limited per IP. Pair it with webhooks fake, below, to cover the whole local loop before any real event exists.
webhooks fake
When the spec declares webhooks, webhooks fake <event> sends (or prints) a signed sample event with Standard Webhooks headers, so handlers are testable before any real event exists:
acme webhooks fake # list the declared events
acme webhooks fake account.updated # print headers and payload
acme webhooks fake account.updated --forward-to localhost:3000/webhooksThe signing key is --key, then ACME_WEBHOOK_KEY, then a throwaway key. See Webhooks.
docs
The reference and the guides, in the terminal. docs alone prints the API overview. docs <resource> <command> is the full operation reference with untruncated descriptions, richer than --help.
acme docs accounts create
acme docs search "idempotency"
acme docs read quickstart
acme docs --webGuides come from your documentation site through the llms.txt convention, which most docs hosts publish automatically. Set the site in the console, or with config set docs-url. Your spec's externalDocs URL is the default. search covers both the reference and the guides. read prints a guide page. Pages cache locally for an hour, and nothing is fetched unless a docs command runs. The same two layers back the MCP server's search_docs and read_docs tools.
completion
Tab completion for resources, commands, every flag, and the values a flag takes (enum values, true/false, --color on|off|auto), plus config keys and mcp install targets, generated from the same operation table as the CLI:
# ~/.bashrc
eval "$(acme completion bash)"
# ~/.zshrc
eval "$(acme completion zsh)"
# fish
acme completion fish > ~/.config/fish/completions/acme.fishupgrade
When the package is published to an npm registry, the CLI can update itself. upgrade compares the running version against the registry's latest and installs it with npm install -g. upgrade --check reports without installing. A custom registry is honored through npm_config_registry. If the package is not published, upgrade says so. A binary that was installed with pnpm, bun, or yarn, or is running through npx, is not upgraded in place: upgrade names the right command for that manager instead. Vendored packages update by regeneration, not registries.
By default the CLI never contacts a registry on its own. You can opt into an update notice in the console. Then a once-a-day check caches the latest version and later runs print a one-line hint on stderr. The check has a 1.5 second timeout, and --non-interactive silences it.
feedback
When you configure a support URL in the console, feedback opens it in a browser. GitHub issue URLs arrive with the CLI and API version, Node version, and platform prefilled in the body, visible before anything is submitted. Nothing is sent on its own.
Color
Help text and stderr hints use light color on a TTY. JSON output is never colored, so parsers and agents see identical bytes either way. --color on|off|auto overrides detection, and NO_COLOR is honored. An explicit --color on wins over everything, for terminals that hide their TTY.
Debug output
--debug (or ACME_DEBUG=1) prints one line per HTTP attempt to stderr: method, path, status, duration, request id. stdout stays pure JSON, so piping is unaffected. Debug lines never include headers or bodies.
Runtime validation
--validate schema-checks request and response bodies against the spec with the SDK's zero-dependency validator. Violations exit 1 with a ValidationError payload listing each JSON path. It is the fastest way to see whether an API has drifted from its published spec.
Agent mode
The CLI knows when an agent is driving it: --mode agent, ACME_MODE=agent, or no terminal on stdin and stdout. In agent mode nothing prompts and no browser opens (docs --web and feedback print the URL instead), and every stop is an action_required envelope with next_steps the agent can follow. --mode human forces the interactive behavior back on.
Destructive commands (every DELETE) need --force (or --yes, -y, or ACME_YES=1). A person at a terminal is asked; an agent gets CONFIRMATION_REQUIRED with the exact command to run once the user has agreed. Nothing is deleted on a guess.
Every request carries a User-Agent of <package>-cli/<version> (typeship; harness=<name>; agent): the harness when one is detected (Claude Code, Cursor, Codex, Gemini CLI, and others, by their environment variables), agent when a harness is detected or --mode agent is set, and non-interactive for a bare run without a terminal, so a CI job is not counted as an agent. No secrets ride in it.
--out <dir> writes a file-shaped response (an array of {path, content} objects) into a directory and prints the rest of the response with a summary, for APIs that return generated or exported files.
Non-interactive mode
--non-interactive (or ACME_NON_INTERACTIVE=1) is the explicit switch: login skips the device flow and the prompt and fails fast with TTY_REQUIRED unless the credential arrives through a flag or --with-token, and the update notice stays silent. Agent mode implies everything non-interactive mode does except that login still runs the device flow, printing the URL and code as JSON for the agent to relay.
In CI
Credentials come from the environment, output is JSON, and failures are exit codes. Build the package in the job, then run the compiled bin with a secret:
- name: Build the SDK package
working-directory: vendor/acme
run: npm install && npm run build
- name: Export account ids
env:
ACME_TOKEN: ${{ secrets.ACME_TOKEN }}
run: |
node vendor/acme/dist/cli.js accounts list --all \
| jq -r '.id' > account-ids.txtUploads and event streams
Operations with multipart bodies take their binary fields as file paths: acme uploads avatar --file ./me.png --label "me" reads the file and sends it as a form part with the other fields alongside. An operation whose whole body is binary takes --file <path> for the body. Operations that stream server-sent events print one JSON line per event ({ "event", "id", "data" }) until the stream ends, the same shape --all uses for pages, so acme events stream | jq works. These operations are SDK and CLI only; the MCP server does not expose them as tools.
Go
The generated Go SDK: context-first methods, (T, error) returns, typed error structs, and gofmt-clean output on net/http alone.
MCP server
Every operation in your API becomes a tool that coding agents can call. Zero dependencies, two transports, one login shared with your CLI, and a hosted endpoint at a stable URL.