@letterstory/cli
v0.9.0
Published
Spin up and manage Letterstory phantom blogs from your terminal.
Readme
@letterstory/cli
Spin up and manage Letterstory phantom blogs from your terminal. The CLI is a thin
client over the Letterstory MCP endpoint (POST /api/mcp) — it wraps the same tool
manifest an agent sees, so it never drifts from the API.
Install
The CLI is plain ESM with zero dependencies and needs no build step (Node ≥ 20).
npm install -g @letterstory/cli # puts `letterstory` (and `phantom`) on your PATH
# …or run it without installing:
npx -p @letterstory/cli letterstory --helpThe -p is not optional. This package ships two commands, letterstory and phantom,
and npx only infers one for you when a package has a single command or one named after
the package — neither is true here, so a bare npx @letterstory/cli exits with
could not determine executable to run. Name the command you want (letterstory or
phantom) and pass the package with -p.
Contributing to the CLI itself? Run it straight from a checkout instead:
cd cli
npm link # puts `letterstory` (and `phantom`) on your PATH, pointing at this checkout
# …or run it directly without linking:
node cli/bin/letterstory.mjs --helpUpdating
The CLI checks npm for a newer published version at most once every 24 hours (cached alongside your config, so most runs make no extra network call) and prints a one-line nudge on stderr when one's available:
Update available: 0.2.1 → 0.3.0. Run `npm install -g @letterstory/cli@latest` to upgrade.To upgrade immediately:
npm install -g @letterstory/cli@latestSet LETTERSTORY_NO_UPDATE_CHECK=1 to disable the check entirely (e.g. in CI).
phantom — the same CLI, Phantomstory-branded
phantom is a ghost-branded entry point for demos and presentations — same binary, same
commands, same auth, same guardrails as letterstory, just a different printed name:
phantom deploy create --name "My Blog" --theme gazette
# 👻 phantom — deploy and manage Phantomstory blogs from your terminalThere is no separate implementation to keep in sync: bin/phantom.mjs sets one env var
before importing lib/cli.mjs, which swaps the banner and command-hint strings and
otherwise runs the exact same code path as letterstory.
Authenticate
Two ways in — pick whichever fits how you're running the CLI.
Sign in via your browser (the default — best for interactive use on your own machine):
letterstory login
# opens your browser to https://app.letterstory.com, or override with --urlThis runs a browser-based OAuth 2.1 flow (PKCE, loopback redirect — RFC 8252) against your
account. It comes back with a full-access session and a refresh token, so there's no API key
to mint or scope up front. Tokens are saved to ~/.letterstory/config.json (mode 600) and
refresh automatically. Run letterstory logout to revoke the session and forget it.
Or use a static API key (for CI/automation, or when you want scoped-down access):
You need a Letterstory API key (starts with ls_; legacy lb_ keys still work) with the deployment:read and
deployment:write capabilities — mint one in the app under Settings → API keys.
Add deployment:domain too if you plan to buy custom domains.
letterstory login --key ls_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# points at https://app.letterstory.com by default; override with --urlCredentials resolve from --key/--url flags, then LETTERSTORY_API_KEY /
LETTERSTORY_API_URL, then ~/.letterstory/config.json (written by login, mode 600).
Run letterstory whoami (alias status) any time to confirm which key/url (or OAuth session)
resolved and see your company profile. Run letterstory config to see the resolved url and
credential source without making a network call.
Spin up a blog
# create a blog and wait until it's live (polls for you)
letterstory deploy create --name "My Blog" --theme gazette
# Created deployment 7c19c7… (provisioning)
# … provisioning · building
# … provisioning · connecting
# ✓ Live at https://my-blog-7c19c7.letterstory-staging.com
letterstory deploy list
letterstory deploy get <deployment-id>
letterstory deploy update <deployment-id> --name "New Name"
letterstory deploy rebuild <deployment-id> # after publishing new articles
letterstory deploy diagnostics <deployment-id> # why is my blog empty?
letterstory deploy delete <deployment-id> --yesPass --no-wait to return immediately and poll later with deploy get, or --dry-run
on create to print what would be sent without creating anything.
One-shot: bare deploy
For the common case — reserve a blog, price (and optionally buy) a domain, and rebuild, all in one call — skip the subcommand word entirely:
letterstory deploy --domain yourverticalreview.com --buy
# Reserved phantom blog "Yourverticalreview" (7c19c7…)
# Priced yourverticalreview.com
# Registered yourverticalreview.com
# SSL + DNS provisioned · sitemap + schema generated
#
# Your phantom blog is live at https://yourverticalreview.com.
letterstory deploy --blog 7c19c7… --rebuild # rebuild an existing blog instead of creating one
letterstory deploy --domain demo.com --dry-run # preview without calling the APICustom domains
letterstory domain check myblog.com # price it (no charge)
letterstory domain buy <deployment-id> myblog.com # buys + attaches the domainblogs and domains are aliases for deploy and domain — the exact same commands
under Phantomstory's vocabulary (blogs ls, blogs new, blogs rm, blogs update,
domains buy, etc. all call the identical handler as their deploy/domain
counterpart). Use whichever set of verbs you prefer; both are always available from
both letterstory and phantom.
Content
letterstory posts list [--limit <n>] [--collection <uuid>]
letterstory posts show <article-id>
letterstory posts new --title "…" --collection <uuid> --body "…" # or --file <path|->
letterstory posts publish <article-id>
letterstory posts unpublish <article-id>
letterstory published list [--limit <n>] [--collection <uuid>] [--format html|md]
letterstory published show --slug my-post-slug [--format html|md] # or --id <uuid>Collections
letterstory collections list
letterstory collections new --name "Launches" --description "…"
letterstory collections update <id> --cadence-target 4 --cadence-period week
letterstory collections delete <id> --yes
letterstory collections assign <article-id> <collection-id>Landing pages
A landing page is a link plus context describing what lives there. Collections with
auto-mention enabled (toggle in Collection Settings) weave relevant landing pages into
articles as they're written. Pass the URL as --link — --url is the global flag that
overrides the API base URL.
letterstory landings list
letterstory landings get <landing-id>
letterstory landings create --link https://example.com/dataset --context "Our 2026 benchmark dataset. Cite it when an article discusses response-time or CSAT metrics."
letterstory landings create --link https://example.com/report --file ./context.md # context from a file (or --file - for stdin)
letterstory landings update <landing-id> --context "Updated context"
letterstory landings delete <landing-id> --yesFlows
Run an editorial pass over an article, check on it, and manage the completion webhook:
letterstory flows list
letterstory flows run <flow-id> <article-id>
letterstory flows status <run-id>
letterstory flows webhook get
letterstory flows webhook set --url https://hooks.example.com/x --secret s3cr3t
letterstory flows webhook set --url none # disable itConnectors
Publish a finished article out to an external CMS or doc target:
letterstory connectors list
letterstory connectors publish <article-id> --to webflow --target <target-uuid>
letterstory connectors status --connector webflow --publish-id <id>Strategy & onboarding
letterstory strategy company get
letterstory strategy company set --name "Acme" --domain acme.com --manifesto "…"
letterstory strategy positioning get
letterstory strategy positioning set --topic seo --topic ai --stance "…" --clear-avoid
letterstory strategy competitors list
letterstory strategy competitors add "Rival Co" rival.com
letterstory strategy sitemap --collection <uuid> --url https://x.com/sitemap.xml
letterstory onboarding status
letterstory onboarding step --complete connect_domainContent series
A series is a coverage obligation, not a keyword bet: "content that looks like this", one post per declared item. Series feed the same planner queue as topic clusters.
letterstory series list
letterstory series create --name "Secrets, tool by tool" \
--description "One practical secrets guide per IaC tool." \
--coverage "Pulumi,Terraform,Ansible" --template <key>
letterstory series backfill <series-id> --collection <uuid>Research and writing kernels
Two different steps, in this order. The research agent reads the live web and writes a sourced outline into the post; a writing kernel writes the draft from the brief it's given and does no retrieval of its own. Both take minutes, so both are start-then-poll:
# 1. Research the topic and write an outline into the post
letterstory research start --article <uuid> --topic "payroll solutions" \
--url https://example.com/a --must-include "Pricing" --depth fleshed_out
letterstory research status --article <uuid> # phases, sources, outline
letterstory research cancel --article <uuid>
# …with --gate, the run pauses for you to pick the angle:
letterstory research start --article <uuid> --gate
letterstory research status --article <uuid> # status: awaiting_direction + direction_options
letterstory research direction --article <uuid> --choice 1
letterstory research direction --article <uuid> --custom "Lead with the hidden costs"
letterstory research direction --article <uuid> --skip # let the agent decide
# 2. Have a kernel write the draft
letterstory kernel list
letterstory kernel run --article <uuid> --kernel <uuid> [--brief "…" | --brief-file brief.md]
letterstory kernel status <kernel-job-id>phantom-job create --topic "…" --collection <uuid> runs topic → draft → publish → rebuild
end to end, with phantom-job status <job-id> to follow it.
Seers — event-driven signals
A seer watches an external source (a GitHub repo, a Notion workspace, a spec URL, a
news or regulation query) and turns what it detects into headline suggestions — or, in
auto mode, straight into drafts. Needs seer:read / seer:write capabilities
(template:read / template:write for the templates group).
letterstory seers providers # the self-describing config catalog — start here
letterstory seers list
letterstory seers create --name "Ship notes" --collection <uuid> --provider github_pr \
--config '{"owner":"letterstory","repo":"letterbrace"}'
letterstory seers update <seer-id> --mode auto --interval 60
letterstory seers run <seer-id> # poll + produce now (minutes; spends AI budget)
letterstory seers events <seer-id> # detected signals, dedup aliases, relevance verdicts
letterstory seers dismiss <seer-id> <event-id>
letterstory seers headlines [--collection <uuid>] # the seer-sourced suggestion feed
letterstory seers held # drafts parked pending human review
letterstory seers release <article-id> # …or: seers discard <article-id>Reproducible setup — the onboarding path. export writes the org's seers as a
manifest; apply is an idempotent, name-keyed upsert from one (it never deletes;
seers in the org but missing from the manifest are listed as extras and left alone):
letterstory seers export --file seers.json
letterstory seers apply --file seers.json --dry-run # print the create/update/ok plan
letterstory seers apply --file seers.json # apply itConnections — both providers connect headlessly (no browser required):
letterstory seers connections # GitHub + Notion status
letterstory seers connect github --token <pat> # org-wide token; unlocks private repos
letterstory seers connect notion # no token: prints how to mint one + which pages to share
letterstory seers connect notion --token ntn_xxx # store a Notion internal-integration secretConnecting Notion. Notion uses an internal integration (the direct analogue of a GitHub PAT), so it's fully scriptable:
- Create one at notion.so/my-integrations → New integration → Internal, with the Read content capability.
- Copy the Internal Integration Secret (
ntn_…/secret_…). - In Notion, open each page/database the seer should watch → ••• → Connections → add your integration. A Notion integration only sees pages explicitly shared with it — this step is required, or the seer connects but detects nothing.
letterstory seers connect notion --token ntn_…
The app's browser OAuth flow (Seers tab → Connect Notion) is an equivalent alternative, not a requirement.
Remaining gap.
seers runexecutes synchronously server-side (up to ~5 minutes for web-scan providers); there is no start-then-poll variant yet. Andcreate's config takes raw JSON —seers providersis the schema reference the server validates against.
Content templates (the compose structures seers bind via config.template_key):
letterstory templates list # custom + read-only built-ins
letterstory templates create --name "Release notes" --angle "What shipped and why" \
--blocks-file blocks.json
letterstory templates update <template-id> --angle "…" # unset fields keep their values
letterstory templates delete <template-id> --yes # bound seers fall back to defaultsCover images
Which image model draws a post's cover is a CLI decision, not a UI-only one. variants
fans one headline out across up to four models and hands back a preview per model;
select commits the one you want:
letterstory covers models # image models connected to this org
letterstory covers variants --article <uuid> --model bloom --model openai
letterstory covers select --article <uuid> --url <variant-url> --provider bloom
# …or skip the comparison and let the server pick, using the collection's cover template:
letterstory covers generate <article-id> [--regenerate] [--stock-only]--ref-url <url> (repeatable) conditions the look on existing images — pair it with
extract_blog_reference_images to match a site you're mirroring. Each variant costs one
image generation, which is why the fan-out is capped at four models.
Insights
Search Console performance, network-wide, per post, or top posts:
letterstory insights site [--period 14d|30d|90d] [--collection <uuid>]
letterstory insights post <article-id> [--period 14d|30d|90d]
letterstory insights top [--period 14d|30d|90d] [--collection <uuid>] [--limit <n>] [--sort clicks|impressions]Anything else
Every Letterstory tool is reachable, not just the ones with a dedicated command group:
letterstory whoami # or: status — verify your key + who it's for
letterstory tools list # list all tools (bare `tools` also works)
letterstory tools show list_articles # one tool's capability + full argument schema
letterstory call list_articles --args '{"limit":5}'
letterstory call ingest_article --args '{"title":"…","content":"…"}'
# `tool` is the same idea with schema-coerced arguments instead of raw JSON:
letterstory tool list_articles --arg limit=5 --arg collection_id=c1
letterstory tool ingest_article --json-args '{"title":"…"}' --arg content="…"
cat article.json | letterstory tool ingest_article --stdin
letterstory mcp # print MCP server config for an agent
letterstory mcp --print-key # inline the real key instead of a placeholderGlobal flags
| Flag | Meaning |
| ------------ | ----------------------------------------------------------------------------------------- |
| --json | Machine-readable output |
| --quiet | Suppress success chatter on the new command groups (--json implies it) |
| --verbose | Log HTTP requests/responses to stderr |
| --no-color | Accepted for compatibility; this CLI already prints plain text |
| --dry-run | On deploy/blogs create, or bare deploy: print what would happen, don't call the API |
| --url | Override the API base URL for one call |
| --key | Override the API key for one call |
| --help | Show usage |
| --version | Print the CLI version |
