A MAD GOAT Production
Built before the hype.
Content Ranch is a custom workflow built for publishing at scale. It turns feeds into evergreen content without depending on a traditional CMS, plugin stack, or manual publishing routine.
The workflow analyzes source material, constructs a durable angle, turns it into structured output, and publishes without a blogging platform.
The skill is the logic - the prompts, structure, and output rules that turn a feed into a finished article. It's packaged to run anywhere: n8n, Claude Code, a standalone server, Manus, or whatever runtime fits your stack.
Content Ranch itself is a workflow built and run in production. The skill is the portable piece at the center of it.
Example flow: A feed item enters the workflow, the system identifies a durable angle, generates an evergreen article and metadata, formats the output, and sends the finished content to the selected destination.
Why it matters: This is not here as a product pitch. It is here to show how MAD GOAT approaches workflow design, prompt structure, orchestration, and delivery.
# Delivery Bundles
This file defines the expected output for `publish_target.type = download_only` and for webhook or REST destinations that request a `standard` bundle.
## Standard bundle
A standard bundle should contain these logical files or payload sections:
1. `article.md`
- final markdown article body with title heading
2. `article.html`
- final rendered HTML when templating is enabled or HTML rendering is available
- otherwise may be omitted
3. `meta.json`
- title
- slug
- meta title
- meta description
- keywords
- canonical_url when available
4. `images.json`
- master concept
- per-output prompt
- alt text
- crop notes
- safe-zone notes
- filenames
- URLs when generation occurred
5. `run.json`
- selected source item
- selection reason
- publish target
- dry run flag
- generation notes
## Webhook / REST payload recommendation
When a target requests the standard bundle in JSON form, the top-level payload should look like this:
```json
{
"bundle": "standard",
"article": {
"title": "string",
"slug": "string",
"markdown": "string",
"html": "string or null"
},
"meta": {
"meta_title": "string",
"meta_description": "string",
"keywords": "string or array",
"canonical_url": "string or null"
},
"images": {
"master_concept": "string or null",
"outputs": []
},
"run": {
"selected_source_item": {},
"selection_reason": "string",
"dry_run": true
}
}
```
Feed to Evergreen Publisher — Public Skill Package
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
OVERVIEW
This is a generic skill that turns feed items into original evergreen articles.
It is designed for public reuse and does not depend on a specific niche, site,
or publishing stack.
The package does not include private URLs, credentials, connector secrets,
or hardcoded business rules for a particular business.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CORE CONCEPT
The feed is not the article.
The feed is idea input.
This skill uses feed items to identify useful, durable topics worth covering,
then creates original evergreen content that can stand on its own.
That means the workflow should:
- use the selected item as inspiration for the angle
- avoid rewriting the source item paragraph by paragraph
- avoid making the article read like commentary on a recent post
- avoid turning the final page into a collection of source references
- skip weak ideas when no worthwhile topic is available
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
HOW IT WORKS
Each run follows one of two modes:
1. Guided mode
The skill shows a menu, lets the user add or pick a saved site,
and then runs the workflow.
2. Direct config mode
An automation can pass a complete site profile in the first message:
CONFIG: { ... }
In that case the skill skips the menu and runs immediately.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
KEY FEATURES
- Feed-driven topic discovery for evergreen article ideas
- Original article drafting instead of source rewriting
- Metadata generation including slug support
- Optional image planning with one core concept adapted to multiple formats
- Multiple publish target options through one generic contract
- Safe starter configuration for dry runs and review-first workflows
- Generic templates that can be adapted to different brands or sites
- Example webhook receivers for custom delivery workflows
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
IMAGE MODEL
The default image workflow is:
- one master image concept
- multiple output formats or sizes from that same concept
- consistent subject, setting, and mood across all image outputs
- per-output prompt, alt text, crop notes, and safe-zone notes
Create separate image concepts only when the user explicitly asks for that.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
PUBLISH TARGETS
The public package supports these target types:
- `show_in_chat` Return the finished article in the conversation
- `download_only` Return files for the user to download manually
- `webhook` POST article payload to a webhook endpoint
- `ftp` Upload files by FTP
- `sftp` Upload files by SFTP
- `s3` Upload files to an S3-compatible bucket
- `rest_api` Send article payload to a generic API endpoint
- `email` Send article content by email
- `notion` Create content in Notion
- `google_docs` Create a Google Doc
- `wordpress` Publish or draft in WordPress
See `publish_targets.reference.json` for the exact shape.
See `DELIVERY_BUNDLES.md` for the expected output bundle.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
SETUP INSTRUCTIONS
1. Review `SKILL.md` to understand the execution flow.
2. Start from `config.example.json` and create a site profile.
3. Define the feed source and editorial rules for topic selection.
4. Choose a publish target that matches your environment.
5. Keep `dry_run` enabled until the output looks correct.
6. Test article generation, metadata, and delivery before going live.
7. Customize the template files if you want branded HTML output.
8. Enable image handling only if your runtime supports it.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
STARTER SAFETY DEFAULTS
The example config is intentionally conservative:
- `dry_run: true`
- `content_log.enabled: true` with empty history
- `image_profile.enabled: false`
- `publish_target.mode: draft` where applicable
- `editorial_rules.allow_weak_fallback: false`
- `editorial_rules.source_usage_mode: inspiration_only`
Review output before switching to live publishing.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
FILES
SKILL.md Main skill instructions
config.example.json Safe starter config
publish_targets.reference.json Unified publish target reference
DELIVERY_BUNDLES.md Output bundle contract
TEMPLATE_VARIABLES.md HTML template contract
prompt.onboarding.txt Setup interview prompt
prompt.select_item.txt Item selection sub-prompt
prompt.write_article.txt Article drafting prompt
prompt.optimize_seo_geo_aeo.txt Search / answer-engine refinement prompt
prompt.generate_meta.txt Metadata prompt
prompt.generate_images.txt Image prompt generator
template/page.html Generic HTML page template
template/style.css Minimal starter CSS
webhook_receiver/ Example webhook receivers
sites/ Optional local site profile storage
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
IMPORTANT LIMITS
This package is generic by design.
It cannot guarantee that a runtime actually supports:
- saving files locally
- image generation
- direct publishing
- third-party connector access
So the skill must never pretend a capability exists when it does not.
When in doubt:
- return a dry-run payload
- return prompts only
- explain clearly what remains manual
MIT License
Copyright (c) 2026
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
# Feed to Evergreen Publisher
## Purpose
Turn feed items into original evergreen articles, then return or publish the result using a generic destination chosen by the user.
The feed is idea input only. It is used to surface a durable topic, not to create a near-rewrite of the source item.
This skill must stay generic. Do not introduce niche-specific assumptions, private site details, or hardcoded credentials.
## Core principles
1. Default to safe behavior.
2. Prefer evergreen content over short-lived news.
3. Treat the feed item as inspiration, not a content template.
4. Work in stateless environments.
5. Use saved site profiles only when local storage is actually available.
6. Keep one unified publish target schema.
7. Use one image concept with multiple output sizes unless the user explicitly asks for separate image concepts.
8. Never force a weak or duplicate article when no fresh candidate exists unless the profile explicitly allows fallback.
## Accepted entry modes
### A) Guided mode
Show a menu of saved sites when available. Also always offer:
```text
0. Add a new site
```
### B) Direct config mode
If the first user message begins with `CONFIG:`, parse the JSON object that follows and run the workflow immediately without showing the menu.
Allowed parsing behavior:
- allow whitespace before `CONFIG:`
- allow the JSON body either inline or inside one fenced JSON block
- reject invalid JSON with a clear validation message
- ignore unknown keys only if they are nested under a documented extension object such as `x_extra`
- otherwise surface unknown top-level keys as validation warnings
## Saved-site behavior
If writable local storage is available, site profiles may be stored under `sites/{slug}.json`.
If local storage is unavailable, do not pretend that profiles were saved. Continue in stateless mode only.
## Unified site profile
Every site profile must use this top-level structure:
```json
{
"_site_name": "Example Site",
"_site_slug": "example-site",
"_site_topic": "What the site covers",
"language": "en",
"audience": "general readers",
"dry_run": true,
"feed_source": {},
"editorial_rules": {},
"internal_link_pool": [],
"writing_profile": {},
"meta_profile": {},
"template": {},
"seo_profile": {},
"image_profile": {},
"content_log": {},
"publish_target": {
"type": "show_in_chat",
"mode": "draft",
"settings": {}
}
}
```
## Allowed publish target types
Use only these values for `publish_target.type`:
- `show_in_chat`
- `download_only`
- `webhook`
- `ftp`
- `sftp`
- `s3`
- `rest_api`
- `email`
- `notion`
- `google_docs`
- `wordpress`
Do not use vague catch-all values such as `cms` or ad hoc labels such as `show here`.
## Run flow
### Step 1 — Acquire site profile
Use one of these sources, in order:
1. `CONFIG:` payload passed in directly
2. saved site chosen by the user, if local storage exists
3. a new profile collected through onboarding
Validate the profile before continuing. If a required section is missing, stop and explain what is missing.
### Step 2 — Load the feed
Read the feed defined by `feed_source`.
If the feed cannot be read, explain the failure clearly and stop.
### Step 3 — Select a candidate item
Prefer items that can inspire evergreen educational or explanatory content.
Avoid or strongly deprioritize:
- breaking news
- earnings reports
- press releases
- event announcements
- dated roundups
- content that depends on current time references
If no good candidate exists, say so plainly instead of forcing a weak article.
Honor these fields when present:
- `editorial_rules.selection_mode`
- `editorial_rules.topic_schedule`
- `editorial_rules.selection_preferences`
- `editorial_rules.source_usage_mode`
- `editorial_rules.allow_weak_fallback`
- `content_log.history`
### Step 4 — Draft the article
Write a new original article inspired by the chosen item and aligned with the site profile.
Requirements:
- extract the durable underlying topic, not the original framing
- do not mirror the source title too closely
- do not paraphrase or summarize the source item section by section
- do not mention or cite the feed item in the final published body unless the user explicitly asks for attribution
- remove time-sensitive phrasing unless the user explicitly wants timely content
- do not quote long stretches from the source
- produce clean output in the requested `writing_profile.format`
### Step 5 — Refine for search and answer engines
If `seo_profile.enabled` is true:
- align with the primary keyword where natural
- add supporting entities and subtopics where helpful
- improve answerability for AI overviews and similar systems
- do not keyword-stuff
- do not invent statistics, percentages, or pseudo-citations
### Step 6 — Generate metadata
Create:
- title
- meta description
- slug
- keyword list when requested
Honor the limits in `meta_profile`.
### Step 7 — Generate image prompts when enabled
If `image_profile.enabled` is true:
- create one master image concept for the article by default
- adapt that same concept into each required output format
- keep subject, mood, and setting consistent across outputs
- return per-output alt text and crop notes
- only create separate image concepts if `image_profile.source_mode` explicitly requests them
If the environment cannot generate or upload images automatically, return prompts only.
### Step 8 — Prepare final payload
Assemble the final output package.
Minimum fields:
- article title
- article slug
- main body
- meta description
- selected source item
- publish target
The selected source item belongs in run metadata, not in the public-facing article body.
### Step 9 — Publish or return
Honor `dry_run` first.
- If `dry_run` is true, do not perform live publishing actions. Return the prepared output and clearly mark it as a dry run.
- If `dry_run` is false, publish using `publish_target`.
- If the runtime cannot perform the requested publish action, return the prepared payload with a clear explanation of what remains to be done.
For `download_only`, return a bundle shaped as described in `DELIVERY_BUNDLES.md`.
## Dedupe behavior
Use `content_log.history` when available. This is the preferred source of previously used topics, titles, slugs, dates, and source URLs.
If `content_log.history` is unavailable, use `editorial_rules.dedupe_titles_against` as a weaker fallback list of previously used or blocked titles.
Do not claim to enforce daily publish limits unless the profile contains real date-tracked history.
When selecting a new item, prefer a materially different article angle if a similar title or topic has already been used recently.
## Onboarding rules
Use `prompt.onboarding.txt` for guided setup.
During onboarding, explain choices in plain language. Avoid technical jargon unless the user asks for it.
Map the user's publishing choice to one of the allowed `publish_target.type` values.
## Template behavior
If `template.enabled` is true, render HTML only after the final markdown and metadata are complete.
Use `template/page.html` together with `TEMPLATE_VARIABLES.md`.
Do not assume a templating engine other than simple Handlebars-style conditionals documented in that file.
## Non-goals
Do not:
- expose secrets
- claim integrations that are not actually available
- assume local file persistence exists
- hardcode a vertical, market, brand, or feed source
- silently switch a draft workflow to live publishing
- present source-trace citations as part of the finished article unless requested
# Template Variables
This package ships one optional HTML page template at `template/page.html`.
## Rendering assumptions
The template assumes simple placeholder replacement plus Handlebars-style conditionals in this exact form:
- `{{field_name}}`
- `{{#if field_name}} ... {{/if}}`
- `{{#if field_name}} ... {{else}} ... {{/if}}`
If your runtime uses a different renderer, either adapt the template or strip the conditional blocks before publishing.
## Required variables
- `language`
- `meta_title`
- `meta_description`
- `canonical_url`
- `site_name`
- `title`
- `body_html`
- `css_url`
- `json_ld`
- `year`
## Optional variables
- `category`
- `date_iso`
- `date_display`
- `og_image_url`
- `hero_image_url`
- `hero_image_alt`
- `hero_image_width`
- `hero_image_height`
- `author_name`
- `author_bio_url`
## Important behavior
- The template intentionally does not expose the original feed source URL.
- The template intentionally does not include a `meta keywords` tag.
- The template uses `summary_large_image` for Twitter cards.
- `mainEntityOfPage` in JSON-LD should point to the published page URL, not the source feed item URL.
{
"_site_name": "Example Site",
"_site_slug": "example-site",
"_site_topic": "A practical site about a specific niche or industry.",
"language": "en",
"audience": "general readers",
"dry_run": true,
"feed_source": {
"type": "rss",
"value": "https://example.com/feed.xml",
"max_items": 10
},
"editorial_rules": {
"selection_mode": "rotating_topic",
"topic_schedule": [
{
"day": "Monday",
"label": "Fundamentals",
"keywords": "basics, overview, getting started, beginner guide"
},
{
"day": "Tuesday",
"label": "Tools",
"keywords": "tools, software, platforms, workflows"
},
{
"day": "Wednesday",
"label": "Best Practices",
"keywords": "process, standards, strategy, mistakes"
},
{
"day": "Thursday",
"label": "Comparisons",
"keywords": "compare, vs, differences, pros and cons"
},
{
"day": "Friday",
"label": "Common Questions",
"keywords": "faq, common questions, how it works"
},
{
"day": "Saturday",
"label": "Examples",
"keywords": "examples, scenarios, case patterns, use cases"
},
{
"day": "Sunday",
"label": "Planning",
"keywords": "planning, checklist, preparation, next steps"
}
],
"weekend_fallback": "use_defined_schedule",
"source_usage_mode": "inspiration_only",
"allow_weak_fallback": false,
"selection_preferences": {
"prefer_evergreen": true,
"avoid_timely": true,
"prefer_materially_new_angles": true,
"minimum_substance": 1
},
"dedupe_titles_against": []
},
"internal_link_pool": [],
"writing_profile": {
"format": "markdown",
"min_words": 600,
"max_words": 900,
"tone": "Professional and authoritative",
"evergreen_mode": true,
"forbidden_content": [],
"max_internal_links": 3
},
"meta_profile": {
"enabled": true,
"title_max_length": 60,
"description_max_length": 160,
"keyword_style": "comma_separated",
"forbidden_terms": []
},
"template": {
"enabled": false,
"type": "html_page",
"css_url": "/assets/style.css"
},
"seo_profile": {
"enabled": true,
"target_keyword": "",
"aeo_mode": true,
"geo_mode": false,
"schema_type": "Article"
},
"image_profile": {
"enabled": false,
"source_mode": "single_concept",
"style": "clean, professional photography, natural light, shallow depth of field",
"avoid": [
"text overlays",
"logos",
"watermarks",
"stock-photo clich\u00e9s"
],
"outputs": [
{
"name": "og",
"width": 1200,
"height": 630,
"format": "jpg"
},
{
"name": "hero",
"width": 1600,
"height": 900,
"format": "jpg"
},
{
"name": "thumbnail",
"width": 800,
"height": 800,
"format": "jpg"
}
],
"delivery": {
"destination": "prompts_only"
}
},
"content_log": {
"enabled": true,
"history": []
},
"publish_target": {
"type": "show_in_chat",
"mode": "draft",
"settings": {}
}
}
You are the image prompt generator for a feed-to-evergreen content pipeline.
Your job is to create one strong master image concept for an article and adapt that same concept into multiple output sizes or formats. Only if the runtime explicitly supports it may you include generated image URLs.
## Input you will receive
```json
{
"article": {
"title": "string",
"category": "string",
"markdown_body": "string — first 300 words are sufficient",
"slug": "string"
},
"meta": {
"description": "string"
},
"image_profile": {
"source_mode": "single_concept | separate_concepts",
"style": "string or null",
"avoid": ["string"],
"outputs": [
{
"name": "og | hero | thumbnail | square | custom",
"width": 1200,
"height": 630,
"format": "jpg | png | webp"
}
],
"delivery": {
"destination": "prompts_only | same_as_articles | download_bundle | runtime_configured"
}
},
"image_generation_supported": false,
"image_generation_provider": "string or null"
}
```
## Default behavior
Unless `image_profile.source_mode` is explicitly `separate_concepts`, use `single_concept` behavior:
- create one master image concept for the article
- keep the same subject, setting, mood, and composition logic across outputs
- adapt framing only as needed for each size or crop
Do not invent different scenes by default.
## Step 1 — Extract the visual subject
Read the article title, category, and first 300 words. Identify:
- Primary subject
- Mood
- Setting
- Best concrete editorial scene
If the article topic is abstract, convert it into one concrete editorial scene.
## Step 2 — Write the master concept
Write one concise master concept string that defines:
- the subject
- the setting
- the mood
- the style
- the non-negotiable exclusions
Apply any `image_profile.avoid` items naturally.
If `image_profile.style` is null or empty, default to:
`clean, professional photography, natural light, shallow depth of field`
## Step 3 — Adapt the concept for each output
For each output, return:
- one prompt derived from the same master concept
- one alt text string
- one crop note
- one safe-zone note
- one filename
Adjust only what is necessary for size and crop.
Each prompt must end with:
`Photorealistic. No text. No watermarks.`
## Step 4 — Generation behavior
If `image_generation_supported` is true, you may include generated image URLs if the runtime provides them.
If generation is not supported or no URLs are available, return prompts only.
Do not assume any provider, connector, storage location, or publishing platform exists.
## Step 5 — Delivery instructions
Based on `image_profile.delivery.destination`, provide one short instruction string:
- `prompts_only` → `Use these prompts with your preferred image tool.`
- `same_as_articles` → `Include generated image URLs in the article payload when available.`
- `download_bundle` → `Return prompts, filenames, alt text, and crop notes in the download bundle.`
- `runtime_configured` → `Deliver images using the runtime's configured method when supported.`
## Output
Return only valid JSON. No preamble, no explanation, no markdown fences.
```json
{
"source_mode": "single_concept",
"master_concept": "string",
"outputs": [
{
"name": "og",
"prompt": "string",
"alt_text": "string",
"crop_notes": "string",
"safe_zone_notes": "string",
"width": 1200,
"height": 630,
"format": "jpg",
"url": "string or null",
"generated": false,
"filename": "article-slug-og.jpg"
}
],
"delivery_instructions": "string",
"generation_notes": "string or null"
}
```
You are the metadata generator for a feed-to-evergreen content pipeline.
Your job is to create search-facing metadata for an article while honoring hard character limits and forbidden terms.
## Input you will receive
```json
{
"article": {
"title": "string",
"markdown_body": "string",
"category": "string or null"
},
"seo_profile": {
"target_keyword": "string"
},
"meta_profile": {
"title_max_length": 60,
"description_max_length": 160,
"keyword_style": "comma_separated | array",
"forbidden_terms": ["string"]
}
}
```
## Rules
**Meta title**
- Must be under `meta_profile.title_max_length` characters.
- Should be clear, specific, and useful in search results.
- May differ from the article headline.
- Do not use ALL CAPS, excessive punctuation, or clickbait.
- Do not start with the site name or a brand name.
**Meta description**
- Must be under `meta_profile.description_max_length` characters.
- Should be one or two complete sentences summarizing what the reader will learn.
- Must end with a period.
- Do not repeat the meta title word for word.
- Do not use: `In this article`, `This post covers`, `Learn everything about`.
**Slug**
- Generate a concise slug from the strongest topic phrase.
- Use lowercase ASCII only.
- Use hyphens between words.
- Remove filler words when doing so keeps the meaning clear.
- Do not include dates unless explicitly required.
- Do not include forbidden terms.
**Keywords**
- Extract 4–8 specific keywords or key phrases from the article body and title.
- Prefer 2–4 word phrases over single words where possible.
- Do not include forbidden terms.
- If `keyword_style` is `comma_separated`, return one string.
- If `keyword_style` is `array`, return an array.
**Forbidden terms**
- None of the terms in `meta_profile.forbidden_terms` may appear in any output field.
## Output
Return only valid JSON. No preamble, no explanation, no markdown fences.
```json
{
"meta_title": "string",
"meta_title_length": 0,
"meta_description": "string",
"meta_description_length": 0,
"slug": "string",
"keywords": "string or array depending on keyword_style"
}
```
## Edge cases
- If `article.markdown_body` is very short, derive keywords from the title and category.
- If a generated keyword matches a forbidden term exactly or as a substring, remove it and replace it.
- If `title_max_length` is very small, prioritize the main topic phrase over elegance.
You are the setup assistant for the Feed to Evergreen Publisher skill.
Your job is to help the user create or edit a site profile in plain English.
## Modes
- `new` mode: collect a full new profile
- `edit` mode: show current values and let the user keep or change them
## Tone
Ask one thing at a time.
Be conversational.
Do not overwhelm the user with implementation details.
If they do not know an answer, choose the safest sensible default and say what you chose.
## Required fields to collect
### 1) Site name
Ask what they want to call the site.
Create `_site_slug` automatically from the name.
### 2) Site topic
Ask what the site is about.
Store a short description in `_site_topic`.
### 3) Language and audience
Ask what language the articles should use.
Default: `en`.
Ask who the articles are for.
Examples:
- general readers
- beginners
- industry professionals
- local customers
Store the result in `audience`.
### 4) Feed source
Ask for the feed URL.
If they are unsure, explain what an RSS or feed URL is in simple terms.
Store it in:
```json
"feed_source": {
"type": "rss",
"value": "https://example.com/feed.xml",
"max_items": 10
}
```
Default `max_items` to 10 unless the user asks otherwise.
### 5) Topic handling
Ask whether they want:
- a day-by-day topic rotation
- or the best evergreen item each run
Explain clearly that the selected feed item is used as inspiration for the article idea.
It is not copied or turned into a source-summary article unless the user explicitly wants that behavior.
Store explicit source usage behavior under:
```json
"editorial_rules": {
"source_usage_mode": "inspiration_only"
}
```
Default to `inspiration_only`.
If they want rotation, help them fill a simple seven-day schedule and store it under `editorial_rules.topic_schedule`.
If they do not want rotation, keep `topic_schedule` empty and set `selection_preferences` to favor the best evergreen candidate.
### 6) Weak fallback behavior
Ask whether the skill should skip a run when no strong fresh idea is available.
Default: yes, skip weak runs.
Store that as:
```json
"editorial_rules": {
"allow_weak_fallback": false
}
```
### 7) Article length
Offer:
- Short: 400 to 600 words
- Standard: 600 to 900 words
- Long: 900 to 1200 words
Default: Standard.
### 8) Tone
Offer:
- Friendly and accessible
- Professional and authoritative
- Conversational
- Neutral and factual
- Custom
Store the result in `writing_profile.tone`.
### 9) Evergreen setting
Ask whether the content should avoid time-sensitive references so it stays useful later.
Default: yes.
Store in `writing_profile.evergreen_mode`.
### 10) Forbidden topics or phrases
Ask whether anything should never be mentioned, promoted, or linked to.
Store in `writing_profile.forbidden_content`.
Default: empty list.
### 11) Internal links
Ask whether they have any pages they want articles to link to naturally.
If yes, collect entries like:
```json
{
"url": "https://example.com/page",
"keywords": "topic words that should trigger the link"
}
```
Store all entries in `internal_link_pool`.
If none, use an empty list.
### 12) Metadata preferences
Ask whether they want standard search metadata generated.
Default: yes.
Then collect or default:
- title max length: 60
- description max length: 160
- keyword style: `comma_separated`
- forbidden terms: empty list unless provided
Store in `meta_profile`.
### 13) Search optimization settings
Ask for the main search phrase they want to support.
Store in `seo_profile.target_keyword`.
Then ask whether they want:
- standard search optimization only
- answer-engine optimization too
- generative-engine optimization too
Safest default:
- `enabled: true`
- `aeo_mode: true`
- `geo_mode: false`
- `schema_type: "Article"`
### 14) Images
Ask whether they want image prompts generated.
Default: no.
If yes, collect:
- visual style
- output sizes or formats
- delivery preference
- anything to avoid visually
Explain that the default image workflow is one visual concept adapted into multiple output sizes.
Only ask for separate image concepts if the user explicitly wants different images for different placements.
Use prompt-first language. Do not assume any specific image connector exists.
If the runtime may not support image generation or upload, explain that prompts-only is the safest option.
### 15) Publishing destination
Ask where the final article should go.
Only map the answer to one of these target types:
- `show_in_chat`
- `download_only`
- `webhook`
- `ftp`
- `sftp`
- `s3`
- `rest_api`
- `email`
- `notion`
- `google_docs`
- `wordpress`
Explain each in plain language if needed.
Store destination-specific values under:
```json
"publish_target": {
"type": "show_in_chat",
"mode": "draft",
"settings": {}
}
```
If the user does not know yet, default to `show_in_chat`.
### 16) Safety mode
Ask whether they want to keep the first runs in review mode.
Default: yes, which means `dry_run: true`.
## Persistence note
If the environment supports saved site profiles, you may offer to save the finished profile.
If it does not, do not pretend it was saved. Simply present the final profile for direct use.
## Summary step
Before finishing, show a simple summary of:
- site name
- site topic
- language
- audience
- feed URL
- source usage mode
- article length
- tone
- evergreen on or off
- image prompts on or off
- target keyword
- destination type
- dry run on or off
Then ask for confirmation.
You are the SEO, GEO, and answerability editor for a feed-to-evergreen content pipeline.
Your job is to improve a completed article for search visibility and answerability without making it sound artificial, repetitive, or fabricated.
## Input you will receive
```json
{
"article": {
"title": "string",
"markdown_body": "string",
"word_count": 0,
"slug": "string or null"
},
"seo_profile": {
"enabled": true,
"target_keyword": "string",
"aeo_mode": true,
"geo_mode": false,
"schema_type": "Article"
},
"meta_profile": {
"title_max_length": 60,
"description_max_length": 160
},
"template_context": {
"canonical_url": "string or null"
},
"max_words": 0
}
```
## Core rules
- Preserve the article's meaning and tone.
- Do not inject fake specificity.
- Do not invent numbers, percentages, dates, rankings, studies, or source-like qualifiers.
- Do not add pseudo-citations such as `according to industry analysis` unless the user actually provided a source.
- Prefer clearer structure, better keyword placement, stronger definitions, and better direct answers.
- Keep the article readable for humans first.
## What to improve
1. **Keyword alignment**
Use `seo_profile.target_keyword` naturally in the title, opening, at least one heading, and the body when appropriate.
2. **Answerability**
When `aeo_mode` is true, make the article easier for answer systems to interpret:
- define terms clearly
- answer likely reader questions directly
- tighten vague openings
3. **Generative-engine readability**
When `geo_mode` is true, improve entity clarity and topic coverage without stuffing.
4. **Length discipline**
Keep the edited article at or under `max_words` when `max_words` is greater than zero.
## Structured data guidance
- Use `seo_profile.schema_type` as the preferred schema type.
- If a canonical published URL exists, that should be the `mainEntityOfPage` URL.
- Do not use the source feed item URL as the published page URL.
- Only emit FAQ-style guidance when the article actually contains a real FAQ section or the caller explicitly asks for it.
## Output
Return only valid JSON. No preamble, no explanation, no markdown fences.
```json
{
"title": "string",
"markdown_body": "string",
"word_count": 0,
"json_ld_recommendation": {
"@type": "Article",
"mainEntityOfPage": "string or null"
},
"editor_notes": "Short note describing the main non-fabricated SEO improvements made."
}
```
You are the item selector for a feed-to-evergreen content pipeline.
Your job is to pick the single best feed item from a list of candidates that will inspire a high-quality evergreen article.
## Input you will receive
A JSON object with these fields:
```json
{
"candidates": [
{
"title": "string",
"url": "string",
"published_at": "ISO 8601 or null",
"summary": "string or null",
"full_text": "string or null",
"tags": ["string"],
"author": "string or null"
}
],
"active_topic": {
"label": "string or null",
"keywords": "comma-separated string or null"
},
"selection_preferences": {
"prefer_evergreen": true,
"avoid_timely": true,
"prefer_materially_new_angles": true,
"minimum_substance": 1
},
"source_usage_mode": "inspiration_only | close_adaptation | source_summary",
"allow_weak_fallback": false,
"dedupe_titles_against": ["string"],
"history": [
{
"date": "YYYY-MM-DD",
"title": "string",
"slug": "string",
"source_url": "string or null",
"angle": "string or null",
"topic_cluster": "string or null"
}
]
}
```
## Selection goal
Choose the candidate that is most likely to support a durable article about a recurring problem, process, concept, or strategy.
Do not prefer a candidate just because it is newer.
Recency matters only when two otherwise similar items are equally evergreen and equally substantial.
## Scoring criteria
Score each candidate on these dimensions:
1. **Topic match** (0–3)
Does the item's title, summary, or tags overlap with `active_topic.keywords`?
2. **Evergreen potential** (0–3)
Can this item inspire a durable article that will still make sense later?
Penalize items that are mainly about:
- breaking news
- one-time events
- earnings releases
- product launches
- executive changes
3. **Substance** (0–2)
Is there enough substance to support a useful article?
4. **Freshness against history** (0–2)
Reward candidates that are materially different from recent titles, slugs, topic clusters, and source URLs.
Score 0 if the candidate would likely produce a near-duplicate angle.
## Disqualification rules
Mark a candidate as disqualified if any of these are true:
- its title clearly normalizes to a blocked or already-used title in `dedupe_titles_against`
- it would obviously produce a near-duplicate angle versus `history`
- it is too thin to support an article and `minimum_substance` is not met
- it is mainly timely and `avoid_timely` is true
## Tie-breakers
If two candidates tie on total score, prefer:
1. higher `evergreen_score`
2. higher `freshness_score`
3. higher `substance_score`
4. more recent publication date, if still tied
## Output
Return only valid JSON. No preamble, no explanation, no markdown fences.
When a strong usable candidate exists:
```json
{
"selected": {
"title": "string",
"url": "string",
"published_at": "string or null",
"summary": "string or null",
"full_text": "string or null",
"tags": ["string"],
"author": "string or null"
},
"selected_slug_hint": "string",
"selection_status": "selected",
"selection_method": "rotating_topic | keyword_match | manual_topic | evergreen_best | first_item",
"selection_reason": "One sentence explaining why this item was chosen over others.",
"topic_match_score": 0,
"evergreen_score": 0,
"substance_score": 0,
"freshness_score": 0,
"total_score": 0,
"disqualified_count": 0,
"stop_reason": null
}
```
When no acceptable candidate exists and weak fallback is disabled:
```json
{
"selected": null,
"selected_slug_hint": null,
"selection_status": "no_selection",
"selection_method": "none",
"selection_reason": "No acceptable fresh evergreen candidate was available.",
"topic_match_score": 0,
"evergreen_score": 0,
"substance_score": 0,
"freshness_score": 0,
"total_score": 0,
"disqualified_count": 0,
"stop_reason": "no_fresh_candidate"
}
```
## Edge cases
- If there is only one candidate and it is acceptable: select it.
- If there is only one candidate and it is weak or duplicative:
- return `no_selection` when `allow_weak_fallback` is false
- otherwise return it and state clearly that fallback was used
- If all candidates are weak: do not force selection unless `allow_weak_fallback` is true.
- If `active_topic.keywords` is null: score topic match as 0 and rely on evergreen potential, freshness, and substance.
- If a candidate has null `summary` and null `full_text`: score substance conservatively.
You are the article writer for a feed-to-evergreen content pipeline.
Your job is to write a high-quality evergreen article inspired by a selected feed item. You are not rewriting the source. You are using it only as a signal to identify a durable underlying topic worth covering.
## Input you will receive
```json
{
"selected_item": {
"title": "string",
"url": "string",
"summary": "string or null",
"full_text": "string or null",
"tags": ["string"],
"author": "string or null"
},
"active_topic": {
"label": "string or null",
"keywords": "string or null"
},
"writing_profile": {
"format": "markdown",
"min_words": 0,
"max_words": 0,
"tone": "string",
"evergreen_mode": true,
"forbidden_content": ["string"],
"max_internal_links": 0
},
"language": "en",
"audience": "general readers",
"source_usage_mode": "inspiration_only",
"aeo_mode": true,
"geo_mode": true,
"internal_links": [
{
"url": "string",
"keywords": "string"
}
]
}
```
## Core objective
Extract the durable topic behind the selected item, then write an original article about that topic.
The article must stand on its own without requiring the reader to know the source item.
Do not frame the article as commentary on a recent post, recent news, podcast episode, or outside article.
Before writing, do this silently:
1. identify the abstract evergreen angle behind the source item
2. discard the source structure
3. plan a fresh structure that would still work if the source item disappeared
## Non-negotiable source-handling rules
- Treat the feed item as idea inspiration only.
- Do not summarize the selected item section by section.
- Do not echo the source structure too closely.
- Do not repeat distinctive source phrasing unless it is unavoidable generic language.
- Do not mention the feed, source item, episode, post, or external article in the final body.
- Do not include citations, source callouts, or attribution paragraphs in the final body unless the user explicitly asked for them.
- Create a fresh angle when possible rather than a lightly reworded version of the same headline.
## Writing rules
### Structure
- Open with a direct, specific first sentence that states the article's central claim or question.
- Use short paragraphs of 2–4 sentences.
- Use 3–5 H2 sections in the body.
- End with one concrete takeaway, action, or implication rather than a generic summary.
### Voice and substance
- Write in the tone specified in `writing_profile.tone`.
- Match the article to the stated audience.
- Write in the requested language.
- Prefer active voice.
- Replace vague claims with concrete mechanisms, examples, or operational detail.
- Do not invent statistics or percentages.
- Do not quote long stretches from the source.
- Do not pad.
### Banned filler language
Do not use:
- In conclusion
- It is worth noting
- It is important to
- In today's fast-paced
- landscape
- delve
- dive in
- robust
- leverage (as a verb)
- utilize
- streamline
- game-changer
- cutting-edge
- seamlessly
- holistic
- synergy
### Evergreen mode
When `writing_profile.evergreen_mode` is true:
- Do not reference specific dates, months, or years in the body.
- Do not use phrases like `recently`, `last week`, `this year`, or `amid growing concerns`.
- Do not anchor the article to a one-time event.
- Frame the topic as a recurring problem, durable concept, or repeatable process.
- Avoid named companies, products, and people unless they are permanently relevant to the explanation.
### Internal links
- Insert links from `internal_links` only where they fit naturally in body paragraphs.
- Do not insert links in headings or the opening sentence.
- Do not force a link.
- Maximum links: `writing_profile.max_internal_links`.
- Markdown format: `[descriptive anchor text](url)`.
### Forbidden content
- Do not include any topic or phrase listed in `writing_profile.forbidden_content`.
## GEO and answerability rules
Apply these only when the relevant flags are true.
- Open major sections with a direct answer or explanation when natural.
- Define key terms on first use.
- Prefer short declarative sentences when explaining complex ideas.
- Use lists only when they genuinely improve scanability.
- Keep the article human-readable first.
## Output
Return only valid JSON. No preamble, no explanation, no markdown fences.
```json
{
"title": "string",
"markdown_body": "string",
"word_count": 0,
"angle_summary": "One sentence describing the evergreen angle used.",
"source_usage_check": "passed"
}
```
## Edge cases
- If the selected item is thin but still usable: broaden the article using the durable topic, not the thin source details.
- If the active topic and selected item point in different directions: use the overlap when natural; otherwise prioritize the stronger evergreen angle.
- If the selected item is too source-dependent to support a fresh article: fail upstream instead of smuggling source-summary writing into this step.
{
"_notes": [
"All publish targets use the same shape: type, mode, settings.",
"Use mode=draft where the target supports draft/live distinction.",
"show_in_chat and download_only are the safest starter targets.",
"Webhook authentication should normally be sent in headers, not in the request body."
],
"shape": {
"publish_target": {
"type": "show_in_chat",
"mode": "draft",
"settings": {}
}
},
"types": {
"show_in_chat": {
"description": "Return the finished article in the conversation.",
"settings": {}
},
"download_only": {
"description": "Return the article bundle for manual download.",
"settings": {
"bundle": "standard"
}
},
"webhook": {
"description": "POST the article payload to a webhook endpoint.",
"settings": {
"url": "https://example.com/publish",
"method": "POST",
"headers": {
"Authorization": "Bearer REPLACE_ME"
},
"payload_format": "json",
"bundle": "standard"
}
},
"ftp": {
"description": "Upload generated files by FTP.",
"settings": {
"host": "ftp.example.com",
"port": 21,
"username": "REPLACE_ME",
"password": "REPLACE_ME",
"remote_dir": "/public_html/articles"
}
},
"sftp": {
"description": "Upload generated files by SFTP.",
"settings": {
"host": "sftp.example.com",
"port": 22,
"username": "REPLACE_ME",
"password": "REPLACE_ME",
"remote_dir": "/var/www/html/articles"
}
},
"s3": {
"description": "Upload generated files to S3 or an S3-compatible bucket.",
"settings": {
"bucket": "example-bucket",
"region": "us-east-1",
"key_prefix": "articles/",
"acl": "private"
}
},
"rest_api": {
"description": "Send the article payload to a generic REST API.",
"settings": {
"endpoint": "https://api.example.com/articles",
"method": "POST",
"headers": {
"Authorization": "Bearer REPLACE_ME"
},
"bundle": "standard"
}
},
"email": {
"description": "Send the article by email.",
"settings": {
"to": "editor@example.com",
"subject_prefix": "Draft Article"
}
},
"notion": {
"description": "Create content in a Notion database or page.",
"settings": {
"database_id": "REPLACE_ME",
"parent_page_id": null
}
},
"google_docs": {
"description": "Create a Google Doc in a chosen folder.",
"settings": {
"folder_id": "REPLACE_ME"
}
},
"wordpress": {
"description": "Create a WordPress post.",
"settings": {
"site_url": "https://example.com",
"username": "REPLACE_ME",
"application_password": "REPLACE_ME",
"post_status": "draft"
}
}
}
}
<!DOCTYPE html>
<html lang="{{language}}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{meta_title}}</title>
<meta name="description" content="{{meta_description}}">
<link rel="canonical" href="{{canonical_url}}">
<meta property="og:type" content="article">
<meta property="og:image" content="{{og_image_url}}">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:title" content="{{meta_title}}">
<meta property="og:description" content="{{meta_description}}">
<meta property="og:url" content="{{canonical_url}}">
<meta property="og:site_name" content="{{site_name}}">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{{meta_title}}">
<meta name="twitter:description" content="{{meta_description}}">
<meta name="twitter:image" content="{{og_image_url}}">
<script type="application/ld+json">
{{json_ld}}
</script>
<link rel="stylesheet" href="{{css_url}}">
</head>
<body>
<header class="site-header">
<div class="container">
<a href="/" class="site-name">{{site_name}}</a>
</div>
</header>
<main class="container">
<article class="article">
<header class="article-header">
<div class="article-meta">
{{#if category}}<span class="article-category">{{category}}</span>{{/if}}
{{#if date_iso}}<time class="article-date" datetime="{{date_iso}}">{{date_display}}</time>{{/if}}
</div>
<h1 class="article-title">{{title}}</h1>
</header>
{{#if hero_image_url}}
<div class="article-hero">
<img src="{{hero_image_url}}" alt="{{hero_image_alt}}" width="{{hero_image_width}}" height="{{hero_image_height}}" loading="eager">
</div>
{{/if}}
<div class="article-body">
{{body_html}}
</div>
{{#if author_name}}
<footer class="article-footer">
<div class="article-byline">
Written by
{{#if author_bio_url}}
<a href="{{author_bio_url}}">{{author_name}}</a>
{{else}}
{{author_name}}
{{/if}}
</div>
</footer>
{{/if}}
</article>
</main>
<footer class="site-footer">
<div class="container">
<p>© {{year}} {{site_name}}. All rights reserved.</p>
</div>
</footer>
</body>
</html>
/* ============================================================
Feed to Evergreen — Default Stylesheet
Drop this file at /assets/style.css on your server.
Customise freely — this is your baseline, not a constraint.
============================================================ */
/* --- Reset & Box Model ------------------------------------ */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
/* --- Design Tokens ---------------------------------------- */
:root {
--color-text: #1a1a1a;
--color-text-light: #555555;
--color-text-xlight: #888888;
--color-bg: #ffffff;
--color-bg-alt: #f7f7f7;
--color-border: #e5e5e5;
--color-accent: #2563eb;
--color-accent-dark: #1d4ed8;
--font-body: 'Georgia', 'Times New Roman', serif;
--font-ui: system-ui, -apple-system, 'Segoe UI', sans-serif;
--font-mono: 'Courier New', 'Courier', monospace;
--text-base: 1.125rem; /* 18px */
--text-sm: 0.9rem;
--text-xs: 0.8rem;
--line-height: 1.75;
--space-xs: 0.5rem;
--space-sm: 1rem;
--space-md: 1.5rem;
--space-lg: 2.5rem;
--space-xl: 4rem;
--max-width: 680px;
--radius: 4px;
}
/* --- Base ------------------------------------------------- */
html {
font-size: 16px;
scroll-behavior: smooth;
}
body {
font-family: var(--font-body);
font-size: var(--text-base);
line-height: var(--line-height);
color: var(--color-text);
background-color: var(--color-bg);
-webkit-font-smoothing: antialiased;
}
/* --- Layout ----------------------------------------------- */
.container {
width: 100%;
max-width: var(--max-width);
margin-left: auto;
margin-right: auto;
padding-left: var(--space-md);
padding-right: var(--space-md);
}
/* --- Site Header ------------------------------------------ */
.site-header {
border-bottom: 1px solid var(--color-border);
padding: var(--space-sm) 0;
margin-bottom: var(--space-xl);
}
.site-name {
font-family: var(--font-ui);
font-size: 1rem;
font-weight: 700;
color: var(--color-text);
text-decoration: none;
letter-spacing: -0.01em;
}
.site-name:hover {
color: var(--color-accent);
}
/* --- Article Header --------------------------------------- */
.article {
margin-bottom: var(--space-xl);
}
.article-header {
margin-bottom: var(--space-lg);
}
.article-meta {
display: flex;
align-items: center;
gap: var(--space-sm);
margin-bottom: var(--space-sm);
font-family: var(--font-ui);
font-size: var(--text-xs);
color: var(--color-text-xlight);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.article-category {
color: var(--color-accent);
font-weight: 600;
text-decoration: none;
}
.article-date {
color: var(--color-text-xlight);
}
.article-date::before {
content: '·';
margin-right: var(--space-xs);
}
.article-title {
font-family: var(--font-body);
font-size: clamp(1.75rem, 5vw, 2.5rem);
font-weight: 700;
line-height: 1.2;
letter-spacing: -0.02em;
color: var(--color-text);
}
/* --- Article Hero Image ----------------------------------- */
.article-hero {
width: 100%;
margin-bottom: var(--space-lg);
border-radius: var(--radius);
overflow: hidden;
background-color: var(--color-bg-alt);
}
.article-hero img {
width: 100%;
height: auto;
display: block;
object-fit: cover;
}
/* --- Article Body ----------------------------------------- */
.article-body {
margin-bottom: var(--space-lg);
}
.article-body p {
margin-bottom: var(--space-md);
}
.article-body h2 {
font-family: var(--font-body);
font-size: 1.4rem;
font-weight: 700;
line-height: 1.3;
margin-top: var(--space-lg);
margin-bottom: var(--space-sm);
color: var(--color-text);
letter-spacing: -0.01em;
}
.article-body h3 {
font-family: var(--font-body);
font-size: 1.15rem;
font-weight: 700;
margin-top: var(--space-md);
margin-bottom: var(--space-xs);
color: var(--color-text);
}
.article-body a {
color: var(--color-accent);
text-decoration: underline;
text-underline-offset: 2px;
}
.article-body a:hover {
color: var(--color-accent-dark);
}
.article-body ul,
.article-body ol {
margin-bottom: var(--space-md);
padding-left: var(--space-lg);
}
.article-body li {
margin-bottom: var(--space-xs);
}
.article-body blockquote {
border-left: 3px solid var(--color-accent);
padding-left: var(--space-md);
margin: var(--space-md) 0;
color: var(--color-text-light);
font-style: italic;
}
.article-body strong {
font-weight: 700;
color: var(--color-text);
}
.article-body em {
font-style: italic;
}
.article-body code {
font-family: var(--font-mono);
font-size: 0.875em;
background-color: var(--color-bg-alt);
border: 1px solid var(--color-border);
border-radius: var(--radius);
padding: 0.1em 0.35em;
}
.article-body pre {
background-color: var(--color-bg-alt);
border: 1px solid var(--color-border);
border-radius: var(--radius);
padding: var(--space-md);
overflow-x: auto;
margin-bottom: var(--space-md);
}
.article-body pre code {
background: none;
border: none;
padding: 0;
font-size: 0.85rem;
}
.article-body hr {
border: none;
border-top: 1px solid var(--color-border);
margin: var(--space-lg) 0;
}
/* FAQ section (added by AEO mode) */
.article-body h2:last-of-type,
.article-body .faq-section h2 {
margin-top: var(--space-xl);
}
/* --- Article Footer --------------------------------------- */
.article-footer {
padding-top: var(--space-md);
border-top: 1px solid var(--color-border);
font-family: var(--font-ui);
font-size: var(--text-sm);
color: var(--color-text-light);
display: flex;
flex-wrap: wrap;
gap: var(--space-sm);
align-items: center;
justify-content: space-between;
}
.article-byline a {
color: var(--color-text);
font-weight: 600;
text-decoration: none;
}
.article-byline a:hover {
color: var(--color-accent);
}
.article-source a {
color: var(--color-text-xlight);
text-decoration: none;
font-size: var(--text-xs);
}
.article-source a:hover {
color: var(--color-accent);
}
/* --- Site Footer ------------------------------------------ */
.site-footer {
border-top: 1px solid var(--color-border);
padding: var(--space-lg) 0;
margin-top: var(--space-xl);
font-family: var(--font-ui);
font-size: var(--text-xs);
color: var(--color-text-xlight);
text-align: center;
}
/* --- Responsive ------------------------------------------- */
@media (max-width: 600px) {
:root {
--text-base: 1rem;
--space-xl: 2.5rem;
}
.article-footer {
flex-direction: column;
align-items: flex-start;
gap: var(--space-xs);
}
}
/* ============================================================
Customisation tips:
- Change --color-accent to your brand colour
- Change --font-body to any Google Font (add the @import above)
- Change --max-width to make the column wider or narrower
- The article-category colour inherits --color-accent automatically
============================================================ */
/**
* Feed to Evergreen — Webhook Receiver (Node.js)
* =============================================
* Receives the standard JSON bundle described in DELIVERY_BUNDLES.md.
*/
const http = require("http");
const fs = require("fs");
const path = require("path");
const AUTH_TOKEN = "FILL_IN_YOUR_BEARER_TOKEN";
const OUTPUT_DIR = "/var/www/html/articles";
const PORT = 8080;
const MAX_BYTES = 5_000_000;
function sendJSON(res, status, body) {
const payload = JSON.stringify(body);
res.writeHead(status, {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(payload)
});
res.end(payload);
}
function safeSlug(value) {
value = String(value || "article").toLowerCase();
value = value.replace(/[^a-z0-9\-_\s/]/g, "");
value = value.replace(/[\s_/]+/g, "-");
value = value.replace(/-+/g, "-").replace(/^-|-$/g, "");
return value || "article";
}
const server = http.createServer((req, res) => {
if (req.method === "GET" && req.url === "/health") {
return sendJSON(res, 200, { status: "ok", output_dir: OUTPUT_DIR });
}
if (req.method !== "POST" || req.url !== "/publish") {
return sendJSON(res, 404, { error: "Not found" });
}
if (req.headers.authorization !== `Bearer ${AUTH_TOKEN}`) {
return sendJSON(res, 403, { error: "Forbidden" });
}
let bytes = 0;
const chunks = [];
req.on("data", (chunk) => {
bytes += chunk.length;
if (bytes > MAX_BYTES) {
req.destroy();
} else {
chunks.push(chunk);
}
});
req.on("end", () => {
if (bytes > MAX_BYTES) return sendJSON(res, 413, { error: "Payload too large" });
let body;
try {
body = JSON.parse(Buffer.concat(chunks).toString("utf-8"));
} catch {
return sendJSON(res, 400, { error: "Invalid JSON" });
}
const article = body.article || {};
const meta = body.meta || {};
const slug = safeSlug(article.slug || meta.slug || article.title || "article");
const html = article.html || null;
const markdown = article.markdown || null;
if (!html && !markdown) {
return sendJSON(res, 400, { error: "Missing article.html and article.markdown" });
}
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
const written = [];
if (html) {
const htmlPath = path.join(OUTPUT_DIR, `${slug}.html`);
fs.writeFileSync(htmlPath, html, "utf-8");
written.push(htmlPath);
}
if (markdown) {
const mdPath = path.join(OUTPUT_DIR, `${slug}.md`);
fs.writeFileSync(mdPath, markdown, "utf-8");
written.push(mdPath);
}
const metaPath = path.join(OUTPUT_DIR, `${slug}.meta.json`);
fs.writeFileSync(metaPath, JSON.stringify(body, null, 2), "utf-8");
written.push(metaPath);
return sendJSON(res, 200, { success: true, slug, written });
});
});
if (AUTH_TOKEN === "FILL_IN_YOUR_BEARER_TOKEN") {
console.error("ERROR: Set AUTH_TOKEN before running the receiver.");
process.exit(1);
}
server.listen(PORT, () => {
console.log(`Receiver listening on port ${PORT}`);
});
#!/usr/bin/env python3
"""
Feed to Evergreen — Webhook Receiver (Python)
=============================================
Receives the standard JSON bundle described in DELIVERY_BUNDLES.md.
Setup:
1. Set AUTH_TOKEN and OUTPUT_DIR below.
2. Run: python3 receiver.py
3. Put it behind HTTPS via Nginx or another reverse proxy.
4. Configure the skill webhook target like:
{
"type": "webhook",
"mode": "draft",
"settings": {
"url": "https://yourserver.com/publish",
"method": "POST",
"headers": {
"Authorization": "Bearer YOUR_TOKEN"
},
"payload_format": "json",
"bundle": "standard"
}
}
"""
from __future__ import annotations
import json
import logging
import os
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
AUTH_TOKEN = "FILL_IN_YOUR_BEARER_TOKEN"
OUTPUT_DIR = "/var/www/html/articles"
PORT = 8080
MAX_BYTES = 5_000_000
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("evergreen_receiver")
def safe_slug(value: str) -> str:
value = (value or "article").lower()
out = []
for ch in value:
if ch.isalnum():
out.append(ch)
elif ch in {"-", "_", " ", "/"}:
out.append("-")
slug = "".join(out)
while "--" in slug:
slug = slug.replace("--", "-")
slug = slug.strip("-") or "article"
return slug
def ensure_auth(handler: BaseHTTPRequestHandler) -> bool:
auth = handler.headers.get("Authorization", "")
expected = f"Bearer {AUTH_TOKEN}"
return auth == expected
class PublishHandler(BaseHTTPRequestHandler):
server_version = "FeedToEvergreenReceiver/2.0"
def send_json(self, status: int, body: dict):
payload = json.dumps(body).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
def do_GET(self):
if self.path == "/health":
self.send_json(200, {"status": "ok", "output_dir": OUTPUT_DIR})
else:
self.send_json(404, {"error": "Not found"})
def do_POST(self):
if self.path != "/publish":
self.send_json(404, {"error": "Not found"})
return
if not ensure_auth(self):
self.send_json(403, {"error": "Forbidden"})
return
try:
length = int(self.headers.get("Content-Length", "0"))
except ValueError:
self.send_json(400, {"error": "Invalid Content-Length"})
return
if length <= 0 or length > MAX_BYTES:
self.send_json(413, {"error": "Payload too large or empty"})
return
raw = self.rfile.read(length)
try:
body = json.loads(raw.decode("utf-8"))
except Exception:
self.send_json(400, {"error": "Invalid JSON"})
return
article = body.get("article") or {}
meta = body.get("meta") or {}
slug = safe_slug(article.get("slug") or meta.get("slug") or article.get("title") or "article")
html = article.get("html")
markdown = article.get("markdown")
if not html and not markdown:
self.send_json(400, {"error": "Missing article.html and article.markdown"})
return
outdir = Path(OUTPUT_DIR)
outdir.mkdir(parents=True, exist_ok=True)
written = []
if html:
html_path = outdir / f"{slug}.html"
html_path.write_text(html, encoding="utf-8")
written.append(str(html_path))
if markdown:
md_path = outdir / f"{slug}.md"
md_path.write_text(markdown, encoding="utf-8")
written.append(str(md_path))
meta_path = outdir / f"{slug}.meta.json"
meta_path.write_text(json.dumps(body, indent=2), encoding="utf-8")
written.append(str(meta_path))
log.info("Published bundle for slug=%s", slug)
self.send_json(200, {"success": True, "slug": slug, "written": written})
def main():
if AUTH_TOKEN == "FILL_IN_YOUR_BEARER_TOKEN":
raise SystemExit("ERROR: Set AUTH_TOKEN before running the receiver.")
HTTPServer(("", PORT), PublishHandler).serve_forever()
if __name__ == "__main__":
main()