--- name: brevo-cli description: "Use when working in a project that uses the Brevo Developer CLI (the `brevo` binary from `@getbrevo/cli`) — managing OAuth apps, UI apps, and Brevo Functions, scaffolding integrations, running the local OAuth test server, or invoking any `brevo …` command. Activates on: brevo, brevo cli, brevo app, brevo function, app-config.json, OAuth Brevo, BREVO_API_KEY, getbrevo." compatibility: "Needs a terminal: it runs shell commands, so it works in coding agents (Claude Code, Codex, Cursor, Gemini CLI, Antigravity), not in chat-only apps." metadata: version: 1.2.0 status: public author: brevo --- # Brevo CLI This project uses the Brevo Developer CLI to create and manage Brevo applications (OAuth apps, UI apps, and Brevo Functions). Treat the `brevo` binary as the canonical entry point — don't shell out to `curl https://api.brevo.com/...` for things the CLI already covers. ## `brevo --help` is the source of truth `brevo --help` and `brevo app --help` list everything the installed binary can do. Treat that as the complete surface and build from it — **the version in front of you, not the version you remember.** Every command this file documents ships in every published build. Nothing is held back behind a flag, an env var, or an account setting. So when a documented command is missing, there is exactly one explanation left: **the user's install is older than this file.** (It was not always so. Public app distribution and the review lifecycle were eliminated from published builds until they went GA, and this section used to exist to stop agents recommending them. That gate is gone.) What this means in practice: - **Don't act on a command you remember rather than one you can see.** Your recollection may be a newer release than the user has, or an older one. `--help` in the current session is the only reliable source. - **`unknown command` plus exit `1` means "upgrade", not "retry".** Don't hunt for an alternative route and don't tell the user to request access — there is no access to request. Check `brevo --version` against the skill version (see *Before starting a new session*), and if they're behind, say so and point at `npm install -g @getbrevo/cli`. - **A flag value can be refused even when its command exists.** `--distribution public` on an install that predates public-apps GA is refused with *"That command is not available yet. It is part of a Brevo feature that has not been released."* — a flag has to parse before it can be rejected, so this is what an older binary says instead of `unknown option`. It reads like a permissions error and is not one: it means upgrade. Same for the *UI app* choice, absent from the app-type prompt on an older install. The Brevo API enforces its own boundaries independently, so nothing is gained by routing around the CLI — a hand-rolled `curl` hits the platform's own refusal. > **Reading this from the repo rather than from your agent's skills directory?** Install it with `brevo skill install brevo-cli --agent ` so your agent loads it natively and the CLI keeps it current: `claude` (the default) writes `~/.claude/skills/brevo-cli/`, `codex` and `gemini` write `~/.agents/skills/brevo-cli/` (read by Codex and Gemini CLI), `all` writes both. `brevo skill install` with no name installs every Brevo skill from the catalog. Agents that don't load skills from a directory (Claude Desktop **chat**, Copilot CLI) should read `agent-context/AGENTS.md` instead; it covers the same surface in less depth. ## Before starting a new session Run two preflight checks before any other work. ### 1. Is `brevo` installed? Run `brevo --version`. If you get `command not found` (or any "no such binary" error), the CLI isn't installed. **Stop and tell the user to install it:** ```bash npm install -g @getbrevo/cli # or yarn global add @getbrevo/cli ``` Don't fall back to raw HTTP against `api.brevo.com` — the `brevo` binary is the canonical entry point. Only continue once `brevo --version` returns a version string. ### 2. Is this skill up to date? * [ ] Run `brevo skill check`. Exit code `0` means every installed Brevo skill, this one included, is current and unmodified. * [ ] If it exits `1`, **stop and tell the user to update** — `brevo skill update` reinstalls what is behind the catalog (any `brevo` command prints an update notice once the catalog cache is older than 12 hours, but changes nothing). Do not proceed with stale guidance. * [ ] Offline, or on a copy installed before `brevo skill check` existed: read the `.brevo-skill.json` next to this file (`~/.claude/skills/brevo-cli/` or `~/.agents/skills/brevo-cli/`). A `source` of `brevo-cli` means the bundled copy, whose `version` must equal `brevo --version`; a `source` of `brevo-skills` means a catalog install, whose `version` is the skill's own and is checked by `brevo skill check`. ## Decision tree - "Set me up from scratch" → `brevo app init` - "Install / update the Brevo agent skills" → `brevo skill install` (all) or `brevo skill install `, `brevo skill update`, `brevo skill list`, `brevo skill check` (exit `1` when a skill is outdated or modified), `brevo skill uninstall`; `--agent claude,codex,gemini|all`, `--pin `, `--mcp`, `--force`, `--dry-run`, `--json` - "Authenticate" → `brevo login` (or `BREVO_API_KEY=xkeysib-... brevo login` for CI) - "Who am I logged in as?" → `brevo whoami --json` - "Show / pick an app" → `brevo app list --json` - "Show only apps of one kind" → `brevo app list --type --json`. Filtered server-side. The values are the CLI's own spellings, not the wire's — `ui`, not `ui_app`; `function`, not `brevo_function` — and an unknown one is refused with the valid values listed, before any request. **`--type oauth` includes M2M apps** (verified — it covers both OAuth flows), so `--type m2m` is what isolates them; they also report `appType: "oauth"` in `--json`, since M2M is a flow within the OAuth type. **The four values are the server's classification, not a partition** — in practice an app can come back under two of them, and some come back under none — so `brevo app list` with no `--type` stays the complete view. Use `--type` to narrow a listing, never to enumerate an account by running all four. - "Create an app" → `brevo app create --name "" --distribution --redirect-uri --json` (new apps default to scopes `contacts:read`, `contacts:write`, `crm:read`, `crm:write`). There is no logo input at creation time — set `logo_uri` in `app-config.json` and run `brevo app upload` afterward. **`private` for apps used exclusively by the user's own organisation, `public` for apps distributed to end users or marketplace listings; default to `private` when the user hasn't said which.** `distribution_type` is **immutable after create** — `brevo app upload` refuses to change it, so getting this wrong means creating a new app. Only a `public` app can be submitted for review (see *Publication & review*). **A public app requires agreement to the Brevo Developer Terms** (https://developers.brevo.com/docs/apps-developer-terms): interactively the CLI prints the terms notice, **opens the terms in the browser automatically** (printing the URL; a headless box gets a "use the link above" fallback instead of an error), and asks *"Do you confirm that you have read and agree to the Brevo Developer Terms?"* (y/N, default No; declining cancels the create with exit `0` and nothing is sent); non-interactively (`--json` or piped) a `--distribution public` create is **refused before any prompt or request unless `--accept-developer-terms` is passed** — that flag is explicit consent and also skips the question in an interactive run. Private apps never see this. **Fails immediately if run from a directory that already has `app-config.json`** — `cd` elsewhere first, or use `brevo app scaffold` in that directory instead. Otherwise resolves (creates/`cd`s into) its target directory, creates the app, and writes the **basic project structure** (`app-config.json` + `.gitignore`/`AGENTS.md`/`CLAUDE.md`/`README.md`). Interactively, a **private** OAuth app is also asked *"Which OAuth flow does this app use?"* — *Consent Based* (the default flow everything above describes) or *Machine to Machine*; see the M2M entry below. It scaffolds a feature (the OAuth test server) **only** when the interactive confirm (*"Scaffold the Test OAuth App?"*, default yes) is answered yes — there is no separate "which feature?" question while the CLI ships one; **non-interactive runs (`--json` or piped) stay base-only** — run `brevo app scaffold` afterward to add the OAuth code. Under `--json`, the response's `directory` field is where it landed and `scaffolded` is the base file count; check for `scaffold_skipped` (also `scaffoldSkipped`) instead of `scaffolded` if that directory already existed (both directory setup and scaffolding are skipped together in that case, but the app is still created). - "Create a UI app" (an action link that renders inside Brevo CRM records) → run `brevo app create` **interactively** and pick *UI app* at the *"What type of app are you building?"* prompt, **or non-interactively** with `--ui-app --record-page --placement --label --url [--more-info ]`, or `--ui-config ` (a JSON file: `{ extension_type, record_page, surface_point_name, label, more_info?, redirect_link }`). There is still **no `--type` flag on `brevo app create`** (`brevo app list --type` is a filter, not a creator) — a plain non-interactive run with none of `--ui-config`, `--ui-app` or `--m2m` creates a consent-based OAuth app, exactly as before. Both non-interactive routes only support `extension_type: "actionLink"` today; an unknown `--record-page`/`--placement` fails with the valid options listed in the error. Interactively, the flow asks for the integration type, one record page, one placement on it, a label, optional supporting text, and the destination URL — every route authors exactly **one** placement; add more by hand as further `surface_point_list` entries in `app-config.json` (each with its own `label` and `redirect_link`), then `brevo app upload`. - "Create a machine-to-machine (M2M) app" (a server-to-server integration using the `client_credentials` grant — no Brevo user, no callback) → `brevo app create --name "" --distribution private --m2m --scopes "contacts:read,crm:read" --json`, or run `brevo app create` interactively and pick *Machine to Machine* at the *"Which OAuth flow does this app use?"* prompt — which then offers a multi-select over the live IdP scope catalog (see *Scopes* below) rather than asking for a typed list. **M2M apps do not support scaffolding**: the command creates the app, prints the credentials, and stops — **no directory and no `app-config.json` are written**, so `brevo app upload`, `brevo app scaffold` and `brevo app start` do not apply to an M2M app, and its `--json` output has no `directory`, `scaffolded` or `redirectUri` field (it carries `authType: "m2m"` and `scopes` instead; `appType` stays `"oauth"`). **Scopes must be named at creation** — there is no config file to edit afterwards — but they are no longer permanent: change them later with `brevo app scopes update` (see below). M2M is **private only**, and `--scopes` is required with `--m2m`; combining `--m2m` with `--redirect-uri`, `--ui-app`, `--ui-config` or `--distribution public` is refused before anything is created. Retrieve the client ID later with `brevo app credentials --app-id --json`; the **secret** needs `--reveal-secret` in an interactive terminal (off a TTY it returns the literal `[hidden]` placeholder — see the `app credentials` entry below). - "Create a Function app" (a serverless function running on Brevo's infrastructure) → run `brevo app create` **interactively** and pick *Brevo Function* at the *"What type of app are you building?"* prompt. Private distribution only. Non-interactive runs (`--json` or piped) create an OAuth app as before — there is no `--type` flag on `brevo app create` for Function apps (`brevo app list --type function` filters the listing; it does not create one). The created `app-config.json` carries `brevo_function: {}` (a static discriminator, not authored content) and `app_type: "function"` (informational). A Function app has no `auth` block and no `ui_app` block. - "Create a new function" → `brevo function init` (alias `brevo fn init`). **Interactive only** — refused under `--json` or piped input. Prompts for the parent Function app, then offers AI generation or template selection to create the function. Once generated, the function can be iterated on, previewed, and deployed. - "Deploy a draft function" → `brevo function deploy --id [--app-id ] --json`. Deploys a draft function to production. `--app-id` links the deployed function to an app. Interactively, omitting `--id` shows a draft picker; under `--json` or off a TTY, `--id` is required. Omitting `--app-id` interactively shows an app picker (Function apps only). - "List functions" → `brevo function list --json` (add `--draft` for draft functions only). Lists all deployed Brevo Functions in the account, or drafts with `--draft`. - "Show function details" → `brevo function get --id --json`. Interactively, omitting `--id` shows a picker; under `--json` or off a TTY, `--id` is required. - "Activate a function" → `brevo function activate --id --json`. Same picker behavior as `get`. - "Deactivate a function" → `brevo function deactivate --id --json`. Same picker behavior as `get`. - "Delete a function" → `brevo function delete --id --force --json`. Same picker behavior as `get`. `--force` skips the confirmation prompt. - "Install a UI app into an account" → `brevo app install [account-id] --app-id --force --json`. **UI apps only** — an OAuth app has nothing to install (it becomes usable when a user authorizes it) and the CLI refuses with exit `1`. The `[account-id]` positional is optional: omitted, a plain account installs into itself (no prompt, so `--json`/CI works) and a corporate account picks a sub-account interactively (non-interactive corporate runs must pass it explicitly). The app must have been validated with `brevo app upload` first — installing before an upload is refused locally. Interactively, omitting `--app-id` outside a linked project opens an app picker that lists **only UI apps**; with no UI app to offer, the command errors (exit `1`) naming `brevo app create`. That picker needs a terminal: under `--json` or off a TTY, omitting `--app-id` outside a linked project is refused with exit `1` — so always pass `--app-id` when scripting from an unlinked directory. Before it acts, `install` prints the configuration it will install **as stored on the server** — app ID, name, `version`, extension type and every placement — because that is what the account will render, not whatever the local `app-config.json` now says; under `--json` the same facts come back as `version` and `ui_app` on the result. If the linked project's `ui_app` block has drifted from the stored one, it warns and names `brevo app upload`, then installs anyway (exit `0`) — the stored configuration is a legitimate thing to install, so this is a notice, not a refusal. - "Uninstall a UI app from an account" → `brevo app uninstall [account-id] --app-id --force --json`. Same target resolution as `install`. Uninstalling an app that isn't installed is **informational, exit `0`** — not an error. - "Update app metadata" → edit the relevant field(s) in `app-config.json` (`app_name`, `auth.redirect_uris`, `auth.scopes`, `logo_uri`, `version`) — every key in the file is snake_case (older projects may still carry the camelCase spellings `appId` / `appName` / `logoUri` / `appType` / `auth.redirectUris`, or the even older `auth.redirectUrls`; the CLI reads all of them and rewrites the file with the snake_case keys on its next write, including a `brevo app upload` or `brevo app scaffold` run that otherwise has nothing to change), then run `brevo app upload --json` (no `--app-id`/`--name`/`--redirect-uri`/`--scope`/`--logo-uri` flags exist — `upload` always pushes the whole file, resolved only from cwd's `app-config.json`). **`distribution_type` is immutable** — it's set at `app create` time and cannot be changed via `upload`; if the local value differs from the server, `upload` errors and tells you to restore it (create a new app to get a different distribution). For a **UI app**, `upload` is also the way to change what an installed app renders — there is no re-install and no publish step: the diff prints every `ui_app` placement field by field (`before → after`, plus `(new)` / `(removed)` placements), then warns that the app may already be installed in Brevo accounts and asks *"Proceed with upload and update every account this app is installed in?"*. `--yes` skips the question but still prints the warning; `--json` prints neither and stays a single parseable document. **There is no `brevo app update`** — it was removed, with no shim and no flag-for-flag equivalent; if you find it in a user's script, CI job, README, or your own recollection, replace it with the edit-then-`upload` flow above. Invoking it — with any of the old flags, with `--help`, or as `brevo app help update` — prints a message naming `brevo app upload` and exits `1` **without uploading anything**, so a `1` from `brevo app update` means the command is gone, not that an upload failed. - "Get client credentials" → `brevo app credentials --app-id --json` (add `--reveal-secret` to print the secret — interactive terminals only, see below). **`--reveal-secret` only works in an interactive terminal.** It prompts for a confirmation before printing, and off a TTY — which is every agent shell, every piped run and every CI job — it is a **no-op**: the command prints a notice and returns the literal placeholder `[hidden]` (`clientSecret: "[hidden]"` under `--json`; `[hidden — run …]` in human output). **Never treat `[hidden]` as a secret value** — do not store it, echo it, or write it into a `.env`; it is a placeholder, not a short secret. To get the real secret, ask the user to run `brevo app credentials --app-id --reveal-secret` themselves in a terminal and confirm the prompt. **`--app-id` is not optional here** — without it the command wants an interactive app picker, and under `--json`/off a TTY it refuses with exit `1` rather than prompting. **OAuth apps only** — a UI app has no OAuth credentials (no client ID, secret, scopes, or callbacks), so the command refuses it with exit `1` and points at `brevo app list` for the app's type. - "Set up a project for an app that already exists" → `brevo app scaffold --app-id ` (`brevo app list` gives the IDs). Fetches the app and writes `app-config.json` + the base files, then adds a feature as below. Interactively it asks which directory to write into first (default `./`); under `--json`/off a TTY it uses the current one. **This is the only way to get an `app-config.json` for an existing app** — `brevo app create` makes a *new* app, and `brevo app upload` only ever reads the project in the current directory. It refuses if the directory is already linked to a *different* app; pointing it at the app the directory is already linked to is a no-op. **Interactively you can omit `--app-id`**: plain `brevo app scaffold` in a directory with no `app-config.json` says so, asks *"Set up a project for an app you already have?"* (default yes), and on yes lists the account's apps so you can pick one — no need to look the ID up first. **Every interactive bootstrap (picker or `--app-id`) then asks `Output directory:`, defaulted to `./`** — the same question `brevo app create` asks — creates that directory and `cd`s into it, so the files don't land in whatever folder you happened to be standing in; answer `.` to use the current directory instead. It then writes the project (`app-config.json` + the base files), shows what it wrote, and asks *"Scaffold the Test OAuth App?"* (default yes) — declining is normal and leaves the project in place, exit `0`. The *Next steps* box opens with `cd `, because the CLI can only move its own process, never your shell. **Under `--json` or off a TTY there is no directory question and the files go into the current directory**, so scripted `brevo app scaffold --app-id ` runs are unchanged — `mkdir` and `cd` yourself first if you want them somewhere specific. Answering **no** is a normal outcome, not an error: it exits `0` after printing the remaining routes (`brevo app create` here, or `cd` into an existing project). **Always pass `--app-id` when scripting** — the offer needs a terminal, so under `--json` or off a TTY the command errors instead of prompting. **If the directory you point it at already holds a project, the bootstrap is a refresh, not a fresh write**: the config found there is diffed against the server and rewritten only on consent (*"…will update app-config.json to match the server. Continue?"*, default yes) — answering **Merge** at the directory question does not suppress that, because merging keeps the file that exists and so would skip the very file a bootstrap is for. No drift means `app-config.json` is left alone with a one-line notice, and the feature still gets added. A directory holding a project for a **different** app is refused outright, naming both apps. Two refusals apply to both forms, before any write: that different-app case, and the directory must not be **inside** an existing project (a nested second `app-config.json` would make a later `brevo app upload` push the wrong app). - "Add a feature (e.g. the OAuth test server) to an existing project" → `brevo app scaffold` (run **inside** the project directory; it reads the linked app from `app-config.json`, so `--app-id` is only needed to bootstrap a directory that has none). Not needed right after `app create` if you already accepted the feature prompt there. If feature files already exist it prompts Overwrite / Merge / Cancel (default Merge); pass `--overwrite` to force a full overwrite without prompting. **The scaffolded OAuth flow depends on the app's distribution:** a **private** app gets the confidential-client flow — `/auth/callback` authenticates the token exchange with the `CLIENT_SECRET` written into the scaffolded `.env.local`. A **public** app gets Authorization Code + **PKCE** (RFC 7636) instead: `/auth/login` generates a `code_verifier` and sends `code_challenge` + `code_challenge_method=S256`, the exchange and refresh send the verifier with **no client secret**, and the generated `.env.local` / `.env.example` carry none. So don't tell a public-app developer to fetch their client secret — they don't need it and the scaffold won't read it. Either way it is the **consent-based** flow — an M2M app has no project to scaffold into, so this command does not apply to one. - "Run the OAuth test server" → `brevo app start oauth --port 3009` (must be inside the scaffolded directory) - "Delete an app" → `brevo app delete --app-id --force`. **`--app-id` is not optional here either** — omitting it means an interactive picker, which under `--json`/off a TTY is refused with exit `1`. Never script a delete without naming the app. `--force` skips the prompt but still prints the install-loss warning line; under `--json` stdout stays JSON only. - "List supported OAuth scopes" → `brevo app available-scopes --json` - "Change an M2M app's scopes" → `brevo app scopes update --app-id --scopes "" --yes --json`. **M2M apps only** — refused for a consent-based OAuth, UI, or Function app. **`--scopes` is always the full desired scope set, not a delta** — interactively (omitting `--scopes`), the picker/typed prompt opens pre-filled with the app's *current* scopes so you edit a complete list rather than retype it; non-interactively, whatever you pass in `--scopes` becomes the app's exact new scope list, so include every scope you want kept, not just the ones you're adding. Prints a current → new diff (flagging any scope that would be **removed**) and asks to confirm unless `--yes` is passed — **under `--json`, or in any run without an interactive terminal, the prompt can't be shown, so `--yes` is required there: without it the command is refused (exit `1`) before anything is sent**; a request that changes nothing is a no-op (exit `0`, nothing sent) even without `--yes`. `--app-id` is not optional when scripting, same convention as `app delete`/`app credentials`. - "Mint an M2M access token" → `brevo app token --app-id --json`. **M2M apps only** — refused for a consent-based OAuth, UI, or Function app. Prints the Bearer `access_token` and `expires_in`; add `--scope ""` to request a subset of the app's granted scopes (omit to request the full granted set). The token is short-lived by design and printed in full — it is never written to `~/.brevo/credentials.json`. `--app-id` is not optional when scripting, same convention as `app delete`/`app credentials`. - "Rotate an M2M app's client secret" → `brevo app secret rotate --app-id --yes --json`. **M2M apps only** — refused for a consent-based OAuth, UI, or Function app. This immediately invalidates the app's current secret — any caller still using it starts failing right away — so it asks to confirm unless `--yes` is passed, and **under `--json`, or in any run without an interactive terminal, the prompt can't be shown, so `--yes` is required there: without it the command is refused (exit `1`) before anything rotates**. Prints the **new** `client_secret` once, in full (unlike `app credentials`, there is no `--reveal-secret` gate here — the whole point of the command is to hand back the new secret, including for a non-interactive rotation script). Tell the user to store it in a secret manager immediately — this output is the one place a non-interactive run ever sees the secret in full. It stays retrievable afterward with `brevo app credentials --app-id --reveal-secret`, but **only from an interactive terminal**: off a TTY that command returns the literal `[hidden]` placeholder, never the secret (see the `app credentials` entry above). `--app-id` is not optional when scripting, same convention as `app delete`/`app credentials`. - "Check an app's review status" → `brevo app status --app-id --json` (read-only; returns `{ state, message }`, `state` ∈ `draft`/`submitted`/`in_review`/`approved`/`rejected`/`changes_requested`, or `unknown` when the server returns no state. Reviewer feedback comes by email, not here.) `--app-id` is optional — it falls back to the linked `app-config.json`, then to an interactive picker. - "Submit a public app for review" → `brevo app submit --app-id --json --accept-submission-terms` (prints the submission form URL as `{"app_id","form_url"}` without opening a browser; without `--json` it shows the full app definition, then opens the form in the user's browser). **The form hand-off requires confirming the Submission Process statement** (you have read and agree to the Brevo Developer Terms, the submission is accurate and complete, the app complies with the terms and applicable laws, and activation is not a review/certification/endorsement): interactively the CLI prints the statement and asks *"Do you confirm the above statement?"* (y/N, **default No** — this one question is both the legal affirmation and the submit confirmation; declining cancels with exit `0`); non-interactively (`--json` or piped stdin) the run is **refused with exit `1` before the form URL is printed or opened unless `--accept-submission-terms` is passed** — that flag is explicit consent and also skips the question in an interactive run. The gate fires only once the form URL is known to exist, so every other refusal below still comes first with its own message. Before any of that it runs a status preflight (the same review-state read as `brevo app status`) and aborts if that read fails. The app's `distribution_type` must be `public`, and when `app-config.json` describes the target app it must match the server — if the command reports drift, either update the local config with the server values or push local changes with `brevo app upload`. **The app is only actually submitted once the Google Form is completed and submitted; the command itself changes nothing server-side** — so exit `0` here does not mean "submitted". See *Publication & review*. - "Withdraw an app from submission" → `brevo app withdraw --app-id --force` (omit `--app-id` inside a scaffolded project to use the app pinned in `app-config.json`; if the app was never submitted, it prints a hint to submit first and exits `0` — not an error) - "Sign out" → `brevo logout --force` ## Hard rules - **`app-config.json` keys are snake_case (`app_id`, `app_name`, `logo_uri`, `app_type`, `version`, `distribution_type`, `auth.scopes`, `auth.redirect_uris`, `ui_app`, `brevo_function`).** Earlier CLI releases wrote `appId` / `appName` / `logoUri` / `appType` / `auth.redirectUris`. The CLI still reads those, but every write — including a `brevo app upload` or `brevo app scaffold` that has nothing else to change — rewrites the file with the snake_case keys and prints a one-line notice. **If the user's own scripts, CI steps or code read `app-config.json`, tell them to switch to the snake_case names**; do not write the camelCase names into a new or edited file. Commands' `--json` output is a separate contract and is unchanged (`brevo app create --json` still returns `appId`). **`app_type` is a label, not the discriminator** — `brevo app create` / `brevo app scaffold` write it (`"oauth"`, `"ui"` or `"function"`) so the file says in one word what it is, but the presence of the `ui_app` / `brevo_function` / `auth` blocks is what actually decides the type. It is never sent to Brevo — note `brevo app create` *does* send a top-level `app_type` on its API request, but that is a different field with different values (`oauth.consent` / `oauth.m2m` / `ui_app.actionLink` / `brevo_function`), derived by the CLI from the app's own blocks; nothing reads the file's label to build it, and `brevo app upload` sends no `app_type` at all. `brevo app upload` checks the two agree and refuses a config whose label contradicts its blocks, so if you add or remove a `ui_app` block by hand, update `app_type` to match — or delete it, since it is optional and configs written by older releases have no such key. - **`--json` output keys come in both spellings during a transition; read the snake_case one.** Every `--json` document (and each element of an array document) carries each camelCase key together with its snake_case twin — `appId` and `app_id`, `clientId` and `client_id`, `upToDate` and `up_to_date`, and in the error envelope `exitCode` / `exit_code`, `statusCode` / `status_code`. `app create --json`'s `redirectUri` is twinned as `redirect_uris` (the wire name). Nested objects (`ui_app`, `current` / `next`, Function records) are not aliased — they were already snake_case. **Prefer the snake_case key in anything new you write**; the camelCase spellings are deprecated and will be removed in the next major release. Existing scripts that read `appId` keep working until then. 1. **Always pass `--json`** when you intend to parse output. Every command supports it, **on success and on failure alike** — a failing `--json` run writes a single `{"error": {...}}` document to stdout (see *JSON errors* below), so you can read the reason instead of only seeing a non-zero exit. 2. **Never print, log, or commit** API keys (`xkeysib-…`), client secrets, refresh tokens, or contents of `~/.brevo/credentials.json` / `.env.local`. Redact before sharing diagnostics. 3. **Don't use `--api-key`** — the flag was removed. Use the `BREVO_API_KEY` env var. 4. **`brevo app create` refuses to run inside an already-linked directory** (`app-config.json` present) — `cd` elsewhere or use `brevo app scaffold` there instead. **`brevo app scaffold` requires an `app-config.json` in the current directory unless you pass `--app-id` or answer its bootstrap offer** (it adds a feature to an already-created project); with none present and no `--app-id` it offers to set the directory up for an existing app when interactive, and otherwise errors, listing the three ways out (`cd` into a project, `--app-id` to set this directory up for an app you already have, or `brevo app create` for a new one). With `--app-id ` in a directory that has no config it fetches that app, asks (interactively only) which directory to write into — default `./`, `.` to stay put — writes `app-config.json` + the base files there, and continues; that is the migration path off the removed `brevo app update --app-id`, and off a TTY or under `--json` it writes into the current directory with no prompt. If the directory it is told to write into already holds a project for the **same** app, it diffs that config against the server and rewrites it only on consent — the directory prompt's **Merge** answer does not silently skip the refresh, and no drift leaves `app-config.json` untouched with a notice. It refuses (before any network call) if the directory is already linked to a *different* app — whether that is the directory the command ran in or the one it was pointed at — or if the directory is **inside** an existing app project (a nested second `app-config.json` would make a later `brevo app upload` from there push the wrong app — `cd` to the project root or outside it). It reads the linked app from that config, diffs the config against the server, and if fields drifted it tells you and (on consent) rewrites `app-config.json` to match before writing the feature files. When any feature file already exists it prompts **Overwrite / Merge / Cancel** (default **Merge** — existing, e.g. hand-edited, files are kept; Cancel aborts). Pass `--overwrite` to force a full overwrite and skip that prompt (works interactively and under `--json`). **Under `--json` it never prompts**: a config diff comes back as `{ "cancelled": true, "reason": "...", "diffs": [...] }`; otherwise it scaffolds the feature (merging existing files unless `--overwrite` is passed) and returns `{ "scaffolded": , "directory": "..." }`. 5. **Prefer flag-driven over interactive** in agent contexts: `--name`, `--app-id`, `--force`, `--yes` so the command doesn't block on prompts. 6. **Write only the `app-config.json` keys this file documents.** `brevo app upload` validates the whole file and rejects keys it doesn't recognise, so an invented block fails at upload rather than doing anything useful. 7. **Never mix app types in one `app-config.json`.** The presence of `ui_app` or `brevo_function` is the app-type discriminator: an OAuth app has a populated `auth` block and no `ui_app` or `brevo_function`; a UI app has a `ui_app` block and an **empty** `auth: {}` (it has no OAuth callback, scopes, or credentials); a Function app has `brevo_function: {}` and no `auth` block. See *UI apps* and *Brevo Functions* below for each type's shape. 8. **Never report a successful `brevo app submit` as "submitted for review".** It opens a form and changes nothing server-side; the submission happens when the user completes that form. Say the form is open and what they must do next. See *Publication & review*. ## UI apps A **UI app** is an action link that renders inside Brevo CRM record pages (contact, company, deal). It is one of three app types (alongside OAuth apps and Brevo Functions; M2M is not a fourth type — it is a *flow* within the OAuth type, see the decision tree) — created by the same `brevo app create` (interactive only, see the decision tree), pushed by the same `brevo app upload`, and made visible in an account with `brevo app install`. A UI-app project is **configuration only**: there is no feature to scaffold and no `src/oauth/`, because an action link has no local server to run — `brevo app scaffold` inside one says so and exits `0`, and `brevo app start` does not apply. The base docs the scaffold writes (`AGENTS.md` / `CLAUDE.md` / `README.md`) describe whichever type the app is, so a UI app's copies document the `ui_app` block and the `upload` → `install` flow rather than an OAuth server. The `ui_app` block in `app-config.json`: - `extension_type` (root) — one of `actionLink`, `iframeExtension`, `legacyComponent` (camelCase; snake_case spellings like `action_link` are rejected). - `surface_point_list` — one entry per placement. Each entry carries: - `surface_point_name` — the placement's dot-notation slug from the platform's registry (e.g. `contactDetails.header.menu`). The valid names come from the registry — the CLI prompts from it at create time, and `brevo app upload` validates against it (an unregistered name is a 400 naming the offender). Do **not** author the extension-point name (`contactDetails.headerMenu.action`, the `..` grammar) — it is dotted too and looks right because specs quote it, but it is a different string and upload rejects it. - `label` — the menu entry's text (and the CTA button on a card). Required. - `more_info` — supporting text under the menu entry / a card's description. Optional. - `redirect_link` — the destination URL that entry opens; record context arrives as **query parameters** (the path is never templated). - `context` — optional narrowing of the record fields passed along; it can only narrow what the platform allows for that slot. - `size` — optional card size for the widget card this placement renders, e.g. `{ "width": "280px", "height": "160px" }`. `brevo app create` seeds it from the slot's registry default when the platform declares one (same mechanism as `context`) — edit or remove it freely, the entry's own value is what uploads. Each axis is a CSS length string — a positive integer with an explicit `px` unit, or `1%`–`100%` of the host slot's box (shrink-only; >100% is rejected). Both axes are optional; an omitted axis (or the whole key) stays on the host slot's default. - `modal_iframe_url` — `iframeExtension` entries only; rejected on an `actionLink`. - Do **not** write `link_target` or `extension_point_name` anywhere in the file — both are wire/server-stamped values (`app upload` injects `link_target: "_blank"` itself) and the CLI strips them from server echoes. - The old `heading`/`subheading` names are rejected with a migration hint — they are `label`/`more_info` now, and they live **per entry**, not at the `ui_app` root. Install semantics worth knowing: only UI apps install into an account; the app must have a `version` (written by a successful `brevo app upload`) before `install` will proceed; `uninstall` of a not-installed app is informational (exit `0`). **An installed UI app tracks the server's configuration, not the account's copy of it.** There is one stored snapshot per app, so `brevo app upload` changes what every account it is installed in renders, immediately and with no re-install — which is why `upload` warns and asks before pushing a UI app, and why `install` shows the stored configuration it is about to make visible. Tell a user to edit `app-config.json` and run `brevo app upload`; never tell them to uninstall and re-install to pick up a change. ## Publication & review Only a **public** OAuth app goes through review. A private app is usable by the creating organisation the moment it exists, and a UI app goes live through `upload` → `install` (see *UI apps*) with no review step at all. The route, in order: 1. `brevo app create --distribution public …` — `distribution_type` is **immutable after this point**. A private app cannot be promoted; `brevo app upload` refuses the change and tells you to create a new app. **Creating a public app requires agreeing to the Brevo Developer Terms first** — interactively a y/N question (default No, declining cancels with exit `0`), non-interactively the `--accept-developer-terms` flag; see the *Create an app* entry above. **The platform can refuse a public create, and currently does.** The CLI accepts the flag and sends the request; Brevo answers `400` and the CLI reports *"Public apps can't be created from the CLI yet — Brevo rejected this request"*, quoting the server's own text under `Brevo said:`. Verified against production. This is a **server-side policy keyed on the caller being the CLI** — not an account permission, not a CLI gate, and not something a flag, env var or account setting changes. So: don't retry, don't look for a missing scope, and don't tell the user to request access. Report that Brevo is refusing CLI-created public apps, offer `--distribution private`, and note that the review lifecycle below is unreachable until that changes. Read the `Brevo said:` line rather than assuming — it is quoted precisely so a reworded refusal is still visible. 2. `brevo app upload` — the app needs a `version`, which only a successful upload writes. An app that was never uploaded is not reviewable. 3. `brevo app submit` — shows the exact app definition being submitted, prints the Submission Process statement and asks for one y/N confirmation (default No; the answer is both the legal affirmation and the go-ahead), then opens the review form. Non-interactive runs need `--accept-submission-terms` or they are refused before the form URL appears. 4. **Complete and submit the form in the browser.** This is the step that actually submits. 5. `brevo app status` — poll for the outcome. Reviewer feedback arrives **by email**, never in this output. 6. `brevo app withdraw` — pulls a submission back. ### `brevo app submit` exiting `0` does not mean the app was submitted This is the single sharpest edge here. `submit` is a **form hand-off, not a state transition**: it opens a Google Form (the URL comes back on the app payload) and changes nothing server-side by itself. The CLI says so — *"Your app will be submitted for review only after you complete and submit the Google Form"* — and then *"You'll receive an email once your app has been reviewed."* So never report a successful `submit` as "submitted for review". Tell the user the form is open and they must complete it. Under `--json` the form URL comes back as `{"app_id","form_url"}` on stdout while those two notes go to **stderr**, so stdout stays one parseable document. Running `submit` twice is safe: the second call either returns the same `form_url` and exit `0`, or is refused with *"Review submission is currently unavailable"* and exit `1` — which is also what you get for an app already under review. Neither means the first submission was lost; check `brevo app status`. ### The six refusals, in the order they fire `submit` does its checks before opening anything, so read the first failure you get — later ones may also be true. 1. **The app is private** — *"Private apps cannot be submitted for review."* There is no fix but a new app: distribution is immutable. This fires first, before every other check, because no amount of uploading or field-fixing makes a private app submittable — any later refusal would send you fixing an app that can never be submitted. 2. **The app has never been uploaded** — *"App `` has never been uploaded, so it has no version to review."* An app's `version` is written only by a successful `brevo app upload`, and the review state lives on that version. Run `brevo app upload`, then re-run `submit`. This is checked locally, before any review-state read. 3. **The review-state read fails** (network, auth, unknown app) → aborts. `submit` runs the same read `brevo app status` does, deliberately, so a broken read never becomes a half-submission. 4. **The app isn't ready** — the state API reports `submittable: false` plus `missing_fields`. The CLI prints the server's own field keys verbatim (e.g. `logoLink`, `oauth.scopes`) with **no relabelling**, so what you see is what the API calls them. When the server names no fields, the CLI says so and points at `app-config.json` generically. Fix by editing `app-config.json` and running `brevo app upload`, then re-run `submit`. 5. **Local `app-config.json` has drifted from the server** — refused with a field-by-field diff tagging each value `(local only)` or `(server only)`. Two ways out, and the CLI names both: bring the local file in line with the server, or push the local state with `brevo app upload`. The check only runs when the local config describes *this* app — a different `--app-id` makes it irrelevant, not an error, since `submit` never writes locally. 6. **Non-interactive run without `--accept-submission-terms`** (`--json` or piped stdin) — the Submission Process statement cannot be confirmed without a terminal, so the form URL is withheld and the run exits `1` telling you to re-run with the flag. Last on purpose: it fires only once the form URL is known to exist, so an app with no form link still gets *"Review submission is currently unavailable"*, never this. ### Review states `brevo app status --json` returns `{ state, message }`. `state` is one of: | state | meaning | |---|---| | `draft` | set up, not yet submitted | | `submitted` | submitted, waiting to be picked up | | `in_review` | being reviewed | | `approved` | approved | | `rejected` | not approved — details by email | | `changes_requested` | changes needed — details by email | | `unknown` | the server returned no state (normalised sentinel) | `rejected` and `changes_requested` carry **no reason in the CLI** — don't invent one, and don't tell the user to look for it in `status`. Point them at their email. An unrecognised value renders as *"Your app is in state \"\""*, so a state the platform adds later still reads cleanly rather than erroring. ### Withdrawing `brevo app withdraw --app-id --force` pulls a submission back; `--json` returns `{ withdrawn: true, appId }`. Withdrawing an app that was **never submitted** is informational, not a failure: exit `0`, and under `--json` `{ withdrawn: false, reason: "NOT_SUBMITTED", … }` plus the command to submit it. Treat a `withdrawn: false` with that reason as "nothing to do", not an error to retry. ## Brevo Functions A **Brevo Function** is a serverless function that runs on Brevo's infrastructure. It is the third app type alongside OAuth apps and UI apps — created by `brevo app create` (interactive only, pick *Brevo Function* at the app-type prompt), and managed with the `brevo function` command group (alias `brevo fn`). The `brevo_function` block in `app-config.json`: - `brevo_function: {}` — a **static discriminator**, not authored content. Its presence marks the app as a Function app; there are no fields to write inside it. The server does not stamp additional keys onto it. - `app_type: "function"` — an informational top-level field (`'oauth' | 'ui' | 'function'`). The actual discriminator is the `brevo_function` block, not this field. - A Function app has **no `auth` block** (no OAuth callbacks, scopes, or credentials) and **no `ui_app` block**. A Function-app project is **configuration only** at the `app-config.json` level — the functions themselves are created and deployed through the `brevo function` commands, not by editing the config file. `brevo app scaffold` inside a Function-app project has no feature to scaffold (no `src/oauth/`), and `brevo app start` does not apply. ### `brevo function` subcommands All subcommands support `--json`. Commands that pick a function interactively (`get`, `activate`, `deactivate`, `delete`) show a picker when `--id` is omitted on a TTY, and refuse under `--json` or off a TTY — same convention as `brevo app credentials` and `brevo app delete`. **Always pass `--id` when scripting.** | Command | Purpose | |---|---| | `brevo function list` | List deployed functions (`--draft` for drafts, `--json`) | | `brevo function get` | Show function details (`--id `, `--json`) | | `brevo function init` | Create a new function via AI generation or template selection. **Interactive only** — refused under `--json` or piped input. Prompts for the parent Function app, creation method (AI or template), and iterates until deployed. | | `brevo function deploy` | Deploy a draft function (`--id `, `--app-id `, `--json`). Links the deployed function to an app via `--app-id`. Interactively, omitting `--id` shows a draft picker; `--app-id` omitted shows a Function-app picker. | | `brevo function activate` | Activate a function (`--id `, `--json`) | | `brevo function deactivate` | Deactivate a function (`--id `, `--json`) | | `brevo function delete` | Delete a function (`--id `, `--force`, `--json`). `--force` skips confirmation. | ## Locating the linked app If `app-config.json` exists in the working directory, it pins the app — `brevo app upload`, `brevo app start`, `brevo app status`, `brevo app submit` and `brevo app withdraw` use it automatically. All of those except `upload` accept an `--app-id` override to target a different app; `upload` does **not** — it only ever reads cwd's `app-config.json`, hard-erroring if that file is missing, invalid, or lacks `appId`. `app-config.json` carries an optional top-level `logo_uri` string. When set, `brevo app upload` pushes it as `logo_uri`; when empty / absent, the field is left untouched on the API. `app-config.json` also carries a top-level `version` string, shown by `brevo app create`/`brevo app list`. `brevo app upload` sends it on the wire as `version` (falling back to the server's current value if locally absent) and writes back whatever version the server confirms after a successful upload. `brevo app credentials` also backfills a legacy `app-config.json` toward the current shape: when the file exists in cwd and its `app_id` matches the app being inspected, any missing top-level `version` / `distribution_type` is filled in from the server (fill-only-when-missing — an existing local value is never overwritten). This runs silently in all modes; human output prints a one-line note when something was written. It's how projects that are never `upload`ed still converge. ## Scopes - New apps created via `brevo app create` default to `contacts:read`, `contacts:write`, `crm:read`, `crm:write`. The CLI prints the default set on success and points to editing `app-config.json` + `brevo app upload` for changes. - **M2M apps are the exception: they have no `app-config.json` to edit, so scopes at creation are chosen a different way, and changing them later is a different command too.** `brevo app create --m2m` requires `--scopes ""`; interactively, the CLI shows a **multi-select over the live IdP catalog** instead of asking for a typed list — the same scopes `brevo app available-scopes` prints, grouped by category with each scope's description, nothing pre-selected, and at least one required. **A whole category can be taken at once by selecting its heading row** (`Account — all 6 scopes`): that ticks every scope in the section, so the grant is on screen and any one of them can be unticked again — dropping one unticks the heading too, since the heading means "all of these". It is a shortcut, not a scope: the app is created with the ticked scope names and never with a category. inquirer's own `` still toggles everything. If the catalog cannot be reached it warns and falls back to the old comma-separated prompt. Select everything the integration needs at creation for a smooth start — but see below, this is no longer the only chance: `brevo app scopes update` changes an M2M app's scopes after the fact. - To add, remove, or change scopes on a **consent-based OAuth app**: edit `auth.scopes` in `app-config.json` directly, then run `brevo app upload`. Comma- or whitespace-separated values are normalized on read, whether they sit in a single array entry (`["crm:read", "crm:write, campaigns:read"]`) or replace the array entirely (`"crm:write, campaigns:read"`) — either becomes two scopes. To see what's currently set, run `brevo app credentials --app-id --json`. - To add, remove, or change scopes on an **M2M app** (no `app-config.json` to edit): run `brevo app scopes update --app-id ` — omitting `--scopes` opens the picker pre-selected with the app's current scopes, so ticking/unticking edits the existing grant rather than starting from nothing; or pass `--scopes ""` directly as the full new list — see the decision-tree entry above. - `brevo app available-scopes [--json] [--web]` lists the OAuth scopes the IdP currently supports. It reads a **public** catalog and works **without `brevo login`** (no API key needed). Text output groups names by category (e.g. `account`, `data_crm`, `messaging`); `--json` returns a flat `{ scopes: string[] }` of names. OIDC-reserved scopes (`openid`, `profile`, `email`, `offline_access`) and magic wildcards are excluded. The CLI validates scope **format** locally (must match `[A-Za-z0-9][A-Za-z0-9:_.-]*`) but does **not** validate that a scope is recognized by the IdP — use `app available-scopes` to confirm spelling before passing an unfamiliar scope. - Passing `--web` to `brevo app available-scopes` **also opens a browser** to a styled local page (loopback `http://127.0.0.1:/`) and stays running until Ctrl+C. Without `--web` the command exits after printing the list — TTY detection no longer triggers the browser. `--json` always suppresses the browser, so agent invocations using `--json` behave the same regardless of `--web`. ### Legacy `'all'` scope deprecation The legacy catch-all `'all'` OAuth scope is deprecated. The CLI **blocks** `brevo app upload` and `brevo app start oauth` when scopes still contain `'all'` (no escape hatch, no silent rewrite); the only mutating path that proceeds is editing `auth.scopes` in `app-config.json` and running `upload`. To handle a legacy app: 1. **Detect** `'all'` in `auth.scopes` of a local `app-config.json`, or on a remote app via `brevo app list --json` — affected apps carry `"legacy_all_scope": true` (text output appends `(legacy 'all' — deprecated)` to the scopes line). 2. **Prompt the user to pick granular scopes** — use `brevo app available-scopes --json` for the catalog, or fall back to the four defaults (`contacts:read`, `contacts:write`, `crm:read`, `crm:write`). 3. **Migrate** by editing `auth.scopes` in `app-config.json` to replace `'all'` with the granular scopes your integration uses, then run `brevo app upload` (the summary shows a "Migrating from legacy 'all' scope" line and `all (removed)`). Writing `app-config.json` for an app whose remote scopes contain `'all'` never propagates it: the file keeps the app's remaining granular scopes (or the four default scopes when `'all'` was the only scope), with a one-line substitution notice (suppressed under `--json`). This applies when `brevo app create` writes the base config, and when `brevo app scaffold` rewrites it after a detected server drift. Note the substitution is local-only — the remote app still needs the `app-config.json` edit + `brevo app upload` migration above. ## Exit codes `0` success · `1` general error · `2` aborted · `3` auth failure · `4` network · `5` not found. ## JSON errors Under `--json`, a command that fails writes **one** JSON document to stdout describing the failure, and the human-readable message still goes to stderr. The `error` key is the discriminator — no success payload has one: ```json { "error": { "name": "CliError", "message": "Not authenticated. Run: brevo login", "exitCode": 1, "exit_code": 1 } } ``` `name` is the error class (`CliError`, `ApiError`, `AuthExpiredError`, `AbortError`), `message` is the same text printed to stderr, and `exit_code` (also present as the deprecated `exitCode`) matches the process exit code. An `ApiError` adds `status_code` / `statusCode` (the HTTP status) and, when the API classified the failure, `code` — one of `AUTH_INVALID`, `AUTH_EXPIRED`, `ACCESS_DENIED`, `APP_NOT_FOUND`, `REDIRECT_INVALID`, `PORT_IN_USE`, `NETWORK_ERROR`, `RATE_LIMITED`, `APP_LIMIT_REACHED`, `REGISTRY_ERROR`, `AUTH_GATEWAY`: ```json { "error": { "name": "ApiError", "message": "App not found", "exitCode": 5, "exit_code": 5, "code": "APP_NOT_FOUND", "statusCode": 404, "status_code": 404 } } ``` Two things to rely on: stdout is always **exactly one** parseable document, and commands that describe their own outcome keep doing so instead of emitting this envelope — `brevo whoami --json` still returns `{"authenticated": false, "reason": "no_key"}` (exit `1`), and `brevo app withdraw --json` on a never-submitted app returns `{"withdrawn": false, "reason": "NOT_SUBMITTED", …}` at exit **`0`**. Check for `error` first, then fall back to the command's own shape — and don't read a `false` in a command's own payload as a failure, since `withdraw`'s is a normal outcome. ## Command help `brevo --help` prints the grouped overview of every command. `brevo --help` prints that command's own usage line, arguments, flags, and examples — e.g. `brevo app scaffold --help` documents `--app-id` / `--overwrite` / `--json`. When you need to confirm a flag exists on the version actually installed, read it from there rather than assuming from this file. ## Forced update When the installed CLI is a full **major** version behind the latest npm release, every command except `--help` / `--version` prints a blocking update banner to stderr and exits `1` **without running** — so a `brevo` call that suddenly exits `1` with an update banner means the CLI must be upgraded (`npm install -g @getbrevo/cli` or `yarn global add @getbrevo/cli`) before it will work. The gate honors the same opt-outs as the soft update notice (`BREVO_NO_UPDATE_NOTIFIER=1`, `--no-update-notifier`, CI, non-TTY), so it never fires in those contexts. ## Update notice wording The update banner's first line comes from the app-store service (`GET /cli/info`). It is called directly rather than through the v3 API gateway and requires no API key, so it works while logged out or with expired credentials. It runs once per invocation, **before** the command, and the response is cached at `~/.brevo/cli-info-cache.json` for **15 minutes**, keyed to the installed `cliVersion` — so reworded text or a new block reaches the CLI within minutes rather than after the old 12h npm-style TTL, without a network call on every single command. Whether an update banner appears is still decided from the npm registry, exactly as before, and a failed call just means the banner uses local wording (a failed call never overwrites a good cache entry). `BREVO_APP_STORE_URL` overrides that service's base URL for non-production testing. ## Server-side block The same response may carry `"is_blocked": true`. When it does, every command except `--help` / `--version` prints a banner to stderr and exits `1` **without running** — a separate mechanism from the major-version gate above, and one the server can turn on without shipping a new CLI. Those two are exempt from the **block**, not from the lookup: they still call `/cli/info` and still render the server's wording on their update banner. They simply never exit non-zero because of it, so a blocked CLI can always still report what version it is. Two things to know when you hit this: - **The notice opt-outs do not apply.** `BREVO_NO_UPDATE_NOTIFIER=1`, `--no-update-notifier`, CI, and a non-TTY all suppress the *notice*; none of them suppress a block. Don't suggest them as a workaround — the only fix is upgrading the CLI. - **It fails open.** A timeout, a non-2xx, or an unparseable body lets the command run normally; only a literal `true` blocks. So a network problem never manifests as a block. - **A block can persist up to 15 minutes after the server lifts it, and vice versa**, because the whole response is cached. If you need a verdict that reflects the server's *current* state, delete `~/.brevo/cli-info-cache.json` (or bump the version) before checking. The soft (non-blocking) notice also prints after a command **fails**, not just after it succeeds — so stderr can hold the error message followed by the update box. This does not change exit codes: a failing command still exits with its own code, and the box is never printed twice in one run. A Ctrl-C abort skips it. If you parse stderr strictly, suppress the notice with `BREVO_NO_UPDATE_NOTIFIER=1` or `--no-update-notifier`. ## Before sharing or committing output * [ ] No `xkeysib-…` API keys, client secrets, refresh tokens, or contents of `~/.brevo/credentials.json` / `.env.local` in messages, logs, or diffs. * [ ] Real production account / org / app IDs redacted to placeholders before sharing diagnostics. ## How this skill stays current This SKILL.md is installed by `brevo skill install` (from the Brevo skills catalog, alongside the other Brevo skills) into `~/.claude/skills/brevo-cli/` (Claude Code, the default) and, with `--agent codex`, `--agent gemini` or `--agent all`, into `~/.agents/skills/brevo-cli/` (Codex and Gemini CLI). Once installed, **`brevo` invocations check the catalog** whenever its cache is older than 12 hours and, when an installed, unpinned copy is behind, print one stderr line: `↑ Brevo skill update available: brevo-cli (vX → vY). Run \`brevo skill update\`.` Nothing is rewritten until `brevo skill update` runs; `brevo skill check` reports drift (outdated, or modified on disk) with exit code `1` and changes nothing. With `BREVO_SKILL_AUTOREFRESH=1` the outdated copies are reinstalled right away instead (`↻ refreshed brevo-cli skill (vX → vY)`), so a hand-edited copy is overwritten. Opt out of the check with `BREVO_NO_SKILL_AUTOREFRESH=1`, or pin a release with `brevo skill install --pin `. The manual escape hatch is `brevo skill uninstall brevo-cli`, which removes every copy the CLI installed (or only one agent's, with `--agent`). When the catalog cannot be fetched, `brevo skill install brevo-cli` installs the copy bundled with the CLI. `brevo skill:cli install|uninstall` still work as aliases. ## More - Help: `brevo --help`, `brevo --help` - npm: - Repo: - Brevo developer docs: - CLI reference: