@headroom-cms/cli
v0.9.0
Published
Command-line interface for managing Headroom CMS sites
Readme
@headroom-cms/cli
Command-line interface for managing Headroom CMS sites.
Install
The CLI is published to npm and can be invoked with npx without installing:
npx @headroom-cms/cli --helpOr install globally:
npm install -g @headroom-cms/cli
headroom --helpAuthenticate
headroom login accepts either a deployed admin UI URL or a Lambda function /
API URL. With an admin URL, the CLI auto-discovers the API URL by fetching
/.well-known/headroom.json published by the admin site.
# Recommended: paste the admin URL
headroom login https://headroom-admin.example.com
# Or the API URL directly (legacy form)
headroom login https://xxx.lambda-url.us-east-1.on.aws
# Set the active site at the same time
headroom login https://headroom-admin.example.com mysite.comA bare hostname is auto-prefixed with https:// (or http:// for
localhost / 127.0.0.1 / *.local).
Sessions
headroom login creates a session — one revocable, labelled login for this
machine (the label is the hostname). Sessions are not site-scoped; one session
spans every site you administer.
headroom sessions list --table # CURRENT=yes marks the session running this command
headroom sessions revoke <sessionId> # sign out one machine/browser
headroom sessions revoke-others # sign out everything except this sessionSession ids are bare 32-character lowercase hex, exactly as sessions list
prints them — there is no prefix.
Revoking takes effect on the target's next token refresh, bounded by the
access-token lifetime. revoke-others prompts unless you pass --force.
Scripted / unattended access
A session needs a human at a keyboard. For a nightly script, a CI job or an agent, mint an admin API token — a durable bearer credential you set as an environment variable.
# 1. Mint it. It asks for your own admin password to confirm it's you. The
# token is printed to stdout and NOTHING else is, so this captures the
# credential cleanly:
export HEADROOM_ADMIN_TOKEN=$(headroom tokens create --label "nightly-import" --expires-in-days 90)
# 1b. No terminal? Pipe the password in. Same command, same output contract —
# `--password-stdin` replaces the PROMPT, not the password requirement:
export HEADROOM_ADMIN_TOKEN=$(echo "$ADMIN_PASSWORD" | \
headroom tokens create --label ci --expires-in-days 90 --password-stdin)
# 2. Put that value in your secret manager (GitHub Actions secret, AWS Secrets
# Manager, Vault…). It is shown ONCE and can never be retrieved again.
# 3. In the job, export it and run any admin command. No `headroom login`,
# no `.headroom/` directory, no refresh requests:
export HEADROOM_ADMIN_TOKEN='hrt_…'
headroom sites list
headroom content list --site example.com --collection postsYou can also mint a token in the admin UI under Account settings → API tokens; it is the same credential.
headroom tokens list --table # your tokens; EXPIRES reads NEVER for a permanent one
headroom tokens revoke <tokenId> # ends it within 60 seconds
headroom tokens list --user <sub> # another admin's tokens (super admin only)What a token can do, and for how long
- Full authority of the admin who minted it, across every site they can reach, without a two-factor prompt. If that admin is a super admin, the token can create and delete admin accounts and strip their MFA. A stored non-expiring token is the practical equivalent of that person's password.
--expires-in-daysis optional, and omitting it means the token never expires. Only two things then end it: revoking it, and deleting the admin account it is bound to.createwarns on stderr when you omit it.- Disabling the bound admin in Cognito suspends the token rather than destroying it — re-enabling the admin makes it work again. Revoke it to end it permanently.
- Revocation takes effect within 60 seconds (an in-process cache bounds it). An expiry date is different — it is enforced exactly, with no grace.
- A token cannot mint another token. That removes the cheapest way for a leaked token to renew itself; it is not containment (see below).
- There is a limit of 20 live tokens per admin.
Why minting asks for your password, and why there is no --password
Minting requires confirming your own admin password. An ordinary API session is not enough: without that step, anything that could act as you for a moment — a stolen access token, script injection in the admin UI — could turn itself into a permanent, MFA-free, full-admin credential.
There is deliberately no --password flag and no password environment
variable: either would put your password into shell history, into a process
listing (ps shows every process's argv, to every user on the box) and into CI
logs, which is the exposure this whole feature exists to remove.
A pipe has none of those problems, so that is the non-interactive route:
--password-stdin reads the password from stdin and takes no value on the
command line. It is the same flag, the same spelling and the same reader as
headroom login --password-stdin. Everything else is unchanged — the password
is still required, still verified by the server, and the token still goes to
stdout alone.
echo "$ADMIN_PASSWORD" | headroom tokens create --label ci --password-stdinWithout a terminal and without that flag, tokens create refuses rather than
minting with no confirmation.
tokens create will not run while HEADROOM_ADMIN_TOKEN is set, and says
so before asking for anything. A token cannot mint another token, and the
variable outranks your stored login for as long as it is exported — so
headroom login cannot break the loop. unset HEADROOM_ADMIN_TOKEN first, or
mint from the admin UI. (If you have just run step 1 above and want a second
token, this is the case you are in.)
If a token leaks: containment
Revoking the token ends the token, not the compromise. A full-admin bearer
can independently re-establish access before you revoke it, through routes that
have nothing to do with tokens: inviting a new Cognito admin at an address it
controls, rewriting a site's admin list or the super-admin list, minting a site
API key (which also yields that site's image-signing secret), rotating webhook
secrets, creating a new site, restoring a site from an uploaded archive, and
stripping an admin's MFA. Two of those leave no audit row at all — inviting
a Cognito admin (POST /v1/admin/sites/{host}/invite) and rewriting a site's
admin list (PUT /v1/admin/sites/{host}/admins) — so that much of the
persistence is invisible. (It was four until 2026-08-15: the super-admin rewrite
and the MFA strip are audited from that date, as admin.super_admins_update and
admin.mfa_disable. Both land in the global audit partition, which neither
headroom audit list nor the admin UI's Audit page can reach — both are
site-scoped. Read them from GET /v1/admin/audit directly, as in step 2 below.)
So, in order:
Revoke the token —
headroom tokens revoke <tokenId>, or from the admin UI. Effective within 60 seconds.Find what it did. Every audit row records which credential performed the action, in
details.credentialId. There is no server-side filter for it —GET /v1/admin/auditacceptsbefore,actionandadminonly, andheadroom audit listexposes exactly those — so select on it client-side from the JSON output:TOKEN_ID=<the leaked token id> headroom audit list --site example.com --limit 1000 \ | jq --arg id "$TOKEN_ID" '.items[] | select(.details.credentialId == $id)'Repeat per site; the global log (
GET /v1/admin/audit, super admin only) carries the mint and revoke events themselves. In the admin UI, a row driven by an API token is badged “via API token” and the detail drawer names the credential id, but the list cannot be filtered on it there either — so for anything past a page or two, use thejqform above.This narrows the search; it does not close it. The two unaudited routes above emit nothing to filter, so steps 3–6 are not optional even if the log looks clean. And the
jqform here is site-scoped: the global log carriesadmin.delete,admin.mfa_disable,admin.super_admins_update,apitoken.*andsession.*, so check it as well as each site's.Rotate every site API key the token could have minted or read (
headroom api-keys list/create/delete --site <host>). A leaked site key also exposes that site's image-signing secret via the public/versionendpoint, so treat key rotation as covering both.Audit and remove the principals. Detection is not containment: an attacker-invited admin survives every other step in this list, and the runbook is not complete while one is still standing.
headroom tokens list --table # note the leaked token's CREATED date first headroom users list --table # admin accounts, with creation datesUse
--tableon both: in JSON,tokens listemits Unix seconds andusers listemits epoch milliseconds, so the raw numbers differ by 1000×. The table renders both asYYYY-MM-DD.For anything created since that date, or that you cannot account for:
- Delete the Cognito admin —
headroom users delete <sub>. The invite that created it is one of the two routes above that leave no audit row, and a Cognito admin is the longest-lived thing the token could have planted, so treat an unrecognised admin as hostile rather than as a colleague you have forgotten. (The deletion itself IS audited —admin.delete, global partition — as is stripping the account's MFA on the way out.) - Remove it from every site's admin list — admin UI → Site Settings →
Admins (
PUT /v1/admin/sites/{host}/admins; there is no CLI command). Deleting the Cognito account does not rewrite the site rows. - Demote it from the super-admin list — admin UI; the list lives in the installation's global config.
- Re-enroll MFA for every admin whose factor the token may have stripped.
Stripping MFA leaves no trace in the account's own state beyond the missing
factor, so compare against your own roster rather than against the console.
headroom users list --tableshows anMFAcolumn.
- Delete the Cognito admin —
Rotate webhook secrets on every site (
headroom webhooks rotate-secret --site <host> <webhookId>).Account for new sites and for anything restored. Two of the eight re-establishment routes change the site inventory rather than a principal inside it, and step 4's per-site sweep silently assumes the inventory is the pre-incident one.
headroom sites list --table # CREATED is rendered as a date- A site created during the exposure window is attacker infrastructure
until proven otherwise — its creator is its sole admin by construction,
which is a self-bootstrap to full authority over it. Confirm each against
your own records;
headroom sites delete <host>removes one. - Restore-from-upload rewrites content and site configuration from an archive the uploader supplied. There is no integrity check to run after the fact: if a restore happened during the window (the audit log records it, when it records anything), treat that site's content and settings as untrusted and restore it again from a backup you know predates the leak.
- A site created during the exposure window is attacker infrastructure
until proven otherwise — its creator is its sole admin by construction,
which is a self-bootstrap to full authority over it. Confirm each against
your own records;
Then re-mint a replacement token with an expiry.
Not on this list: the search index. It is derived data, fully rebuildable from content, and it grants nothing — a rebuild is a repair, not a containment step, so do not spend window time on it. See Search if content you restored in step 6 is missing from search results afterwards; the same
--siterule stated there applies to every command in this runbook.
Bootstrap your project
headroom bootstrap adds Headroom to an existing Astro or Next.js project.
Run it from the project root:
cd ./my-astro-site
npx @headroom-cms/cli bootstrapWhat it does:
- Detects the framework (
next/astro/generic) and package manager (npm/pnpm/yarn/bun). - Resolves the API + media URLs from your active login.
- Prompts you to pick (or create) the site to bootstrap against.
- Creates an API key on the site (label format
bootstrap-{framework}-{YYYYMMDD}-{shortid}). - Reads the site's frontend callback secret (see below).
- Writes
HEADROOM_API_URL,HEADROOM_MEDIA_URL,HEADROOM_SITE,HEADROOM_API_KEYandHEADROOM_FRONTEND_SECRETto.env(.env.localfor Next.js). - Adds the env file to
.gitignoreif missing. - Installs
@headroom-cms/apiwith your detected package manager. - Drops
HEADROOM_AGENTS.mdat the project root — an instruction sheet for AI agents working in the repo.
Idempotency
Re-running bootstrap is safe:
- The existing
HEADROOM_API_KEYis reused when its hash is still recognized by the server. No new key is created. - The frontend secret is read, never rotated — a re-run against a site whose frontend is already deployed hands back the same value.
HEADROOM_AGENTS.mdis never overwritten — delete it first if you want it regenerated.
To explicitly rotate the API key:
npx @headroom-cms/cli bootstrap --rotate-key --yesNon-interactive mode (CI)
# Use a specific site without prompts; create it if missing
npx @headroom-cms/cli bootstrap --site mysite.com --create-site --yesOther useful flags:
--framework next|astro|generic— override detection--env-file <path>— write to a custom env file--skip-install— leavepackage.jsonalone--skip-agents-md— don't writeHEADROOM_AGENTS.md
The frontend callback secret
HEADROOM_FRONTEND_SECRET is the one HMAC identity behind every
backend→frontend callback: live preview and newsletter rendering. The SDK's
injected routes verify X-Headroom-Signature with it and fail closed —
an unset secret answers 401, which reads like a broken integration rather
than a missing setting.
It is per site, not per project: every environment and deployment of the same host verifies with the same value. So unlike an API key or an admin API token, it is readable as often as you need rather than shown once — the server keeps the plaintext because it signs with it.
# Read it (minted on first read; safe to repeat)
headroom sites frontend-secret mysite.com
# Capture straight into a frontend's env
export HEADROOM_FRONTEND_SECRET=$(headroom sites frontend-secret mysite.com --raw)headroom bootstrap and create-headroom-site both do this for you. Reading
requires the admin role on the site and is recorded in the audit log
(site.frontend_secret_revealed); the Site Settings → Frontend secret card in
the admin UI is the same operation.
Rotating it
headroom sites frontend-secret mysite.com --rotateRotation mints a new secret and moves the current one to
previousSecret. Headroom signs callbacks with both for 24 hours
(model.FrontendSecretGracePeriodSeconds), so the rollout is:
- Rotate, and put the new value in
HEADROOM_FRONTEND_SECRETand the displaced one inHEADROOM_FRONTEND_SECRET_PREVIOUS. - Redeploy every frontend of that site.
- Clear
HEADROOM_FRONTEND_SECRET_PREVIOUSand redeploy again.
Step 3 is what actually ends the rotation: the frontend accepts a configured
previous secret for as long as the variable is set — there is no expiry on
that side, exactly like the image-signing master's OLD-key fallback. Skipping
step 2 strands every deployment still holding the old value once the 24-hour
window closes, which is why --rotate prompts for confirmation (use --force
in CI).
Admin commands
Super-admin maintenance commands live under headroom admin. These talk to
endpoints gated by the installation's super-admin allow-list — most users
will never need them.
restore-from-file — Restore a backup from a different installation
Use case: you downloaded an archive from installation A and want to recreate that site on installation B (a staging clone, a dev environment, a one-off test bed).
The target host MUST NOT already exist on this installation. To refresh an
existing site, delete it first or use the in-UI restore flow. Against a
fresh installation with no super-admin yet, the first caller of this command
becomes the new super-admin (bootstrap fallback — matches headroom sites create).
headroom admin restore-from-file ./prod-site-2026-04-01T120000Z.tar.gz \
--target-host staging-clone.localYou'll be prompted for confirmation showing the source host + record counts
- what's being copied. The caller becomes the sole admin of the new site.
Defaults:
--include-site-users(audience PII; default ON — appropriate for dev/test clones. Pass--no-site-usersto skip.)--include-api-keys(default OFF — keys from source installation are stripped. Pass--include-api-keysto preserve.)--include-audit(default OFF.)--yesskips the confirmation prompt.
After a successful restore, the CLI prints a headroom admin backups delete
command for the uploaded archive — it sits in the destination's S3 bucket
alongside native backups. Run that command once you no longer need it.
Resolved reads (--resolved / --public)
headroom content get <id> returns the admin projection: blocks live behind
a publishedBlockId reference, so fields.body looks empty. The output carries
a _hint reminding you how to see the runtime view.
Pass --resolved (alias --public) to read the content through the public
endpoint instead — the same shape the SDK sees at runtime, with resolved blocks
inlined at fields.body:
headroom content get <id> --resolved--resolved exercises the API-key-gated public read endpoint, so it needs a
per-site API key. The CLI resolves it from the HEADROOM_API_KEY environment
variable, or from HEADROOM_API_KEY= in your consumer project's .env (the
file headroom bootstrap writes). Without one, the command exits non-zero with
a NO_API_KEY error telling you to run headroom bootstrap or set
HEADROOM_API_KEY.
--resolved is the narrow precursor to the cdn command tree,
which exercises the full public CDN API (not just content-by-id) with the same
API-key auth. Reach for headroom cdn content get <id> when you want the public
projection as a first-class command.
CDN commands
headroom cdn … drives the public CDN API (/v1/{site}/..., gated by the
X-Headroom-Key header) — the same surface the published @headroom-cms/api
SDK uses at runtime. This is independent of the admin (Cognito) login: the CLI
works with either auth mode configured, or both.
- Admin commands (
sites,content,collections, …) need an admin login (headroom login, Cognito JWT). cdncommands need only a CDN API key — no admin login required. They are the right tool for read-only consumers, CI smoke tests, and exercising the exact path production sites depend on.
API-key precedence
The CDN API key is resolved with strict precedence (highest first):
| Rung | Source | Set by |
|------|--------|--------|
| 1 | --api-key <key> | per-command flag |
| 2 | HEADROOM_API_KEY | environment variable |
| 3 | .headroom/config.json apiKeys[<site>] | headroom cdn login |
| 4 | <consumer project>/.env HEADROOM_API_KEY=… | headroom bootstrap |
whoami reports which rung resolved (under cdn.apiKeySource) and doctor
probes the key against the live CDN — neither ever prints the key itself. CDN
auth is optional, so doctor skips (does not fail) the CDN check when no
key resolves.
The other CDN coordinates follow the same flag > env > config precedence:
apiUrl : --api-url > HEADROOM_API_URL > config.apiUrl
site : --site > HEADROOM_SITE > config.activeSite
mediaUrl : --media-url > HEADROOM_MEDIA_URL > config.mediaUrlmediaUrl is required even for media-free reads because the SDK constructor
enforces it.
Persist a key (optional)
For interactive use you can stash a per-site key in .headroom/config.json so
you don't re-export it every shell. Scripts that already set HEADROOM_* env
vars don't need this.
headroom cdn login --site mysite.com # prompts for the key
headroom cdn login --site mysite.com --api-key sk_…
headroom cdn logout --site mysite.com # removes the stored keyCommands
# Read published content
headroom cdn content list --collection <name> [--select <s>] [--limit <n>]
[--cursor <c>] [--sort <s>] [--before <ts>]
[--after <ts>] [--related-to <id>] [--rel-field <name>]
headroom cdn content get <contentId>
headroom cdn content by-slug <collection> <slug>
headroom cdn content singleton <collection>
headroom cdn content batch <id...> [--select <s>]
# Read schemas / metadata
headroom cdn collections list
headroom cdn collections get <name>
headroom cdn block-types list
headroom cdn version # site content version (raw)
headroom cdn openapi # the site's openapi.json
# Submission-collection content (write surface)
headroom cdn submissions list --collection <name> [--status approved|pending]
[--select <s>] [--limit <n>] [--cursor <c>] [--sort <s>]
headroom cdn submissions get <contentId> [--select <s>]
headroom cdn submissions batch <id...> [--select <s>]
headroom cdn submissions create --collection <name> --data <json> [--session <token>]
headroom cdn submissions update <contentId> --data <json> [--session <token>]
# Site-user OTP auth (email → session token)
headroom cdn auth request-otp <email>
headroom cdn auth verify-otp <email> <code> # prints { token, expiresAt, user }
headroom cdn auth session --session <token>
# Optional persistent key store
headroom cdn login [--site <host>] [--api-key <key>]
headroom cdn logout [--site <host>]All cdn read commands print JSON by default and honor --table / --csv /
-q, so jq over the output sees the SDK shape verbatim.
Search
Site search over the admin index, so results include drafts, scheduled and
unpublished rows — the operator's view of the site, not a visitor's. Search is
opt-in per site and off by default; until it is enabled (Site Settings → Search,
or ?section=search on the settings page) all three commands answer 404.
headroom search <query> [--collection <name>] [--tag <tag>] [--status <status>]
[--limit <n>] [--offset <n>] [--site <host>]
headroom search status [--site <host>]
headroom search reindex [--site <host>] [--force]search <query>prints the whole envelope in JSON ({total, limit, offset, indexStatus, processed, totalEstimate, results}) —totalis the pagination denominator andindexStatusis how a script tells "no matches" from "the index is still building".--table/--csvprint just the rows, asTITLE / COLLECTION / STATUS / SLUG. Terms are ANDed and matched whole-word; accents and case fold.--statusis repeatable (published|draft|unpublished|scheduled).search statusprints the index state as-is.totalEstimate: 0means UNKNOWN, not zero — a first build has no denominator, so readprocessedas a count rather than dividing.lastError: "doc_cap_exceeded"means the index is truncated at its document cap and still serving, not that it failed.search reindexrebuilds from scratch. One HTTP call is one bounded chunk of work, so the command loops until the rebuild completes, printing progress to stderr and a single JSON summary to stdout —headroom search reindex | jqworks.--forceskips the confirmation, and is required non-interactively. A409means another rebuild already holds the site's lease (a second operator, or the admin UI's own loop after someone ticked the Search box); the command reports it on stderr and stops rather than failing.
Scripting the 409 path: it is invisible to set -e and &&. On a lease
collision the command writes its explanation to stderr, leaves stdout
empty, and exits 0. That is deliberate — an empty stdout means jq emits
nothing, so a script cannot mistakenly read a summary and conclude the index is
current. But it also means a chain proceeds:
headroom search reindex --site X --force && ./smoke-test # ./smoke-test RUNSUsually that is fine — another rebuild genuinely is running — but this process
did not finish one, and there is no signal short of parsing stderr. A caller that
must wait for a rebuild should poll headroom search status until status is
no longer building:
headroom search reindex --site X --force
until [ "$(headroom search status --site X | jq -r .status)" != "building" ]; do
sleep 2
doneReindex is also the headless remedy for a lost journal row — the case where the index has fallen behind in a way ordinary syncing cannot repair. It is the same operation as the admin UI's Rebuild button.
Site resolution here is the admin rule: --site <host>, else the active site
from headroom site <host>. HEADROOM_SITE is NOT read — that variable
belongs to the headroom cdn chain, and a script that exports it and expects
these commands to follow will silently search whichever site is active. The same
rule governs every command under Admin commands; it is
restated here because search is the command most likely to be reached for from a
shell that already has HEADROOM_SITE set for cdn reads.
Singletons
A singleton collection holds exactly one content row. Resolve it in one command:
headroom content singleton get <collection>
headroom content singleton get <collection> --resolved # inline blocksResolution order:
- If the collection isn't a singleton, the command errors
(
COLLECTION_NOT_SINGLETON). - If the collection carries a
singletonContentId, that row is fetched directly. - Otherwise the command lists the collection's content and returns the
published row (falling back to the most recently edited row). If more
than one row exists — orphans left by re-running
collections createfor a singleton — it prints a warning to stderr and still returns the canonical row.
The canonical accessor for the singleton's id remains collections get
<collection>, whose output includes singletonContentId. (That field is
deliberately stripped from config export / config.json, which is
schema-only — it's server-managed, not schema-as-code.)
Cleaning up orphan rows
Re-running collections create for a singleton mints a fresh row, leaving the
old one behind as an unpublished orphan. To remove them in one pass — keeping
only the published row:
headroom content singleton cleanup <collection> --forceOnly unpublished rows are deleted; the published singleton row is protected
(the API returns 409 if you try to delete it directly). After a cleanup run,
content list --collection <collection> returns exactly one row.
See also the Field Types reference for the
collection field vocabulary and the params.body wire shape used by
content create / content draft.
Schema-as-code workflow
headroom config export pulls the live collections + block types into a
schema-only config.json; config diff <file> shows how a file differs from
live; config import <file> applies a file's changes back to the site. The
canonical edit-then-regenerate loop is:
1. Edit config.json
2. headroom config import # apply locally-authored changes
3. headroom config diff # confirm "no changes"
4. headroom config export # re-pull from live (or your wrapper
# script, e.g. pnpm headroom:types)Order matters. config export re-pulls the live schema, so running it
(or any wrapper such as pnpm headroom:types, which runs config export
first) before importing your local edits would overwrite them with the old
live schema — the new field never generates and you don't know why.
To prevent that silent revert, config export guards a dirty local file:
when --output <file> points at a non-empty config.json that differs from
the current live schema, the command aborts with a DIRTY_LOCAL_CONFIG error
telling you to config import first. Pass --overwrite-local to skip the
guard (back-compat for CI that drives codegen from live). A missing or empty
target file is the normal first-time bootstrap path and is never flagged.
Generated bindings (and config export output) sort collections and fields
alphabetically, so config.json insertion order is immaterial — see the
Field Types reference (bottom note) for
details. (singletonContentId is server-managed and deliberately stripped
from config export / config.json.)
Generate typed schemas (codegen)
headroom codegen writes a TypeScript file of Zod schemas for the site's
collections plus a discriminated Block union — one member per built-in
block type and per custom block type the site defines, keyed on type. Point
your content-loading code at the emitted file to get compile-time narrowing of
block.props (including props.data.* for custom blocks).
# Writes ./headroom.generated.ts for the active site
headroom codegen
# Explicit site + output path + plain `zod` import (default is "astro/zod")
headroom codegen --site mysite.com --out src/headroom.generated.ts --zod-import zod| Flag | Default | Effect |
|------|---------|--------|
| --site <host> | active site | Site to generate for. Resolves --site then config.activeSite; does not read HEADROOM_SITE (this is an admin-scoped command — see below). |
| --out <path> | headroom.generated.ts | Output file (relative to cwd). |
| --zod-import <src> | astro/zod | Import source for z — use zod for a plain Zod dependency. |
Auth. codegen resolves its site by the admin rule (--site >
config.activeSite), but the per-site block union it reads
(GET /v1/{site}/openapi.json) is API-key gated, so an API key must also be
resolvable (--api-key > HEADROOM_API_KEY > the cdn login key store >
the consumer project's .env, same precedence as the cdn commands). Run
headroom login (for the site/URL config) and have a key available. Unlike the
other cdn commands, codegen does not require a media URL — it reads only
text endpoints, so an admin who never ran headroom bootstrap can still run it.
Narrowing custom-block props. The emitted union appends an open
UnknownBlock escape-hatch member (so a stale cache or a newer block type
degrades gracefully instead of failing to type-check). Because
UnknownBlock.type is a bare string, a bare switch (block.type) guard
narrows to Member | UnknownBlock — and UnknownBlock has no typed
props.data. For deep custom-block prop access, type against the member
interface directly (e.g. CallToActionBlock["props"]["data"]["text"]) or use
the SDK's BlockComponentProps<Member> helper (@headroom-cms/api/react),
which reads block.props.data.* fully typed.
Concurrent-editing safety
content draft and content publish are unconditional by default — an agent
write during a live editing session silently clobbers (or is clobbered by) the
editor's next autosave. Two opt-in guards make sessionless writes concurrency-aware.
They are recommended for agents editing content a human might also edit; omit them
for bulk seeding where you are the only writer.
| Flag | Commands | Effect |
|------|----------|--------|
| --expected-updated-at <ms> | content draft | Conditions the write on the draft's updatedAt (Unix ms — the value content get returns as draft.updatedAt). Mismatch → 409 STALE_WRITE. |
| --respect-lock | content draft, content publish | Rejects the write if a live edit lock exists (a human has the content open in the admin UI) → 409 LOCK_HELD. |
The recommended agent flow is read-modify-write:
# 1. Read the current draft — note draft.updatedAt (the concurrency base stamp).
headroom content get <id>
# 2. Write with both guards.
headroom content draft <id> --data '…' \
--expected-updated-at <draft.updatedAt> --respect-lock
# 3. On STALE_WRITE: re-run step 1, merge, retry with the new stamp.
# On LOCK_HELD: a human is editing — back off and retry later.Neither flag set = today's unconditional write (existing behavior; safe for seed
scripts and migrations). Combining either guard with a sessionId/generation
(the admin UI's own machinery) is rejected with a 400 — the guards are
sessionless-only.
Conflict codes
On a 409 the CLI writes a machine-readable JSON envelope to stderr — code
plus the structured recovery field, and a human hint line beside it:
code: "STALE_WRITE"— the draft changed since your--expected-updated-atbase. The envelope carriescurrentUpdatedAtas a top-level field (the row's current stamp,0if no draft exists yet). Re-read withcontent get, merge, retry with the new stamp.code: "LOCK_HELD"— a live edit lock exists. The envelope carrieslockHolderas a top-level field ({ name?, email? }). Back off and retry later, or omit--respect-lockto override.
Configuration
The CLI keeps a per-repo config in .headroom/config.json (at the git root).
Session tokens live in .headroom/tokens/*.json with mode 0o600. The
bootstrap command also drops .headroom/.gitignore (*) so contents are
never committed.
A token file written by an older CLI (one that stored a Cognito refreshToken)
is deleted on first read and the CLI asks you to run headroom login again —
that credential cannot authenticate against the current server.
Tips
- All commands output JSON by default (pipe to
jqfor filtering). - Use
--tablefor human-readable output. Nested objects/arrays render as JSON strings (no more[object Object]). - Use
--csvto emit CSV (list commands only; nested objects render as JSON strings). For single-object commands (e.g.headroom sites get),--csvfalls through to JSON silently. - The CLI auto-paginates when
--limitexceeds the per-endpoint cap (100 for admin endpoints). For example,headroom content list --limit 500makes five paginated requests under the hood and returns all 500 (or fewer if exhausted). Set--quietto suppress the "(more results available)" notice. - When editing content a human might also have open, guard your writes — see
Concurrent-editing safety for the
--expected-updated-at/--respect-lockflags and theSTALE_WRITE/LOCK_HELDconflict codes.
See also
headroom doctor— diagnose API reachability, auth, and API-key validityheadroom whoami— show the active sessionheadroom sessions list— list and revoke your loginsheadroom collections list --table— discover the schemasheadroom content list --collection posts— read content- Field Types reference — every field
type, its
options.*, the generated TS/Zod shape, and when to use each (and when not to reach forjson) when editing collection schemas
