A command-line client for the Appcues API v2. It covers flows, experiences, checklists, segments, tags, users, and groups, plus analytics queries and exports, with profile-based authentication. Mutating commands (publishing, editing, deleting) are supported; access control comes from the API key's permissions (read-only vs write) and from the operator running the CLI.
The Rust CLI crate lives in crates/appcues/; all cargo commands run
from there.
The agent skills that use this CLI, and the guide to setting up an agent runtime with it, live in appcues/skills. Start there; this README documents the CLI itself.
This CLI is a companion to the Appcues MCP server,
not a replacement for it. The MCP server is built for agents (Claude, other
LLM tools) to call the API programmatically inside a conversation. This CLI
is built for a human at a terminal or a CI script — scriptable, pipeable, and
scoped to -o json output when you need to feed the result to something
else. It also doubles as a local MCP server: appcues mcp exposes the same
commands as typed tools over stdio, using the saved profile's credentials,
for agents that run on your machine (see "MCP server" below).
With Homebrew (macOS and Linux):
brew install appcues/tap/appcuesThe formula lives in appcues/homebrew-tap
and is updated after each release. Upgrade with brew upgrade appcues.
git clone https://github.com/appcues/cli.git
cd cli
cargo install --path crates/appcuesThis installs an appcues binary onto your PATH (wherever cargo install
puts binaries, typically ~/.cargo/bin).
Every merge to main that bumps the version in crates/appcues/Cargo.toml
publishes a GitHub Release tagged vX.Y.Z, with the matching CHANGELOG
section as its notes and one tarball per platform:
appcues-<target>.tar.xz for aarch64-apple-darwin, x86_64-apple-darwin,
aarch64-unknown-linux-gnu, and x86_64-unknown-linux-gnu. Each holds a
directory of the same name containing the appcues binary. Merges that do
not bump the version release nothing. Download a tarball from the Releases
page, or with the GitHub CLI:
gh release download -R appcues/cli -p 'appcues-aarch64-apple-darwin.tar.xz'
tar -xf appcues-aarch64-apple-darwin.tar.xz --strip-components=1 '*/appcues'Then put the binary on your PATH:
mkdir -p ~/.local/bin
install -m 755 appcues ~/.local/bin/
appcues --versionIf the last command fails, ~/.local/bin is not on your PATH; export it from
your shell's login profile. The macOS binary is unsigned and not notarized, so
Gatekeeper quarantines browser downloads; clear the flag with
xattr -d com.apple.quarantine appcues, or download with gh, which skips
quarantine.
appcues profiles add
appcues status
appcues flows list
appcues flows publish 3f8a1c2e-1111-4444-9999-abcdefabcdefprofiles add prompts for an API key, API secret, account ID, and environment,
and saves them as the default profile, which every command uses unless you
pass --profile. status checks the saved credentials against the API.
Everything else reads from that profile unless you override it with flags or
environment variables.
In CI/automation contexts, pass --api-key, --api-secret, --account-id,
and --env flags to profiles add to skip all prompts — no interaction
needed. Alternatively, set credentials via environment variables
(APPCUES_API_KEY, APPCUES_API_SECRET, APPCUES_ACCOUNT_ID,
APPCUES_ENV) and skip profiles add entirely.
appcues profiles add [NAME] [--api-key K --api-secret S --account-id A]
[--env prod|prod-eu] [--base-url <URL>]
[--tools-base-url <URL>]
Save a new profile (default name: default)
appcues profiles list List saved profiles (credentials truncated)
appcues profiles edit <NAME> [--api-key K --api-secret S --account-id A --env E --base-url U --tools-base-url U]
Update fields; omitted flags keep their value
appcues profiles remove <NAME> Delete a profile
appcues status Verify the active profile's credentials
appcues mcp Serve the account as MCP tools over stdio (for
Claude Code, Codex, and other MCP clients)
appcues flows list List all flows
appcues flows get <FLOW_ID> Show one flow
appcues flows publish <FLOW_ID> Publish a flow
appcues flows unpublish <FLOW_ID> Unpublish a flow
appcues flows +digest [--days 7] One-call digest: published flows' performance
this period vs the previous period, with deltas
appcues experiences list <TYPE> List all experiences of one type
appcues experiences get <TYPE> <EXPERIENCE_ID>
Show one experience
appcues experiences publish <TYPE> <EXPERIENCE_ID>
Publish an experience
appcues experiences unpublish <TYPE> <EXPERIENCE_ID>
Unpublish an experience
TYPE: pins|mobile|launchpads|banners|flows-v2|embeds|nps
(the API has no single experiences route; each
type is its own resource)
appcues checklists list List all checklists
appcues checklists get <CHECKLIST_ID> Show one checklist
appcues checklists publish <CHECKLIST_ID> Publish a checklist
appcues checklists unpublish <CHECKLIST_ID>
Unpublish a checklist
appcues screenshots <RESOURCE_ID> [--out FILE]
Download a flow/experience/checklist's draft
screenshots as a ZIP (default:
<RESOURCE_ID>-screenshots.zip); one API route
serves every type, the id alone identifies it
appcues segments list List all segments
appcues segments get <SEGMENT_ID> Show one segment
appcues segments create --name <NAME> [--description <DESCRIPTION>]
appcues segments update <SEGMENT_ID> [--name <NAME>] [--description <DESCRIPTION>]
appcues segments delete <SEGMENT_ID>
appcues segments add-users <SEGMENT_ID> --user-ids <USER_IDS>
appcues segments remove-users <SEGMENT_ID> --user-ids <USER_IDS>
appcues tags list List all tags
appcues tags get <TAG_ID> Show one tag
appcues users get <USER_ID> Show a user's profile
appcues users update <USER_ID> --attr <ATTRS> Update profile attributes (repeat --attr key=value)
appcues users delete <USER_ID> Delete a user's profile
appcues users events <USER_ID> [--limit <LIMIT>] Show a user's recent events
appcues users track <USER_ID> --name <NAME> [--timestamp <TIMESTAMP>] [--attr <ATTRS>]
appcues groups get <GROUP_ID> Show a group's profile
appcues groups update <GROUP_ID> --attr <ATTRS> Update group attributes (repeat --attr key=value)
appcues groups add-users <GROUP_ID> --user-ids <USER_IDS>
appcues analytics query --spec <FILE|-> [--async]
Run an analytics spec (sync), or submit an async export job
appcues analytics +compare --spec <FILE|-> [--days 7]
Run an aggregate spec for this period and the previous
one, joined per dimension value with per-metric deltas
appcues jobs get <JOB_ID> Show an analytics export job's status
appcues jobs wait <JOB_ID> [--timeout 15m] Poll an analytics export job until done or failed
appcues jobs download <JOB_ID> [--out FILE] [--timeout 15m]
Wait for the job, download its result, print the path
appcues tools list [--full] List the account tools available to this API key
(summary by default; --full includes schemas)
appcues tools describe <NAME> Show one tool's entry, including its input schema
appcues tools call <NAME> [--input <JSON|-> | --input-file <FILE> | --attr k=v ...]
[--raw] [--out <DIR>]
Call a tool by name; prefer this over raw API
calls for anything without a typed command
Run appcues --help or appcues <group> --help for the exact flags on any
command — the above is a summary, not a substitute.
Notes on flag shapes:
--user-idstakes a comma-separated list:--user-ids user_1,user_2,user_3.--attris repeatable, onekey=valuepair per flag:--attr plan=pro --attr seats=12.- Destructive commands run without asking by default. Pass
-i(or--interactive), or setinteractive = truein the profile, to get a y/N prompt first; see "Interactive confirmation" below. --timestamponusers tracktakes RFC 3339 (e.g.2026-08-12T00:00:00Z) and defaults to now.--dry-runpreviews the request a write command would send (method, path, body) and exits 0 without calling the API; reads still run normally. Dry-run previews bypass confirmation prompts, even in interactive mode.
analytics query POSTs a spec JSON file (or stdin with --spec -) to the
v2 analytics API. The CLI checks only that the spec is well-formed JSON:
the server validates everything else and returns structured 400s naming
the offending field. Sync (the default) returns the rows inline as a bare
JSON array (use -o json to pipe it); --async submits an export job
and returns a job_id. jobs download <JOB_ID> waits for the job and
downloads its result to a local file (default <JOB_ID>.json), printing
the path — the presigned URL never touches the output. To handle the
steps yourself, poll with jobs get, or block with jobs wait <JOB_ID>
until it reports done (which includes a presigned download_url,
valid for 1 hour — if it expires, jobs get mints a fresh one) or
failed (which includes a failure_reason).
Responses from these routes carry rate-limit state (X-RateLimit-*,
X-Concurrency-*) and a truncation marker (X-Rows-Truncated) when the
server clamps a sync result to its row cap; the CLI echoes them as one
JSON line on stderr, e.g.
{"rate_limit":{"remaining":41},"rows_truncated":true}, so scripts and
agents can pace themselves.
appcues analytics query --spec q.json -o json
cat q.json | appcues analytics query --spec - --async
appcues jobs download <JOB_ID> --out result.jsonjobs get and jobs wait serve analytics export jobs only; job ids
minted by the legacy email-delivery export system are not visible on
this route and return a 404.
Commands whose names start with + (Google Workspace CLI style) combine
several API calls into one expressive answer. flows +digest runs
GET flows plus two analytics queries — the last --days (default 7)
and the same-length window immediately before — and joins them
client-side into one report: shown, completed, skipped, errors, unique
users, and completion rate per published flow, each with the
previous-period numbers and deltas.
analytics +compare does the same period math for any aggregate spec
(metrics + dimensions): it replaces the spec's start_time/end_time
with the last --days and the same-length window before, runs both, and
joins the rows on their dimension values. Each row carries current,
previous, and deltas (<metric>_pct, null when the previous value is
0), sorted by the first metric's current value descending. A row present
in only one window counts as zero in the other. Raw columns specs are
rejected: there is nothing to compare.
appcues flows +digest --days 7 -o json # weekly digest input, one call
appcues analytics +compare --spec errors.json --days 1 -o json # today vs yesterdayappcues tools talks to a second origin: the account tools service, which
serves the same tool set as the Appcues MCP server over plain HTTP
(GET /v1/tools, GET /v1/tools/:name, POST /v1/tools/:name). It uses
the same API key and secret as everything else, sent as the
appcues-api-key header. Tool names are identical on both transports, so
a skill can name a tool once and run on either.
tools list requests the lightweight summary view (name, role,
description; a fraction of the full payload), which pairs with
tools describe <name> as a search-then-load flow: find the tool in the
summary, then fetch only its schema. --full fetches complete entries
with inputSchema and annotations in one call. A server without the
summary view returns the full listing for either form, so the flag is
safe against older servers.
tools call builds the tool's arguments object from --input '<json>'
(or --input - for stdin), --input-file <path>, or repeated
--attr key=value pairs (values are JSON-typed: 3 is a number, true
a boolean). The flags are mutually exclusive; with none of them the tool
runs with empty arguments.
Output follows the data-first rule: when the result carries a data list
it is rendered (columns type, id, name); otherwise a single text
result that parses as JSON is printed parsed; otherwise the text prints
verbatim. --raw prints the whole result envelope instead. Image results
(screenshots) are written to --out <dir> (default: the current
directory) as PNG files.
In interactive mode (-i, or interactive = true in the profile), a
tool that is not read-only asks for confirmation first, after one extra
GET /v1/tools/<name> to check the tool's readOnlyHint. The
non-interactive default calls the tool directly with no extra request.
--dry-run prints the would-be POST and sends nothing, like every other
write.
A tool that runs but reports failure (isError) exits 4 with type
"tool" in the one-line JSON error; the full envelope is embedded under
body.
appcues mcp starts a Model Context Protocol
server on stdin/stdout. It is the same binary and the same command code as
the CLI, presented as strongly typed tools: list_flows, get_flow,
publish_flow, list_experiences(experience_type),
create_segment(name, description?), update_user(user_id, attributes),
run_analytics_query(spec), start_analytics_export(spec),
download_export_job(job_id, ...), call_account_tool(name, arguments),
and so on, one per CLI command. There is no "run a CLI command" tool: every
tool has a name, a description, and a JSON schema an agent can read without
knowing the CLI syntax. Run appcues mcp and send a tools/list request,
or look at the tool list in your MCP client, for the full catalog.
Credentials, account, and environment come from the active profile exactly
as for the CLI (--profile, --account, and the APPCUES_* variables all
apply), so the MCP client never sees or handles the API key.
Register it for yourself in the current project only (stored in your own Claude Code config, not in the repo):
claude mcp add appcues -- appcues mcpRegister it for every project on this machine:
claude mcp add -s user appcues -- appcues mcpRegister it for everyone who clones a repo, written to that repo's
.mcp.json so it is versioned:
claude mcp add -s project appcues -- appcues mcpAny of these accepts CLI flags before mcp, e.g. --profile eu to pin
a profile or --dry-run for a preview-only server:
claude mcp add appcues -- appcues --profile eu mcpAny other MCP client takes the equivalent stdio configuration. For Codex,
add to ~/.codex/config.toml:
[mcp_servers.appcues]
command = "appcues"
args = ["mcp"]For clients that read a JSON mcpServers map (Claude Desktop, Cursor, and
most others):
{
"mcpServers": {
"appcues": {
"command": "appcues",
"args": ["mcp"]
}
}
}Behavior worth knowing:
- Read tools return the API's JSON verbatim, except the two composites
(
flow_performance_digest,compare_analytics_periods), which return the CLI's computed envelope, andcall_account_tool, which forwards the account tool's own content blocks (text, images). Write tools return a one-line confirmation, the same text the CLI prints. - A failure comes back as an MCP
isErrorresult whose text is the CLI's one-line JSON error (type,status,message,body,rate_limit), so an agent can tell a bad argument (api, 4xx) from a credentials problem (auth,config) from a transient one (rate_limited,server) and act accordingly. Secrets never appear in tool results. - Interactive confirmation is always off in MCP mode, whatever the profile
says: stdin is the protocol channel, there is nobody to answer a prompt.
Safety comes from the API key's permissions and from the MCP client's own
tool approval, guided by the
readOnlyHintanddestructiveHintannotations each tool carries. appcues --dry-run mcpserves a preview-only server: every write tool returns the request it would have sent and sends nothing. Handy for trying an agent against a production profile.- The job-polling tools (
wait_for_export_job,download_export_job) default to a 120 second timeout because MCP clients time out long calls; passtimeout_secsor call again.download_screenshotsanddownload_export_jobwrite files inside the server's working directory (the MCP client's, usually) and return the path; anout_paththat is absolute, contains.., or passes through a symlink leaving that directory is rejected. - stdout carries only protocol messages; the rate-limit and truncation lines the CLI emits go to stderr as before, where MCP clients log them.
These work before or after the subcommand:
| Flag | Description |
|---|---|
--profile <PROFILE> |
Config profile to use (env: APPCUES_PROFILE, default: default) |
--account <ACCOUNT> |
Override the profile's account ID |
-o, --output <table|json> |
Output format (default: table) |
--dry-run |
Print write requests instead of sending them |
-i, --interactive |
Prompt y/N before destructive commands (default: run without asking) |
Use -o json for scripting: list and get commands emit structured JSON
on success, so you can pipe output to jq or another tool instead of
parsing table text. Mutation commands (publish/unpublish, segments delete/add-users/remove-users, users delete/track, groups add-users, status) print a confirmation message regardless of
-o, since there's no structured result to return. For list commands,
-o json prints the bare array — if the API response wraps it in an
object, the CLI unwraps it for you.
appcues flows list -o json | jq '.[] | select(.published) | .id'Credentials live in ~/.config/appcues/config.toml, one profile per
section:
[profiles.default] # prod: nothing else to specify
api_key = "test-key"
api_secret = "test-secret"
account_id = "12345"
[profiles.eu]
api_key = "test-key-eu"
api_secret = "test-secret-eu"
account_id = "67890"
env = "prod-eu"
[profiles.local] # any explicit URL wins over env
api_key = "test-key"
api_secret = "test-secret"
account_id = "13579"
base_url = "http://localhost:4000"appcues profiles add eu writes to a named profile; the config file
is written with 0600 permissions. Change a saved profile with appcues profiles edit eu --account-id 99999 (omitted flags keep their
value), and delete one with appcues profiles remove eu. Select a
profile per-invocation with --profile eu or the APPCUES_PROFILE
env var.
Any of these override the active profile's file values, field by field — handy for CI, where you don't want a config file on disk at all:
| Variable | Overrides |
|---|---|
APPCUES_API_KEY |
api_key |
APPCUES_API_SECRET |
api_secret |
APPCUES_ACCOUNT_ID |
account_id |
APPCUES_ENV |
env (named environment, see below) |
APPCUES_BASE_URL |
base_url (explicit API origin) |
APPCUES_TOOLS_BASE_URL |
tools_base_url (explicit tools origin) |
APPCUES_INTERACTIVE |
interactive (y/N prompts before destructive commands) |
APPCUES_PROFILE |
which profile to load |
If the credential variables are set, no config file is needed at all:
export APPCUES_API_KEY=test-key
export APPCUES_API_SECRET=test-secret
export APPCUES_ACCOUNT_ID=12345
appcues flows list -o jsonThe API origin resolves as: APPCUES_BASE_URL env var > the profile's
base_url > APPCUES_ENV env var > the profile's named env > prod.
The named environments:
env |
API URL | Tools URL |
|---|---|---|
prod (default) |
https://api.appcues.com |
https://mcp.appcues.com |
prod-eu |
https://api.eu.appcues.com |
https://mcp.eu.appcues.com |
Set the environment per profile at profiles add time (--env prod-eu), or
per invocation with APPCUES_ENV=prod-eu. base_url and tools_base_url
are the escape hatch for anything the CLI doesn't know by name (a local api,
a proxy, a non-production stack). Note that credentials are per environment:
a US key will not work against the EU API.
appcues status prints the URL it verified against, so you can always
see where a profile points.
Requests that hit a 429 or 5xx are retried with backoff automatically.
Destructive commands (delete, remove, non-read-only tools call) run
without asking by default: the CLI is built for agents and scripts, and
safety is delegated to the API key's permissions. Humans who want a y/N
prompt before those commands opt in, in one of three ways:
- per invocation:
appcues -i segments delete s1 - per profile:
interactive = trueunder the profile in~/.config/appcues/config.toml(set by hand; new profiles start non-interactive) - per environment:
APPCUES_INTERACTIVE=true(overrides the profile field;APPCUES_INTERACTIVE=falseforces a profile'strueoff)
The -i flag turns prompting on for that invocation and wins over the
profile. Interactive mode requires a terminal: with prompting on and
stdin not a tty, the command refuses with a config error (exit 3) instead
of hanging, since there is nobody to answer.
Failures print exactly one JSON line to stderr and exit with a typed code, so scripts and agents can branch without parsing prose:
{"error":true,"type":"api","status":404,"message":"API error 404: flow not found","exit_code":4}| Exit code | type |
Meaning |
|---|---|---|
| 0 | — | success |
| 1 | unexpected |
network/IO/other unexpected failure |
| 2 | — | command-line usage error (from clap, prose on stderr) |
| 3 | config, auth |
missing/invalid credentials or profile; HTTP 401/403 |
| 4 | api, tool |
the API rejected the request (other 4xx), or a tool ran and reported failure (isError) |
| 5 | rate_limited, server |
429 or 5xx still failing after retries |
This is the v1 command set. Planned next, roughly in order: the remaining
jobs subcommand (list), bulk import/export, ingestion rules, and SDK
key management.
Planned install channels beyond the release tarballs:
- Installers via cargo-dist: shell and Homebrew installers plus a Windows target.
- Homebrew tap:
brew install appcues/tap/appcues. - npm shim:
npm install -g @appcues/cli, a thin package that downloads the platform binary, for agents and JS toolchains that already havenpx.
The crate lives in crates/appcues/, and scripts/dev runs cargo from
there so you can work from the repo root:
scripts/dev test
scripts/dev build
scripts/dev install
scripts/dev run flows listtest is the CI gate (cargo fmt --check, cargo clippy --all-targets -- -D warnings,
cargo test). build produces crates/appcues/target/release/appcues;
install puts the binary on your PATH via cargo install.
docs/best-practices.md collects this repo's engineering practices; follow
it while building and as a self-review pass before opening a PR.
See CHANGELOG.md for release history.