npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@letterstory/cli

v0.98.3

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 --help

The -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 --help

Updating

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@latest

Set 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 terminal

There 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 --url

This 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 phantom:read and phantom:write capabilities — mint one in the app under Settings → API keys (https://app.letterstory.com/settings?tab=api-keys). Add phantom:domain too if you plan to buy custom domains.

letterstory login --key ls_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# points at https://app.letterstory.com by default; override with --url

Credentials 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.

Phantomstory — a collection Letterstory hosts as an independent publication

A phantom is a site we host that renders one of your collections; it never carries your company's name. Phantoms are metered in SITES live at once (phantom balance), not posts.

# provision a site and follow it until it's live (then it configures itself in the background:
# strategy, visuals, writing kernel, cadence, topic map)
letterstory phantom new --name "Saved Query" --config '{"cadence":{"posts_per_week":6}}'

#   Phantom 7c19c7… provisioning — following…
#     … provisioning · deploy
#     … provisioning · connect
#   Live at https://saved-query-7c19c7.letterstory-staging.com. Configuration continues in the background

letterstory phantom list
letterstory phantom get <phantom-id>
letterstory phantom config <phantom-id>                        # the site's file joined to its collection's
letterstory phantom config <phantom-id> --theme commons          # patch the site (identity/theme rebuild it)
letterstory phantom config <phantom-id> --collection-config '{"cadence":{"posts_per_week":4}}'
letterstory phantom config <phantom-id> --configure --dry-run   # what re-configuring would decide, applying nothing
letterstory phantom rebuild <phantom-id>                        # after publishing outside a collection run
letterstory phantom diagnostics <phantom-id>                    # why is my site empty?
letterstory phantom balance                                     # sites live / granted, and the cadence cap
letterstory phantom delete <phantom-id> --yes

Pass --no-wait to return immediately and poll later with phantom get, --no-configure for a bare site, or --dry-run to see the plan without provisioning anything.

Publishing to the site is a collection run on its collection (below). Letterstory's own launch recipe from a client domain — positioning inferred, a masthead proposed, the first posts seeded — is phantom-onboard (see "Propose, then onboard a phantom").

Custom domains

letterstory phantom domain check myblog.com                     # price it (no charge)
letterstory phantom domain buy <phantom-id> myblog.com --yes    # buys + attaches (spends money; phantom:domain)
letterstory phantom domain connect <phantom-id> myblog.com      # attach one you already own

Content

letterstory posts list [--limit <n>] [--collection <uuid>]
letterstory posts show <article-id>
letterstory posts new --title "…" --collection <uuid> --body "…" [--article-id <uuid>]   # or --file <path|->; --article-id updates that article instead of creating one
letterstory posts new --title "…" --collection <uuid> --body "…" --context "Internal data from 120 accounts: disputes fell 38%."   # your own data: stated in the post with your attribution
letterstory posts publish <article-id>
letterstory posts unpublish <article-id>
letterstory posts delete --id <uuid> [--id <uuid> ...]                # permanent; content, history, and any published copy are all removed

letterstory published list [--limit <n>] [--collection <uuid>] [--format html|md]
letterstory published show --slug my-post-slug [--format html|md]    # or --id <uuid>

Collections — the content engine, one door

A collection is a publication with a ONE-FILE configuration (identity, content, cadence, publishing, targets, measurement) that produces and publishes posts on a cadence. collection run produces ONE post: source → write → gate → publish → (hosted) rebuild.

letterstory collection list                                     # public_id = the HTTP door's address
letterstory collection overview                                 # site, state, posts, in Google, AI reads per collection
letterstory collection new --name "Launches" --config-file cfg.json
letterstory collection config <id>                              # print the one file
letterstory collection config <id> --config '{"targets":{"enabled":true,"tier":"ramping"}}'   # patch it

# one post, followed to the end (20–60 min); stops at the gate under manual approval
letterstory collection run --collection <id> --spoke <topic-id>
letterstory collection run --collection <id> --topic "…" --approval manual --no-wait
letterstory collection run --collection <id> --at 2026-10-01T09:00:00Z   # a future slot
letterstory collection run-status <run-id>                      # status, steps, constraints, verification
letterstory collection runs --collection <id> --status ready
letterstory collection approve <run-id> --now
letterstory collection cancel <run-id>
letterstory collection inventory <id>                           # every piece; each finished draft's publish-on-its-own time
letterstory collection links <id>                               # internal-link refresh: on/off, posts due
letterstory collection links <id> --refresh                     # run the next batch now
letterstory collection delete <id> --yes

# the legacy verbs still work under `collections`
letterstory collections update <id> --cadence-target 4 --cadence-period week
letterstory collections assign <article-id> <collection-id>
letterstory collections activate <id> --cadence 6 --approval autopilot
letterstory collections autopen <id> --kernel-family gazette

A direct run spends one of the collection's weekly cadence slots (its posts_per_week, capped by the org's cadence cap) and is refused — never queued silently — once the week is spent; the planner's own slots are the budget being spent as designed. The same door over HTTP: POST /api/collection/ingest/{public_id} (202 + poll_url), GET …/runs/{run_id}, POST …/runs/{run_id}/approve.

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> --yes

Flows

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 it

Connectors

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_domain

What needs you — the collection's "needs you" strip, from the terminal

Everything in the strip either applies itself after its countdown or waits for your decision; the same reads and decisions are here.

letterstory suggestions list <collection-id>          # strategy cards + when each applies itself
letterstory suggestions dismiss proposal <id>         # reject one inside its window
letterstory collection inventory <collection-id>      # finished drafts + when each publishes on its own
letterstory collection links <collection-id>          # internal links due for a refresh

# proposed edits to live posts: a mention an AI citation earned, or fresher internal links
letterstory revisions list --collection <collection-id>
letterstory revisions accept <revision-id>                     # every change; live where we publish
letterstory revisions reject <revision-id> --change <change-id> --note "not this one"

Offerings — one org, many products

Context is layered org → offering → collection. An offering is anything the org would name when recommending it ("use Lettershred"). Each layer stores only what it overrides: an omitted field inherits, and --reset <field> puts one back to inheriting. Stances, avoid topics and blocked mentions add across layers (a collection's stances lead in prompts); priority topics (--topic) replace the inherited list, so a product never picks up its siblings' focus.

letterstory offerings list
letterstory offerings create lettershred --name Lettershred --link https://lettershred.com \
  --one-liner "Removes AI watermarks from text" --stance "Detection is an arms race" --topic "AI watermarks"
letterstory offerings update <offering-id> --reset priority_topics      # inherit the org's focus again
letterstory offerings competitor <offering-id> --competitor <competitor-domain-id> --stance avoid

# A collection publishes as its primary offering and may mention the others it lists.
letterstory offerings set-collection <collection-id> --primary <offering-id> --also <other-id>:0.5
letterstory offerings set-collection <collection-id> --none               # the org itself again

# Why is this collection writing what it's writing? (every field + the layer that set it)
letterstory offerings collection <collection-id>
letterstory offerings collection-context <collection-id> --stance "…" --avoid "pricing" --framing "…"

# The planner picks an offering per topic; override one pick (or --offering none to clear).
letterstory offerings topic <topic-id> --offering <offering-id>

One-shot domain onboarding

A bare company domain in, a fully-configured organization out — as ONE durable job that composes the existing engines: the main blog (imported from its sitemap or a connected CMS, content areas, topic map, SEO, series, company GEO probing, headlines), org visuals inferred from the site, the main blog's Enhance flow configured (new posts are enhanced; imported posts are left as published), a phantom provisioned + configured + seeded with posts that are actually written and published, and Seers in suggest mode when they fit. The job finishes from a readback: it waits (default 2h) for the seed posts to publish and the first GEO probe to land, then reports what landed.

# On the org behind your key (its registered domain is the source):
letterstory phantom-onboard domain --about "payroll for platform teams" --posts 3 --watch
letterstory phantom-onboard domain --sitemap https://acme.com/sitemap.xml --seers skip --no-visuals
letterstory phantom-onboard domain --attach <job-id> --watch     # watch a job started elsewhere
letterstory phantom-onboard domain --status                      # the org's latest domain onboarding

# Staff: the key exchange (create the org first) needs a STAFF key with org:create —
# mint your OWN, don't share one. --watch makes the whole recipe ONE command: the
# freshly-minted org key is used only in this process's memory to poll to completion.
LETTERSTORY_STAFF_API_KEY=<your staff key with org:create> \
  letterstory admin create-org --domain acme.com --onboard --watch --about "payroll for platform teams"

# Without --watch, it prints the org id, a fresh ORG-scoped API key (once), and the job
# id — watch it yourself with:
#   LETTERSTORY_API_KEY=<the printed key> letterstory phantom-onboard domain --attach <job-id> --watch

Everything the parent job does is also its own tool for existing orgs: ensure_collection_enhance_flow, provision_org_visuals, propose_seers (--args '{"apply":true}').

Staff: ask the fleet a question

# Needs a STAFF key with the fleet:query capability. The answer is derived from
# read-only SQL across every org -- nothing here can write, and credential columns
# are unreadable, so this is safe to point at production because it cannot be
# anything else.
LETTERSTORY_STAFF_API_KEY=<your staff key with fleet:query> \
  letterstory admin ask "which writing kernels are hooked up to the most clients?"

# --sql prints every query behind the answer, which is what makes a scripted answer
# checkable; --json emits the whole result (answer + steps) for a caller that wants
# the queries as data.
letterstory admin ask "which clients published nothing in the last 30 days?" --sql

This is the headless half of /superadmin/ask — same function, same schema — so a long-running agent can watch the fleet on a schedule without a browser.

Staff: client patterns and Fable

# What separates clients whose AI visibility moves (docs/architecture/client-patterns.md):
# every client against itself on share of voice, mention rate and page access, with what we
# did for it. Needs fleet:read; `run` and `ask` spend Fable tokens and need fleet:query.
letterstory admin patterns --period 30d
letterstory admin patterns chain --dimension arenaVersion --outcome cited14   # what each kernel/arena/research change did
letterstory admin patterns reports            # Fable's weekly reports and runs, newest first
letterstory admin patterns report <id>        # one report's markdown
letterstory admin patterns run                # a report now (takes minutes)
letterstory admin patterns ask "do answer-gap posts help paying clients too?"

The headless half of Admin → Client performance → Patterns: same functions, same schemas.

Staff: kernel experiments

# Needs a STAFF key with the tuning:experiment capability. A key always acts as an
# agent: it can propose, plan, note, spend and conclude, never approve a plan.
export LETTERSTORY_STAFF_API_KEY=<your staff key with tuning:experiment>

letterstory admin tuning-context --q "colons"          # what the agent knows (JSON)
letterstory admin tuning kernels                       # every catalog kernel: live version, writer, candidates
letterstory admin tuning kernel <service-kernel-id> --version live   # one version's kernel file (JSON)
letterstory admin tuning candidate <service-kernel-id> --file hazel.json --note "Echo as the writer"
letterstory admin tuning compare --title "Echo vs live" --candidates Hazel:21,Aspen:20
letterstory admin tuning comparison <id>               # per-measure verdicts (better / worse / within noise / refused)
letterstory admin tuning picks <id>                    # the blind queue; then: admin tuning pick <id> --cell <key> --pick left|right|tie
letterstory admin tuning reading-list <id> --n 10      # your next blind pairs as links (Text A / Text B), to read off the screen; pick in the console
letterstory admin tuning pair <id> --cell <key>        # one pair named: live and candidate, both texts, the measures per arm (a key reads every pair)
letterstory admin tuning bank                                     # the eval bank: every eval (rules and drafts), instances, labels
letterstory admin tuning bank-pool <eval-id>                      # draw an eval's pool from our shipped runs and the human corpus
letterstory admin tuning bank-judge <eval-id> --prompt-file judge.md --mode score --threshold 4   # a judge version (or --live: the rule's own)
letterstory admin tuning bank-score <eval-id> --version 2         # score it over the pool; prints agreement on held-out labels
letterstory admin tuning bank-try <eval-id> --version 2           # the judge as a trial rule in candidates, and a comparison against live
letterstory admin tuning invite --email [email protected]        # invite an editor (Pick and Eval bank only); `people`, `revoke <id>`
letterstory admin tuning assign --pick <comparison-id> --instructions "Read for the opening"   # a task for every editor (--to <email> for one)
letterstory admin tuning assign --label bank-draft-x-not-y --target 50                      # editors cut or keep 50 of this eval's sentences
letterstory admin tuning side-by-side <id>,<id>,<id> [--cell <key>]   # several writers as one board: each one's measures against live; with --cell, every writer's text on that input
letterstory admin tuning candidates --kernels all --patch '{"writer":"echo/echo"}' --note "Echo"   # the same change as a candidate of each kernel
letterstory admin tuning session-new --title "Echo round 1" --kernel Hazel --input-file brief.md --kind outline [--version 22] [--versions 23,29]   # --versions: saved candidates, a trial each
letterstory admin tuning propose <session> --text "Echo with rarity on; live with the shred after" --create   # trials from words, over the dials
letterstory admin tuning trial-run <trial> --repeats 2  # runs advance on the server; poll `admin tuning session <id> --texts`
letterstory admin tuning trial-save <trial> --note "…"  # a trial as a candidate version (never live); then `admin tuning compare`
letterstory admin tuning promote <experiment-id> --versions <service-kernel-id>:21   # from a concluded "ship" decision
letterstory admin experiments list --q colons          # what we've tried
letterstory admin experiments list --primitive write --stage rarity   # what we learned about one stage
letterstory admin experiments new "A different writer, voice rewritten, rarity rewrite off" --primitive write   # an investigation in Write's notebook
letterstory admin experiments new "We use too many colons" --as "claude-code"
letterstory admin experiments update <id> --plan-file plan.json --budget 120
# ...a person approves the plan on the investigation's page (approval gates spending a budget; a person's
# investigation needs none and is concluded straight away)...
letterstory admin experiments spend <id> 54
letterstory admin tuning-import lab/results/colons/publish/<brief>.json --experiment <id>
letterstory admin experiments conclude <id> --decision drop \
  --conclusion "Colons fell 60%; P(machine) did not move; quality held." \
  --evidence lab:"kernels lab/results/colons/report.md" --stages rules

The ledger behind Kernel Tuning's Experiments tab (docs/architecture/kernel-experiment-agent.md): every question asked of the kernels, how it was tested, and what we learned and why.

Staff: the CRM pipeline

# Needs a STAFF key with crm:read (reads) and crm:write (writes). The same routes the
# crm.letter.dev front end calls with a staff JWT; any /api/superadmin/crm/* route takes
# the key as x-staff-api-key (GET → crm:read, anything else → crm:write).
export LETTERSTORY_STAFF_API_KEY=<your staff key with crm:read,crm:write>

letterstory admin crm signals --status new             # the Inbound feed (unmatched signals)
letterstory admin crm record --source bookface --kind message \
  --title "Asked about phantoms on Bookface" --name "Jane Doe" --company Acme --domain acme.com
# Re-running that is a no-op: with no --external-id the intake derives an idempotency key from
# the signal's content, so a retried agent loop cannot fill the feed with the same observation.
# Pass --external-id when the source has a stable id (a thread id, a tweet id), or to record that
# an otherwise identical signal happened AGAIN.
letterstory admin crm record --source twitter --kind social_engagement \
  --title "Liked our launch tweet" --name "Pat" --linkedin https://linkedin.com/in/pat
letterstory admin crm signal <signal-id> qualify       # link/create the account
letterstory admin crm signal <signal-id> dismiss       # the easy delete (kept, marked)
letterstory admin crm accounts --stage demo_scheduled  # with likelihood
letterstory admin crm score <account-id>               # recompute; prints the factors
letterstory admin crm slack-channels [--all]           # Watchdog channels + the account each is linked to
letterstory admin crm link-slack <account-id> C0123ABC # the CRM's watch-only Slack link (`none` unlinks)
letterstory admin crm import leads.csv --dry-run       # bulk-import leads: what would be created/matched
letterstory admin crm import leads.csv --tag "YC W26"  # …and for real (dedupes by domain, then company name)
letterstory admin crm capture thread.png --context "this is for tryordinal.com" --dry-run
                                                       # a screenshot → a lead: what it read, which account
letterstory admin crm capture thread.png --context "this is for tryordinal.com"  # …and for real
letterstory admin crm capture --text-file notes.txt    # pasted text or notes work the same way

# One-click onboarding of a won client (key needs crm:write + fleet:onboard + org:create)
letterstory admin crm onboard <account-id> --domain acme.com \
  --site "Where fintech compliance is heading" --site "" \
  --slack [email protected] --invite [email protected]:admin --invite [email protected]
letterstory admin crm onboarding <account-id>          # step-by-step progress (sites refreshed)
letterstory admin crm retry-onboarding <onboarding-id> # re-run only the failed steps
letterstory admin crm onboard-plan <account-id>        # what the calls say: sites, Slack/app people, paid?
letterstory admin crm payment-link <account-id>        # Stripe Payment Link at the deal amount (billing:write)

onboard runs org → link → phantom sites → Slack channel → checklist → app invites → stage in one call. Each site is a real (not test) fleet build; --site "" lets the engine infer its beat from the client's homepage. --disclose 2 (or --disclose all) turns on sponsor disclosure ("safe mode") for that site, and --disclose-note "…" records the client's words as the reason; onboard-plan flags the sites whose calls asked for it. onboard-plan reads the account's recorded calls (newest first, cached per set of calls; --refresh re-reads) and says how many sites, what each is about, who to invite, and whether they've paid. payment-link stamps the account's org on the Stripe subscription, so paying through it binds and marks the account paid by itself.

Every trigger — Cal.com bookings, Circleback recordings, Letterhome dinner RSVPs, RB2B visits, and what an agent records here — goes through one intake (ingestSignal, src/lib/crm/signals-intake.ts), so an agent's signal dedupes against a webhook's exactly as two webhooks do. Qualified kinds (booking, call recording, dinner, message, form) link or create the account; unqualified ones (a visit, a like) only attach to an existing account and otherwise wait in the Inbound feed.

Propose, then onboard a phantom

The Fleet wizard's Plan and Name screens as two commands: pick the beat, then the name, before anything is created. Both persist nothing and spend nothing (domain checks are read-only). Save the proposal as JSON; the chosen plan travels by --plan-file + --plan-id, so its direction AND its content areas are pinned on the site exactly as approved.

# Step one: the beat. Omit the domain to use your org's own.
letterstory phantom-onboard propose acme.com --json > proposal.json
letterstory phantom-onboard propose acme.com --about "bank regulators on agentic AI"   # variations on a beat

# Step two: the name — candidate mastheads for ONE plan, each with its own priced domains.
letterstory phantom-onboard mastheads acme.com --plan-file proposal.json --plan-id plan-2
jq '.topicPlans[1]' proposal.json | letterstory phantom-onboard mastheads acme.com --plan-file -

# Then onboard with the plan (direction + pillars), the masthead and, if any, the domain.
letterstory phantom-onboard acme.com --plan-file proposal.json --plan-id plan-2 \
  --name "The Compliance Wire" --domain compliancewire.com --domain-mode buy_now --real
# (drives plan_phantom; the seeded posts are collection runs — poll them with `collection run-status`)

propose reads the client's homepage and returns distinct editorial angles (topicPlans), the single fallback plan, the client's existing publications with an overlap warning, and the org's custom-domain headroom. mastheads returns names that never carry the client's own name. Without --plan-file, phantom-onboard <domain> still plans from the homepage alone.

To review a plan and then apply that plan, hand it back with --accept-plan. A bare onboarding computes the plan twice — once for the dry run you read, once inside the job — and the second masthead proposal need not match the first, so the name committed into the URL would not be the one you approved. --accept-plan pins it, implies --accept, and skips the second homepage read.

letterstory phantom-onboard acme.com --json > plan.json     # dry run: read it, edit nothing
letterstory phantom-onboard acme.com --accept-plan plan.json
jq '.plan' plan.json | letterstory phantom-onboard acme.com --accept-plan -

Content 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>

Arenas and writing kernels

Two different steps, in this order. An arena — an adversarial cluster of research agents — reads the live web and writes a sourced, fact-checked outline (into the post when you pass --article, otherwise back on the run); 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 — hosted on a post (outline written into it) or standalone
letterstory arena run --article <uuid> --topic "payroll solutions" \
  --source https://example.com/a --must-include "Pricing" --depth fleshed_out
letterstory arena run --topic "payroll solutions" --no-wait   # standalone; prints the run id
letterstory arena run-status <run-id>                  # phases, sources, outline, verification
letterstory arena cancel <run-id>
letterstory arena balance                              # arena runs left (direct runs only)

# …with --gate, the run pauses for you to pick the angle:
letterstory arena run --article <uuid> --gate
letterstory arena run-status <run-id>                  # status: awaiting_direction + direction_options
letterstory arena direction <run-id> --choice 1
letterstory arena direction <run-id> --custom "Lead with the hidden costs"
letterstory arena direction <run-id> --skip            # let the agents decide
letterstory arena run --topic "payroll solutions" --direction "Lead with the hidden costs"   # no gate, no vote

# Watching runs (what an agent monitoring the account polls):
letterstory arena runs --status awaiting_direction     # the gates waiting on you
letterstory arena runs --since 2026-10-02T00:00:00Z --limit 50
letterstory arena runs --before <next_cursor>          # the next (older) page
letterstory arena latest --article <uuid>              # the post's latest run, whoever started it

# What research decided each site is — and your call on one (research never overrides it):
letterstory arena roles --rival                        # competitors research found that you never listed
letterstory arena role set reviewsite.com --role context
letterstory arena role set rival.com --role company --company "Rival" --rival
letterstory arena role reset rival.com                 # hand it back to research

# Arenas are callable endpoints with one-file defaults (the org's default arena is "Letterstory"):
letterstory arena list
letterstory arena endpoint create --name "Docs research" --defaults '{"depth":"barebones"}'
letterstory arena endpoint config <arena-id> [--file cfg.json]
letterstory arena endpoint update <arena-id> --name "Docs research v2"   # creator or org admin only
letterstory arena endpoint delete <arena-id> --yes

# The research agent as ONE versioned file (like a kernel file; schema at /api/schema/arena):
letterstory arena file <arena-id> > arena.json            # the file the arena runs now
letterstory arena file <arena-id> --patch '{"rules":{"context-figures":"measure"}}' --note "try it"
letterstory arena file <arena-id> --file arena.json        # save a whole file as the next version
letterstory arena versions <arena-id>                      # your copy's versions and our releases
letterstory arena pin <arena-id> <version-id|none>         # pin one of our releases, or follow live
letterstory arena offer <arena-id> [--accept]              # our newer version, over your own changes

# Ledes: a prospect's context in, ready-to-send opening lines out (chosen from a
# hand-approved library, never generated; the top choice is flagged). Synchronous.
letterstory lede create --name "Clay outbound table"
letterstory lede run <endpoint-id> --first-name Jane --company Acme --company-url acme.com --years 3
letterstory lede run <endpoint-id> --email [email protected] --tone direct --candidates 3
letterstory lede run <endpoint-id> --company Acme --default-lede "Came across {{company}} this week."
letterstory lede runs --limit 20
letterstory lede balance
letterstory lede endpoint config <endpoint-id> [--file cfg.json]

# 2. Have a kernel write the draft
letterstory kernel list
letterstory kernel write <endpoint-id> --article <uuid> [--kernel <uuid>] [--file brief.md]
letterstory kernel status <kernel-job-id>

kernel run (and the submit_kernel_run tool behind it) was removed on 2026-09-27. Its one job is kernel write <endpoint-id> --article <uuid>: with no --body/--file it writes from the article's own content, and kernel status still polls the job it starts. Its --brief/--brief-file flags have no replacement: the input is --body/--file, and guidance that is not content goes in --directives.

The standalone kernel endpoint

kernel write and kernel polish hit the standalone door: prose comes back, nothing is saved into an article, and it is metered in kernel-run credits rather than the AI-usage gate.

letterstory kernel endpoint create --name "Writer"
letterstory kernel endpoint delete <endpoint-id> --yes

letterstory kernel write <endpoint-id> --file outline.md \
  --read-as outline --person first --author "Ada" --shredder <endpoint-id>
# Follows the run and prints the ARTICLE when it lands. --no-wait returns the run id instead.

Shredding

letterstory shredder run <endpoint-id> --file post.md --format markdown
# Queues, follows the job, prints the finished DOCUMENT. Exits non-zero if the job lands
# as error or degraded, so a script can branch on it without parsing the payload.

letterstory shredder run <endpoint-id> --file post.md --no-wait   # just queue it
letterstory shredder job <job-id>                                 # poll it yourself

# The shredder as ONE versioned file (like a kernel file; schema at /api/schema/shredder):
letterstory shredder file <endpoint-id> > shredder.json       # the file the endpoint shreds with
letterstory shredder file <endpoint-id> --patch '{"rewriters":{"excludeWriterFamily":true}}'
letterstory shredder versions <endpoint-id>                    # your copy's versions and our releases
letterstory shredder pin <endpoint-id> <version-id|none>       # pin one of our releases, or follow live
letterstory shredder offer <endpoint-id> [--accept]            # our newer version, over your own changes
letterstory shredder families [--hours 6]                      # how each rewriter family is doing

Both follow loops poll every 15–20 s. LETTERSTORY_POLL_INTERVAL_MS overrides that — set it to 0 in CI so a test does not wait on a real interval.

collection run --topic "…" --collection <uuid> runs topic → draft → publish → rebuild end to end, with collection run-status <job-id> to follow it.

Enhancing

The passes that run after the writer — visuals, internal links, external links (citations), metadata — as one versioned file per collection (models and settings), each run recorded.

letterstory enhance run <article-id> --pass internal_links    # run one pass on a post now
letterstory enhance runs --article <article-id>               # what each pass did (also --auto-pen, --outcome skipped)
letterstory enhance get <run-id>                              # one run: version, models, what it added, cost
letterstory enhance file <collection-id> > enhancer.json      # the file the collection's passes run
letterstory enhance file <collection-id> --patch '{"internalLinks":{"maxPerPost":3}}'
letterstory enhance versions <collection-id>                  # your copy's versions and our releases
letterstory enhance pin <collection-id> <version-id|none>     # pin one of our releases, or follow live
letterstory enhance offer <collection-id> [--accept]          # our newer version, over your own changes
letterstory autopen run --article <id> --skip finish,metadata # leave passes out of one run

Org config

An org's whole configuration as one JSON bundle (export_org_config; docs/architecture/org-config.md): settings, primitives and data, keyed by name, live primitive files, no runs or credentials.

letterstory org export > acme.json                 # the bundle — the same document MCP and the API return
letterstory org export --sections primitives.kernels --sections data.collections
letterstory org export --split acme/               # one file per record, for keeping config in git
letterstory org join acme/ > acme.json             # rebuild the identical bundle from the directory
letterstory org diff acme.json                     # what applying the bundle would change: + add, ~ change, - only in the org
letterstory org apply acme.json --dry-run           # what apply would write; then drop --dry-run to apply
letterstory org apply acme.json --prune --yes      # also delete records the bundle lacks
letterstory org git connect acme/config --path letterstory/org.json   # keep the config in GitHub: each new commit is planned and posted to Slack
letterstory org git connect acme/config --mode apply                   # …or applied (deletes only with --prune)
letterstory org git status                         # the connected repo and its last sync
letterstory org git sync [--force]                 # sync now instead of waiting for the 15-minute poll

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 it

Connections — 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 secret

Connecting Notion. Notion uses an internal integration (the direct analogue of a GitHub PAT), so it's fully scriptable:

  1. Create one at notion.so/my-integrations → New integration → Internal, with the Read content capability.
  2. Copy the Internal Integration Secret (ntn_… / secret_…).
  3. 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.
  4. 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 run executes synchronously server-side (up to ~5 minutes for web-scan providers); there is no start-then-poll variant yet. And create's config takes raw JSON — seers providers is 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 defaults

Primitive compositions (a named bundle of kernel/shredder/template/seer references — docs/formats/compositions.md; not a primitive, no endpoint or run. Needs composition:read / composition:write):

letterstory compositions list
letterstory compositions create --name "Regulation-watch site kit" \
  --content '{"kernel_family":"Cedar","shred":true,"template_key":"tmpl_howto"}' \
  --phantom '{"theme":"gazette"}' \
  --seer '{"name":"AI regulation watch","provider":"regulation_change","mode":"auto","config":{"query":"AI regulation"}}'
letterstory compositions resolve comp_9f1a2b3c4d5e     # preview, read-only — costs nothing
letterstory compositions update <composition-id> --clear-seer
letterstory compositions delete <composition-id> --yes  # non-destructive to phantoms already built from it
letterstory phantom create --name "EU AI Act Weekly" --composition '{"key":"comp_9f1a2b3c4d5e"}'

Cover 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]

AI visibility and page access — the Measure tabs' reads, for scripts and agents:

# The Measure Overview: how often AI answers name you and cite your pages (rates
# with 95% intervals and gated trends), share of voice, search for your site and
# the phantom network kept apart, and each ranking target's standing. --part
# (repeatable) returns only those blocks: audience, ai, shareOfVoice, pageAccess,
# search, actions, targets — `ai` is the slow one on a large org.
letterstory insights summary [--period 14d|30d|90d] [--part <block>]...

# GEO → Competitors: every tracked brand's share of mentions (one per brand per
# answer) on the window, whether each is moving (the same question on the same
# engine in both windows — the only up/down read), the question × engine cells
# behind it, the line per engine, each question's top rival, and every dated
# change under the number. --period sets the windows: 14d → last 2 complete
# weeks vs the 2 before, 30d → 4 vs 4, 90d → 13 vs 13.
letterstory insights competitors [--period 14d|30d|90d]

# The AI Visibility Bank: which questions named you, cited your pages, or both (a
# breakthrough). All time unless --period; --source picks the phantom network
# (default), your marketing site, or the whole portfolio; --phantom narrows to
# specific phantom sites (repeatable); --answers caps receipts per question.
letterstory insights bank [--source phantoms|marketing|all] [--phantom <uuid>]... [--period 14d|30d|90d] [--answers <n>]

# Page access: AI answer engines, search crawlers and confirmed human readers on
# your phantom sites; --collection narrows to the site(s) a collection publishes.
letterstory insights access [--period 14d|30d|90d] [--collection <uuid>]

Outbound email — mailers, sequences, prospects

The cold-email stack: sending mailers (real Google mailboxes on domains we own), sequences (multi-step flows sent from them, with a review queue) and prospects (who you write to). Writes here can SEND email or spend money, so they refuse unknown flags instead of ignoring them. Scopes: mailer:read / mailer:manage / mailer:provision, sequence:read / sequence:write, prospect:read / prospect:write (the write scopes are admin-only keys).

# Mailers
letterstory mailers list [--domain acme.com]                 # status + daily send limit
letterstory mailers domain <owned-domain-id>                 # provisioning stage, DNS, mailers on it
letterstory mailers messages <mailer-id> [--folder inbox|sent|all] [--limit 25]   # envelopes
letterstory mailers message <mailer-id> <uid> --folder inbox # one message, plain-text body
letterstory mailers forward <mailer-id> --to [email protected]    # or --clear
letterstory mailers warmup start|pause <mailer-id>           # 10 days to ready
letterstory mailers limit <mailer-id> 40                     # 0..500; refused while warming
letterstory mailers retire <mailer-id>                       # disable; history kept
letterstory mailers retire <mailer-id> --release-mailbox --yes   # also delete the hosted mailbox (irreversible)
letterstory mailers suppressions [list] [--reason bounce] [--limit 100]
letterstory mailers suppressions add [email protected] --reason manual --note "asked to stop"
letterstory mailers suppressions remove [email protected]         # only 'manual' entries; opt-outs stay

# Launching buys a domain (up to $30) and the mailboxes, so it is a tool call, not a command:
letterstory call launch_mailers --args '{"domain":"acme-mail.com","mailers":[{"local_part":"hello"}],"consent_attested":true,"mail_from_postal_address":"…"}'

# Org-level mail settings
letterstory mail connections                                 # connected mailboxes, key + postal address status
letterstory mail postal-address "123 Main St, Portland, OR 97201"   # CAN-SPAM footer

# Sequences (approving or enrolling SENDS)
letterstory sequences list | get <id> | create | update <id> | pause <id> | resume <id>
letterstory sequences enroll <id> --list <list-id>           # or --email [email protected] ...
letterstory sequences review list | approve <item-id> | skip <item-id> | edit <item-id> --subject "…"
letterstory sequences links create --days 2                  # no-login phone review link, shown once
letterstory sequences update <id> --test-mode                # internal accounts only: walks as live, sends nothing

# Dispatches (the review desks a sequence queues to)
letterstory dispatches list | create --name "Founders" | rename <id> --name "VIPs" | delete <id> --yes

# Prospects
letterstory prospects lists | ls --list <list-id> | get <id> | import --csv-file leads.csv --list-name "Q4"
letterstory prospects source --description "heads of growth at seed-stage fintech" --list-name "Fintech"
letterstory prospects openers <list-id> --new-group "Fintech openers"   # print an opening line per row
letterstory prospects columns add "Cake image" | columns delete <column-id> --yes
letterstory prospects delete <prospect-id>... --yes

Connecting a bring-your-own Gmail mailbox needs a browser (Google consent), so it is done in the app; letterstory mail connections shows what is connected.

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 placeholder

Global 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 |