Many sources → evidence-backed themes → your approval → reviewed writing. View animated demo · Demo notes.
Content Engine brings your configured community, news, publishing-platform and competitor sources into one signal bank, groups them into evidence-graded themes, and turns the themes and formats you approve into reviewed writing. It runs in Claude Code or Codex and bundles the Blog and Editorial Draft workflows.
- What it does says what a run does and which helper scripts come with it.
- Quick start takes a fresh clone to a dry run and then to your first approval card.
- Choosing an entry point matches what you have to the entry point that uses it.
- Usage shows each entry point in one complete example.
- Reference holds the exact rules for each entry point, with its steps, sources, files, flags and settings.
- Limitations lists what the tool does not do for you.
- Project covers versioning, tests, contributing and the license.
/content-enginesaves each new post from your sources, called a signal, to a file called the signal bank and groups the posts into themes graded STRONG or THIN. On the next run it drafts a blog post, a LinkedIn post or a video brief for each format you approve on the theme's Slack card or in its theme doc. The grouping and the grade are the model's judgement, and no code checks them (Reference).- The helper scripts run single steps without the daily command: reporting, drafting one theme, scoring, fetching posts, and saving your own posts as voice examples. Choosing an entry point matches each one to what you have.
Nothing is ever published. Each draft is a Markdown file in docs/content-themes/drafts/ that you review and post yourself.
Editorial Draft ← Blog ← Content Engine. This repository owns all three layers; each lower layer accepts caller inputs without depending on the planning engine. There is no dependency on a product-workflow repository. Reviewed facts, source scope, voice, public-use permissions and output paths come from the caller.
- Content Engine routes scans, clustering, approval, inventory, competitor gaps, refresh plans, weekly queues and multi-format writing.
- Blog owns the complete article workflow: intent research, claim ledger, approved outline, sourced drafting/revision, editorial review, SEO/GEO checks when extensions exist, and the publishing package.
- Editorial Draft owns writing presets, eight editorial reviewer contracts, AI-pattern scrub and final polish. It also runs directly for requested refinement. Known voice and approvals skip repeated setup.
To use the pipeline in either host, link the native skills into your working repository:
python3 /absolute/path/to/content-engine/scripts/install-skills.py --into /absolute/path/to/your-repoIn Claude Code, invoke /content-engine or /blog through the installed skill catalog.
The writing skill is now named editorial-draft. Re-run the installer to add its
canonical host links. Existing articleforge links remain usable through the legacy
skills/articleforge compatibility symlink; the installer leaves local copies and
custom edits untouched. New integrations should use editorial-draft. The context
resolver retains its deprecated articleforge key as an alias to the same entrypoint.
In Codex, ask it to use the installed content-engine, blog or editorial-draft skill
with your approved inputs. The root commands/ and .claude-plugin/plugin.json
also expose Claude plugin commands without a second copy of the editorial pipeline.
For a weekly review, use Content Engine's weekly guide; it
stops at a review packet and queue. Planning covers
inventory, competitor-gaps and refresh-plan as well as evidence/format eligibility.
Engine code, rules and templates are read from the installed checkout. Configuration,
credentials, archives, signal bank, themes and drafts belong to your working repository.
The skill resolves these paths explicitly; imports never scan, write or configure
accounts. Config paths are caller-root-relative, or explicit absolute authorized
paths. --config overrides the default packages/content-engine/config.yaml in the
caller repository. Do not write state into an imported checkout. The standalone
clone continues to use the existing layout because engine and working roots coincide.
SEO skills/providers are optional extensions supplied by the host. Their upstream origins and licenses are documented in third-party notices. Missing providers leave metrics unmeasured; missing extension procedures permit labeled qualitative checks, not claims that formal audits ran. Blog and LinkedIn do not silently fall back to a one-prompt writer. Review personas are labeled as same-session work unless separate agents really execute. Nothing is published automatically.
The machine-readable pipeline manifest lists entrypoints, writing routes and required resources for importing applications. Consumers record an exact revision and own refresh policy; upstream changes are not fetched during ordinary skill execution. Upgrades never migrate your config or drafts into the engine.
You need macOS or Linux, Claude Code or Codex, Python 3.9 or newer, and git. Slack and external providers are optional.
CI tests the scripts, not a run in either host. Works with says what has been run.
The default writing pipeline runs in the host session and needs no separate API key beyond your host access: blog articles use Blog followed by Editorial Draft; LinkedIn posts use Editorial Draft directly; video output remains a brief. An explicitly configured custom writer remains supported. Existing configs with write.blog.command: packages/content-engine/write_reference.py keep that explicit reference route; remove that key in a reviewed config change to adopt the full default pipeline. write_reference.py is an opt-in one-prompt/API implementation, not the full editorial pipeline; its no-key response-file path remains available.
git clone https://github.com/tetriz-ai/content-engine.git
cd content-engine
python3 -m venv .venv
. .venv/bin/activate
pip install -r packages/scanner-core/requirements.txtKeep the virtual environment active when you start either host, so the command's scripts find PyYAML. Without one, a run never installs into the system Python: it stops and asks you to activate .venv.
Choose your host, keeping the virtual environment active and the working directory at the repo root.
For Claude Code, start claude, then type:
/content-engine --dry-run
For Codex, register the native skills in this checkout, then start the host:
python3 scripts/install-skills.py --into .
codexIn the Codex session, ask:
Use the installed content-engine skill with --dry-run.
Later /content-engine examples use Claude Code notation. In Codex, ask the
installed content-engine skill to run with the same mode and arguments.
For a noninteractive Claude Code preview, use the command in
Run it every day with /content-engine --dry-run in place
of /content-engine.
With no config yet, the preview uses the bundled example keywords, asks no questions, installs nothing and writes only under working/. It reads Hacker News and the first 2 example subreddits, sends nothing to Slack, and takes a few minutes because of Reddit's pacing (Reference).
The bundled keywords are about flaky CI tests, for a made-up product, so the preview may group a few live posts into a theme or find none. Its summary says how many posts each source returned, that it ran on the bundled examples, and which preview files it wrote under working/, such as working/preview-scan-<date>.json.
To see a finished draft file before you configure anything, see Draft one theme by hand.
To get approval cards in Slack, connect the Slack connector in Claude Code (type /mcp to check it is there) and create the setup file:
cp packages/content-engine/.env.example packages/content-engine/.envSet CONTENT_ENGINE_SLACK_CHANNEL in that file to where the cards go. A member ID, which starts with U, sends every card to you as a direct message. A channel ID, which starts with C or G, posts the cards in that channel.
To copy your member ID, open your Slack profile, click ⋮, then Copy member ID.
In Claude Code, run:
/content-engine
In Codex, ask:
Use the installed content-engine skill for a normal run without --dry-run.
If you skipped step 3, this run creates .env from the template and stops before asking anything. Fill in the Slack member ID, or leave it empty to go without Slack, and run /content-engine again.
With .env in place, the first run asks eight short questions, two of them optional, and writes your config from the answers. They cover what you build, who it is for, your competitors, the words your users use and the subreddits they post in. The last three ask which voice to write in, where you already publish, and whether to add Google Trends or Semrush.
In Slack, react 👍 on a theme's card to approve it, or ✏️ and reply in the card's thread with what to change. Without Slack, open the theme's doc in docs/content-themes/ and change its lines to **Status:** approved and, for example, **Formats approved:** [blog]. The next run writes the drafts you approved.
A theme doc looks like packages/content-engine/examples/theme-example-ci-flakiness.md, and packages/content-engine/examples/draft-example-ci-flakiness-blog.md is a hand-written example draft, both for a made-up product.
Before you publish one, check each claim against the theme doc's signals. Editorial review does not guarantee factual accuracy.
| You have | Use | Example |
|---|---|---|
| A fresh clone, and you want to preview a whole run, approvals and drafts included | /content-engine --dry-run |
Preview a run |
| A fresh clone, and you want only the scan and grouping on the bundled examples | /content-engine --mode quickstart |
Preview a run |
| Your own keywords, with or without Slack | /content-engine |
Run it every day |
| A few runs behind you, with their signal bank and theme docs | report.py |
See which sources pay off |
| An approved theme doc | write_reference.py |
Draft one theme by hand |
| Your own editorial pipeline | write.<format>.command |
Plug in a writer of your own |
| Posts of your own on Substack, Medium, LinkedIn or a blog | archive_fetch.py |
Save your own posts as voice examples |
| A saved archive of your posts | archive_tag.py |
Tag your posts by type |
A writer profile, a YAML file of your topics and archive settings like packages/content-engine/writer-profile.example.yaml |
relevance_scorer.py |
Score a theme against your writer profile |
| Competitor blogs with a feed | competitor-blog-scan.py |
Read competitor blog feeds |
| Keywords, and no Claude Code | scanner_core.py |
Use the fetchers on their own |
Every entry point keeps its state in plain files in your working repository. Only /content-engine posts to Slack.
The command file documents every mode and step, and the examples folder holds complete examples included in the CI workflow. docs/walkthrough.md follows one theme from its signals to a draft, and docs/glossary.md defines the terms. docs/demo-transcript.md is a trimmed dry run of an earlier command, without the source plan.
When: before your first real run, or after changing your config.
Run /content-engine --dry-run as in Quick start step 2. Read the summary it ends with. It names every file it wrote and every source that failed.
To preview only the scan and grouping, always on the bundled examples, type this instead:
/content-engine --mode quickstart
When: you want new themes each day and drafts for the ones you approved.
/content-engine
To run it on a schedule, do the first run by hand. Then schedule the commands below from your working repository, for example with cron. Replace <Slack tools> with your Slack connector's tool names, and add your Semrush MCP tools if you use Semrush.
Type /mcp in Claude Code to see the names. A scheduled run cannot approve tool calls, so a tool left out fails silently. Read each run's summary in working/content-engine.log.
engine_root="$(pwd -P)" # standalone checkout; for an import, use its absolute engine directory
. .venv/bin/activate && claude -p "/content-engine" --allowedTools "Read Write(./working/**) Write(./context-library/**) Write(./docs/content-themes/**) Edit(./working/**) Edit(./context-library/**) Edit(./docs/content-themes/**) Glob Grep WebSearch WebFetch Bash(python3 -c \"import yaml\") Bash(python3 -c \"import sys; print(sys.prefix != sys.base_prefix)\") Bash(python3 $engine_root/packages/scanner-core/scanner_core.py:*) Bash(python3 $engine_root/packages/content-engine/competitor-blog-scan.py:*) Bash(python3 $engine_root/packages/content-engine/report.py:*) Bash(python3 $engine_root/packages/content-engine/write_reference.py:*) Bash(python3 $engine_root/packages/content-engine/relevance_scorer.py:*) Bash(python3 $engine_root/packages/content-engine/archive_fetch.py:*) Bash(python3 $engine_root/packages/content-engine/archive_tag.py:*) Bash(python3 $engine_root/scripts/resolve-context.py:*) <Slack tools>" >> working/content-engine.log 2>&1For an importing application, set engine_root to the installed Content Engine
checkout and keep the working directory at the caller. Activate a caller-accessible virtual environment.
The allow list uses that engine's absolute script paths; it does not permit arbitrary
Python commands. WebFetch supports publication-date verification for approved sources.
Keep Slack tools absent when delivery has not been authorized. Complete setup manually first.
The Write/Edit permissions cover the default output paths only; if your caller config
uses different bank, archive or theme paths, replace them with those exact directories.
Never grant writes to the whole caller checkout.
Reddit scanning uses a 240-second budget by default, including pacing, retries,
worker startup, DNS, connection setup and response reads. Each request runs in a
short-lived worker that is stopped and joined at the deadline. Feeds above 8 MiB
are rejected with a failure record.
Completed feeds survive a timeout; every unfinished feed is listed in failures,
so a partial scan is never presented as complete. Review those failures before
using a quiet day as evidence of low activity. See scanner settings.
Remember:
- After each run, commit
context-library/content-signal-bank.jsonl,docs/content-themes/andcontext-library/content-themes-log.md. - A source that fails is reported in the summary and skipped. It never stops the run.
When: you want to know which configured sources ever produce a theme.
python3 packages/content-engine/report.py \
--bank packages/content-engine/examples/content-signal-bank.example.jsonl \
--themes-dir packages/content-engine/examples=== Signal bank ===
Total rows: 3 (3 un-spent, 0 spent)
By source — spent / total (the real tuning signal: a source
that's always near 0% is scanning noise, not evidence):
competitor_blog 0 / 1 0%
hn 0 / 1 0%
reddit 0 / 1 0%
=== Themes ===
Total: 1
By status:
approved 1
By coverage tier:
STRONG 1
On your own data, after your first /content-engine run has written config.yaml, run python3 packages/content-engine/report.py --config packages/content-engine/config.yaml.
Remember:
- A path that does not exist is named in a
[warn]line, so a wrong path does not look like an empty bank. - A source that stays near 0% never feeds a theme, so tune its keywords or drop it. A THIN theme can wait for more posts, or go out as a hedged blog post.
When: you want a draft of one approved theme outside the daily run, or to see its prompt.
examples/draft-without-an-api-key/run.shDraft written -> working/examples/draft-without-an-api-key.md
---
status: draft
format: blog
source_theme_doc: "packages/content-engine/examples/theme-example-ci-flakiness.md"
generated_at: <time>
drafted_by: "agent-session"
---
# Flaky tests cost more than the re-run
One Show HN post this month described a small CI plugin that re-runs a failed test once and
tags it flaky if the second run passes. Its authors say false-red builds fell by about a third
in the first month.
The same week, an engineer on r/ExperiencedDevs described a suite where about 15 tests fail
roughly one run in twenty, and a team that now re-runs every red build.
One named survey of 400 teams puts the median cost at 6.5 engineering hours a week. That is
one survey's median, not a rule for every team.
If your team also re-runs and moves on, start by counting how often it happens.
The example passes write_reference.py a fixed sample answer with --response-file, so it shows the draft file, not the model's writing. The generated_at line shows your own time.
On your own theme, run python3 packages/content-engine/write_reference.py --theme-doc docs/content-themes/<theme>.md --format blog --config packages/content-engine/config.yaml. It calls the Anthropic API with ANTHROPIC_API_KEY from .env or the environment, and sends the whole theme doc.
To see the prompt instead:
python3 packages/content-engine/write_reference.py \
--theme-doc packages/content-engine/examples/theme-example-ci-flakiness.md \
--format video \
--config packages/content-engine/config.example.yaml \
--dry-run | sed -n '/^FORMAT SETTINGS/,/^- Pick/p'FORMAT SETTINGS (these override the format description above where they differ):
- Length: short (60-90 seconds).
- Key facts: 1-2 signal-cited facts.
- Pick the persona target from: practitioner, manager, executive.
Remember:
- Pass
--config, or thewritesettings in your config do not apply.
When: you already have an editorial pipeline and want it to write the drafts.
examples/plug-in-your-own-writer/run.sh | head -n 1Outline written -> working/examples/outline.md
The example writer takes --theme-doc, --format and --out, and writes a Markdown draft with status: draft frontmatter. Point write.<format>.command in config.yaml at a program like it, and the run calls it in place of the bundled per-format workflow.
Remember:
- Your writer must refuse a theme with no signal itself. The bundled writer's refusal does not cover it.
- Your writer gets the theme doc, including the
## Author's takeand## Requested changessections.
When: you already publish on Substack, Medium, LinkedIn or a blog, and want drafts in your voice.
examples/save-a-whole-archive/run.sh | head -n 5 [warn] 2026-07-02-members-only-our-ci-bill-line-by-line.md is for paid subscribers, so only its preview was saved. Paste the full post into <tmp>/archive for a better voice example.
<site> (archive): 3 posts, 3 new, 0 already saved
[skip] 1 folder page(s) that list other pages
<site>/sitemap.xml: 2 pages under /guides/
<site>/sitemap.xml: 2 posts, 2 new, 0 already saved
This example serves a sample Substack archive and a sample blog sitemap on 127.0.0.1, so it runs offline. On your own posts, list them under archive_sources in your writer profile and run python3 packages/content-engine/archive_fetch.py --profile <your writer profile>.
| Source entry | What it saves |
|---|---|
substack: <name or domain> |
Every post in the publication's archive, each read whole |
medium_export: <zip or folder> |
Your published stories from Medium's "Download your information" export |
linkedin_export: <zip or folder> |
Your posts from LinkedIn's "Get a copy of your data" export |
sitemap: <URL> with path_prefix: /guides/ |
Every page in that folder of the site, with its category from the URL folder |
medium: "@handle" or rss: <feed URL> |
The posts the feed carries, about 10 on Medium |
A post no source covers goes into archive_dir by hand, one .md or .txt file each. examples/read-your-exports reads both exports, and examples/save-your-own-posts reads a feed.
Remember:
- A post already saved is never fetched again. A long first run can stop and pick up where it left off on the next run.
- Point
write.voice_profileinconfig.yamlat your writer profile, or the writer does not read the archive.
When: you have saved your posts and want each draft's examples to be posts of the same kind.
examples/tag-your-archive/run.sh tag index: 4 post(s) added, from files with no entry
4 tagged, 0 untagged left -> <tmp>/archive/.tags.json
posts: 4, untagged: 0
categories: ci-reliability (3)
content types: checklist (1), personal story (1), postmortem (2)
voice examples: 2 of type "postmortem", 1 of category "ci-reliability"
The example applies a fixed sample answer with --response-file, so it shows the format, not the model's judgement. On your own archive, run python3 packages/content-engine/archive_tag.py --profile <your writer profile>, which calls the Anthropic API.
Remember:
- The types come from
content_typesin your writer profile. Edit the list to match what you write. - Each post is tagged once.
--retagmarks them all to be tagged again. Give it on the first pass only, then repeat the tagging without it until nothing is left.
When: you have a writer profile and want to check one candidate by hand.
python3 packages/content-engine/relevance_scorer.py \
--profile packages/content-engine/writer-profile.example.yaml \
--title "Flaky tests are a quiet tax on CI" \
--rationale "Three posts this week on re-running red builds" \
--response-file examples/score-a-theme-without-an-api-key/answer.txt{
"score": 8,
"fit": true,
"why": "Flaky tests and CI re-runs are the core of the writer's CI reliability beat."
}
The answer file is a fixed sample, so this shows the output format, not the model's judgement. Without --response-file, the scorer calls the Anthropic API.
Remember:
- The score is the model's judgement of fit with your writer profile, not a measure of the theme's evidence.
- The daily run scores each new theme against your writer profile when
cluster.writer_profileinconfig.yamlpoints at that file.
When: a competitor's blog has an RSS or Atom feed and you want its new posts as signals.
examples/read-a-competitor-feed/run.shstatus: ok, 3 in the feed, 2 kept, 1 dropped with no date
2026-10-01T09:00:00+00:00 blog:example_blog:urn:example:1 Why we quarantine flaky tests
2026-09-30T08:00:00+00:00 blog:example_blog:urn:example:2 Measuring CI re-runs
The example serves a sample feed on 127.0.0.1, so no request leaves the machine. On your own feeds, list them under scan.competitor_blogs.feeds and run python3 packages/content-engine/competitor-blog-scan.py --config packages/content-engine/config.yaml.
Remember:
- Check that a feed URL resolves and parses before you list it. Guessing a
/feedpath often finds nothing.
When: you want matching Hacker News, Reddit, Product Hunt or X posts as JSON, without Claude Code.
examples/find-posts-from-python/run.shhn:101 Show HN: A flaky test detector for CI [flaky test]
reddit:t3_aaa Our CI pipeline reliability is getting worse [ci pipeline reliability]
failed: reddit r/devops hot: HTTP Error 500: Internal Server Error
This example calls scan_hn and scan_reddit from Python with canned replies, one of them a failed feed, so it runs offline. scanner-core's README covers its output and Python functions. For Product Hunt, see examples/list-matching-product-hunt-launches.
Remember:
- Read
failuresin the output as well ascandidates. An emptycandidateslist with no failures means nothing matched. - The block above is standard output. Progress lines go to standard error.
Each run takes these steps, in this order.
| Step | What happens |
|---|---|
| Setup check | If .env is missing, creates it from the template and stops. If PyYAML is missing, installs it into the active virtual environment, or stops and asks you to activate one. On the first real run, asks the eight setup questions. |
| Approvals | Reads the reactions on earlier cards and records each decision in its theme doc. |
| Drafts | Writes a draft for every approved format that has none yet, then sends "✍️ Draft ready" with the file path. |
| New themes | Scans every source, groups new posts into themes, grades them, and sends a card for each. |
| Summary | Lists approvals, drafts, new themes and every failed source or request, then reminds you to commit. |
Grouping posts into themes, judging relevance and writing drafts are the model's judgement. The STRONG or THIN grade is a count the model applies by the command's rule, and no code checks it.
| Reaction on a card | Result |
|---|---|
| 👍 | Approved for the formats on the card. The next run writes the drafts. A reply in the card's thread becomes your take. |
| ✏️ | Approved with changes. Reply in the card's thread with what to change, and the next run records each reply under ## Requested changes and writes the drafts. With no reply yet, the theme waits. |
| 👎 | Rejected. A 👎 wins over a 👍 or ✏️ on the same card. |
| 💬 | Needs edit, and nothing is drafted. Reply in the card's thread. The next run quotes your reply under ## Review notes in the theme doc, or under ## Author's take when it starts with take:. |
Without Slack, approve a theme by editing its doc, as in Quick start step 5.
With the example config's brief.coverage_tier, a theme is STRONG with 3 or more posts from 2 or more sources, and THIN otherwise. A LinkedIn post under your own name needs a STRONG theme, set by brief.format_eligibility.linkedin.personal_requires. A company LinkedIn post and a proper-length video (--length proper) need a STRONG theme plus one of these: 5 or more posts, posts from 3 or more sources, or one post from a named source such as a published study.
| Source | How far back (default) | What it needs |
|---|---|---|
| Hacker News | 48 hours | Nothing |
| 48 hours | Nothing | |
| News, Medium, Substack | 7 days | Nothing. Claude Code's web search runs the queries. |
| Competitor blogs | 14 days | An RSS 2.0, RSS 1.0 or Atom feed URL in config.yaml. With none listed, the run skips this source. |
| Product Hunt | 48 hours, or today's board with window: ph_day |
PH_API_TOKEN in .env (free) |
| X | Up to 7 days | TWITTER_BEARER_TOKEN in .env (paid) |
| Google Trends | Point in time | Nothing. Read from the web, so a weak signal. |
| Semrush | Point in time | A Semrush MCP server connected to Claude Code. Its AEO report runs only with scan.semrush.aeo_check: true. |
| Whatever you paste | Posts you paste into working/content-engine/signals/linkedin/ |
Each run prints its source plan before it scans: every source, on or off, and why. docs/optional-sources.md covers what each optional source needs, what it saves and what happens when it fails.
| Path | What it holds |
|---|---|
packages/content-engine/.env |
Your Slack ID and keys. Never committed. |
packages/content-engine/config.yaml, hn-reddit-scan.yaml, context.md |
Your config, written by the first run. Git-ignored. |
context-library/content-signal-bank.jsonl |
Every signal ever found, one per line. |
docs/content-themes/ |
Theme docs, and drafts in drafts/. |
context-library/content-themes-log.md |
One line per theme, used to skip a topic seen in the last 21 days. |
working/ |
Scratch: raw fetches, dry-run previews and example output. Never committed. |
--mode quickstart previews the scan, grouping and grading only. --dry-run also previews approvals and drafts, on your own config once you have one.
The command file also has one mode per step, for running a step by hand. They are --mode init, scan, cluster, brief, approve, write and report. The package README maps each setting to what it changes.
| Flag | What it does |
|---|---|
--theme-doc, --format |
The approved theme doc, and blog, linkedin or video. Both required. |
--out |
Where the draft goes. Default working/drafts/<theme doc name>-<format>.md. |
--config |
Applies write.model, write.voice_profile, the SEO pass and the per-format settings. |
--dry-run |
Prints the prompt and calls nothing. |
--response-file |
Uses a saved answer instead of calling the API. The draft says drafted_by: agent-session. |
--model, --profile, --seo, --no-seo |
Override the config for one run. |
--length |
short or proper, video only. |
- Priority. For the model, the writer profile (
write.voice_profile) and the SEO pass, a flag wins overwrite.<format>.<setting>, which wins overwrite.<setting>, which wins over the default. The settings in the table below are read fromwrite.<format>only. - SEO pass. Blog only, and
seo_optimizemust betrueorfalse. - Approval. It refuses, with a one-line reason and in every mode, a theme doc whose
Status:is notapprovedorwritten, a format not inFormats approved:, and a LinkedIn post or proper-length video on a theme below the tierbrief.format_eligibilitysets, STRONG in the example config. It readsStatus:,Formats approved:and the coverage tier only from the doc's header, the lines above its first##section. - Signals. A signal is a table row or list item under
## Signalsthat carries the post's http(s) URL. A row inside a code block or an HTML comment is not one. A theme doc with none is refused, because there is nothing to cite. - Failure. A missing theme doc, no signals, an empty answer, a cut-off answer, a missing key, an API error or an unreachable API each exit non-zero with a message.
These settings go into the prompt when --config is given:
| Setting | Goes into the prompt as |
|---|---|
write.blog.length |
The blog's length |
write.linkedin.word_count_target |
The post's word range |
write.linkedin.voice_override |
Point of view, pov: personal (default) or pov: company. Whether to open with a story of your own failure, which company leaves out by default. The call-to-action target. |
write.video.default_length or --length |
Short or proper, with that length's duration and fact count |
write.video.persona_targets |
The personas to choose from |
- Author's take. An
## Author's takesection in the theme doc goes into the prompt as the author's opinion: the draft argues it, and still cites a signal for every fact. - Requested changes. A
## Requested changessection goes into the prompt after the take, and the draft makes each change. A requested change overrides the format description, the format settings and a voice example's structure, such as the LinkedIn word range. A change that would need an uncited claim, a made-up quote or story, or anything on the "Do not say" list is left out. - Voice examples. With a writer profile, the writer picks past posts matching the theme doc's
**Content type:**, then**Category:**, then**Closest type in archive:**line, newest first within each. It prints onevoice examples:line, and uses none when nothing matches. - Example budget. The writer reads up to
max_examplesposts (default 5), each whole, up toarchive_max_charsin all (default 100,000). A post that does not fit is left out, not cut. - Copying. The draft copies the examples' style, and a same-type example's sections and their order. It never reuses their facts.
It reads archive_dir and archive_sources from --profile and saves each post as <date>-<title>.md, with its URL, date and category at the top. A sitemap page is named by its path below path_prefix, since its date is known only once it is read.
- Kept. A file already in
archive_diris never fetched again, so hand edits survive and an interrupted run resumes.--refreshrewrites them. - Dry run.
--dry-runlists what it would write and writes nothing. - Substack. It reads every post in the archive list,
min_gap_secondsapart (default 1), or the RSS feed when the list fails. A paid post is saved as its preview, with a warning. - Sitemap.
path_prefixis required. Pages are read newest first, up tomax_pages(default 2000). A page with under 200 characters of text, or the folder page of other pages, is skipped with a notice. - Exports. Medium's drafts and responses are skipped. A LinkedIn share with no text of its own is skipped. A file the script does not recognise is skipped with a notice.
- Failures. One failed source or page does not stop the others. The script exits 1 when every source failed.
- Tag index. Every saved post gets an entry in
archive_dir/.tags.json, with its URL, date, category, word count and content type.
It gives each post in the tag index one type from the profile's content_types, once. Without an API key, --dry-run prints the prompt for the next batch, and --response-file applies an answer written in its <file name>: <content type> format.
- Rejected lines. A line naming a type not on the list, or a post not asked about, is rejected with a message. The other lines still apply.
- Files by hand. A
.mdor.txtfile added toarchive_dirby hand gets an entry and is tagged too. --listprints the categories and types in the archive, which the brief step reads when it gives a new theme its category and type.--previewprints what--listprints, plus the titles of the posts a theme of the most common type would get as voice examples.
It prints {"score", "fit", "why"} as JSON. It exits 1 when the answer has no SCORE from 0 to 10 or no FIT of YES or NO, and still prints what it could read. Its prompt includes as many of your newest posts as the writer's (max_examples), each cut at excerpt_chars (default 6,000).
It reads --bank and --themes-dir, or both from --config, and never writes. A bank line that is not a JSON object is skipped with a warning. A theme's status and coverage tier are read as the writer reads them, from the doc's header, so anything but STRONG or THIN counts as THIN, with a warning.
It reads RSS 2.0, RSS 1.0 and Atom feeds from scan.competitor_blogs.feeds, keeps no state, and drops an undated post. A feed that returns a non-feed page fails alone, and the other feeds carry on.
scanner-core's README covers its config keys, its output and its Python functions.
| Host or part | What it runs | Status |
|---|---|---|
| Claude Code 2.1.292 or newer | /content-engine from this checkout, or the native skills scripts/install-skills.py links into your repo |
Run by hand |
| Codex | The content-engine, blog and editorial-draft skills that scripts/install-skills.py links into your repo |
Run by hand |
| Any shell with Python 3.9 or newer | The scripts, such as scanner_core.py and write_reference.py |
Covered by the configured CI suites |
| A Slack MCP server in your host | Approval cards and reactions | Needed for cards |
Both hosts have been run by hand on earlier workflow revisions. The current writing route has not been run end to end with a model in either host. CI runs neither host.
- Untrusted text in the agent run. The scripts' prompts mark outside text as data, but the agent still reads untrusted web text while it scans. Tool allow lists are the backstop (Trust boundary).
- Editorial review does not guarantee factual accuracy. Check each claim against the theme doc's signals before you publish.
- Hosts tested by hand. CI runs the scripts, never a Claude Code or Codex session.
- Editorial review is one session. The Blog and Editorial Draft reviewers run as instructions in the host session, not as independent agents, unless your host really runs separate agents.
The metadata version is 0.1.0, and the changelog tracks this snapshot as Unreleased. content-engine follows semantic versioning and is at 0.x, so a minor release may break things. A breaking change is one to the command name, its flags and modes, the .env keys, the config keys, the scripts' flags, or the fields of the output JSON. Raising the Python 3.9 or Claude Code 2.1.292 floor is a minor release.
The tests need no network, no keys and no Slack. A guard in each suite blocks every connection and points HOME at a temp folder. The suites cover theme-doc parsing, prompt assembly with every write setting, each fetcher's failures and undated posts, the archive sources and tagging, and the report counts.
(cd packages/scanner-core && python3 -m unittest discover -s tests)
(cd packages/content-engine && python3 -m unittest discover -s tests)
scripts/run-examples.shThe CI workflow is configured to run the tests on Python 3.9, 3.13 and 3.14, and another job checks that every script path the command file names exists. On 3.14 it also runs the examples, shellcheck, ruff check, ruff format --check and mypy, pinned at shellcheck 0.11.0.1, ruff 0.16.10 and mypy 2.4.0.
The workflow includes pip-audit and a gitleaks scan of the git history on pull requests, on pushes to main and weekly. See the workflow run history for execution results. The test and example jobs install PyYAML from packages/scanner-core/requirements-lock.txt with --require-hashes.
- Contributing. See CONTRIBUTING.md. Adding a source has its own recipe there.
- Security. See SECURITY.md. Report a vulnerability privately, not as an issue.
- License. Project-owned material is MIT-licensed. See LICENSE, NOTICE and THIRD-PARTY-NOTICES.md for attribution and separate third-party terms. The Python scripts require PyYAML; optional SEO/GEO extensions and the demo renderer have their own dependencies and licenses.
- Contributors. See CONTRIBUTORS.md.
Copyright 2026 Workloom Solutions Private Limited
