
Brief Outline Generator
- 77 installs
- 93 repo stars
- Updated June 28, 2026
- infrasity-labs/dev-gtm-claude-skills
Brief Outline Generator is an agent skill that produces a structured go-to-market brief outline solo builders can approve before full copy or campaign work.
About
Brief Outline Generator is an agent skill from the infrasity-labs dev-gtm-claude-skills collection that helps solo and indie builders produce a structured go-to-market brief outline before they invest time in full copy, creative assets, or launch plans. Instead of asking a coding agent to improvise positioning in chat, you invoke the skill when you know you need a brief—product announcement, campaign one-pager, landing narrative, or partner pitch—but only have rough inputs. The skill is aimed at Claude Code and similar agent environments where reusable SKILL.md capabilities beat one-off prompts. Use it when you are still scoping what the brief must cover: audience, promise, proof points, channels, and calls to action. It matters because outlines set the contract between your idea and downstream generators; a clear scaffold makes later validate, launch, and grow tasks faster and easier to review. Catalog metadata for the readable SKILL.md body was unavailable at ingest, so capability wording follows the skill slug and repository context.
- Generates a structured outline for marketing or product briefs instead of free-form chat
- Fits infrasity-labs dev-gtm-claude-skills for indie go-to-market workflows
- Useful before writing landing pages, launch posts, or campaign copy with an agent
- Outputs section-ready scaffolding agents can expand into full briefs or docs
- Reduces rework by agreeing on outline structure before creative execution
Brief Outline Generator by the numbers
- 77 all-time installs (skills.sh)
- +4 installs in the week ending Jul 25, 2026 (Skillselion tracking)
- Ranked #492 of 854 Sales & Marketing skills by installs in the Skillselion catalog
- Data as of Jul 26, 2026 (Skillselion catalog sync)
npx skills add https://github.com/infrasity-labs/dev-gtm-claude-skills --skill brief-outline-generatorAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 77 |
|---|---|
| repo stars | ★ 93 |
| Last updated | June 28, 2026 |
| Repository | infrasity-labs/dev-gtm-claude-skills ↗ |
What it does
Scaffold a go-to-market brief outline before your agent writes landing copy, campaign assets, or launch messaging.
Who is it for?
Best when you're preparing a product or launch brief and want a repeatable outline before handing work to other GTM or content skills.
Skip if: Skip if you already have an approved brief, spec, or creative deck and only need light edits or formatting.
When should I use this skill?
You need a structured go-to-market or product brief outline and do not yet have approved sections for messaging, proof, or channels.
What you get
You get a sectioned brief outline your agent can expand into landing copy, launch posts, or campaign docs after you approve the scaffold.
- Structured brief outline with named sections
- Scaffold ready for agent expansion into full GTM copy
- Approved section list for downstream launch or content work
Files
Brief Outline Generator
Generates a content outline as a formatted .docx file. The output is a skeleton — section headings, short topic prompts, angles for each section — that a writer fills in with their own conclusions, numbers, and prose.
This is an outline generator, not a brief generator. If your output reads like an article in note form, you've gone too far. Read references/section-rules.md before generating anything.
The DOCX is always produced by running `scripts/generate-brief.py`. Do not reimplement the renderer. Do not write inline docx code. Assemble the config JSON and run the script.
---
Inputs — collect from user before proceeding
| Field | Required | Notes |
|---|---|---|
title | ✅ | Blog post title. Warn if > 70 chars. |
focus_keyword | ✅ | Primary keyword |
sitemap_url | ✅ | Full sitemap URL, e.g. https://firefly.ai/sitemap.xml. domain_url is derived from this automatically. |
word_count_range | ✅ | e.g. 1500-2000 |
target_intent | ✅ | Informational, Commercial, Transactional, or Navigational |
target_product | ⬜ | Product name — triggers a product integration section if provided |
secondary_keywords | ⬜ | Pre-supplied list; skip generation if provided |
---
Execution workflow — follow these steps in order
Step 1 — Validate inputs
title: non-empty; warn (don't block) if > 70 charsfocus_keyword: non-emptysitemap_url: non-empty, starts withhttp://orhttps://(accept any path —.xml,.xml.gz, or extensionless dynamic URLs are all valid)word_count_range: two positive integers separated by-(e.g.1500-2000)target_intent: one ofInformational,Commercial,Transactional,Navigational(case-insensitive)
Derive two values from sitemap_url:
domain_url— strip the path to get the origin, e.g.https://firefly.ai/sitemap.xml→https://firefly.ai. Used in the config JSON.domain_hostname— strip the protocol too, e.g.https://firefly.ai→firefly.ai. Used in tool calls that require a bare hostname (e.g.site:firefly.ai).
Stop and report all validation errors before proceeding.
---
Step 2 — Read the rules
Read `references/section-rules.md` in full now. It contains:
- The outline-vs-brief distinction (the most important rule)
- Hard bullet rules (≤ 12 words, no invented numbers, no conclusions, no em-dash clauses)
- Hard structure rules (no
topic_summary, no Writer Directives box, TLDR carries 2–3 topic pointers) - The four archetypes (Listicle, Comparison, How-to, Concept/Explainer) and their section sets
- Per-section rules and good/bad bullet examples
- A final quality check to run before generating the DOCX
Do not skip this step. Generating without reading the rules produces brief-style output every time.
---
Step 3 — Domain analysis
DataForSEO tools are deferred — load them before calling. Call tool_search(query="on_page content parsing") at the start of this step.
3a — Discover site URLs
Call web_fetch on sitemap_url.
- Readable XML (response contains
<loc>tags) → extract every<loc>URL. These are your site URLs. - Binary / compressed (unreadable response) → fall back: call
dataforseo:serp_organic_live_advancedwithkeyword="site:{domain_hostname}"(e.g.site:firefly.ai) to get all indexed URLs.
Cap at 20 URLs. If more exist, prioritise: homepage → product/use-case pages → blog/resource pages.
3b — Read full page content
For each URL from 3a, call dataforseo:on_page_content_parsing with enable_javascript: true.
This returns fully rendered page text — headings, body copy, product descriptions, etc. Collect all output.
If a page fails (bot protection, timeout), skip it and continue — do not abort.
3c — Extract meta title and description
From the homepage content parsed in 3b, extract:
- Page
<title>→meta_title <meta name="description">content →meta_description
Surface character count advisories if outside recommended ranges:
meta_titleoutside 50–60 chars → "Note: fetched meta title is N chars (recommended: 50–60)."meta_descriptionoutside 150–160 chars → "Note: fetched meta description is N chars (recommended: 150–160)."
These are passed into the config as meta_title and meta_description and rendered in the metadata table.
3d — Compile domain_context
From all parsed page content, extract and store:
- Product name and core value proposition
- Key technical terms and vocabulary used on the site
- Target audience signals (roles, team types, use cases mentioned)
- Existing content topics — used to avoid duplication in the outline
Store as domain_context. Do not render `domain_context` in the output document. If all fetches fail, set domain_context = null and continue.
---
Step 4 — Classify the title's archetype
Based on the title pattern, pick one of:
- Listicle / Tool Roundup — "Top N", "Best X", "X Alternatives"
- Comparison / Versus — "X vs Y", "X or Y"
- How-to / Implementation Guide — "How to X", "How do X teams Y", "Implementing X"
- Concept / Explainer — "What is X", "Designing X", "[Function/Pattern]: Designing X"
If multiple seem to fit, use the defaults from section-rules.md. If still unclear, ask the user.
Announce the chosen archetype to the user before generating — one line, e.g. "Detected archetype: How-to. Generating outline with intro → strategies → case studies → implementation → FAQs." Let them override if they disagree.
---
Step 5 — Generate keyword volumes
Fetch USA monthly search volumes for the focus keyword and each secondary keyword using DataForSEO.
DataForSEO tools are deferred — load them before calling.
1. Call tool_search(query="keyword search volume google ads") to load the DataForSEO keyword tools. 2. Call dataforseo:kw_data_google_ads_search_volume with the full list of keywords (focus + all secondaries) in a single call. Use location_code for the USA (2840). 3. From the response, extract the search_volume field for each keyword. 4. Format each volume as a thousands-separated string (e.g. 3400 → "3,400"). Volumes under 1,000 stay as plain digits (e.g. "500", "30"). Volume of 0 should be rendered as "0", not "N/A" — it's a real datapoint. 5. If a keyword returns no data, set its volume to "N/A" and continue. Don't abort the whole run. 6. If tool_search returns no DataForSEO tools (connector not installed), set every volume to "N/A" and surface a one-line warning: "DataForSEO connector not available — keyword volumes set to N/A."
Never omit the volume field on any keyword. Every row must have one.
Flag volume mismatches. If the focus keyword's volume is more than 10× smaller than any secondary keyword's volume, tell the user before generating: "Note: your focus keyword has volume X, but secondary keyword Y has volume Z. Consider whether Y should be the focus." Let them decide; don't auto-swap.
Store as:
"focus_keyword_volume": "2,400",
"secondary_keywords": [
{ "keyword": "disaster recovery plan", "volume": "1,900" },
{ "keyword": "cloud DR strategy", "volume": "N/A" }
]If no secondary keywords were supplied, generate 5 by combining base terms from the focus keyword, top domain key terms, and modifiers ("best practices", "guide", "checklist", "for teams", current year). Then fetch volumes for them the same way.
---
Step 6 — Build the outline using the archetype's section set
Use the section set for the archetype you chose in Step 4. Do not force every topic into a how-to template. A listicle has no Problem or Case Studies section. A comparison has no Implementation steps.
Each section object shape:
{
"heading": "H2",
"title": "Section Title",
"rules": ["short topic prompt 1", "short topic prompt 2"],
"subsections": [
{
"heading": "H3",
"title": "Subsection Title",
"rules": ["..."],
"subsections": []
}
]
}Fields removed from the previous schema (do not use):
- ~~
topic_summary~~ — removed. Outlines don't have abstracts. - ~~
directives~~ — removed. The bullets themselves carry the direction. - ~~
visual~~ — removed. If a visual matters, write it as a bullet prompt: "Include a comparison table: dimension × tool". - ~~
faqs~~ — removed. FAQs are questions only (inrules); the writer drafts answers.
Bullets in `rules` follow the hard rules in `section-rules.md`:
- ≤ 12 words each
- Must be specific and a complete thought
- No invented numbers
- No conclusions — topic prompts only
- No em-dash explanatory clauses
Length follows substance, not a fixed cap. Use as many bullets as the section honestly needs — 2 if 2, 9 if 9. The "typical ranges" in section-rules.md are orientation, not limits. Padding to hit a target number is wrong; truncating to hit one is also wrong.
TLDR section: include a TLDR heading with 2–3 short topic pointers (each ≤ 12 words) that name what the writer should cover in the TLDR. Pointers are topics, not finished takeaways. Pull from the article's central tension, the main shift the reader should make, and the practical next step. See the TLDR section in references/section-rules.md for good/bad examples.
FAQs section: Use the rules field with a list of question strings — questions only, no answers. The writer drafts the actual answers. Each question is one bullet. Focus on how questions are framed: concrete and action-oriented ("How do I install a Claude skill?"), not abstract ("How does Claude decide which skill to invoke?"). All user-supplied keywords (focus + each secondary) must appear naturally across the question set — paraphrased into something a real searcher would type, never keyword-stuffed. Typically 5–8 questions but no fixed cap. See the FAQs section in section-rules.md for examples.
---
Step 7 — Run the final quality check
Before assembling the config, walk the outline and verify (this is from section-rules.md):
1. Could a writer publish this by adding transition words? → If yes, strip back. 2. Does every bullet read as a topic prompt, not a sentence? (except FAQ questions, which are naturally full sentences) → If no, rewrite. 3. Could a writer guess each bullet's meaning in 3 different ways? → If yes, the bullet is too abstract. Name the actual thing (files, components, technical terms). 4. Is every bullet a complete thought when read aloud? → If a bullet is a sentence fragment with no meaning ("Who this is for: builders doing X"), finish the thought. 5. Is every technical claim accurate? → Skills are packages not folders; Claude reads SKILL.md, not the whole skill eagerly. If unsure, rewrite to avoid the claim. 6. Any invented numbers? → Remove. 7. Any sub-bullet that feels like its own H2? → Promote it. 8. Are H2 titles specific to the article's topic (not generic verb phrases)? → "Define the task narrowly" → "Define what your skill should do." 9. Section order matches the archetype? → Verify. 10. No topic_summary, directives, or faqs fields present? → Strip if present. FAQ questions live in rules as flat bullets. 11. Do all user-supplied keywords (focus + each secondary) appear naturally across the FAQ questions? → If any is missing, rewrite a question to incorporate it. 12. Are FAQ questions concrete and action-oriented ("How do I install...", "What is the difference between..."), not abstract ("How does Claude decide...")? → Rewrite vague ones.
If all 12 pass, proceed.
---
Step 8 — Assemble the config JSON
Write the complete config to /home/claude/brief-config.json:
{
"title": "...",
"focus_keyword": "...",
"focus_keyword_volume": "N/A",
"domain_url": "...",
"word_count_range": "...",
"target_intent": "...",
"target_product": "...",
"archetype": "how_to",
"meta_title": "...",
"meta_description": "...",
"secondary_keywords": [
{ "keyword": "...", "volume": "N/A" }
],
"output_path": "/mnt/user-data/outputs/outline-{file_slug}.docx",
"outline": [ ... ]
}Slug note: Two slugs are generated internally:
- URL Slug (shown in the metadata table) — derived from
focus_keyword, e.g."cloud disaster recovery"→cloud-disaster-recovery. - File slug (used in the filename) — derived from
title, e.g."How Do Platform Teams Implement Cloud Disaster Recovery"→outline-how-do-platform-teams-implement-cloud-disaster-recovery.docx.
The output_path in the config should reflect the title-based file slug.
Do not include `domain_context` in the config. It informs generation upstream; it never appears in the rendered doc.
---
Step 9 — Run the generator script
python /path/to/skill/scripts/generate-brief.py --config /home/claude/brief-config.jsonThe script handles all DOCX rendering. Do not write any docx code yourself.
If the script exits non-zero, report the error from stdout/stderr and show the outline as plain text as a fallback.
---
Step 10 — Present the file
Call present_files with the output path. Report:
✅ Outline generated: outline-{file_slug}.docx
Archetype: {archetype}
Slug (URL): {url_slug} (from focus keyword)
Slug (file): {file_slug} (from title)
Audience: {audience}
Focus KW: {focus_keyword} ({volume})
Secondary KWs: {kw} ({vol}), ...
Sections: {N} sections---
What the script renders (reference only — do not reimplement)
The script produces a .docx with:
1. Metadata + keyword table — 3 columns: Field | Value | USA Search Volume. Standard rows (Title, URL Slug, Word Count, Target Intent, Target Audience, Meta Title (50-60 chars), Meta Description (150-160 chars)) span cols 2+3. URL Slug is derived from `focus_keyword`, not the title. Focus keyword and each secondary keyword get their own row with volume in col 3 (amber background). Domain and Domain Context rows are NOT included in the table. 2. H1 title 3. Sections — each heading field renders as [H2] or [H3] grey label prefix + heading text. rules render as bullet points. No `topic_summary` block, no Writer Directives box — those have been removed from the format.
---
Reference files
references/section-rules.md— Outline-first rules, archetypes, per-section rules, good/bad examples. Read in Step 2 before doing anything else.scripts/generate-brief.py— The DOCX renderer. Always run this. Never reimplement it.examples/— Canonical good outlines, for reference only. Read these to calibrate bullet length, density, and tone — not to copy section structure, headings, or topics. The archetype rules inreferences/section-rules.mddecide structure for the current topic. Never use an example outline as a template for the user's outline, even if the topic looks similar.
Brief Outline Generator
Turns a blog title and a focus keyword into a structured, formatted Word document — a content outline a writer can fill in. Not a finished article: section headings, short topic prompts, and FAQ questions, formatted and ready to hand to a writer.
<p align="center"> <img src="../../assets/brief-outline-generator-ss.png" width="100%" alt="Brief Outline Generator output" /> </p>
<!-- Video demo --> <!-- <video src="../../assets/brief-outline-generator-demo.mp4" width="100%" controls></video> -->
---
What this skill does
You provide a title, a focus keyword, and a domain URL. The skill runs domain analysis to extract product context, fetches keyword volumes from DataForSEO, classifies the content archetype, selects the right section set, runs a 12-point quality check, and renders a .docx file with a metadata table, keyword volume block, and full outline.
Built for:
- SEO and content teams who want consistent outlines without writing them by hand
- Developer marketing producing technical content at scale
- Agencies delivering structured briefs to freelance writers
---
Installation
Claude Code (Recommended)
Clone the repo — the skill activates automatically when you open it in Claude Code:
git clone https://github.com/Infrasity-Labs/dev-gtm-claude-skills.git
cd dev-gtm-claude-skills
claudeThen trigger it with:
/dev-gtm brief-outline "How to Build Claude Skills"Or describe what you want naturally — Claude activates the skill when you provide a title or keyword and ask for an outline or brief.
Claude Web (Free / Pro)
1. Go to [Settings → Capabilities](https://claude.ai/settings/capabilities) and enable Code execution and file creation 2. Go to [Customize → Skills](https://claude.ai/customize/skills) 3. Click + → Create skill → Upload a skill 4. Zip this skill folder and upload it:
cd dev-gtm-claude-skills/skills
zip -r brief-outline-generator.zip brief-outline-generator/Upload brief-outline-generator.zip and toggle it on.
---
DataForSEO Setup (Required for keyword volumes)
This skill uses DataForSEO to fetch US monthly search volumes for the focus keyword and secondary keywords. Without it, volumes show as N/A but the outline still generates correctly.
Claude Code
Open .claude/settings.json in the cloned repo and replace the placeholder credentials:
{
"mcpServers": {
"dataforseo": {
"command": "npx",
"args": ["-y", "@dataforseo/mcp-server"],
"env": {
"DATAFORSEO_USERNAME": "your-dataforseo-username",
"DATAFORSEO_PASSWORD": "your-dataforseo-password"
}
}
}
}Claude Web (Free / Pro)
Go to [Settings → Integrations](https://claude.ai/settings/integrations), find DataForSEO, and connect your account.
---
How to use
Generate an outline for "How Do Platform Teams Implement Cloud Disaster Recovery"Create a brief for the keyword: observability for platform teams/dev-gtm brief-outline "API authentication best practices"Claude asks for any missing required inputs before starting.
Inputs
| Field | Required | Notes |
|---|---|---|
| Title | ✅ | Blog post title |
| Focus keyword | ✅ | Primary SEO keyword |
| Domain URL | ✅ | Used to fetch product context |
| Word count range | ✅ | e.g. 1500-2000 |
| Target intent | ✅ | Informational, Commercial, Transactional, or Navigational |
| Target product | ⬜ | Optional. Adds a "How [Product] Helps with X" section |
| Secondary keywords | ⬜ | Optional. If omitted, the skill generates five |
<p align="center"> <img src="../../assets/brief-outline-generator-gif.gif" width="100%" alt="Brief Outline Generator demo" /> </p>
---
Output
A .docx file named outline-{slug}.docx containing:
Metadata + keyword volume table — title, URL slug, word count, target intent, target audience, focus keyword and secondary keywords with their US monthly search volumes.
H1 title — the full blog post title.
Sections — each heading prefixed with a [H2] or [H3] label so the writer can never misread the level. Bullets under each section are short topic prompts. Subsections are indented and styled consistently.
Saved to /mnt/user-data/outputs/outline-{slug}.docx (or your Downloads folder in Claude Code).
---
Things to know
Volume mismatch flag. If your focus keyword has 10× less monthly search volume than any secondary keyword, the skill flags it before generating and asks whether you want to swap. You decide — it never auto-swaps.
Domain fetch can fail. Some sites (Cloudflare-protected, bot-blockers) return 403. The skill continues and skips product-specific context. The outline still generates correctly.
DataForSEO is optional. If credentials aren't configured, all volumes show as N/A and the skill warns you once. Everything else works.
---
How it works
1. Validate inputs — checks all required fields are present and well-formed before any work starts 2. Read section rules — reads references/section-rules.md for the outline structure rules 3. Domain analysis — fetches the homepage and sitemap, extracts key product terms and context 4. Keyword volumes — calls DataForSEO for US monthly search volumes on the focus keyword and all secondaries 5. Volume mismatch check — flags and asks about swaps if the focus keyword is significantly lower volume than a secondary 6. Build the outline — assembles the section set based on detected archetype (how_to, listicle, comparison, concept) 7. Quality check — runs 12 checks on the assembled outline; revises and re-checks until all pass 8. Render the `.docx` — calls scripts/generate-brief.py to produce the formatted Word document
---
Config schema reference
The JSON Claude assembles before calling the renderer:
{
"title": "How to Build Claude Skills",
"focus_keyword": "building claude skills",
"focus_keyword_volume": "30",
"domain_url": "https://example.com/",
"word_count_range": "1500-2000",
"target_intent": "Informational",
"target_product": "OptionalProductName",
"archetype": "how_to",
"secondary_keywords": [
{ "keyword": "Claude Code Skills", "volume": "3,400" }
],
"output_path": "/path/to/outline.docx",
"outline": [
{
"heading": "H2",
"title": "Section Title",
"rules": ["short topic prompt", "another prompt"],
"subsections": []
}
]
}---
File structure
brief-outline-generator/
├── SKILL.md # Skill instructions Claude follows
├── README.md # This file
├── references/
│ └── section-rules.md # Outline rules, archetypes, per-section rules, examples
├── scripts/
│ ├── generate-brief.py # DOCX renderer
│ └── domain-analyzer.py # Domain fetcher and term extractor
└── examples/
└── *.docx # Reference outlinesOutline Rules Reference
This is an outline generator, not a brief generator. The distinction is the most important rule in this document — every other rule descends from it.
---
Outline vs Brief — the rule everything else follows
A brief pre-writes the article in note form: conclusions, numbers, narrative arc, opinions. The writer paraphrases what's already decided.
An outline is a skeleton: section headings, the topic of each bullet, the angle for that section. The writer brings the conclusions, numbers, prose, and voice.
The test: If a writer could publish what you generated by adding transition words and minor paraphrasing, you've written a brief. Strip it back to an outline.
What this means concretely
| Brief (wrong) | Outline (right) |
|---|---|
| "High RTO due to manual reconstruction — typically 4–24 hours" | "High RTO due to manual reconstruction" |
| "Situation: Multi-AZ app with warm standby; backups stored in same region. Failure: Regional degradation; standby drifted 3 weeks. Recovery took 4 hours." | "What failed / What had to be rebuilt manually / Where automation helped and where it didn't" |
| "Teams that test restores quarterly recover 3–5× faster than those who don't" | "Why preparation matters more than tooling" |
| Long paragraph-bullets with em-dash clauses adding explanation | Short prompts pointing at the topic |
If you can't tell whether something is a brief or an outline, ask: would the writer have anything left to think about? If no, it's a brief.
---
Hard rules for every bullet
1. ≤ 12 words per bullet. Hard limit. Split if longer. 2. Must be a complete thought, not a fragment. "Who this guide is for: builders standardizing repeat tasks" is meaningless — what about that audience? Fix: "Who this guide is for: developers automating repeat Claude workflows with reusable skills." Every bullet has to make sense on its own when read aloud. 3. Must be specific, not abstract. A bullet should name the actual thing — file names, components, technical terms — not a vague gesture toward it. "A skill as a folder Claude reads on demand" is wrong (also factually wrong: a skill is a package, not a folder). Fix: "A skill: a package containing SKILL.md plus optional references/, scripts/, and examples/." The writer still does the explaining; the bullet just won't let them drift onto the wrong topic. 4. Must be factually accurate. No technical hand-waving. If the bullet says "Claude reads SKILL.md", that has to actually be true. If you're unsure, look it up or rewrite to avoid the claim — never approximate. 5. No em-dash explanatory clauses inside bullets. "X — why X matters" is a brief sentence, not an outline prompt. Cut everything after the em-dash unless it's a parenthetical clarification of 2–3 words. 6. No invented numbers. Banned: specific durations ("typically 4–24 hours"), specific percentages ("99.99%+"), specific multipliers ("3–5×"), specific counts of failed resources, specific time-ago references ("drifted 3 weeks behind"). If a number matters, the bullet says "include a credible benchmark" — the writer sources it. 7. No conclusions. A bullet should name a topic to cover, not state what the writer should conclude. "Why preparation matters more than tooling" is a topic. "Preparation matters more than tooling" is a conclusion. 8. No invented case-study specifics. Don't write "A fintech team's region fallback broke compliance" with a fabricated scenario. Write "Case where governance-critical attribute had a silent fallback" — the writer picks a real example or constructs one truthfully.
Length follows substance, not a fixed cap
There is no fixed minimum or maximum number of bullets per section. Some sections genuinely need 2 bullets; some need 9. The right number is the number of distinct things the writer needs to address — no more, no less.
The rule: if a section has 8 things worth saying that each meet the bullet rules above (specific, complete, accurate, prompt-shaped), use 8 bullets. If it has 2, use 2. Padding to hit a target is as wrong as truncating to one.
How to know if you're padding: ask "would removing this bullet leave a hole the writer would have to fill anyway?" If no — it's padding, remove it. How to know if you're truncating: ask "is there a question a reader would have after reading this section that none of my bullets prompt the writer to address?" If yes — add a bullet.
Per-section guidance below uses "typical ranges" not caps. Treat them as orientation, not rules.
The specificity test
The single biggest failure mode of this skill is producing bullets that are correctly short, correctly prompt-shaped, and completely empty of meaning. Before finalizing any bullet, ask:
If a writer read this bullet, would they know exactly what to write about, or would they have to guess?
| Empty (wrong) | Specific (right) |
|---|---|
| "A skill as a folder Claude reads on demand" | "A skill: package with SKILL.md plus optional references/, scripts/, examples/" |
| "Define the task narrowly" | "Pick one repeat workflow: e.g., generating SEO outlines, summarizing PRs, drafting briefs" |
| "Write the description for triggering" | "Write the SKILL.md description: phrases users say that should invoke the skill" |
| "Test on real prompts" | "Test by typing actual user prompts (not synthetic ones) and inspecting drift" |
| "Document examples and ship" | "Add 1–2 examples/ files, then publish the skill folder to your team or registry" |
| "Where automation helped" | "Which steps Terraform automated vs which required manual config" |
Specificity is not the same as length. The "right" examples above are all the same length as the "wrong" ones — they just name the actual thing.
Self-check before finalizing each bullet
- Is it > 12 words? → split or trim
- Could a writer guess what's meant 3 different ways? → make it specific
- Is it a sentence fragment with no complete thought? → finish the thought
- Does it state a conclusion? → downgrade to topic prompt
- Does it contain a number that wasn't sourced from real data? → remove the number
- Is it a complete sentence with subject + verb + qualifier? → it's a paragraph, rewrite
- Is it technically accurate? → if unsure, rewrite to avoid the claim
---
Hard rules for section structure
1. No "Writer Directives" boxes. They duplicate what well-written bullets already say. The bullets themselves carry the direction. 2. No `topic_summary` blocks. None of the canonical good outlines have an abstract paragraph. The title + first section's bullets establish the topic. 3. TLDR carries 2–3 topic pointers, not finished takeaways. Outlines don't pre-write conclusions. The pointers name what the writer should address in the TLDR, not the takeaways themselves. See the TLDR section below for examples. 4. No clever H2 hooks. Banned patterns: "When X Stops Y for No Good Reason", "X Is Not Y — Here's the Distinction That Matters", "Why Most Teams End Up With Z", anything with "Here's", "Actually", or "Real" doing rhetorical work. Section headings describe content; they don't perform. 5. Hide domain context from the rendered output. The metadata table never shows Domain or Domain Context rows. Domain context informs generation; it doesn't appear in the doc. 6. Promote distinct concepts to their own H2. If a sub-bullet could be its own full section (e.g., "How skills differ from prompts and tools" inside "What Claude Skills Are"), it probably should be. Sub-bullets are aspects of a section's topic. If a bullet introduces a separate comparison, distinction, or concept the writer would explore in 200+ words, it belongs as a standalone H2.
Test: Read the sub-bullet aloud. If you find yourself thinking "actually, that's its own thing" — promote it. The good terraform try() outline does this: "try() vs Other Terraform Functions" is its own H2, not a bullet inside "What try() Does". 7. H2 titles must be specific to the article's topic, not generic. "Define the task narrowly" is a generic verb phrase; "Define what your skill should do" is topic-specific. "Test on real prompts" gives no information; "Test the skill with real user prompts" tells the writer what to test and how. Use the article's noun ("skill", "DR strategy", "outline") in section titles, not abstract verbs alone.
---
Adaptive section selection — pick the archetype before outlining
The skill must classify the title into one of four archetypes and use the matching section set. Do not force every topic into the how-to template.
Archetype A — Listicle / Tool Roundup
Signals in title: "Top N", "Best X", "X Alternatives", "X Tools for Y"
Section set: 1. TLDR (heading + 2–3 topic pointers) 2. Introduction 3. (Optional grouping intro) — one sentence describing how the list is organized 4. The list itself — H3 groupings if N > 6, then H4 per item 5. (Optional) Synthesis section — "From Fragmented X to Unified Y" or similar 6. FAQs
Per-item template (H4):
- Role / What it does
- Where it fits in the lifecycle
- (Optional) Limitations or trade-offs
- (Optional) Typical use
Anti-pattern: Adding "Problem", "Solution", "Case Studies", "Implementation" sections to a listicle. Listicles don't need them.
Example: Top 10 Terraform Tools for Multi-Cloud Platform Teams → no Problem section, no Case Studies, just intro + grouped list + synthesis + FAQs.
---
Archetype B — Comparison / Versus
Signals in title: "X vs Y", "X or Y", "X compared to Y"
Section set: 1. TLDR (heading + 2–3 topic pointers) 2. Introduction 3. Quick Overview — one H3 per tool, with pros/cons/pricing as sub-bullets (not full prose) 4. Detailed Comparison by Dimension — one bullet group per dimension (context handling, security, collaboration, model flexibility, etc.) 5. Decision Matrix / Best Fit — a table the writer fills in 6. (Optional) Product Integration — only if target_product is set and the product isn't one of the compared tools 7. Conclusion 8. FAQs
Anti-pattern: Writing the comparison itself in the outline. The outline says what dimensions to compare; the writer reaches the verdict.
Example: Roo Code vs Cline: Enterprise-Grade AI Coding Agents for VS Code
---
Archetype C — How-to / Implementation Guide
Signals in title: "How to X", "How do X teams Y", "Implementing X", "Building X"
Section set: 1. TLDR (heading + 2–3 topic pointers) 2. Introduction 3. What [X] Means — definitions, metrics, scope 4. (Optional) X vs adjacent concepts — kill confusion early 5. Strategies / Approaches — H3 per strategy, with what/when/trade-off as 3 short bullets 6. Real-World Case Studies — H3 per case, 3–4 bullet prompts each (Situation / Failure / Recovery / Lesson — as topics, not pre-written content) 7. Why Traditional Approaches Fail — name the failure modes as topics 8. (Optional) Product Integration — only if target_product is set 9. Implementation Steps — numbered H3s, each with a short prompt list 10. Conclusion 11. FAQs
Anti-pattern: Writing the case studies as mini-articles. Each case study is 3–4 short bullet prompts, not paragraphs.
Example: How Do Platform Teams Implement Cloud Disaster Recovery
---
Archetype D — Concept / Explainer
Signals in title: "What is X", "X explained", "Understanding X", "Designing X with Y", "[Function/Pattern]: Designing X"
Section set: 1. TLDR (heading + 2–3 topic pointers) 2. Introduction — why this exists in real-world practice 3. What [X] Actually Does — precise definition, scope, what's in and out 4. [X] vs Adjacent Concepts — common confusion points 5. Core Design Pattern — the canonical correct usage 6. Use Cases Where [X] Matters — H3 per use case 7. Governance / Testing / CI Implications — how it interacts with the broader system 8. Common Pitfalls and Anti-Patterns 9. (Optional) Product Integration — only if target_product is set 10. FAQs
Anti-pattern: Pre-writing the explanation. The outline points at what to explain; the writer explains it.
Example: Terraform try() Function: Designing Resilient and Governed IaC
---
When the archetype is unclear
Ask the user. Don't guess.
If multiple archetypes seem to fit, default to:
- Listicle if title starts with a number
- Comparison if "vs" or "or" appears
- How-to if the title starts with "How"
- Otherwise Concept/Explainer
---
Per-section rules
These apply within whichever archetype you're using.
TLDR
Purpose: Point at the 2–3 things the article's TLDR should cover, so the writer doesn't reinvent the framing.
Rules:
- 2–3 short topic pointers, each ≤ 12 words
- Pointers name what the TLDR should address, not the conclusions themselves
- Must be specific to the article. A pointer like "The shift from prompt engineering to reusable skills" is too abstract; the writer doesn't know what shift, or in what direction. Fix: "What changes between prompt engineering and shipping a reusable skill"
- Pull from the article's structure: the central tension, the concrete distinction the reader needs, the practical next step they should take
- One pointer per "thing the reader will walk away with"
- Never write a finished takeaway sentence — that's the writer's job after drafting
Good TLDR pointers (specific, scoped, prompt-shaped):
- What a skill is composed of: SKILL.md, references/, scripts/, examples/
- When to build a skill vs use a system prompt or one-off prompt
- Common mistakes: vague descriptions, missing examples, over-engineering
Bad TLDR pointers (vague gestures, no scope):
- ❌ The shift from prompt engineering to reusable skills (what shift? scope?)
- ❌ What separates a useful skill from an over-engineered one (what separates them? give the writer a hook)
- ❌ Where new builders most often go wrong (go wrong how? on what?)
Bad TLDR pointers (these are conclusions, not pointers):
- ❌ Most DR strategies fail because backups aren't tested
- ❌ Teams should run quarterly recovery drills
Anti-pattern: Writing the TLDR itself. The writer drafts the actual TLDR sentences after the article is complete; the outline just tells them what to point at.
Introduction
Purpose: Establish the reader's reality. Set scope.
Rules:
- Open with the reader's lived frustration, not a definition or statistic
- Specific scenario, not category
- Close with scope: what this post covers and (implicitly) what it doesn't
- Typically 4–6 bullets (more if the introduction genuinely earns them; padding is worse than a short intro), each ≤ 12 words
Opening prompts that work:
- "[Specific failure scenario] — a recognizable moment for the reader"
- "Why teams with [X] still fail at [Y]"
- "The real bottleneck: [reframe the problem]"
Closing prompt:
- "Scope: what this post covers — [one short clause]"
No `topic_summary` block. No directives box.
---
Problem Section (used in How-to and sometimes Concept)
Title patterns: "Why Traditional [X] Approaches Fail", "Why [Standard Approach] Falls Short"
Purpose: Name the failure modes the reader has seen but not articulated.
Rules:
- Typically 4–6 failure modes (more if the topic genuinely has them), each as a short bullet
- Lead with the most counterintuitive one
- Each bullet names the failure as a topic, not a conclusion
- No invented frequencies ("most teams find...", "studies show...")
Example bullets (good):
- Snapshots without deployable infrastructure definitions
- Runbooks that assume tribal knowledge
- Drift between actual state and declared IaC
- No safe way to verify recovery without breaking production
- Recovery plans that exist only on paper
Example bullets (bad — these are conclusions, not topics):
- ❌ Most teams discover their runbooks are out of date only during a real outage
- ❌ Snapshots fail in 60% of cases when infrastructure context is missing
---
Solution / Strategies Section (used in How-to)
Title patterns: "[N] [Topic] Strategies", "[N] Approaches to [Outcome]"
Rules:
- H3 per strategy if more than 3
- Each strategy: what / when / trade-off — typically 3 short bullets, more if the strategy is multi-faceted
- No invented RTO ranges, costs, or percentages
- End with a comparison prompt (table or bulleted matrix) — say "include a comparison"; don't write the comparison
Per-strategy template:
- What it is (≤ 8 words)
- When to use it
- Main trade-off or failure mode
Example (DR):
H3: Pilot Light
- Core infrastructure exists but scaled to zero
- Faster recovery than backup-and-restore
- Sensitive to config drift
---
Case Studies Section (used in How-to)
Title patterns: "[Topic] in the Real World: Case Studies", "When [X] Goes Wrong"
Rules:
- Typically 2–3 case studies (more if the topic has well-known public examples worth covering), H3 per case
- Each case is 3–5 short bullet prompts — never a pre-written narrative
- Bullets name what to cover, not the story itself
- No invented company names, durations, or resource counts
Per-case template:
- What failed
- What had to be rebuilt / what required manual intervention
- Where automation helped and where it didn't
- Lesson — as a topic, not a stated conclusion
Good case study bullets:
- What failed
- What had to be rebuilt manually
- Where automation helped, and where it didn't
- Lesson on drift vs declared state
Bad case study bullets:
- ❌ Situation: Multi-AZ app with warm standby; backups stored in same region
- ❌ Failure: Regional degradation hit; standby environment had drifted 3 weeks behind production config
- ❌ Recovery: Data restore worked; infrastructure rebuild took 4 hours manually
- ❌ Lesson: Warm standby with config drift is slower than teams expect
(The bad version pre-writes the case study. The writer has nothing to do except paraphrase.)
---
Implementation Section (used in How-to)
Title patterns: "Executing a [Topic] Scenario", "Implementing [X]: Step by Step"
Rules:
- Numbered H3 steps (1, 2, 3, ...)
- Each step has 2–4 short bullet prompts
- Steps name the action; bullets name the considerations
- Executable by a mid-level engineer — no assumed tribal knowledge
- Final step is verification
Per-step template:
- The H3 title is the action verb
- Typically 2–4 bullets (more if the step has more sub-tasks), each a consideration or sub-task
- No "what could go wrong" boxes — fold into a bullet if needed
Example:
H3: 3. Simulate Failure Safely
- Run recovery in isolated environments
- Avoid destructive tests in production
- Time the drill against your RTO target
---
Product Integration Section (conditional — only if `target_product` is set)
Title pattern: "How [Product] Enables [Topic]", "How [Product] Fits [Workflow]"
Rules:
- 3–4 H3 subsections, each tied to a specific product capability
- Each capability connects to a problem named earlier in the outline
- Reference real product features (from domain analysis) — never generic ("our platform helps you...")
- No superlatives ("best", "most powerful")
- Bullets: capability name → what it addresses → fit in workflow (typically 3 bullets per H3, more if the capability is genuinely rich)
Anti-pattern: Treating this section as a sales pitch. The product appears as a tool that fits a previously-named problem — not as the article's hero.
---
Conclusion (used in How-to, Comparison, Listicle synthesis)
Rules:
- Typically 2–4 bullets (a short conclusion is fine; padding is worse), each ≤ 12 words
- One bullet closes the loop on the introduction's pain
- One bullet states a concrete next step the reader can take
- (Optional) One bullet suggests evaluating
target_productif set — non-pushy - No "in conclusion, we covered..." summary
---
FAQs
FAQs serve two readers at once: the practitioner who finished the article and has follow-up questions, and the searcher who landed on this page via long-tail Google or AI search. Good FAQs answer both. The outline only provides the questions — the writer drafts the answers. Like every other section, FAQs are an outline, not a brief.
Rules:
- Questions only, no answers. Use the
rulesfield with a list of question strings, just like every other section. Do NOT pre-write answers — that turns the outline back into a brief. - No fixed count. Typically 5–8 questions, but the right number is the number of legitimate practitioner questions on this topic. If the topic has 7 real questions, use 7. If it has 4, use 4.
- Keywords must appear naturally across the question set. The focus keyword and each secondary keyword should each appear in at least one question — paraphrased into something a real searcher would type, not keyword-stuffed. Across all questions combined, every supplied keyword should be covered.
- Topic-specific. No generic framings like "What is X?" or "Why is X important?" — those are covered earlier in the article. FAQ questions are the follow-ups, not the basics.
- Concrete and action-oriented. Practitioner questions usually start with "How do I…", "What's the difference between…", "Can I…", "Where do I…", "When should I…". Abstract framings ("How does Claude decide which skill to invoke") are weaker than concrete ones ("How do I know if my skill is triggering correctly?").
- Each question can be longer than 12 words — the per-bullet ≤12-word rule applies to topic prompts in other sections, not to FAQ questions. A natural-sounding question takes the space it needs.
Format:
- Each question is one item in
rules - No answer text, no "Topic:" prefix, no sub-bullets
Good FAQ questions (concrete, action-oriented, keyword-incorporating):
- How do I add or install a Claude skill?
- How do I know if my skill is triggering correctly?
- What is the difference between a skill and a regular prompt?
- How do Claude skills work in Claude Code?
- What is the difference between Claude skills and MCP?
- What are the best Claude skills to start with when building Claude skills for the first time?
- Can I share Claude skills with my team or publicly?
- Where can I find a Claude Skills guide or the official SKILL.md reference?
Note how these questions: (a) use natural phrasing a real user would type, (b) collectively cover all supplied keywords without stuffing, (c) ask the kinds of questions a practitioner has after reading the article.
Bad FAQ questions:
- ❌ What is disaster recovery? (definitional — covered earlier in the article)
- ❌ Why is disaster recovery important? (lazy framing)
- ❌ How do I get started? (vague — get started with what?)
- ❌ How does Claude decide which skill to invoke when multiple match? (overly abstract; few real users ask this)
Coverage check before finalizing FAQs:
- Does each user-supplied keyword (focus + each secondary) appear in at least one question?
- At least one question covers installation / how-to-use (action-oriented)
- At least one question covers a comparison or distinction the reader will have
- At least one question covers sharing, deployment, or scale
- No two questions cover the same ground
---
Final pass — outline quality check
Before generating the DOCX, walk the whole outline and ask:
1. Could a writer publish this by adding transition words? If yes → it's a brief, not an outline. Strip back. 2. Does every bullet (except FAQ questions) have a subject + verb + object + qualifier? If yes → too long, rewrite as a topic prompt. 3. Could a writer guess what each bullet means in 3 different ways? If yes → the bullet is too abstract. Name the actual thing (file names, components, concrete examples, technical terms). 4. Is every bullet a complete thought when read aloud? Sentence fragments without meaning ("Who this guide is for: builders standardizing repeat tasks") must be finished. 5. Is every technical claim accurate? Skills are packages, not folders. Claude reads SKILL.md, not the whole package eagerly. If unsure, rewrite to avoid the claim. 6. Are there invented numbers anywhere? If yes → remove or replace with "include credible benchmark". 7. Does any sub-bullet feel like its own H2? If yes → promote it. Aspect vs. distinct concept matters. 8. Are H2 titles specific to the article's topic, not generic verbs? "Define the task narrowly" → "Define what your skill should do." 9. Does the section order match the archetype? If you forced a how-to template onto a listicle, restart. 10. No `topic_summary`, `directives`, or `faqs` fields present? → Strip if present. FAQ questions live in rules as flat bullets. 11. Do all user-supplied keywords (focus + each secondary) appear naturally across the FAQ questions? → If any is missing, rewrite a question to incorporate it. 12. Are FAQ questions concrete and action-oriented (`"How do I install..."`, `"What is the difference between..."`), not abstract (`"How does Claude decide..."`)? → Rewrite vague ones.
If all 12 checks pass, generate the DOCX.
#!/usr/bin/env python3
"""
Brief Outline Generator — generate-brief.py
Renders a content OUTLINE (not a finished brief) as a .docx file.
The output is a skeleton — section headings and short topic prompts — that a
writer fills in with their own conclusions, numbers, and prose.
Usage:
python generate-brief.py --config brief-config.json
Config JSON shape:
{
"title": "How Do Platform Teams Implement Cloud Disaster Recovery",
"focus_keyword": "cloud disaster recovery",
"focus_keyword_volume": "2,400",
"domain_url": "https://firefly.ai",
"word_count_range": "1500-2000",
"target_intent": "Informational",
"target_product": "Firefly", # optional
"archetype": "how_to", # optional: listicle | comparison | how_to | concept
"meta_title": "Cloud Disaster Recovery: A Platform Team Guide", # optional; fetched from URL
"meta_description": "Learn how platform teams implement cloud disaster recovery ...", # optional
"secondary_keywords": [
{"keyword": "disaster recovery plan", "volume": "1,900"}
],
"output_path": "/mnt/user-data/outputs/outline-xxx.docx",
"outline": [
{
"heading": "H2",
"title": "Introduction",
"rules": ["short topic prompt 1", "short topic prompt 2"],
"subsections": [
{"heading": "H3", "title": "...", "rules": [...], "subsections": []}
]
},
{
"heading": "H2",
"title": "FAQs",
"rules": [
"How do I install a Claude skill?",
"How is a Claude skill different from a regular prompt?"
]
}
]
}
Schema notes:
- `topic_summary`, `directives`, `visual`, and `faqs` fields are no longer
rendered. If present in the config they are silently ignored — kept for
backward compatibility with old configs, but they produce nothing.
- `domain_context` is no longer rendered in the metadata table. The skill
uses it upstream to inform generation, but it does not appear in the doc.
- The FAQs section uses the same `rules` shape as every other section: a list
of question strings as topic prompts. The writer drafts the actual answers.
- The URL Slug in the metadata table is derived from `focus_keyword` (e.g.
`cloud disaster recovery` → `cloud-disaster-recovery`). The output filename
is derived from `title` instead — these are intentionally separate.
- `meta_title` and `meta_description` are optional string fields. When present
they appear as "Meta Title (50-60 chars)" and "Meta Description (150-160 chars)"
rows in the metadata table, directly above the Keywords section. If absent,
the rows render as empty strings.
"""
import argparse
import json
import re
import sys
from pathlib import Path
from docx import Document
from docx.shared import Pt, RGBColor, Inches, Twips
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.oxml.ns import qn
from docx.oxml import OxmlElement
# ─── Colour helpers ───────────────────────────────────────────────────────────
def rgb(hex_str):
h = hex_str.lstrip('#')
return RGBColor(int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16))
# ─── XML helpers — schema-order-safe ─────────────────────────────────────────
def _set_tbl_width(table, width_dxa):
"""Set table width by updating the existing w:tblW element (always present)."""
tblPr = table._tbl.find(qn('w:tblPr'))
tblW = tblPr.find(qn('w:tblW'))
if tblW is None:
tblW = OxmlElement('w:tblW')
tblPr.insert(0, tblW)
tblW.set(qn('w:w'), str(width_dxa))
tblW.set(qn('w:type'), 'dxa')
def _set_cell_width(cell, width_dxa):
"""Update the existing w:tcW element inside tcPr (always present after table creation)."""
tcPr = cell._tc.get_or_add_tcPr()
tcW = tcPr.find(qn('w:tcW'))
if tcW is None:
tcW = OxmlElement('w:tcW')
tcPr.insert(0, tcW)
tcW.set(qn('w:w'), str(width_dxa))
tcW.set(qn('w:type'), 'dxa')
def _set_cell_borders(cell, color='CCCCCC', size=4):
"""Insert w:tcBorders after w:tcW (schema order: tcW → tcBorders → shd).
Uses w:start/w:end (OOXML 2012 strict) instead of w:left/w:right."""
tcPr = cell._tc.get_or_add_tcPr()
existing = tcPr.find(qn('w:tcBorders'))
if existing is not None:
tcPr.remove(existing)
tcBorders = OxmlElement('w:tcBorders')
for side in ('top', 'start', 'bottom', 'end'): # start/end not left/right
el = OxmlElement(f'w:{side}')
el.set(qn('w:val'), 'single')
el.set(qn('w:sz'), str(size))
el.set(qn('w:space'), '0')
el.set(qn('w:color'), color.lstrip('#'))
tcBorders.append(el)
tcW = tcPr.find(qn('w:tcW'))
if tcW is not None:
tcPr.insert(list(tcPr).index(tcW) + 1, tcBorders)
else:
tcPr.append(tcBorders)
def _set_cell_bg(cell, hex_color):
"""Insert w:shd after w:tcBorders (schema order: tcBorders → shd)."""
tcPr = cell._tc.get_or_add_tcPr()
existing = tcPr.find(qn('w:shd'))
if existing is not None:
tcPr.remove(existing)
shd = OxmlElement('w:shd')
shd.set(qn('w:val'), 'clear')
shd.set(qn('w:color'), 'auto')
shd.set(qn('w:fill'), hex_color.lstrip('#'))
# Insert after tcBorders if present, else append
tcBorders = tcPr.find(qn('w:tcBorders'))
if tcBorders is not None:
idx = list(tcPr).index(tcBorders)
tcPr.insert(idx + 1, shd)
else:
tcPr.append(shd)
def _set_cell_margins(cell, top=80, bottom=80, left=120, right=120):
tcPr = cell._tc.get_or_add_tcPr()
existing = tcPr.find(qn('w:tcMar'))
if existing is not None:
tcPr.remove(existing)
tcMar = OxmlElement('w:tcMar')
# OOXML strict order: top, start, bottom, end (not left/right)
for side, val in (('top', top), ('start', left), ('bottom', bottom), ('end', right)):
el = OxmlElement(f'w:{side}')
el.set(qn('w:w'), str(val))
el.set(qn('w:type'), 'dxa')
tcMar.append(el)
tcPr.append(tcMar)
# Required tcPr child order per OOXML strict schema
_TCPR_ORDER = [
'cnfStyle', 'tcW', 'gridSpan', 'hMerge', 'vMerge',
'tcBorders', 'shd', 'noWrap', 'tcMar', 'textDirection',
'tcFitText', 'vAlign', 'hideMark', 'headers',
]
def _reorder_tcpr(cell):
"""Re-sort tcPr children into schema-required order. Call after cell.merge()."""
tcPr = cell._tc.find(qn('w:tcPr'))
if tcPr is None:
return
children = list(tcPr)
def _rank(el):
tag = el.tag.split('}')[1]
try:
return _TCPR_ORDER.index(tag)
except ValueError:
return len(_TCPR_ORDER)
children.sort(key=_rank)
for child in list(tcPr):
tcPr.remove(child)
for child in children:
tcPr.append(child)
def _set_cell_valign(cell, align='top'):
tcPr = cell._tc.get_or_add_tcPr()
existing = tcPr.find(qn('w:vAlign'))
if existing is not None:
tcPr.remove(existing)
vAlign = OxmlElement('w:vAlign')
vAlign.set(qn('w:val'), align)
tcPr.append(vAlign)
def _style_cell(cell, width_dxa, bg_hex, border_color='CCCCCC',
margins=(80, 80, 120, 120)):
_set_cell_width(cell, width_dxa)
_set_cell_borders(cell, border_color)
_set_cell_bg(cell, bg_hex)
_set_cell_margins(cell, *margins)
_set_cell_valign(cell, 'top')
def _set_col_span(cell, span):
tcPr = cell._tc.get_or_add_tcPr()
existing = tcPr.find(qn('w:gridSpan'))
if existing is not None:
tcPr.remove(existing)
gridSpan = OxmlElement('w:gridSpan')
gridSpan.set(qn('w:val'), str(span))
# gridSpan must come AFTER tcW in schema order (cnfStyle? tcW? gridSpan?)
tcW = tcPr.find(qn('w:tcW'))
if tcW is not None:
tcPr.insert(list(tcPr).index(tcW) + 1, gridSpan)
else:
tcPr.insert(0, gridSpan)
def _set_para_spacing(para, before=0, after=0):
pPr = para._p.get_or_add_pPr()
spacing = pPr.find(qn('w:spacing'))
if spacing is not None:
pPr.remove(spacing)
spacing = OxmlElement('w:spacing')
spacing.set(qn('w:before'), str(before))
spacing.set(qn('w:after'), str(after))
pPr.append(spacing)
def _apply_list_numbering(para, num_id, level=0):
pPr = para._p.get_or_add_pPr()
existing = pPr.find(qn('w:numPr'))
if existing is not None:
pPr.remove(existing)
numPr = OxmlElement('w:numPr')
ilvl = OxmlElement('w:ilvl')
ilvl.set(qn('w:val'), str(level))
numPr.append(ilvl)
numId_el = OxmlElement('w:numId')
numId_el.set(qn('w:val'), str(num_id))
numPr.append(numId_el)
# numPr must be before pStyle/jc etc — insert at front of pPr
pPr.insert(0, numPr)
def _fix_zoom(doc):
"""Add required w:percent attribute to w:zoom element in settings."""
zoom = doc.settings.element.find('.//' + qn('w:zoom'))
if zoom is not None and not zoom.get(qn('w:percent')):
zoom.set(qn('w:percent'), '100')
# ─── Numbering ────────────────────────────────────────────────────────────────
def _add_numbering_def(doc, char='•', indent_left=720, hanging=360):
"""
Add an abstractNum + num pair to the numbering part.
Returns the numId (int) to pass to _apply_list_numbering.
"""
num_part = doc.part.numbering_part
num_el = num_part._element
# Count existing abstractNums and nums
abstract_nums = num_el.findall(qn('w:abstractNum'))
abstract_id = len(abstract_nums)
abstract_num = OxmlElement('w:abstractNum')
abstract_num.set(qn('w:abstractNumId'), str(abstract_id))
ml_type = OxmlElement('w:multiLevelType')
ml_type.set(qn('w:val'), 'hybridMultilevel')
abstract_num.append(ml_type)
lvl = OxmlElement('w:lvl')
lvl.set(qn('w:ilvl'), '0')
for tag, val in [('w:start', '1'), ('w:numFmt', 'bullet')]:
el = OxmlElement(tag)
el.set(qn('w:val'), val)
lvl.append(el)
lvl_text = OxmlElement('w:lvlText')
lvl_text.set(qn('w:val'), char)
lvl.append(lvl_text)
lvl_jc = OxmlElement('w:lvlJc')
lvl_jc.set(qn('w:val'), 'left')
lvl.append(lvl_jc)
pPr = OxmlElement('w:pPr')
ind = OxmlElement('w:ind')
ind.set(qn('w:left'), str(indent_left))
ind.set(qn('w:hanging'), str(hanging))
pPr.append(ind)
lvl.append(pPr)
abstract_num.append(lvl)
# abstractNum must come before num elements
existing_nums = num_el.findall(qn('w:num'))
if existing_nums:
num_el.insert(list(num_el).index(existing_nums[0]), abstract_num)
else:
num_el.append(abstract_num)
# num element
num_id = len(existing_nums) + 1
num = OxmlElement('w:num')
num.set(qn('w:numId'), str(num_id))
abstract_ref = OxmlElement('w:abstractNumId')
abstract_ref.set(qn('w:val'), str(abstract_id))
num.append(abstract_ref)
num_el.append(num)
return num_id
# ─── Validation ───────────────────────────────────────────────────────────────
def validate(cfg):
errors, warnings = [], []
if not cfg.get('title', '').strip():
errors.append('title: must not be empty')
elif len(cfg['title']) > 70:
warnings.append(f"title: {len(cfg['title'])} chars — Google may truncate above 60")
if not cfg.get('focus_keyword', '').strip():
errors.append('focus_keyword: must not be empty')
if not cfg.get('domain_url', '').strip():
errors.append('domain_url: must not be empty')
elif not re.match(r'^https?://', cfg['domain_url'], re.I):
errors.append('domain_url: must start with http:// or https://')
if not cfg.get('word_count_range', '').strip():
errors.append('word_count_range: must not be empty')
elif not re.match(r'^\d+-\d+$', cfg['word_count_range'].strip()):
errors.append('word_count_range: e.g. 1500-2000')
valid_intents = {'informational', 'commercial', 'transactional', 'navigational'}
if not cfg.get('target_intent', '').strip():
errors.append('target_intent: must not be empty')
elif cfg['target_intent'].lower() not in valid_intents:
errors.append('target_intent: Informational / Commercial / Transactional / Navigational')
return errors, warnings
# ─── Slug ─────────────────────────────────────────────────────────────────────
def to_slug(title):
s = re.sub(r'[^a-z0-9\s-]', '', title.lower()).strip()
return re.sub(r'-+', '-', re.sub(r'\s+', '-', s)).strip('-')
# ─── Audience ─────────────────────────────────────────────────────────────────
AUDIENCE_PATTERNS = [
(r'devops|cicd|pipeline|terraform|kubernetes|k8s|helm|argo',
['DevOps Engineers', 'Platform Engineers', 'SREs']),
(r'platform|infrastructure|cloud|disaster recovery|\bdr\b|rto|rpo|resilience|failover',
['Platform Engineers', 'DevOps Engineers', 'Cloud Architects']),
(r'frontend|react|vue|angular|css|\bui\b|\bux\b|nextjs|svelte',
['Frontend Developers', 'Full-Stack Engineers']),
(r'backend|api|database|sql|microservices|rest|graphql|grpc',
['Backend Developers', 'Full-Stack Engineers', 'Software Architects']),
(r'security|iam|auth|zero trust|compliance|soc2|devsecops',
['Security Engineers', 'DevSecOps']),
(r'\bai\b|\bml\b|llm|model|embedding|vector|langchain',
['ML Engineers', 'AI Developers', 'Data Scientists']),
(r'open.?source|contributor|library|sdk|cli|npm|pypi',
['Open-Source Contributors', 'Software Developers']),
]
def infer_audience(title, keyword):
text = f'{title} {keyword}'.lower()
seen, roles = set(), []
for pattern, role_list in AUDIENCE_PATTERNS:
if re.search(pattern, text):
for r in role_list:
if r not in seen:
seen.add(r); roles.append(r)
return ', '.join(roles) if roles else 'Software Developers, Full-Stack Engineers, DevOps Engineers'
# ─── Paragraph builders ───────────────────────────────────────────────────────
COLORS = {
'blue_hdr': 'D5E8F0',
'blue_cell': 'EAF4FB',
'blue_bg': 'EFF6FF',
'amber_hdr': 'FEF3C7',
'amber_row': 'FFFBEB',
'amber_vol': 'FFF9E6',
'white': 'FFFFFF',
}
TW = 9360 # total table width DXA
CK = 2000 # key column
CV = 5360 # value column
CS = 2000 # search volume column
def _run(para, text, bold=False, italic=False, color='1F2937', size_pt=11):
run = para.add_run(text)
run.bold = bold
run.italic = italic
run.font.name = 'Arial'
run.font.size = Pt(size_pt)
run.font.color.rgb = rgb(color)
return run
def add_h1(doc, text):
para = doc.add_paragraph(style='Heading 1')
_set_para_spacing(para, before=360, after=240)
_run(para, text, bold=True, color='1F2937', size_pt=20)
return para
def add_h2(doc, text):
para = doc.add_paragraph(style='Heading 2')
_set_para_spacing(para, before=320, after=160)
_run(para, '[H2] ', bold=True, color='B0BEC5', size_pt=10)
_run(para, text, bold=True, color='1D4ED8', size_pt=15)
return para
def add_h3(doc, text):
para = doc.add_paragraph(style='Heading 3')
_set_para_spacing(para, before=220, after=120)
_run(para, '[H3] ', bold=True, color='D1D5DB', size_pt=9)
_run(para, text, bold=True, color='374151', size_pt=13)
return para
def add_spacer(doc):
para = doc.add_paragraph()
_set_para_spacing(para, before=40, after=40)
return para
def add_bullet(doc, text, num_id):
para = doc.add_paragraph()
_set_para_spacing(para, before=0, after=80)
_apply_list_numbering(para, num_id)
_run(para, text, color='1F2937', size_pt=11)
return para
# add_visual_note and add_faq_item removed — `visual` and `faqs` fields are no longer rendered.
# ─── Tables ───────────────────────────────────────────────────────────────────
def build_metadata_table(doc, meta_rows, focus_kw, focus_vol, secondary_kws):
# Rows: top header (Field|Value) + metadata rows + banner + KW sub-header + focus + secondaries
n_rows = 1 + len(meta_rows) + 1 + 1 + 1 + len(secondary_kws)
table = doc.add_table(rows=n_rows, cols=3)
_set_tbl_width(table, TW)
row_idx = 0
# ── Top header row: Field | Value (Value spans cols 2+3, no volume label here)
hk = table.cell(row_idx, 0)
_style_cell(hk, CK, COLORS['blue_hdr'])
_run(hk.paragraphs[0], 'Field', bold=True, color='1F2937', size_pt=11)
hv1 = table.cell(row_idx, 1)
hv2 = table.cell(row_idx, 2)
hv = hv1.merge(hv2)
_style_cell(hv, CV + CS, COLORS['blue_hdr'])
_reorder_tcpr(hv)
_run(hv.paragraphs[0], 'Value', bold=True, color='1F2937', size_pt=11)
row_idx += 1
# ── Standard metadata rows (value spans cols 2+3)
for item in meta_rows:
c0 = table.cell(row_idx, 0)
_style_cell(c0, CK, COLORS['blue_cell'])
_run(c0.paragraphs[0], item['key'], bold=True, color='1F2937', size_pt=11)
c1 = table.cell(row_idx, 1)
c2 = table.cell(row_idx, 2)
merged = c1.merge(c2)
_style_cell(merged, CV + CS, COLORS['white'])
_reorder_tcpr(merged)
_run(merged.paragraphs[0], item['value'] or '', color='1F2937', size_pt=11)
row_idx += 1
# ── Keyword section divider (banner)
d0 = table.cell(row_idx, 0)
d1 = table.cell(row_idx, 1)
d2 = table.cell(row_idx, 2)
div = d0.merge(d1).merge(d2)
_style_cell(div, TW, COLORS['blue_hdr'])
_reorder_tcpr(div)
_run(div.paragraphs[0], 'Keywords & Search Volumes (Tool: Ahrefs)',
bold=True, color='1F2937', size_pt=11)
row_idx += 1
# ── Keyword sub-header: Type | Keyword | USA Search Volume ↓
for i, (label, width) in enumerate(zip(
['Type', 'Keyword', 'USA Search Volume \u2193'],
[CK, CV, CS]
)):
c = table.cell(row_idx, i)
_style_cell(c, width, COLORS['blue_cell'])
p = c.paragraphs[0]
if i == 2:
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
_run(p, label, bold=True, color='1F2937', size_pt=10)
row_idx += 1
# ── Focus keyword row
fk0 = table.cell(row_idx, 0)
fk1 = table.cell(row_idx, 1)
fk2 = table.cell(row_idx, 2)
_style_cell(fk0, CK, COLORS['blue_cell'])
_run(fk0.paragraphs[0], 'Focus Keyword', bold=True, color='1F2937', size_pt=11)
_style_cell(fk1, CV, COLORS['white'])
_run(fk1.paragraphs[0], focus_kw, color='1F2937', size_pt=11)
_style_cell(fk2, CS, COLORS['amber_vol'])
vp = fk2.paragraphs[0]
vp.alignment = WD_ALIGN_PARAGRAPH.CENTER
_run(vp, focus_vol or 'N/A', color='92400E', size_pt=11)
row_idx += 1
# ── Secondary keyword rows
for i, kw in enumerate(secondary_kws):
kw_text = kw if isinstance(kw, str) else kw.get('keyword', '')
vol_text = 'N/A' if isinstance(kw, str) else kw.get('volume', 'N/A')
s0 = table.cell(row_idx, 0)
s1 = table.cell(row_idx, 1)
s2 = table.cell(row_idx, 2)
_style_cell(s0, CK, COLORS['blue_cell'])
_run(s0.paragraphs[0], 'Secondary KWs' if i == 0 else '',
bold=(i == 0), color='1F2937', size_pt=11)
_style_cell(s1, CV, COLORS['white'])
_run(s1.paragraphs[0], kw_text, color='1F2937', size_pt=11)
_style_cell(s2, CS, COLORS['amber_vol'])
svp = s2.paragraphs[0]
svp.alignment = WD_ALIGN_PARAGRAPH.CENTER
_run(svp, vol_text, color='92400E', size_pt=11)
row_idx += 1
return table
# add_topic_summary and add_directives_box removed — `topic_summary` and
# `directives` fields are no longer rendered. Outlines don't carry abstracts
# or instruction boxes; the bullets themselves carry the direction.
# ─── Section rendering ────────────────────────────────────────────────────────
def _heading_for(heading_str, force_h3=False):
"""Map heading string to renderer. H4 falls through to H3 styling with a label."""
if force_h3:
return add_h3
h = (heading_str or '').upper()
if h == 'H4':
# H4 is rare (listicle items). Use H3 styling — the [H4] label prefix
# is added inside the heading text itself by the caller if needed.
return add_h3
if h == 'H3':
return add_h3
return add_h2
def render_section(doc, section, bullet_num_id, force_h3=False):
"""Render a section. The `dir_num_id` parameter is gone — directives are no longer rendered."""
render_heading = _heading_for(section.get('heading'), force_h3=force_h3)
render_heading(doc, section['title'])
# Outline bullets
for r in section.get('rules', []):
add_bullet(doc, r, bullet_num_id)
if section.get('rules'):
add_spacer(doc)
# Subsections
for sub in section.get('subsections', []):
render_section(doc, sub, bullet_num_id, force_h3=True)
# NOTE: `topic_summary`, `directives`, `visual`, and `faqs` fields are
# intentionally ignored. Outlines don't carry pre-written abstracts,
# instruction boxes, visual placeholders, or pre-written FAQ answers.
# If a config still includes them, they produce nothing.
# ─── Main ─────────────────────────────────────────────────────────────────────
def generate_brief(cfg):
errors, warnings = validate(cfg)
if errors:
print('❌ Validation failed:')
for e in errors:
print(f' - {e}')
sys.exit(1)
for w in warnings:
print(f'⚠️ {w}')
url_slug = to_slug(cfg['focus_keyword']) # shown in the metadata table
file_slug = to_slug(cfg['title']) # used for the output filename
audience = infer_audience(cfg['title'], cfg['focus_keyword'])
focus_vol = cfg.get('focus_keyword_volume') or 'N/A'
archetype = cfg.get('archetype', '').strip() or 'unspecified'
# NOTE: domain_context is intentionally not rendered. It informs the upstream
# outline-generation step but does not appear in the rendered metadata table.
secondary_kws = [
k if isinstance(k, dict) else {'keyword': k, 'volume': 'N/A'}
for k in cfg.get('secondary_keywords', [])
]
meta_rows = [
{'key': 'Title', 'value': cfg['title']},
{'key': 'URL Slug', 'value': url_slug},
{'key': 'Word Count', 'value': cfg['word_count_range']},
{'key': 'Target Intent', 'value': cfg['target_intent']},
{'key': 'Target Audience', 'value': audience},
{'key': 'Meta Title (50-60 chars)', 'value': cfg.get('meta_title', '')},
{'key': 'Meta Description (150-160 chars)', 'value': cfg.get('meta_description', '')},
]
# ── Build document
doc = Document()
# Fix python-docx zoom element bug
_fix_zoom(doc)
# Page: US Letter, 1" margins
sec = doc.sections[0]
sec.page_width = Twips(12240)
sec.page_height = Twips(15840)
sec.left_margin = sec.right_margin = Inches(1)
sec.top_margin = sec.bottom_margin = Inches(1)
# Default + heading styles
doc.styles['Normal'].font.name = 'Arial'
doc.styles['Normal'].font.size = Pt(11)
for name, size, color in [
('Heading 1', 20, '1F2937'),
('Heading 2', 15, '1D4ED8'),
('Heading 3', 13, '374151'),
]:
s = doc.styles[name]
s.font.name = 'Arial'
s.font.size = Pt(size)
s.font.bold = True
s.font.color.rgb = rgb(color)
# List numbering — only bullets. The directives arrow-list is gone.
bullet_num_id = _add_numbering_def(doc, char='•', indent_left=720, hanging=360)
# 1. Metadata + keyword volume table
build_metadata_table(doc, meta_rows, cfg['focus_keyword'], focus_vol, secondary_kws)
add_spacer(doc)
add_spacer(doc)
# 2. H1 title
add_h1(doc, cfg['title'])
add_spacer(doc)
# 3. Outline sections
for section in cfg.get('outline', []):
render_section(doc, section, bullet_num_id, force_h3=False)
# Save — note: filename uses 'outline-' prefix (this is an outline generator).
# Ensure the parent directory exists so the script works outside the sandboxed
# Claude environment where /mnt/user-data/outputs/ is pre-created.
output_path = cfg.get('output_path') or f'/mnt/user-data/outputs/outline-{file_slug}.docx'
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
doc.save(output_path)
kw_summary = ', '.join(
f"{k.get('keyword', '?')} ({k.get('volume', 'N/A')})" for k in secondary_kws
)
print(f'✅ Outline generated: {output_path}')
print(f' Archetype: {archetype}')
print(f' Slug (URL): {url_slug}')
print(f' Slug (file): {file_slug}')
print(f' Audience: {audience}')
print(f' Focus KW: {cfg["focus_keyword"]} (vol: {focus_vol})')
print(f' Secondary KWs: {kw_summary or "none"}')
print(f' Sections: {len(cfg.get("outline", []))} top-level')
return {'success': True, 'path': output_path}
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('--config', required=True, help='Path to brief-config.json')
args = parser.parse_args()
with open(args.config) as f:
cfg = json.load(f)
generate_brief(cfg)Related skills
How it compares
Use instead of asking the agent to draft full marketing copy from a blank prompt when you still need to agree on brief structure first.
FAQ
Who is brief-outline-generator for?
Brief Outline Generator is for developers using Claude Code, Cursor, Codex, or similar agents who ship their own go-to-market work and want a structured brief before creative execution.
When should I use brief-outline-generator?
Use it in Validate when scoping messaging and deliverables, in Launch when planning distribution narratives, and in Grow when outlining content series—whenever you need a brief skeleton before full drafts.
Is brief-outline-generator safe to install?
Treat it like any third-party agent skill: review the Security Audits panel on this Prism page and the source repo before enabling it in projects with sensitive customer or revenue data.