πŸ“Š Performance Marketing Weekly Intel

Full engineering documentation β€” system design, pipeline flow, configuration, source code, prompts, and operations.
Authored: June 15, 2026  Β·  Version 2.2  Β·  Engineer-level detail

Table of Contents

1. System Overview

The Performance Marketing Weekly Intel system is a fully automated intelligence pipeline that scans 34 RSS feeds every Monday at 9 AM SGT, extracts articles from the previous week, runs them through an LLM for curation, formats the results as a Slack-ready brief, and delivers it to a marketing agency's Slack workspace via a Make.com webhook.

The system is implemented as a Hermes Agent cron job backed by a Python pre-run script and the blogwatcher-cli RSS reader. The LLM (deepseek-v4-flash) handles curation and formatting, while all deterministic computation (dates, feed scanning) happens in the script layer.

Design Philosophy: The LLM never computes dates, never picks the reporting period, and never touches blogwatcher-cli. Every deterministic operation (dates, feed scanning, article fetching) is done programmatically in the pre-run script and injected into the LLM's context as labeled data blocks. The LLM's sole job is curation, formatting, and webhook delivery. This is the script-does-everything pattern β€” eliminates the top failure modes in LLM-driven cron jobs.

1.1 Architecture Diagram

TRIGGER

⏰ Hermes Cron Scheduler

Cron expression: 0 1 * * 1 (Monday 01:00 UTC = 09:00 SGT)

Runs in gateway background thread with 60s tick interval

SCRIPT

🐍 perf-marketing-scan.py

Computes date range Β· Runs blogwatcher-cli scan against 34 feeds Β· Fetches articles with date filters

Outputs: TITLE_LINE, DATE_RANGE, ARTICLE_SINCE, ARTICLE_BEFORE, and === ARTICLES === block

CONTEXT

πŸ“¦ Injected Context

Script stdout injected into LLM context as labeled blocks (=== METADATA === and === ARTICLES ===)

LLM reads articles directly β€” never touches blogwatcher-cli

CURATE

πŸ€– deepseek-v4-flash (OpenRouter)

Reads all article titles & URLs from context Β· Applies curation rules

Selects 10–15 items across 4 pillars Β· Formats as Slack mrkdwn Β· Sends webhook

DELIVER

πŸ“‘ Make.com Webhook

POST JSON payload to hook.eu2.make.com

Make.com scenario routes β†’ Slack channel

πŸ’¬ Slack

Formatted weekly brief appears in the team's Slack channel

1.2 Component Inventory

ComponentTechnologyLocation / Identifier
SchedulerHermes CronJob ID: fe5decb8d413
Pre-run scriptPython 3/opt/data/scripts/perf-marketing-scan.py
Shell wrapperBash/opt/data/home/.hermes/scripts/perf-marketing-scan.sh
RSS readerblogwatcher-cli v0.2.1~/.local/bin/blogwatcher-cli
Article DBSQLite~/.blogwatcher-cli/blogwatcher-cli.db
LLMDeepSeek V4 FlashProvider: OpenRouter
WebhookMake.comhook.eu2.make.com/8coauj26hwk726pimcq5r8gj8tian5c5
Delivery targetSlackChannel via Make.com automation
Cron DBJSON/opt/data/cron/jobs.json
Skills usedblogwatcher/opt/data/skills/research/blogwatcher/
DocumentationHTML/opt/data/docs/perf-marketing-weekly-intel.html
GitHub mirrorGitgithub.com/social-wiz/private-file-hermes

1.3 Key Numbers

MetricValue
RSS feeds34
Curation pillars4 + 1 (Worth Monitoring)
Articles per brief10–15 (max)
Run frequencyWeekly (every Monday)
Total runs completed24 (as of June 15, 2026)
LLM tokens per run~8,000–12,000 (estimated)
Scan timeout120 seconds
Model useddeepseek-v4-flash (cost-optimized)

2. Pipeline Flow (End-to-End)

πŸ• Cron fires
Mon 01:00 UTC
β†’
🐍 Script runs
scan + fetch articles
β†’
πŸ“¦ Articles injected
into LLM context
β†’
πŸ€– LLM reads
& curates
β†’
πŸ“ LLM formats
Slack mrkdwn
β†’
πŸ“‘ curl POST
to Make.com
β†’
πŸ’¬ Slack
message

2.1 Step 1 β€” Script Execution (perf-marketing-scan.py)

When the cron job fires, the scheduler first executes perf-marketing-scan.py as a pre-run script. The script's stdout is captured and injected into the LLM's context before the prompt runs. v2.1: The script now handles both scanning AND article fetching β€” the LLM never touches blogwatcher-cli.

What the script does:

  1. Calls date.today() to get the current date
  2. Computes the previous Monday–Sunday date range (see Β§4)
  3. Runs blogwatcher-cli scan against all 34 feeds with BLOGWATCHER_YES=true
  4. Runs blogwatcher-cli articles --all with the computed date filters to fetch last week's articles
  5. Outputs labeled blocks that the LLM consumes verbatim
Script stdout (injected into LLM context):
=== METADATA ===
DATE_RANGE: June 8–14, 2026
TITLE_LINE: :bar_chart: *Weekly Intel β€” June 8–14, 2026*
ARTICLE_SINCE: --since 2026-06-08
ARTICLE_BEFORE: --before 2026-06-15

=== ARTICLES ===
[1] [new] Article Title
     Blog: Blog Name
     URL: https://example.com/article
     Published: 2026-06-10
     Categories: Marketing, AI
[2] [new] ...
⚠️ Critical: The ARTICLE_BEFORE flag uses Monday of next week (not Sunday of current week). This is because blogwatcher-cli --before is exclusive β€” articles published on Sunday 23:59 still need to be included, so the cutoff is Monday 00:00.

2.2 Step 2 β€” Articles Delivered (Script-Does-Everything)

v2.1 architecture change: The pre-run script now handles everything deterministically β€” scanning feeds AND fetching articles. The LLM never touches blogwatcher-cli.

The script (perf-marketing-scan.py) runs two operations before the LLM session starts:

  1. blogwatcher-cli scan β€” refreshes all 34 RSS feeds
  2. blogwatcher-cli articles --all ARTICLE_SINCE ARTICLE_BEFORE β€” fetches ONLY last week's articles

Both outputs (scan results + article list + metadata) are captured and injected into the LLM's context as labeled blocks (=== METADATA === and === ARTICLES ===). The LLM simply reads them β€” it never runs blogwatcher-cli commands. This eliminates the #2 failure mode where the LLM would forget to run the fetch, run it twice, or corrupt the date flags.

Design evolution: v1.0 had the LLM run scan (script handled dates only). v2.1 moves articles into the script too. The LLM's sole job is now curation + formatting + webhook delivery. No deterministic work is given to the LLM β€” it's all pre-computed.

2.3 Step 3 β€” LLM Curation

The LLM (deepseek/deepseek-v4-flash via OpenRouter, downgraded from v4-pro June 15) receives:

The LLM never runs blogwatcher-cli. Articles arrive pre-fetched in the === ARTICLES === context block.

The LLM then executes these sub-steps (all articles are already in context β€” no terminal commands needed for fetching):

Sub-stepActionTool Used
3aRead article titles & URLs from injected === ARTICLES === block(in-context)
3bApply curation rules β€” select 10–15 items(in-context reasoning)
3cGroup into 4 pillars + Worth Monitoring(in-context reasoning)
3dFormat as Slack mrkdwn with clickable links(in-context reasoning)
3ePOST to Make.com webhookterminal(curl)
3fOutput brief as final response (for cron delivery)(final message)

2.4 Step 4 β€” Webhook Delivery to Slack

The LLM runs a curl command to POST the formatted brief to Make.com:

curl -s -X POST \
  "https://hook.eu2.make.com/8coauj26hwk726pimcq5r8gj8tian5c5" \
  -H "Content-Type: application/json" \
  -d '{"data":"...formatted brief with escaped newlines..."}'

Make.com receives the payload and runs an automation scenario that formats and delivers the message to the designated Slack channel. The exact Slack channel is configured inside Make.com, not in the cron job β€” changing the destination requires editing the Make.com scenario, not the cron job.

⚠️ Known Delivery Issue: The cron job's deliver field is set to "origin" targeting a Slack DM (D0BA04W29V2). The last run produced this error: "platform 'slack' not configured/enabled".

Workaround: The brief still reaches Slack via the Make.com webhook (Step 4 above) β€” the cron delivery is a secondary path that fails but doesn't block the primary delivery. The webhook is the actual production path.

3. Scheduling

3.1 Cron Job Configuration

SettingValueNotes
Job IDfe5decb8d413Auto-generated hex ID
NamePerformance Marketing Weekly Intel
Schedule0 1 * * 15-field cron: minute=0, hour=1, day=*, month=*, weekday=1 (Monday)
UTC timeMonday 01:00
SGT timeMonday 09:00UTC+8 year-round (Singapore has no DST)
RepeatForevertimes: null
Runs completed24As of June 15, 2026
StatescheduledActive and will fire next Monday
Next runJune 22, 2026 01:00 UTC(June 22, 09:00 SGT)
Attached skillblogwatcherLoaded before prompt execution
Scriptperf-marketing-scan.pyRuns before each agent turn
Enabled toolsetsterminal, web, fileRestricted to reduce token overhead
Modeldeepseek/deepseek-v4-flashvia OpenRouter (downgraded from v4-pro June 15 to cut cost)
Deliveryorigin (Slack DM)Secondary path; primary is Make.com webhook
CreatedJune 12, 2026

3.2 Timezone & DST Considerations

The cron expression 0 1 * * 1 fires at 01:00 UTC. Singapore Standard Time (SGT) is UTC+8 with no daylight saving time, so the SGT delivery time is a stable 09:00 every Monday year-round. No DST shifts to handle.

Scheduler mechanics: The Hermes cron scheduler ticks every 60 seconds. On each tick, it loads all jobs from /opt/data/cron/jobs.json, filters to those where next_run_at ≀ now, and executes them in fresh, isolated agent sessions. After execution, next_run_at is advanced by one week.

4. Date Calculation Logic

This is the most critical piece of the pipeline. The pre-run script computes the correct reporting period programmatically β€” the LLM never performs date arithmetic.

4.1 Algorithm

# Python implementation (from perf-marketing-scan.py)
from datetime import date, timedelta

def get_last_monday():
    today = date.today()
    # Monday = 0, Sunday = 6
    current_monday = today - timedelta(days=today.weekday())
    # The cron runs ON Monday, so "last week" = previous Monday
    return current_monday - timedelta(days=7)

def get_date_range():
    mon = get_last_monday()       # e.g. June 8
    sun = mon + timedelta(days=6) # e.g. June 14

    if mon.month == sun.month:
        return f"{mon.strftime('%B')} {mon.day}–{sun.day}, {sun.year}"
    else:
        # Handle cross-month ranges: "June 29 – July 5, 2026"
        return f"{mon.strftime('%B %d')} – {sun.strftime('%B %d, %Y')}"

4.2 Worked Examples

Cron fires ontoday.weekday()current_mondaylast_mondayDate Range
Mon Jun 15, 20260 (Monday)Jun 15Jun 8June 8–14, 2026
Mon Jun 22, 20260 (Monday)Jun 22Jun 15June 15–21, 2026
Mon Jul 6, 20260 (Monday)Jul 6Jun 29June 29 – July 5, 2026
πŸ’‘ Why the extra -7 days? Without it, today - today.weekday() on Monday returns today (Monday itself). But the cron fires on Monday morning β€” the week that just completed is the previous week. Subtracting 7 days is correct for Monday execution. If the cron ever moves to Tuesday, this logic would need updating: on Tuesday, the "previous Monday" is just today - (today.weekday() + 1).

5. Source Code

5.1 Pre-Run Script

πŸ“„ /opt/data/scripts/perf-marketing-scan.py

#!/usr/bin/env python3
"""
Performance Marketing Weekly Intel β€” pre-scan script.
Runs blogwatcher-cli scan and computes the correct date range
for the weekly brief title (last Monday through last Sunday).

Output: scan results + DATE_RANGE line for the LLM to use.
"""
import subprocess
import sys
from datetime import date, timedelta

def get_last_monday() -> date:
    """Get the PREVIOUS week's Monday (not today if today is Monday).

    The cron runs every Monday, so 'last week' means the week that just ended.
    Example: running on Monday June 15 β†’ returns Monday June 8.
    """
    today = date.today()
    # Monday = 0, Sunday = 6
    days_since_monday = today.weekday()
    # Go back to the start of the current week, then back 7 more days
    current_monday = today - timedelta(days=days_since_monday)
    return current_monday - timedelta(days=7)

def get_date_range() -> str:
    """Return 'Month D–D, YYYY' for last Monday through Sunday."""
    mon = get_last_monday()
    sun = mon + timedelta(days=6)

    if mon.month == sun.month:
        return f"{mon.strftime('%B')} {mon.day}–{sun.day}, {sun.year}"
    else:
        return f"{mon.strftime('%B %d')} – {sun.strftime('%B %d, %Y')}"


def main():
    date_range = get_date_range()

    # Run blogwatcher-cli scan
    result = subprocess.run(
        ["blogwatcher-cli", "scan"],
        env={"PATH": f"{subprocess.os.path.expanduser('~/.local/bin')}:"
                     f"{subprocess.os.environ.get('PATH', '')}",
             "BLOGWATCHER_YES": "true",
             "HOME": subprocess.os.environ.get("HOME", "/root")},
        capture_output=True,
        text=True,
        timeout=120
    )

    if result.returncode != 0:
        print(f"SCAN_ERROR: {result.stderr}", file=sys.stderr)
        print(result.stdout)

    # Output date range info for the LLM to use
    mon = get_last_monday()
    sun = mon + timedelta(days=6)

    print(f"\nDATE_RANGE: {date_range}")
    print(f"TITLE_LINE: :bar_chart: *Weekly Intel β€” {date_range}*")
    # CLI flags for blogwatcher-cli articles filtering
    print(f"ARTICLE_SINCE: --since {mon.isoformat()}")
    # BEFORE uses Monday of following week (exclusive boundary)
    print(f"ARTICLE_BEFORE: --before {(sun + timedelta(days=1)).isoformat()}")


if __name__ == "__main__":
    main()

5.2 Shell Wrapper

πŸ“„ /opt/data/home/.hermes/scripts/perf-marketing-scan.sh

This wrapper is what the cron scheduler actually executes. It activates the virtual environment and runs the Python script:

#!/usr/bin/env bash
# Cron script wrapper β€” activates venv before running the Python scan
cd /opt/data/scripts
/opt/hermes/.venv/bin/python perf-marketing-scan.py

6. LLM Prompt (Full Text)

This is the exact prompt stored in the cron job at /opt/data/cron/jobs.json. It is injected after the blogwatcher skill and the script stdout (which already contains the pre-fetched articles). Updated June 15, 2026: v2.1 script-does-everything pattern β€” the LLM reads articles from context rather than running blogwatcher-cli itself.

You will produce a weekly marketing intelligence brief. Execute EVERY step below, in order. CRITICAL: The pre-run script above has ALREADY: - Scanned all 34 RSS feeds (scan output above) - Fetched ONLY last week's articles (the === ARTICLES === block) - Computed the correct date range and TITLE_LINE (the === METADATA === block) You do NOT need to run blogwatcher-cli. All articles are already in your context. Just read them. ### STEP 1 β€” Read the articles Look through the === ARTICLES === block above. Every article has a URL. You MUST preserve each article's exact URL. ### STEP 2 β€” Curate Select 10-15 items that pass this test: "Would a media buyer or agency exec need to know this?" INCLUDE only: - New ad formats, bidding/targeting changes on Meta, Google, TikTok, Amazon - Ad tech product launches, acquisitions, shutdowns - Ad-related regulatory changes (privacy, antitrust, data) - AI tools with direct marketing/advertising use cases - SEA/APAC market shifts, consumer behavior data - Creator economy monetization or algorithm changes - Attribution/measurement changes SKIP: consumer gadgets, gaming, car reviews, space news, pure politics, PR fluff, generic listicles, healthcare, enterprise software (unless ad-relevant), minor fundraising, crypto/blockchain (unless ad-relevant). Prefer CONCRETE details over vague trends. Skip a pillar entirely if nothing qualifies. ### STEP 3 β€” Organize into pillars Group curated items under these EXACT section headers (emoji + bold): :robot_face: *AI Trends* :mega: *Marketing Updates* :iphone: *Platform Updates* :flag-sg: *Singapore Business* Add a :pushpin: *Worth Monitoring* line with 2-4 one-line items separated by Β· (middle dot). ### STEP 4 β€” Format FORMAT REQUIREMENTS β€” do NOT deviate: TITLE_LINE (exactly as shown in === METADATA === above) :robot_face: *AI Trends* β€’ Title β€” 5-8 word context for a media buyer. <URL|Read more> :mega: *Marketing Updates* β€’ Title β€” 5-8 word context for a media buyer. <URL|Read more> :iphone: *Platform Updates* β€’ Title β€” 5-8 word context for a media buyer. <URL|Read more> :flag-sg: *Singapore Business* β€’ Title β€” 5-8 word context for a media buyer. <URL|Read more> :pushpin: *Worth Monitoring* β€’ Item Β· Item Β· Item CRITICAL RULES: - EVERY bullet: ordinary text (Title β€” context), then a clickable <URL|Read more> link at the end. The title and context are plain text β€” ONLY the "Read more" text is linked. NEVER a bare URL. - Pillar headers MUST have emoji + *bold*. Example: :robot_face: *AI Trends* - Double blank line (two newlines) between each pillar section - Single blank line between items within a pillar - 10-15 curated items total across all pillars (hard max) - No greeting, no "Here's your weekly brief", no sign-off. Title then first pillar. - Skip empty pillars entirely β€” do not write a section with no items ### STEP 5 β€” Send webhook (backend only, do not show to user) Silently run: curl -s -X POST "https://hook.eu2.make.com/8coauj26hwk726pimcq5r8gj8tian5c5" -H "Content-Type: application/json" -d '{"data":"ESCAPED_BRIEF"}' Replace ESCAPED_BRIEF with the entire brief, with double quotes escaped as \" and newlines as \n. This runs silently β€” do NOT include the curl command or its output in your final message. ### STEP 6 β€” Final response Your FINAL RESPONSE must be ONLY the formatted brief. Nothing else. No JSON, no curl output, no "Done!" β€” just the brief text as shown in STEP 4's template. The brief is what gets delivered to the user. If you output JSON or meta-text, the user receives garbage.

7. Curation Rules

7.1 Four Pillars

EmojiPillarScopeExample Topics
πŸ€–AI TrendsAI, emerging tech, AI search, AI for marketingAI agents in ad buying, OpenAI ad tools, Perplexity ad formats
πŸ“£Marketing UpdatesMedia buying, ad platforms, martech, analyticsProgrammatic changes, CTV measurement, retail media, attribution
πŸ“±Platform UpdatesMeta, Google Ads, TikTok, social, programmatic, creatorsAlgorithm changes, new ad formats, API deprecations, policy shifts
πŸ‡ΈπŸ‡¬Singapore BusinessSG news, APAC intel, policy, consumer behaviorMAS regulations, Grab/Shopee ads, SEA consumer trends, tax policy
πŸ“ŒWorth MonitoringOne-line watch itemsEarly signals, things to track β€” no full briefs, just a list

7.2 Include / Skip Matrix

βœ… INCLUDE❌ SKIP
β€’ Ad format, bidding, targeting changes
β€’ Ad tech launches, acquisitions, shutdowns
β€’ Privacy & antitrust regulation
β€’ AI tools with marketing applications
β€’ SEA / APAC market shifts
β€’ Creator economy & algorithm changes
β€’ Attribution & measurement changes
β€’ Concrete, specific details
β€’ Consumer gadgets, gaming, cars
β€’ Pure politics (no business angle)
β€’ PR fluff & generic listicles
β€’ Healthcare & enterprise software
β€’ Minor fundraising rounds
β€’ Vague trend pieces (no specifics)
β€’ Space, crypto (unless ad-relevant)
β€’ Anything failing "so what for a media buyer?"

8. Output Format Specification

8.1 Formatting Rules

RuleSpecification
FormatSlack mrkdwn (not Markdown, not plain text)
Links<URL|Read more> β€” only the "Read more" text is linked. Title and context are plain text preceding the link: β€’ Title β€” 5-8 word context. <URL|Read more>. NEVER a bare URL.
BulletsUnicode bullet β€’ (not dash -, not asterisk *)
Bold headers*Pillar Name* β€” Slack asterisk bold
Section spacingDouble blank line between pillars (two newlines)
Item spacingSingle blank line between items within a pillar
Worth MonitoringEach item gets its own bullet with <URL|Read more> β€” same format as main pillar items
Max items10–15 total across all pillars (hard maximum)
Empty pillarsSkip entirely β€” do NOT include a pillar with "nothing this week"
Context per item5–8 words after the dash β€” explain why it matters to a media buyer
TitleMust be the EXACT TITLE_LINE from the script output
No preambleNo "Good morning!" or "Here's your weekly brief" β€” title first, then first pillar
No sign-offNo "Have a great week!" or emoji rows at the bottom

8.2 Annotated Sample Output

:bar_chart: *Weekly Intel β€” June 8–14, 2026*     ← TITLE_LINE from script (verbatim)


:robot_face: *AI Trends*                           ← Pillar header (bold)
β€’ OpenAI + Visa partnership lets AI agents make purchases β€” could reshape ad checkout flows. <https://example.com/1|Read more>   ← Plain text + linked "Read more"


:mega: *Marketing Updates*                         ← Double blank line before each pillar
β€’ Google enlists Walmart Connect for YouTube ad insights β€” retail media + video convergence. <https://example.com/2|Read more>


:iphone: *Platform Updates*
β€’ Meta unwinds Singapore-based Manus deal β€” SEA creator monetization impact. <https://example.com/3|Read more>


:flag-sg: *Singapore Business*
β€’ Stripe moves global product head to Singapore β€” payments infra signal. <https://example.com/4|Read more>


:pushpin: *Worth Monitoring*                       ← One-liners, each with its own URL
β€’ Item β€” context. 
β€’ Item β€” context. 

9. Webhook Integration (Make.com)

9.1 Payload Format

SettingValue
Endpointhttps://hook.eu2.make.com/8coauj26hwk726pimcq5r8gj8tian5c5
HTTP MethodPOST
Content-Typeapplication/json
Payload shape{"data": "..."} β€” flat string (NOT nested data.text)
⚠️ Critical Gotcha: The webhook expects a flat {"data": "..."} payload β€” NOT the nested {"data": {"text": "..."}} format. This was corrected June 15, 2026 after the nested format was found to silently fail (Make.com returns 200 but the scenario doesn't trigger correctly). The flat data string is what works.

9.2 cURL Example

curl -s -X POST \
  "https://hook.eu2.make.com/8coauj26hwk726pimcq5r8gj8tian5c5" \
  -H "Content-Type: application/json" \
  -d '{"data":":bar_chart: *Weekly Intel β€” June 8–14, 2026*\n\n:robot_face: *AI Trends*\nβ€’ OpenAI + Visa lets AI agents buy β€” checkout flow impact. \n\n:mega: *Marketing Updates*\nβ€’ Google enlists Walmart for YouTube insights β€” retail media. "}'
Escaping notes:

10. RSS Feed Inventory (34 Feeds)

Adweek AdExchanger Adexchanger Business Times Campaign Asia Digiday e27 ExchangeWire Google Ads Blog Inside Retail Asia KrASIA LinkedIn Ads Blog Marketing Dive Marketing Interactive MarTech MediaPost Meta Newsroom Meta for Business Mumbrella Asia Programmatic I/O Reddit for Business Search Engine Journal Search Engine Land Snapchat for Business Social Media Today Straits Times Tech Tech in Asia The Drum TikTok for Business Twitter/X Business VideoWeek Vulcan Post WARC YouTube Blog
πŸ’‘ Feed management: Feeds are stored in the blogwatcher-cli SQLite database. To list them: blogwatcher-cli blogs. To add a feed: blogwatcher-cli add "Name" URL. To remove: blogwatcher-cli remove "Name" --yes. Feeds can also be bulk-imported via OPML: blogwatcher-cli import feeds.opml.

11. Operations

11.1 Manual Run

Trigger the full pipeline immediately (useful for testing or if Monday was missed):

hermes cron run fe5decb8d413

This runs in the current CLI session β€” you'll see the LLM's output stream in real time.

11.2 Check Status

hermes cron list

Shows all jobs with their state, next run time, and last status.

11.3 Pause / Resume / Delete

hermes cron pause fe5decb8d413     # Pause (won't fire next Monday)
hermes cron resume fe5decb8d413    # Resume
hermes cron remove fe5decb8d413    # Delete permanently

11.4 Test Webhook

Test the Make.com webhook independently of the full pipeline:

curl -s -X POST \
  "https://hook.eu2.make.com/8coauj26hwk726pimcq5r8gj8tian5c5" \
  -H "Content-Type: application/json" \
  -d '{"data":"πŸ§ͺ *Test message from Hermes β€” please ignore*"}'

11.5 Inspect the Cron Job Definition

cat /opt/data/cron/jobs.json | python3 -m json.tool

12. Troubleshooting

12.1 Wrong Dates in Title

Root cause: The LLM ignored the script output and computed dates itself.

Fix applied (June 15, 2026): The prompt now uses the pre-run script pattern β€” the script computes dates programmatically and the LLM uses TITLE_LINE verbatim. The prompt explicitly says "Do NOT calculate or guess dates yourself."

Verification: Check the TITLE_LINE: in the most recent run's script output. Run the script manually: python3 /opt/data/scripts/perf-marketing-scan.py

12.2 No Articles Returned

Possible causes:
Debug: The articles are pre-fetched by the script. If the === ARTICLES === block is empty, run the script manually to see what happened:
cd /opt/data/scripts && /opt/hermes/.venv/bin/python perf-marketing-scan.py
Check DB manually:
sqlite3 ~/.blogwatcher-cli/blogwatcher-cli.db "SELECT COUNT(*) FROM articles;"

12.3 Brief Not Arriving in Slack

Check 1: Test the webhook manually (see Β§11.4). If the test message arrives, the webhook is working.

Check 2: Verify the Make.com scenario is active β€” log into Make.com and check the scenario's execution history.

Check 3: Verify the payload shape. The webhook expects a flat {"data": "..."} payload. The old nested {"data": {"text": "..."}} format silently fails β€” Make.com returns 200 but doesn't trigger.

Check 4: Look at the cron job's last delivery error:
python3 -c "
import json
with open('/opt/data/cron/jobs.json') as f:
    jobs = json.load(f)
j = jobs['jobs'][0]
print(f'Status: {j[\"last_status\"]}')
print(f'Delivery error: {j.get(\"last_delivery_error\", \"none\")}')"

12.4 Feeds Failing to Scan

Symptoms: Fewer articles than expected, or missing articles from known publishers.

Debug: Run a verbose scan on a specific feed:
blogwatcher-cli scan "Adweek"
Check feed health: Some feeds may have changed URLs or been discontinued. Verify the feed URL:
blogwatcher-cli blogs | grep -A2 "Feed Name"
Update a feed URL: Remove and re-add with the correct URL:
blogwatcher-cli remove "Old Name" --yes
blogwatcher-cli add "New Name" https://new-url.com --feed-url https://new-url.com/feed.xml

12.5 LLM Producing Wrong Format

Symptoms: Multiple bullet styles (- and β€’ mixed), bare URLs, preamble text, wrong number of blank lines.

Root cause: The LLM sometimes "drifts" from the formatting spec, especially when it has many articles to process.

Mitigation:
To change the model:
hermes cron edit fe5decb8d413  # interactive editor

13. File Manifest

FilePurposeCritical?
/opt/data/scripts/perf-marketing-scan.pyPre-run script: scan feeds + compute date rangeCritical
/opt/data/home/.hermes/scripts/perf-marketing-scan.shShell wrapper (activates venv, runs Python script)Critical
/opt/data/cron/jobs.jsonCron job definition (prompt, schedule, config)Critical
~/.blogwatcher-cli/blogwatcher-cli.dbArticle database (SQLite)Critical
~/.local/bin/blogwatcher-cliRSS reader binary (v0.2.1)Critical
/opt/data/docs/perf-marketing-weekly-intel.htmlThis documentationDocs
/opt/data/skills/research/blogwatcher/SKILL.mdBlogwatcher skill loaded by the cron jobImportant
/opt/data/skills/research/blogwatcher/references/cron-weekly-intel.mdCron design notes & lessons learnedDocs
/opt/data/skills/github/private-file-hermes-upload/SKILL.mdGitHub upload workflow skillTool
/opt/data/home/.hermes/.envEnvironment vars (GITHUB_TOKEN)Important

14. Dependencies

DependencyVersionTypeNotes
Python3.13RuntimeUsed by pre-run script (stdlib only β€” no pip packages)
blogwatcher-cli0.2.1RuntimeGo binary, installed at ~/.local/bin/
Hermes AgentCurrentRuntimeCron scheduler, agent loop, tool execution
DeepSeek V4 Flashβ€”LLMVia OpenRouter API
OpenRouterβ€”APILLM provider gateway
Make.comβ€”AutomationWebhook β†’ Slack routing
Slackβ€”DeliveryFinal message destination
Gitβ€”VersioningUsed to push docs to GitHub

15. Changelog & Known Issues

v2.2 β€” June 15, 2026

v2.1 β€” June 15, 2026 (evening)

v2.0 β€” June 15, 2026

v1.0 β€” June 12, 2026

πŸ“Š Performance Marketing Weekly Intel β€” Engineering Docs v2.2
Last updated: June 15, 2026  Β·  Job ID: fe5decb8d413  Β·  34 feeds  Β·  24 runs completed  Β·  GitHub: social-wiz/private-file-hermes