@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 --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 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 --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.
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> --yesPass --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 ownContent
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 gazetteA 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> --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_domainWhat 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> --watchEverything 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?" --sqlThis 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 rulesThe 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 doingBoth 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 runOrg 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 pollSeers — 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 defaultsPrimitive 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>... --yesConnecting 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 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 |
