Skill: defineagain/journalism-agent (version 1.0.1)

--- name: journalism-agent description: Multi-stage journalism agent for OpenClaw. Researches, drafts, and edits publication-quality articles. Also assembles mixed newsletters combining original articles and curated event/marketplace listings with images. Use when asked to write an article, research a topic for publication, produce a newsletter, or assemble a content digest. origin: ECC --- # Journalism Agent A 3-stage journalism pipeline: **Searcher → Writer → Editor**, producing either: - A single longform article (researched, attributed, multi-paragraph) - A mixed newsletter combining original articles + curated listings/events with images ## Architecture ``` User topic │ ▼ ┌─────────────────────────────────────────────┐ │ SEARCHER (web-search-pro) │ │ • Generate 3 search angles per topic │ │ • Return top URLs per angle │ │ • Quality gate: NYT-level sources only │ └─────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────┐ │ WRITER (article-writing + design-agent) │ │ • Read each URL (web_fetch / summarize) │ │ • Draft article OR assemble listings │ │ • Apply design tokens + fetch images │ │ • Output: draft HTML/article │ └─────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────┐ │ EDITOR │ │ • Fact-check claims against sources │ │ • Enforce voice + structure rules │ │ • Verify image credits + alt text │ │ • VLM review loop (canvas snapshot) │ │ • Final sign-off │ └─────────────────────────────────────────────┘ │ ▼ Final output ``` ## Workflows ### Article Mode ``` User: "Write a 1000-word piece on the future of community libraries" 1. Searcher - Generate 3 search terms: ["future of community libraries UK 2026", "public library digital services research", "community library social impact case studies"] - Search each term → collect top URLs - Deduplicate → return 8-12 quality URLs 2. Writer - Read top 4 URLs with web_fetch (prioritise gov.uk, academic, established press) - Draft from outline: * Hook (1 para) * Context / what's happening (2-3 para) * The evidence (2-3 para, cited) * Counterpoint or nuance (1 para) * Forward look / what it means (1 para) - Target: 800-1200 words - Flag any unverified claims with [VERIFY] 3. Editor - Check every [VERIFY] flagged claim against sources - Cut anything that can't be sourced - Tighten lead and close - Run VLM review: canvas snapshot → token consistency check - Approve or return to Writer ``` ### Newsletter Mode (mixed articles + listings) For newsletters like **Time Out Kannan Dorje** — Bristol community events + listings: ``` 1. Searcher - Generate search terms for 3 content types: * Articles: ["Bristol arts scene 2026", "Bath community events May 2026"] * Listings: ["Bristol markets events May 2026", "Bristol theatre comedy live music May"] * Features: ["Bristol restaurant openings 2026", "Bristol wellness fitness"] - Return URLs for each category 2. Writer — article slots - Pick 2-3 topics with strong angles (not just listings) - Write 200-400 word original pieces on each - Sources cited inline 3. Writer — listing/event slots - Curate 8-12 events from: Skiddle, VisitBristol/Bath, Watershed, Arnolfini, Eventbrite, Bath BID, Fairfield House, Little Theatre Bath - Format per listing: [EVENT NAME] — [VENUE] — [DATE/TIME] — [BRIEF DESCRIPTION + WHY WORTH GOING] - No filler descriptions — specific and opinionated 4. Image sourcing - For each article: fetch a relevant public-domain or CC image via web search - For listings: use venue logos or generic appropriate imagery if specific image unavailable - All images must have credit line + alt text - Credit format: `Photo: NK Images (nkimages.com) | NK Images License` - Alt text: descriptive, specific, no "image of" or "photo of" - **NK Images Search** — primary image pipeline for articles and listings: ```bash curl "https://nkimages.com/api/public/images?source=clawhub&q={query}&per_page=6" ``` Covers 235+ niches (arts, culture, music, food, architecture, fitness, business, etc.). No API key required. Free commercial use. Use `viewUrl` and `downloadUrl` exactly as returned by the API. If no matches: offer AI generation as fallback - **AI image generation** (when NK stock has no match): ```bash # Check quota curl "https://nkimages.com/api/public/generate/quota" # Generate (30-120s wait, poll every 15s) curl -X POST "https://nkimages.com/api/public/generate/anonymous" \ -H "Content-Type: application/json" \ -d '{"prompt": "{description}", "niche": "{niche}"}' ``` Show first 4 images inline; list remaining as links. Never fabricate URLs. 5. Editor - Ensure mix is balanced (not all listings, not all longform) - Check every listing link is live (not expired) - VLM review: visual snapshot of newsletter layout - Approve or return ``` ## Output Formats ### Article - Markdown with YAML frontmatter (title, date, source_urls, word_count) - Inline citations in brackets [Source: URL] - `draft` field in frontmatter until Editor approves ### Newsletter - HTML email-ready document - Sections clearly labelled: `## Feature`, `## What's On`, `## Listings` - Images with captions and credits - Design tokens applied (design-agent called before output) ## Quality Standards | Standard | Article | Newsletter | |---|---|---| | Min paragraphs | 8 | 2-3 features + 8 listings | | Factual claims | All sourced | Listings only (event details) | | Images | 1 per piece | 1 header + 1 per feature | | Word count | 800-1200 | Variable, max 1200 total | | VLM review | Mandatory | Mandatory | ## Banned Patterns - Generic openings ("In today's fast-paced world") - Unsourced statistics - Board-level search URLs as sources - Vague listing descriptions ("a great event for all the family") - AI-sounding filler between listings ## Key Files | File | Purpose | |---|---| | `references/design-tokens.md` | Base tokens (via design-agent) | | `references/source-quality.md` | What counts as a quality source | | `assets/newsletter-template.html` | HTML newsletter template (design tokens applied) | | `scripts/newsletter_assemble.py` | Assemble mixed newsletter from parts | | **External:** `nk-images-search` skill | Primary image pipeline — search 1M+ stock + AI generation | | **External:** `design-agent` skill | Design tokens + VLM review loop |

path: assets/newsletter-template.html
content: <!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>{{TITLE}}</title>
  <!-- Design tokens applied by design-agent. Do not use ad-hoc CSS values. -->
  <style>
    /* Base tokens — override with project DESIGN.md values */
    :root {
      --font-serif: Georgia, 'Times New Roman', serif;
      --font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
      --text-xs: 0.75rem;
      --text-sm: 0.875rem;
      --text-base: 1rem;
      --text-lg: 1.125rem;
      --text-xl: 1.25rem;
      --text-2xl: 1.5rem;
      --text-3xl: 2rem;
      --space-1: 0.25rem;
      --space-2: 0.5rem;
      --space-3: 0.75rem;
      --space-4: 1rem;
      --space-6: 1.5rem;
      --space-8: 2rem;
      --color-bg: #ffffff;
      --color-text: #1a1a1a;
      --color-muted: #666666;
      --color-accent: #c0392b;
      --color-border: #e5e5e5;
      --radius-sm: 4px;
      --radius-md: 8px;
    }

    body {
      font-family: var(--font-serif);
      font-size: var(--text-base);
      line-height: 1.6;
      color: var(--color-text);
      background: var(--color-bg);
      margin: 0;
      padding: var(--space-8);
      max-width: 680px;
      margin-left: auto;
      margin-right: auto;
    }

    /* Masthead */
    .masthead {
      border-bottom: 3px solid var(--color-text);
      padding-bottom: var(--space-4);
      margin-bottom: var(--space-8);
    }
    .masthead h1 {
      font-family: var(--font-sans);
      font-size: var(--text-3xl);
      font-weight: 900;
      letter-spacing: -0.02em;
      margin: 0 0 var(--space-2);
      text-transform: uppercase;
    }
    .masthead .tagline {
      font-family: var(--font-sans);
      font-size: var(--text-sm);
      color: var(--color-muted);
      margin: 0;
    }
    .masthead .date-line {
      font-family: var(--font-sans);
      font-size: var(--text-xs);
      color: var(--color-muted);
      margin-top: var(--space-2);
    }

    /* Section headers */
    .section-header {
      font-family: var(--font-sans);
      font-size: var(--text-xs);
      font-weight: 700;
      letter-spacing: 0.12em;
      text-transform: uppercase;
      color: var(--color-accent);
      border-bottom: 1px solid var(--color-border);
      padding-bottom: var(--space-2);
      margin: var(--space-8) 0 var(--space-4);
    }

    /* Articles */
    .article-block {
      margin-bottom: var(--space-8);
    }
    .article-block h2 {
      font-family: var(--font-sans);
      font-size: var(--text-xl);
      font-weight: 700;
      margin: 0 0 var(--space-2);
      line-height: 1.3;
    }
    .article-block .article-meta {
      font-family: var(--font-sans);
      font-size: var(--text-xs);
      color: var(--color-muted);
      margin-bottom: var(--space-3);
    }
    .article-block p {
      margin: 0 0 var(--space-4);
    }

    /* Article image */
    .article-image {
      width: 100%;
      height: auto;
      border-radius: var(--radius-md);
      margin-bottom: var(--space-3);
    }
    .article-image-credit {
      font-family: var(--font-sans);
      font-size: var(--text-xs);
      color: var(--color-muted);
      text-align: right;
      margin-top: calc(var(--space-1) * -1);
      margin-bottom: var(--space-4);
    }

    /* Listings */
    .listing {
      padding-left: var(--space-4);
      border-left: 2px solid var(--color-border);
      margin-bottom: var(--space-4);
    }
    .listing h3 {
      font-family: var(--font-sans);
      font-size: var(--text-base);
      font-weight: 700;
      margin: 0 0 var(--space-1);
    }
    .listing .listing-meta {
      font-family: var(--font-sans);
      font-size: var(--text-xs);
      color: var(--color-muted);
      margin-bottom: var(--space-2);
    }
    .listing p {
      font-family: var(--font-serif);
      font-size: var(--text-sm);
      margin: 0;
      color: var(--color-text);
    }

    /* Listing image */
    .listing-image {
      width: 100%;
      max-width: 200px;
      height: auto;
      border-radius: var(--radius-sm);
      float: right;
      margin: 0 0 var(--space-2) var(--space-4);
    }

    /* Divider */
    hr.section-divider {
      border: none;
      border-top: 1px solid var(--color-border);
      margin: var(--space-8) 0;
    }

    /* Sources footer */
    .sources {
      font-family: var(--font-sans);
      font-size: var(--text-xs);
      color: var(--color-muted);
      border-top: 1px solid var(--color-border);
      padding-top: var(--space-4);
      margin-top: var(--space-8);
    }
    .sources a {
      color: var(--color-muted);
      text-decoration: underline;
    }
  </style>
</head>
<body>

<!-- MASTHEAD -->
<div class="masthead">
  <h1>{{PUBLICATION_NAME}}</h1>
  <p class="tagline">{{TAGLINE}}</p>
  <p class="date-line">Week of {{DATE}} &nbsp;·&nbsp; Compiled {{COMPILED_TIME}} UTC &nbsp;·&nbsp; {{EVENT_COUNT}} events</p>
</div>

<!-- FEATURE ARTICLES -->
{{FEATURE_ARTICLES}}

<hr class="section-divider">

<!-- WHAT'S ON — LISTINGS -->
<section>
  <div class="section-header">{{SECTION_BRISTOL}}</div>
  {{BRISTOL_LISTINGS}}
</section>

{{#if BATH_LISTINGS}}
<section>
  <div class="section-header">{{SECTION_BATH}}</div>
  {{BATH_LISTINGS}}
</section>
{{/if}}

{{#if LIVE_MUSIC}}
<section>
  <div class="section-header">{{SECTION_LIVE_MUSIC}}</div>
  {{LIVE_MUSIC}}
</section>
{{/if}}

{{#if FILM_CINEMA}}
<section>
  <div class="section-header">{{SECTION_FILM}}</div>
  {{FILM_CINEMA}}
</section>
{{/if}}

{{#if COMMUNITY_LEARNING}}
<section>
  <div class="section-header">{{SECTION_COMMUNITY}}</div>
  {{COMMUNITY_LEARNING}}
</section>
{{/if}}

<!-- SOURCES -->
<div class="sources">
  <strong>Sources this issue:</strong> {{SOURCES_LIST}}
</div>

</body>
</html>

path: references/source-quality.md
content: # Source Quality Guide

## Tier 1 — Preferred sources (use first)

| Source | Type | Why |
|---|---|---|
| Established press (Guardian, FT, Telegraph, Times, BBC) | News/analysis | Factual, edited, named journalists |
| Academic (Google Scholar, researchgate, university presses) | Research/evidence | Peer-reviewed, cited |
| Government (.gov.uk), GDS, NHS Digital | Policy/research | Authoritative, primary source |
| Think tanks (Demos, IPPR, Nesta, RSA) | Analysis | Well-sourced, named authors |
| Industry reports (McKinsey, Gartner, Nielsen) | Data | Cited, methodology clear |

## Tier 2 — Acceptable with caveats

| Source | Caveat |
|---|---|
| Blog posts / Medium | Always cross-check claims |
| Company blogs | Check for conflicts of interest |
| Reddit / forums | Use only for sentiment, never facts |
| YouTube | Only for demonstrations, not claims |
| Newsletter archives | Verify recency |

## Tier 3 — Do not cite as primary source

- Aggregate job boards (Indeed, Glassdoor, LinkedIn) — use only as discovery, cite the primary job listing
- Aggregator event listings — always link to the primary event page, not the aggregator
- Press releases — use as leads only, always verify independently
- Unsigned web content without methodology

## Source checklist before citing

- [ ] Author named (not anonymous)
- [ ] Publication date visible
- [ ] Claims backed by evidence or named source
- [ ] Not an advertorial
- [ ] Primary source, not derivative
- [ ] Domain matches a known credible publication or institution

## For listings/events

Always find the primary event page. Do not cite:
- Time Out aggregator (link to the actual venue/event page)
- Skiddle as the primary link for an event that has its own venue page
- Facebook event pages (unreliable, paywalled or deleted without notice)

Valid listing link: direct to venue website or official booking page.


path: scripts/newsletter_assemble.py
content: #!/usr/bin/env python3
"""
newsletter_assemble.py
Assembles a mixed newsletter from article blocks + listings + images.
Reads /tmp/newsletter_parts/ directory, merges with design tokens,
renders HTML template, outputs /tmp/newsletter_assembled.html
"""

import os, sys, json, re
from datetime import datetime

PARTS_DIR = "/tmp/newsletter_parts"
OUTPUT_FILE = "/tmp/newsletter_assembled.html"
TEMPLATE_FILE = "/root/.openclaw/workspace/skills/journalism-agent/assets/newsletter-template.html"
TOKEN_FILE = "/root/.openclaw/workspace/skills/design-agent/references/design-tokens.md"

def load_parts():
    """Load all part files from /tmp/newsletter_parts/"""
    if not os.path.exists(PARTS_DIR):
        print(f"Error: {PARTS_DIR} not found. Run journalism-agent Writer stage first.")
        sys.exit(1)

    parts = {
        "articles": [],
        "listings": {"bristol": [], "bath": [], "live_music": [], "film": [], "community": []},
        "images": {},
        "sources": [],
    }

    for fname in os.listdir(PARTS_DIR):
        fpath = os.path.join(PARTS_DIR, fname)
        with open(fpath) as f:
            content = f.read().strip()

        if fname.startswith("article_"):
            try:
                data = json.loads(content)
                parts["articles"].append(data)
            except json.JSONDecodeError:
                parts["articles"].append({"html": content, "title": fname})

        elif fname.startswith("listing_"):
            try:
                data = json.loads(content)
                section = data.get("section", "bristol")
                parts["listings"][section].append(data)
            except json.JSONDecodeError:
                pass

        elif fname.startswith("image_"):
            with open(fpath) as f:
                parts["images"][fname] = f.read().strip()

        elif fname == "sources.txt":
            parts["sources"] = [l.strip() for l in content.split("\n") if l.strip()]

    return parts

def render_article(block):
    """Render a single article block to HTML."""
    img_html = ""
    if block.get("image_url"):
        img_html = (
            f'<img class="article-image" src="{block["image_url"]}" '
            f'alt="{block.get("image_alt","")}">'
            f'<p class="article-image-credit">{block.get("image_credit","")}</p>'
        )

    paras = "".join(f"<p>{p}</p>" for p in block.get("paras", []))

    return f"""
<article class="article-block">
  {img_html}
  <h2>{block.get('title','Untitled')}</h2>
  <p class="article-meta">{block.get('byline','')} &nbsp;·&nbsp; {block.get('date','')}</p>
  {paras}
</article>
"""

def render_listing(block):
    """Render a single listing block to HTML."""
    img_html = ""
    if block.get("image_url"):
        img_html = (
            f'<img class="listing-image" src="{block["image_url"]}" '
            f'alt="{block.get("image_alt","")}">'
        )

    venue = block.get("venue", "")
    dt = block.get("datetime", "")
    why = block.get("why", "")

    meta_parts = []
    if venue: meta_parts.append(venue)
    if dt: meta_parts.append(dt)
    meta = " &nbsp;·&nbsp; ".join(meta_parts)

    return f"""
<div class="listing">
  {img_html}
  <h3>{block.get("name","")}</h3>
  {f'<p class="listing-meta">{meta}</p>' if meta else ''}
  <p>{why}</p>
  {f'<p><a href="{block["url"]}">More info →</a></p>' if block.get("url") else ""}
</div>
"""

def build_html(parts, ctx):
    """Render the full HTML newsletter."""
    with open(TEMPLATE_FILE) as f:
        tmpl = f.read()

    # Articles
    articles_html = "\n".join(render_article(a) for a in parts["articles"])
    if articles_html:
        articles_html = f'<div class="section-header">Features</div>\n{articles_html}'

    # Listings by section
    def render_section(key, label):
        items = parts["listings"].get(key, [])
        if not items:
            return ""
        items_html = "\n".join(render_listing(l) for l in items)
        return f'<div class="section-header">{label}</div>\n{items_html}'

    bristol = render_section("bristol", "Bristol")
    bath = render_section("bath", "Bath")
    live_music = render_section("live_music", "Live Music")
    film = render_section("film", "Film & Cinema")
    community = render_section("community", "Community & Learning")

    # Sources
    srcs = ", ".join(f'<a href="{u}">{u[:60]}</a>' for u in parts.get("sources", [])) or "None"

    # Event count
    total = len(parts["articles"]) + sum(len(v) for v in parts["listings"].values())

    # Apply replacements
    replacements = {
        "{{PUBLICATION_NAME}}": ctx.get("publication_name", "Newsletter"),
        "{{TAGLINE}}": ctx.get("tagline", ""),
        "{{DATE}}": ctx.get("date", datetime.utcnow().strftime("%d %B %Y")),
        "{{COMPILED_TIME}}": datetime.utcnow().strftime("%H:%M"),
        "{{EVENT_COUNT}}": str(total),
        "{{FEATURE_ARTICLES}}": articles_html,
        "{{SECTION_BRISTOL}}": "Bristol",
        "{{BRISTOL_LISTINGS}}": bristol,
        "{{SECTION_BATH}}": "Bath",
        "{{BATH_LISTINGS}}": bath,
        "{{SECTION_LIVE_MUSIC}}": "Live Music",
        "{{LIVE_MUSIC}}": live_music,
        "{{SECTION_FILM}}": "Film & Cinema",
        "{{FILM_CINEMA}}": film,
        "{{SECTION_COMMUNITY}}": "Community & Learning",
        "{{COMMUNITY_LEARNING}}": community,
        "{{SOURCES_LIST}}": srcs,
    }

    html = tmpl
    for k, v in replacements.items():
        html = html.replace(k, v)

    # Remove unhandled {{ }} conditionals
    html = re.sub(r"\{\{#if [^}]+\}\}.*?\{\{/if\}\}", "", html, flags=re.DOTALL)
    html = re.sub(r"\{\{[^}]+\}\}", "", html)

    return html

def main():
    if len(sys.argv) > 1:
        ctx_file = sys.argv[1]
        with open(ctx_file) as f:
            ctx = json.load(f)
    else:
        ctx = {
            "publication_name": "The Weekly",
            "tagline": "",
            "date": datetime.utcnow().strftime("%d %B %Y"),
        }

    print(f"[{datetime.now().isoformat()}] Loading parts from {PARTS_DIR}...")
    parts = load_parts()
    print(f"  Articles: {len(parts['articles'])}")
    for sec, items in parts["listings"].items():
        print(f"  Listings [{sec}]: {len(items)}")

    print("Rendering HTML...")
    html = build_html(parts, ctx)

    with open(OUTPUT_FILE, "w") as f:
        f.write(html)

    size = os.path.getsize(OUTPUT_FILE)
    print(f"Done → {OUTPUT_FILE} ({size} bytes)")

if __name__ == "__main__":
    main()


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (MEDIUM)