Full engineering documentation β system design, pipeline flow, configuration, source code, prompts, and operations.
Authored: June 15, 2026 Β· Version 2.2 Β· Engineer-level detail
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.
Cron expression: 0 1 * * 1 (Monday 01:00 UTC = 09:00 SGT)
Runs in gateway background thread with 60s tick interval
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
Script stdout injected into LLM context as labeled blocks (=== METADATA === and === ARTICLES ===)
LLM reads articles directly β never touches blogwatcher-cli
Reads all article titles & URLs from context Β· Applies curation rules
Selects 10β15 items across 4 pillars Β· Formats as Slack mrkdwn Β· Sends webhook
POST JSON payload to hook.eu2.make.com
Make.com scenario routes β Slack channel
Formatted weekly brief appears in the team's Slack channel
| Component | Technology | Location / Identifier |
|---|---|---|
| Scheduler | Hermes Cron | Job ID: fe5decb8d413 |
| Pre-run script | Python 3 | /opt/data/scripts/perf-marketing-scan.py |
| Shell wrapper | Bash | /opt/data/home/.hermes/scripts/perf-marketing-scan.sh |
| RSS reader | blogwatcher-cli v0.2.1 | ~/.local/bin/blogwatcher-cli |
| Article DB | SQLite | ~/.blogwatcher-cli/blogwatcher-cli.db |
| LLM | DeepSeek V4 Flash | Provider: OpenRouter |
| Webhook | Make.com | hook.eu2.make.com/8coauj26hwk726pimcq5r8gj8tian5c5 |
| Delivery target | Slack | Channel via Make.com automation |
| Cron DB | JSON | /opt/data/cron/jobs.json |
| Skills used | blogwatcher | /opt/data/skills/research/blogwatcher/ |
| Documentation | HTML | /opt/data/docs/perf-marketing-weekly-intel.html |
| GitHub mirror | Git | github.com/social-wiz/private-file-hermes |
| Metric | Value |
|---|---|
| RSS feeds | 34 |
| Curation pillars | 4 + 1 (Worth Monitoring) |
| Articles per brief | 10β15 (max) |
| Run frequency | Weekly (every Monday) |
| Total runs completed | 24 (as of June 15, 2026) |
| LLM tokens per run | ~8,000β12,000 (estimated) |
| Scan timeout | 120 seconds |
| Model used | deepseek-v4-flash (cost-optimized) |
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:
date.today() to get the current dateblogwatcher-cli scan against all 34 feeds with BLOGWATCHER_YES=trueblogwatcher-cli articles --all with the computed date filters to fetch last week's articles=== 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] ...
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.
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:
blogwatcher-cli scan β refreshes all 34 RSS feedsblogwatcher-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.
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.
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-step | Action | Tool Used |
|---|---|---|
| 3a | Read article titles & URLs from injected === ARTICLES === block | (in-context) |
| 3b | Apply curation rules β select 10β15 items | (in-context reasoning) |
| 3c | Group into 4 pillars + Worth Monitoring | (in-context reasoning) |
| 3d | Format as Slack mrkdwn with clickable links | (in-context reasoning) |
| 3e | POST to Make.com webhook | terminal(curl) |
| 3f | Output brief as final response (for cron delivery) | (final message) |
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.
deliver field is set to "origin" targeting a Slack DM (D0BA04W29V2).
The last run produced this error: "platform 'slack' not configured/enabled".
| Setting | Value | Notes |
|---|---|---|
| Job ID | fe5decb8d413 | Auto-generated hex ID |
| Name | Performance Marketing Weekly Intel | |
| Schedule | 0 1 * * 1 | 5-field cron: minute=0, hour=1, day=*, month=*, weekday=1 (Monday) |
| UTC time | Monday 01:00 | |
| SGT time | Monday 09:00 | UTC+8 year-round (Singapore has no DST) |
| Repeat | Forever | times: null |
| Runs completed | 24 | As of June 15, 2026 |
| State | scheduled | Active and will fire next Monday |
| Next run | June 22, 2026 01:00 UTC | (June 22, 09:00 SGT) |
| Attached skill | blogwatcher | Loaded before prompt execution |
| Script | perf-marketing-scan.py | Runs before each agent turn |
| Enabled toolsets | terminal, web, file | Restricted to reduce token overhead |
| Model | deepseek/deepseek-v4-flash | via OpenRouter (downgraded from v4-pro June 15 to cut cost) |
| Delivery | origin (Slack DM) | Secondary path; primary is Make.com webhook |
| Created | June 12, 2026 |
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.
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.
# 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')}"
| Cron fires on | today.weekday() | current_monday | last_monday | Date Range |
|---|---|---|---|---|
| Mon Jun 15, 2026 | 0 (Monday) | Jun 15 | Jun 8 | June 8β14, 2026 |
| Mon Jun 22, 2026 | 0 (Monday) | Jun 22 | Jun 15 | June 15β21, 2026 |
| Mon Jul 6, 2026 | 0 (Monday) | Jul 6 | Jun 29 | June 29 β July 5, 2026 |
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).
π /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()
π /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
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.
| Emoji | Pillar | Scope | Example Topics |
|---|---|---|---|
| π€ | AI Trends | AI, emerging tech, AI search, AI for marketing | AI agents in ad buying, OpenAI ad tools, Perplexity ad formats |
| π£ | Marketing Updates | Media buying, ad platforms, martech, analytics | Programmatic changes, CTV measurement, retail media, attribution |
| π± | Platform Updates | Meta, Google Ads, TikTok, social, programmatic, creators | Algorithm changes, new ad formats, API deprecations, policy shifts |
| πΈπ¬ | Singapore Business | SG news, APAC intel, policy, consumer behavior | MAS regulations, Grab/Shopee ads, SEA consumer trends, tax policy |
| π | Worth Monitoring | One-line watch items | Early signals, things to track β no full briefs, just a list |
| β 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?" |
| Rule | Specification |
|---|---|
| Format | Slack 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. |
| Bullets | Unicode bullet β’ (not dash -, not asterisk *) |
| Bold headers | *Pillar Name* β Slack asterisk bold |
| Section spacing | Double blank line between pillars (two newlines) |
| Item spacing | Single blank line between items within a pillar |
| Worth Monitoring | Each item gets its own bullet with <URL|Read more> β same format as main pillar items |
| Max items | 10β15 total across all pillars (hard maximum) |
| Empty pillars | Skip entirely β do NOT include a pillar with "nothing this week" |
| Context per item | 5β8 words after the dash β explain why it matters to a media buyer |
| Title | Must be the EXACT TITLE_LINE from the script output |
| No preamble | No "Good morning!" or "Here's your weekly brief" β title first, then first pillar |
| No sign-off | No "Have a great week!" or emoji rows at the bottom |
: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.
| Setting | Value |
|---|---|
| Endpoint | https://hook.eu2.make.com/8coauj26hwk726pimcq5r8gj8tian5c5 |
| HTTP Method | POST |
| Content-Type | application/json |
| Payload shape | {"data": "..."} β flat string (NOT nested data.text) |
{"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.
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. "}'
data string must be backslash-escaped: \"\n (not actual newlines β the entire -d string is one line)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.
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.
hermes cron list
Shows all jobs with their state, next run time, and last status.
hermes cron pause fe5decb8d413 # Pause (won't fire next Monday)
hermes cron resume fe5decb8d413 # Resume
hermes cron remove fe5decb8d413 # Delete permanently
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*"}'
cat /opt/data/cron/jobs.json | python3 -m json.tool
TITLE_LINE verbatim. The prompt explicitly says "Do NOT calculate or guess dates yourself."
TITLE_LINE: in the most recent run's script output. Run the script manually: python3 /opt/data/scripts/perf-marketing-scan.py
scan step β check SCAN_ERROR in the script output--since / --before flags are wrong β check the script's computed dates=== 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;"
{"data": "..."} payload. The old nested {"data": {"text": "..."}} format silently fails β Make.com returns 200 but doesn't trigger.
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\")}')"
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
- and β’ mixed), bare URLs, preamble text, wrong number of blank lines.
deepseek-v4-flash β downgraded from v4-pro June 15, 2026 to cut cost. Flash occasionally drifts on formatting details.deepseek-v4-prohermes cron edit fe5decb8d413 # interactive editor
| File | Purpose | Critical? |
|---|---|---|
/opt/data/scripts/perf-marketing-scan.py | Pre-run script: scan feeds + compute date range | Critical |
/opt/data/home/.hermes/scripts/perf-marketing-scan.sh | Shell wrapper (activates venv, runs Python script) | Critical |
/opt/data/cron/jobs.json | Cron job definition (prompt, schedule, config) | Critical |
~/.blogwatcher-cli/blogwatcher-cli.db | Article database (SQLite) | Critical |
~/.local/bin/blogwatcher-cli | RSS reader binary (v0.2.1) | Critical |
/opt/data/docs/perf-marketing-weekly-intel.html | This documentation | Docs |
/opt/data/skills/research/blogwatcher/SKILL.md | Blogwatcher skill loaded by the cron job | Important |
/opt/data/skills/research/blogwatcher/references/cron-weekly-intel.md | Cron design notes & lessons learned | Docs |
/opt/data/skills/github/private-file-hermes-upload/SKILL.md | GitHub upload workflow skill | Tool |
/opt/data/home/.hermes/.env | Environment vars (GITHUB_TOKEN) | Important |
| Dependency | Version | Type | Notes |
|---|---|---|---|
| Python | 3.13 | Runtime | Used by pre-run script (stdlib only β no pip packages) |
| blogwatcher-cli | 0.2.1 | Runtime | Go binary, installed at ~/.local/bin/ |
| Hermes Agent | Current | Runtime | Cron scheduler, agent loop, tool execution |
| DeepSeek V4 Flash | β | LLM | Via OpenRouter API |
| OpenRouter | β | API | LLM provider gateway |
| Make.com | β | Automation | Webhook β Slack routing |
| Slack | β | Delivery | Final message destination |
| Git | β | Versioning | Used to push docs to GitHub |
<URL|Read more> per item (was dot-separated list without URLs)<URL|Read more> β never output "Read more" as plain text{"data": {"text": "..."}} to flat {"data": "..."} β nested format was silently failing on Make.com~/.hermes/.envorigin fails ("platform 'slack' not configured/enabled") β webhook is the working pathdata.text) β fixed in v2.1
π 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