Markdown · llms.txt

Commands

tf <command> [options]
tweet-factory <command>   # same program, via the package bin

tf --help, tf -h, and tf help print the short list and exit 0. tf --version, tf -v, and tf version print tf <version> and exit 0. An unknown command prints the help and exits 2.

Every command accepts --dir <path> (see Configuration). This page is checked against the command table in src/cli.ts when the docs build: a new command that is missing here fails the build.

tf auth

OAuth 2.0 PKCE. Writes .tokens.json.

bash
tf auth [--port N] [--bind HOST] [--redirect-uri URI] [--no-browser] [--timeout SECS]
FlagDefault
--portOAUTH_PORT or 8675
--bindOAUTH_BIND_HOST or 127.0.0.1
--redirect-uriOAUTH_REDIRECT_URI or http://127.0.0.1:<port>/callback
--no-browseroff; when set, the URL is printed and no browser is launched
--timeout300 seconds

Scopes: tweet.read, users.read, like.read, bookmark.read, offline.access. Details and the human click are in X app setup.

Human required. Someone clicks Authorize app in a browser.

Verify. .tokens.json exists and the log includes Has refresh token: true.

tf fetch

Pull likes, bookmarks, and your posts into tweets.db.

bash
tf fetch [like|bookmark|own] [--incremental]

With no source words, it fetches all three. User id comes from /2/users/me. Pages are 100 tweets, with a one-second pause between pages. A 401 refreshes the access token and retries. A 403 skips that source. A 429 sleeps until the rate-limit reset.

--incremental stops a source early once a page contains no ids that are already stored. The first pull should be a full fetch (no flag). tf refresh and tf cron always fetch incrementally.

Verify. The log ends with a database summary: counts for like, bookmark, own, and distinct authors. test -s tweets.db.

tf onboard

OpenTUI onboarding. Needs a terminal.

bash
tf onboard [--selftest]

Screens, in order: welcome, four interview questions, account tagging, a heuristic roast, forge, optional Notion, generate.

Interview questions:

  1. What do you want to be known for, in one breath?
  2. Your hottest take that's gotten you in trouble?
  3. What are you building right now?
  4. Describe your voice in 3 words.

Account tags, cycled with space: (none), inspiration, competitor, north-star, friend. Arrow keys move. Enter continues.

The roast uses the same per-head scorer as tf generate: editorial strengths times the published action weights. It does not apply account calibration. tf analyze prints whether your own history is thick enough to calibrate.

On the forge screen, f writes persona/PERSONA.md. n opens Notion search. g generates drafts with your LLM. q quits.

--selftest renders one frame and exits. It still starts the TUI renderer, so it needs an environment where tf onboard can open.

Verify. After forge, persona/PERSONA.md exists and names the handle tf analyze prints.

tf generate

Draft tweets with your LLM. Writes drafts/YYYY-MM-DD.md and onboarding.json.

bash
tf generate [--n N] [--timely] [--post-to-slack] [--provider NAME] [--model NAME] [--json] [--no-style-check]
FlagEffect
--nHow many ideas to ask for. Default 4. Each idea is 2–3 variants.
--timelyOne Tavily news search is added to the prompt when TAVILY_API_KEY is set. The query is AI agents coding startups when your posts hit the analyzer’s energy-marker list, otherwise technology. For a query you choose, run tf search yourself.
--post-to-slackAfter writing the file, post it with tf notify.
--providerOverrides TF_LLM_PROVIDER for this run.
--modelOverrides TF_LLM_MODEL for this run.
--jsonPrint the bundle on stdout instead of writing the markdown file. onboarding.json is still written.
--no-style-checkSkip the style check. The prompt's style rules are dropped too.

The model is asked for one JSON object: persona summary, content pillars, reply targets, cadence, strategy, ideas (each with 2–3 variants), and a flat drafts array. The model’s score field is ignored. After Zod validates the JSON, tweet-factory scores every variant locally: copy-link share, reply (20 on an original a mutual would see, 5 otherwise), DM share, quote, follow, repost, like, and the negative heads report, mute, not-interested, and block. The markdown file shows that breakdown, the weighted total, and a 0–100 score. A note at the top says to post one variant per idea, because a second post from you in one viewer’s pool is multiplied by 0.625 and DPP separates near-duplicates.

When tweets.db has at least 8 of your own posts with impressions, reply, quote, repost, and like rates (per impression, per format) scale those four strengths. Bookmarks are printed as a diagnostic and do not scale a head. Fewer than 8 impressed posts leaves the scales at 1 and says the history is thin. Posts older than 48 hours are preferred when 8 of them exist.

Context sent as fenced reference data (not as instructions): flush/pending.md when it has new items, a few recent posts, tagged Notion pages (about 6,000 characters), the calibration block, and the optional Tavily blurb. The system prompt is your persona plus measured stats plus the ranking prompt built from src/rank/weights.ts.

Default backend and keys: LLM providers.

Every variant goes through the style check before it is scored, so the score in the file is the score of the text in the file. A variant the check touched shows a Style check: block with each fix and any flags left, and the score before fixes. In --json and onboarding.json, each variant carries style: { fixes, flags, original?, originalScore? }. stderr ends with a one-line count.

Verify. drafts/<today>.md contains a | Head | table and Post one variant, and onboarding.json parses as JSON with a drafts array. --json prints that object and exits 0.

tf brain-dump

Turn raw dictation into an outline with notes and 2–3 scored angles, before full drafting. Same LLM, persona, and scorer as tf generate.

bash
tf brain-dump "rough thoughts, pasted or dictated"
tf brain-dump --file memo.txt
pbpaste | tf brain-dump -
tf brain-dump --file memo.txt --draft 2
tf brain-dump [<text> | --file PATH | -] [--angles 2|3] [--draft N] [--json] [--provider NAME] [--model NAME] [--no-style-check]
FlagEffect
<text>Dictation as a quoted argument.
--file, -fRead the dictation from a file, such as a voice-memo transcript.
-Read stdin. Piped input is read without the dash.
--anglesHow many distinct angles to ask for, clamped to 2–3. Default 3.
--draft NAfter printing, feed angle N into the tf generate flow. That writes drafts/YYYY-MM-DD.md and onboarding.json like tf generate.
--jsonPrint the outline and scored angles as JSON.
--provider, --modelOverride TF_LLM_PROVIDER and TF_LLM_MODEL for this run.
--no-style-checkSkip the style check on the angles and on --draft drafts.

Output is a one-line summary, a numbered outline where each point can carry notes (your own words worth keeping, gaps), and the angles. Each angle is one tweet in your voice with its aim and a 0–100 score from the same per-head scorer as tf generate; the model's own scores are ignored. Angles go through the style check first, and any fixes or flags print under the angle. Nothing is written to disk unless you pass --draft. No input exits 2.

The dictation is sent to your LLM as fenced reference data, not instructions, and is cut at 20,000 characters. Persona loading and backends are the same as tf generate (LLM providers).

Verify. tf brain-dump --help prints usage. With a configured LLM, tf brain-dump "a rough thought" prints ## Outline and ### Angle 1.

tf lint

Run the style check on one draft without an LLM. Fixed text goes to stdout and the report to stderr, so it pipes.

bash
tf lint "Here's the thing — agents need memory"
pbpaste | tf lint -
tf lint --file draft.txt --json --score
tf lint [<text> | --file PATH | -] [--json] [--no-fix] [--strict] [--score]
FlagEffect
<text>, --file, -The draft: a quoted argument, a file, or stdin.
--no-fixLeave the text alone and report every rewrite as a flag.
--strictExit 1 when a flag remains.
--scoreAdd the 0–100 score before and after fixes (uncalibrated).
--jsonPrint { text, original, changed, fixes, flags } (plus score, originalScore with --score).
text
$ tf lint "Here's the thing — agents need memory. Fast. Cheap. Reliable."
Agents need memory. Fast. Cheap. Reliable.

fixed (heres-the-thing): "Here's the thing — agents need" -> "Agents need"
flag (triad): stacked rhetorical triad reads as AI; keep the one that matters: "Fast. Cheap. Reliable."

No input exits 2.

Style check

Inspired by Theo's noEmDashCopy lint rule. It runs on every draft tf generate and tf brain-dump produce (and on the generate screen in tf onboard), before the draft is scored or shown. The same rules are listed in the generation prompt so the model avoids them in the first place. The tweetfactory.sh score page runs the same check and offers the rewrite.

Rewritten, with deterministic rules:

PatternRewrite
X — and/but/so/which …comma
X — it's / we / the / 3 … (a new clause)period, capitalized unless the draft is all lowercase
X — short tail. (4 words or fewer)colon
X — aside — Yparentheses
dash before a line break / at the end / as a bulletcolon / ... / -
anything else with an em dash, a spaced en dash, or -- comma
pre–seed (en dash between letters)hyphen
delve into (any tense)dig into
leading Here's the thing / In today's fast-paced world,dropped

Kept as is: number ranges such as 2023–2026 and 2–3, inline code, fenced code, and URLs.

Flagged, not rewritten: delve, tapestry, testament to, game-changer, unleash / unlock the potential, it's not X, it's Y and X isn't Y, it's Z, not just X, but Y, leading In a world where, let's dive in, ever-evolving landscape, three or more one- or two-word sentences in a row or no X, no Y, no Z, more than 2 emoji, more than 1 hashtag.

The list is one data file, src/style/tells.ts. Each entry is a regex, an optional replacement, a message, and the phrase the prompt names. Add a replacement only when it is safe in every sentence the pattern can match.

Verify. tf lint "we shipped — and nobody cared" prints we shipped, and nobody cared and exits 0. tf lint --strict "a game-changer" exits 1.

tf notion

Tag Notion pages as grounding for generation. Requires NOTION_API_KEY, and the pages must be shared with the integration.

bash
tf notion search [query] [--json]
tf notion tag <pageId> --tag <label> [--note <text>]
tf notion untag <pageId>
tf notion list [--json] [--full]
tf notion sync [--force]

search lists pages the integration can see. tag stores the page (markdown body included) in tweets.db under your freeform label. list prints tags and titles; --full with --json includes cached content. sync refreshes tagged pages whose last_edited changed. --force refreshes all of them. tf cron calls sync when NOTION_API_KEY is set.

Verify. tf notion list shows the page you tagged. A later tf generate can quote the tag only as background; it is instructed not to paste the page verbatim.

tf enrich

Pull article text for URLs on stored tweets. Requires TAVILY_API_KEY.

bash
tf enrich [--all] [--force]

Default source is bookmarks only. --all also scans likes and your own posts. X article URLs (x.com/i/article/… or twitter.com/i/article/…) are read through the X API. Other URLs go through Tavily Extract. t.co links are skipped. Results are cached in article_content; --force refetches.

Verify. The log reports extract or x-article counts, and the command exits 0.

tf flush

Show interactions that are not yet marked processed.

bash
tf flush [--commit] [--status] [--count]
FlagEffect
(none)Write flush/pending.md. The first run baselines: every current row is marked processed and the pending total is 0.
--commitMark the current pending ids processed, and move pending.md to flush/archive/.
--statusPrint pending counts to stderr.
--countPrint a single integer to stdout. tf cron uses this.

Sections in the file: NEW LIKES, NEW BOOKMARKS, YOUR NEW TWEETS.

Verify. After the first tf flush on a fresh database, the file contains Total pending: 0. After a later incremental fetch of new posts, a second tf flush lists them.

Tavily web search. Requires TAVILY_API_KEY.

bash
tf search "query words" [--news] [--days N] [--max N] [--json]

Without --news the topic is general. With --news, the topic is news and --days (default 7) limits the window. --max defaults to 5. Human output is a short markdown list; --json is the raw response.

Verify. The command prints a ## Tavily: heading, or JSON with --json.

tf notify

Post a file to Slack in chunks of about 3,500 characters. Requires SLACK_WEBHOOK_URL. If the webhook is unset, it logs a skip and returns.

bash
tf notify <file> [header]

The default header inside the library is 🧵 Fresh tweet drafts ready. The argument replaces it.

Verify. The log says posted to Slack, and the channel shows the file. With no webhook, the log says SLACK_WEBHOOK_URL not set.

tf refresh

Incremental fetch, then rebuild flush/pending.md. Does not call an LLM, Tavily, Notion, or Slack.

bash
tf refresh

update.sh in the repo is a thin wrapper around this. This is the command to schedule when you have no Tavily key. It used to be tf update. tf update now replaces a release binary.

Verify. The log shows Fetch mode: incremental and a pending written line (or a baseline line on the very first flush).

tf cron

Server-loop entrypoint used by cron_run.sh.

bash
tf cron [--enrich-all]

Order: incremental fetch, enrich, Notion sync if NOTION_API_KEY is set, flush rebuild, then a Slack ping when the pending count is greater than 0 and SLACK_WEBHOOK_URL is set. The Slack text tells you to run tf generate. It does not generate drafts.

--enrich-all passes --all through to enrich (likes and your own posts, not only bookmarks).

Enrich throws if TAVILY_API_KEY is missing, and that happens after the fetch. Without Tavily, schedule tf refresh instead.

Verify. The stderr log contains ===== cron and pending=<n>. With new items and a webhook, it also contains slack pinged.

tf analyze

Voice and taste stats from tweets.db. No network.

bash
tf analyze [--json] [--roast]

Default output: handle, counts of your tweets / likes / bookmarks, percent of posts that start lowercase, median word count, up to three energy phrases from a fixed list, and the ten authors you have liked most.

--json prints the same stats as JSON, including six recent original posts. --roast scores those recent posts with the per-head scorer and a one-line quip, and prints whether account calibration has enough history. The human-readable summary includes that calibration line too.

Verify. tf analyze --json prints JSON with handle, n_own, n_likes, and n_bookmarks.

tf login

Link this checkout to a tweetfactory.sh account. Does not upload tweets, the persona, or drafts.

bash
tf login [--origin URL]

Prints a device code and a verification URL. Approve it at tweetfactory.sh/account while signed in with X. On success, writes .cloud.json mode 0600.

--origin overrides TF_CLOUD_ORIGIN for this run. The default origin is https://tweetfactory.sh.

Human required. Someone signs in on tweetfactory.sh and approves the code.

Verify. .cloud.json exists, mode 600. The log says nothing is uploaded until tf sync push. See Cloud sync.

tf sync

Optional cloud copy of tweets.db, persona/PERSONA.md, drafts/*.md, and Notion tags. Requires tf login first, except status with no link, which only prints that the checkout is not linked.

bash
tf sync push
tf sync pull [--replace]
tf sync status [--json]
tf sync delete --yes
SubcommandEffect
pushUpload a full snapshot. Replaces the previous remote snapshot on commit.
pullDownload into the local database and files. Keeps a local file newer than the remote copy.
pull --replaceClear local sync tables first, then drop local persona or draft files the remote snapshot lacks.
statusBytes, rows, quota, and counts. --json prints the status object.
delete --yesDelete the remote copy. Local files stay. Without --yes the command refuses.

tf sync --help prints the subcommands and exits 0.

Verify. tf sync status before login says the checkout is not linked. After a push, the same command prints a byte quota. tf sync delete without --yes exits with an error and leaves the remote copy.

tf update

Replace this release binary with the latest one published at tweetfactory.sh.

bash
tf update
tf update --check

The command reads the current tag from https://tweetfactory.sh/download/latest/VERSION and compares it with the version compiled into this binary (tf version). When the published tag is newer, it downloads tf-darwin-arm64, tf-darwin-x64, tf-linux-x64, or tf-linux-arm64 for this machine from https://tweetfactory.sh/download/vX.Y.Z/, checks that file against SHA256SUMS, and replaces the running executable. The new bytes are written beside the current binary, marked executable, then renamed into place. Those URLs redirect to a public Blob store.

When this binary is already that tag, the command prints that it is up to date and does not download. A binary newer than the published tag is left in place. --check prints the comparison and does not download or replace anything.

A dev build (bun run src/index.ts, or a binary compiled without a release tag) refuses and points at install.sh. If the install directory is not writable, the command exits with a permission error and leaves the current binary in place.

The database refresh is tf refresh, not tf update.

Verify. tf update --check on a release binary prints either up to date or is available and exits 0. bun run src/index.ts update exits non-zero and mentions install.sh.

tf version

Print the version and exit 0.

bash
tf version
tf --version
tf -v

A release binary prints the tag it was compiled from, such as tf v0.3.0. Running from a checkout prints the package.json version with -dev appended, such as tf 0.3.1-dev.

Verify. tf version prints a single line starting with tf and exits 0. tf --help lists version.