
Content Blog
- 1 installs
- 7 repo stars
- Updated April 12, 2026
- isaac-flath/agent-starter-skills
Generate a blog post from source materials like text, PDFs, images, and video transcripts using multimodal analysis.
About
Generates a blog post or article from source materials including text, PDFs, images, and transcripts using multimodal analysis. A developer uses it when they want to turn raw source content into a written blog post.
- Generates a blog post from text, PDFs, images, or transcripts
- Uses the gemini-3 skill for multimodal source analysis
Content Blog by the numbers
- 1 all-time installs (skills.sh)
- Ranked #1,983 of 2,715 Automation & Workflows skills by installs in the Skillselion catalog
- Data as of Jul 8, 2026 (Skillselion catalog sync)
npx skills add https://github.com/isaac-flath/agent-starter-skills --skill content-blogAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| repo stars | ★ 7 |
| Last updated | April 12, 2026 |
| Repository | isaac-flath/agent-starter-skills ↗ |
What it does
Generate a blog post from source materials like text, PDFs, images, and video transcripts using multimodal analysis.
Files
/content-blog
Generate a blog post using the gemini-3 skill for multimodal content.
Usage
/content-blogBefore Running
If source/*.mp4 exists but content/transcript.md doesn't, run /content-transcribe first.
Working Directory
Run from the monorepo root. The script finds the linked post in posts/ automatically based on the project directory name.
Source Materials
Accepts any combination from the project directory:
source/*.txtorsource/*.md(text files)source/*.pdf(PDFs)source/*.png,source/*.jpg(images)content/transcript.md(video transcript)content/description.md(video description/chapters)screenshots/(video screenshots, extracted on-demand)
Run
# Generate blog post from project, writes to posts/
uv run .claude/skills/content-blog/scripts/generate_blog.py posts/<id>
# Specify a different Gemini model
uv run .claude/skills/content-blog/scripts/generate_blog.py posts/<id> --model gemini-2.5-proHow It Works
The script: 1. Gathers all source materials from the project directory 2. Builds a comprehensive prompt using Jinja2 templates 3. Calls the /gemini-3 skill with the prompt and all multimodal files 4. Saves the generated blog post to posts/<slug>/blog.md with draft: true
Screenshots
Screenshots must be extracted separately using /content-screenshot before or after blog generation.
1. Identify timestamps from the transcript/chapters that need visual illustration 2. Run /content-screenshot with those timestamps 3. Reference in the blog: 
Output
Saves to posts/<slug>/blog.md (as a draft).
Post-Generation Editing
After the blog is generated, run two Zinsser editing passes using gemini-3 to tighten the prose.
Read writing-with-zinsser.md from the wiki directory for the full style guide, then run two passes:
POST="posts/YYYY-MM-DD-slug/blog.md"
STYLE="writing-with-zinsser.md" # from wiki --add-dir
# First pass
uv run .claude/skills/gemini-3/scripts/query.py \
"Review this blog post and apply Zinsser's writing principles. Return only the improved post, no commentary." \
--context "$STYLE" \
--context "$POST" \
--model gemini-2.5-pro > "${POST}.tmp"
mv "${POST}.tmp" "$POST"
# Second pass (catches clutter the first pass missed)
uv run .claude/skills/gemini-3/scripts/query.py \
"Review this blog post and apply Zinsser's writing principles. Return only the improved post, no commentary." \
--context "$STYLE" \
--context "$POST" \
--model gemini-2.5-pro > "${POST}.tmp"
mv "${POST}.tmp" "$POST"agentkb users: Search for richer results: agentkb search -s wiki "Zinsser writing style principles"Customizing Style
writing-with-zinsser.mdin the wiki directory — Full Zinsser guide.claude/skills/content-blog/references/prompts/blog-generation.md— Blog formatting instructions
Blog Generation Prompt
Write a comprehensive curated article based on the provided source materials.
Requirements
- Use standard Markdown format
- Start with # Title
- Include screenshots using ONLY the exact paths provided
- Preserve technical accuracy
- Include code blocks where relevant
- Add a conclusion with key takeaways
Structure
If an outline is provided, follow it exactly. For each section:
- Use the section title as an H2 heading
- Incorporate the guidance notes from the outline
- Draw from the transcript and source materials
If no outline is provided:
- Create a logical structure based on the content
- Use clear H2 headers for main sections
- H3 for subsections if needed
Format
- H1 for title only
- H2 for main sections
- H3 for subsections if needed
- Code blocks with language specification
- Bullet/numbered lists where appropriate
- Bold for emphasis (sparingly)
Do NOT Include
- Generic filler content
- Excessive adjectives or hype
- Obvious statements
- Meta-commentary about the writing
- Throat-clearing introductions
- AI tells (see writing-style.md)
Screenshot References
When visual aids would help comprehension, reference screenshots:
Only use screenshots that exist in the provided list. Do not hallucinate paths.
Code Examples
Format code blocks properly:
# Clear comment explaining what this does
def example():
return "result"Include language specification for syntax highlighting.
#!/usr/bin/env -S uv run
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "python-dotenv",
# "jinja2",
# ]
# ///
"""
Blog generation using gemini-3 skill for multimodal content.
Usage:
uv run generate_blog.py <project_dir> [--model MODEL]
"""
import argparse
import subprocess
import sys
from pathlib import Path
from dotenv import load_dotenv
from jinja2 import Environment, FileSystemLoader, select_autoescape
load_dotenv()
def load_text_file(path: Path) -> str | None:
"""Load a text file if it exists."""
if path.exists():
return path.read_text()
return None
def get_skills_root() -> Path:
"""Get the skills directory root (.claude/skills/)."""
# Script is at: .claude/skills/content-blog/scripts/generate_blog.py
# Skills root is: .claude/skills/
return Path(__file__).resolve().parent.parent.parent
def get_app_root() -> Path:
"""Get the app root directory."""
# Skills root is at: .claude/skills/
# App root is: .
return get_skills_root().parent.parent
def get_templates_dir() -> Path:
"""Get the templates directory path."""
script_dir = Path(__file__).parent.parent
return script_dir / "templates"
def load_writing_style() -> str:
"""Load the shared writing style guide."""
app_root = get_app_root()
style_path = app_root / "references" / "writing-style.md"
if style_path.exists():
return style_path.read_text()
# Fallback to skill-specific if shared doesn't exist
script_dir = Path(__file__).parent.parent
style_path = script_dir / "references" / "writing-style.md"
if style_path.exists():
return style_path.read_text()
return "# Writing Style\n\nWrite clearly and concisely."
def gather_sources(project_dir: Path) -> dict:
"""Gather all source materials from the project."""
content_dir = project_dir / "content"
source_dir = project_dir / "source"
screenshots_dir = project_dir / "screenshots"
sources = {
"transcript": None,
"chapters": None,
"image_paths": [],
"pdf_paths": [],
"text_files": [],
}
# Load transcript
transcript_path = content_dir / "transcript.md"
sources["transcript"] = load_text_file(transcript_path)
# Load chapters/description if exists
chapters_path = content_dir / "description.md"
sources["chapters"] = load_text_file(chapters_path)
# Load screenshots
if screenshots_dir.exists():
for img_path in sorted(screenshots_dir.glob("*.png")):
sources["image_paths"].append(img_path)
for img_path in sorted(screenshots_dir.glob("*.jpg")):
sources["image_paths"].append(img_path)
# Load source directory files
if source_dir.exists():
for ext in ["*.png", "*.jpg", "*.jpeg", "*.gif", "*.webp"]:
for img_path in sorted(source_dir.glob(ext)):
sources["image_paths"].append(img_path)
for pdf_path in sorted(source_dir.glob("*.pdf")):
sources["pdf_paths"].append(pdf_path)
for ext in ["*.txt", "*.md"]:
for txt_path in sorted(source_dir.glob(ext)):
content = load_text_file(txt_path)
if content:
sources["text_files"].append(
{"path": f"source/{txt_path.name}", "content": content}
)
return sources
def build_prompt(sources: dict) -> str:
"""Build the generation prompt from sources using Jinja2 templates."""
templates_dir = get_templates_dir()
# Set up Jinja2 environment
env = Environment(
loader=FileSystemLoader(templates_dir),
autoescape=select_autoescape(),
trim_blocks=True,
lstrip_blocks=True,
)
# Load the main template
template = env.get_template("blog_post.jinja2")
# Prepare context (without actual image/pdf data, just metadata)
context = {
"writing_style": load_writing_style(),
"transcript": sources["transcript"],
"chapters": sources["chapters"],
"text_files": sources["text_files"],
"images": [{"path": str(p.name)} for p in sources["image_paths"]],
"pdfs": [{"path": str(p.name)} for p in sources["pdf_paths"]],
}
# Render the template
return template.render(**context)
def generate_blog(
sources: dict,
model_name: str = "gemini-2.5-pro",
) -> str:
"""Generate blog post using the gemini-3 skill."""
skills_root = get_skills_root()
# Build the prompt
prompt = build_prompt(sources)
# Build command to call gemini-3 skill (sibling skill in same skills directory)
skill_script = skills_root / "gemini-3" / "scripts" / "query.py"
if not skill_script.exists():
print(f"Error: gemini-3 skill not found at {skill_script}", file=sys.stderr)
sys.exit(1)
cmd = ["uv", "run", str(skill_script), prompt, "--model", model_name]
# Add all file arguments
for img_path in sources["image_paths"]:
cmd.extend(["--file", str(img_path)])
for pdf_path in sources["pdf_paths"]:
cmd.extend(["--file", str(pdf_path)])
print(f"Generating blog post with gemini-3 ({model_name})...")
print(f" - Transcript: {'Yes' if sources['transcript'] else 'No'}")
print(f" - Images: {len(sources['image_paths'])}")
print(f" - PDFs: {len(sources['pdf_paths'])}")
print(f" - Text files: {len(sources['text_files'])}")
# Run the gemini-3 skill
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
print("\nError calling gemini-3:", file=sys.stderr)
print(result.stderr, file=sys.stderr)
sys.exit(1)
return result.stdout
def find_post_path(project_dir: Path) -> Path:
"""Find the linked post in posts/ for this project."""
app_root = get_app_root()
posts_dir = app_root / "posts"
# Extract slug from project dir name (YYYY_MM_DD_slug -> slug)
parts = project_dir.name.split("_", 3)
if len(parts) == 4:
slug = parts[3]
date_hyphen = f"{parts[0]}-{parts[1]}-{parts[2]}"
else:
slug = project_dir.name
date_hyphen = ""
# Search for matching post directory
if date_hyphen:
candidate = posts_dir / f"{date_hyphen}-{slug}" / "blog.md"
if candidate.exists():
return candidate
# Fallback: search by slug suffix
for entry in posts_dir.iterdir():
if entry.is_dir() and entry.name.endswith(slug):
blog_md = entry / "blog.md"
if blog_md.exists():
return blog_md
# No existing post found — create new directory
if date_hyphen:
new_dir = posts_dir / f"{date_hyphen}-{slug}"
new_dir.mkdir(exist_ok=True)
return new_dir / "blog.md"
from datetime import datetime
date_str = datetime.now().strftime("%Y-%m-%d")
new_dir = posts_dir / f"{date_str}-{slug}"
new_dir.mkdir(exist_ok=True)
return new_dir / "blog.md"
def main():
parser = argparse.ArgumentParser(description="Generate blog post using gemini-3")
parser.add_argument("project_dir", nargs="?", default=".", help="Project directory")
parser.add_argument("--model", default="gemini-2.5-pro", help="Gemini model to use")
parser.add_argument("--output", help="Output file path (default: linked post in posts/)")
args = parser.parse_args()
project_dir = Path(args.project_dir).resolve()
if args.output:
output_path = Path(args.output)
else:
output_path = find_post_path(project_dir)
print(f"Project: {project_dir.name}")
print(f"Output: {output_path}")
print("\nGathering source materials...")
sources = gather_sources(project_dir)
if (
not sources["transcript"]
and not sources["image_paths"]
and not sources["pdf_paths"]
and not sources["text_files"]
):
print("Error: No source materials found.")
sys.exit(1)
blog_content = generate_blog(sources, args.model)
# If the post file already exists, preserve its frontmatter draft status
if output_path.exists():
existing = output_path.read_text()
# If generated content has frontmatter, ensure draft: true is set
if blog_content.startswith("---"):
if "draft:" not in blog_content:
blog_content = blog_content.replace("---\n", "---\ndraft: true\n", 1)
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(blog_content)
print(f"\n✓ Blog post saved to {output_path}")
print(f" Word count: ~{len(blog_content.split())}")
print(f" Status: draft (run /publish to go live)")
if __name__ == "__main__":
main()
# Blog Generation Instructions
Write an article based on the provided source materials.
## Format
- Standard Markdown
- Start with # Title
## Screenshots
When visual aids would help, reference screenshots from the video with the following format:
```markdown

```
This only works if there is a transcript.
{# Blog Post Generation Prompt #}
{#
Required context:
- writing_style: str (loaded from agentkb wiki or writing-style reference)
- transcript: str or None
- chapters: str or None
- text_files: list of {path, content}
- images: list of {path}
- pdfs: list of {path}
#}
{{ writing_style }}
---
{% include '_blog_instructions.jinja2' %}
---
# Source Materials
{% if transcript %}
## Transcript
{{ transcript }}
{% endif %}
{% if chapters %}
## Video Description/Chapters
{{ chapters }}
{% endif %}
{% if text_files %}
## Additional Text Sources
{% for file in text_files %}
### {{ file.path }}
{{ file.content }}
{% endfor %}
{% endif %}
{% if images %}
## Images
{{ images|length }} images are attached. Reference these in your blog post where relevant.
{% for img in images %}
- {{ img.path }}
{% endfor %}
{% endif %}
{% if pdfs %}
## PDFs
{{ pdfs|length }} PDF documents are attached.
{% for pdf in pdfs %}
- {{ pdf.path }}
{% endfor %}
{% endif %}
---
# Task
Write the blog post now based on all the above materials and guidelines.
Output in Markdown format. Start directly with the title (# heading).