Commands
tf <command> [options]
tweet-factory <command> # same program, via the package bintf --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.
tf auth [--port N] [--bind HOST] [--redirect-uri URI] [--no-browser] [--timeout SECS]| Flag | Default |
|---|---|
--port | OAUTH_PORT or 8675 |
--bind | OAUTH_BIND_HOST or 127.0.0.1 |
--redirect-uri | OAUTH_REDIRECT_URI or http://127.0.0.1:<port>/callback |
--no-browser | off; when set, the URL is printed and no browser is launched |
--timeout | 300 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.jsonexists and the log includesHas refresh token: true.
tf fetch
Pull likes, bookmarks, and your posts into tweets.db.
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.
tf onboard [--selftest]Screens, in order: welcome, four interview questions, account tagging, a heuristic roast, forge, optional Notion, generate.
Interview questions:
- What do you want to be known for, in one breath?
- Your hottest take that's gotten you in trouble?
- What are you building right now?
- 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.mdexists and names the handletf analyzeprints.
tf generate
Draft tweets with your LLM. Writes drafts/YYYY-MM-DD.md and onboarding.json.
tf generate [--n N] [--timely] [--post-to-slack] [--provider NAME] [--model NAME] [--json] [--no-style-check]| Flag | Effect |
|---|---|
--n | How many ideas to ask for. Default 4. Each idea is 2–3 variants. |
--timely | One 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-slack | After writing the file, post it with tf notify. |
--provider | Overrides TF_LLM_PROVIDER for this run. |
--model | Overrides TF_LLM_MODEL for this run. |
--json | Print the bundle on stdout instead of writing the markdown file. onboarding.json is still written. |
--no-style-check | Skip 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>.mdcontains a| Head |table andPost one variant, andonboarding.jsonparses as JSON with adraftsarray.--jsonprints 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.
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]| Flag | Effect |
|---|---|
<text> | Dictation as a quoted argument. |
--file, -f | Read the dictation from a file, such as a voice-memo transcript. |
- | Read stdin. Piped input is read without the dash. |
--angles | How many distinct angles to ask for, clamped to 2–3. Default 3. |
--draft N | After printing, feed angle N into the tf generate flow. That writes drafts/YYYY-MM-DD.md and onboarding.json like tf generate. |
--json | Print the outline and scored angles as JSON. |
--provider, --model | Override TF_LLM_PROVIDER and TF_LLM_MODEL for this run. |
--no-style-check | Skip 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 --helpprints usage. With a configured LLM,tf brain-dump "a rough thought"prints## Outlineand### 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.
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]| Flag | Effect |
|---|---|
<text>, --file, - | The draft: a quoted argument, a file, or stdin. |
--no-fix | Leave the text alone and report every rewrite as a flag. |
--strict | Exit 1 when a flag remains. |
--score | Add the 0–100 score before and after fixes (uncalibrated). |
--json | Print { text, original, changed, fixes, flags } (plus score, originalScore with --score). |
$ 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:
| Pattern | Rewrite |
|---|---|
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 — Y | parentheses |
| dash before a line break / at the end / as a bullet | colon / ... / - |
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"printswe shipped, and nobody caredand 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.
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 listshows the page you tagged. A latertf generatecan 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.
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.
tf flush [--commit] [--status] [--count]| Flag | Effect |
|---|---|
| (none) | Write flush/pending.md. The first run baselines: every current row is marked processed and the pending total is 0. |
--commit | Mark the current pending ids processed, and move pending.md to flush/archive/. |
--status | Print pending counts to stderr. |
--count | Print 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 flushon a fresh database, the file containsTotal pending: 0. After a later incremental fetch of new posts, a secondtf flushlists them.
tf search
Tavily web search. Requires TAVILY_API_KEY.
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.
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 saysSLACK_WEBHOOK_URL not set.
tf refresh
Incremental fetch, then rebuild flush/pending.md. Does not call an LLM, Tavily, Notion, or Slack.
tf refreshupdate.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: incrementaland apending writtenline (or a baseline line on the very first flush).
tf cron
Server-loop entrypoint used by cron_run.sh.
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
===== cronandpending=<n>. With new items and a webhook, it also containsslack pinged.
tf analyze
Voice and taste stats from tweets.db. No network.
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 --jsonprints JSON withhandle,n_own,n_likes, andn_bookmarks.
tf login
Link this checkout to a tweetfactory.sh account. Does not upload tweets, the persona, or drafts.
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.jsonexists, mode600. The log says nothing is uploaded untiltf 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.
tf sync push
tf sync pull [--replace]
tf sync status [--json]
tf sync delete --yes| Subcommand | Effect |
|---|---|
push | Upload a full snapshot. Replaces the previous remote snapshot on commit. |
pull | Download into the local database and files. Keeps a local file newer than the remote copy. |
pull --replace | Clear local sync tables first, then drop local persona or draft files the remote snapshot lacks. |
status | Bytes, rows, quota, and counts. --json prints the status object. |
delete --yes | Delete the remote copy. Local files stay. Without --yes the command refuses. |
tf sync --help prints the subcommands and exits 0.
Verify.
tf sync statusbefore login says the checkout is not linked. After a push, the same command prints a byte quota.tf sync deletewithout--yesexits with an error and leaves the remote copy.
tf update
Replace this release binary with the latest one published at tweetfactory.sh.
tf update
tf update --checkThe 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 --checkon a release binary prints eitherup to dateoris availableand exits 0.bun run src/index.ts updateexits non-zero and mentionsinstall.sh.
tf version
Print the version and exit 0.
tf version
tf --version
tf -vA 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 versionprints a single line starting withtfand exits 0.tf --helplistsversion.