Blog Editorial Calendar
/install blog-editorial-calendar
blog-editorial-calendar
The scheduler and topic-picker that sits on top of the other two skills. blog-topic-research fills a backlog with evidence-backed topics; seo-blog-writer turns one topic into a published post. This skill decides which topic to write next and when to publish it, so the corpus drifts toward the content mix you defined instead of whatever you felt like writing that day.
/blog-editorial-calendar next [N] # pick + write + auto-schedule into the next slots
/blog-editorial-calendar next [N] --draft # pick + write, leave as a draft (no schedule)
/blog-editorial-calendar next [N] \x3Cslot>[, \x3Cslot> ...] # pick + write + schedule each explicit slot
/blog-editorial-calendar status
/blog-editorial-calendar add "\x3Ctopic>" --cluster \x3Cc> --format \x3Cf> [--priority N] [--notes "..."]
/blog-editorial-calendar mark-done \x3Cid-or-slug>
/blog-editorial-calendar sync # reconcile the backlog against what's live on your CMS
next always writes. With no flags it schedules each post into the next free slot of the rolling cadence; with --draft it writes but leaves the post as a draft; with explicit slot args it uses the named windows. The author and target platform come from config.json (see below) — this skill never overrides them.
Configuration — config.json
Everything site-specific lives in skills/blog-editorial-calendar/config.json, not in this file. Edit it once for your blog; the reference code below reads it. Example:
{
"target_platform": "ghost",
"author": "Editorial",
"cadence": {
"posts_per_day": 2,
"slots_utc": [[6, 0, 11, 59], [18, 0, 22, 59]]
},
"cluster_targets": {
"core-product": 0.30,
"integrations": 0.25,
"use-cases": 0.25,
"ecosystem": 0.20
},
"format_targets": {
"how-to-fix": 0.35, "x-vs-y": 0.15, "how-to-connect": 0.10,
"how-to-automate": 0.10, "use-case": 0.10, "listicle": 0.07,
"migration": 0.05, "release-recap": 0.05, "what-is": 0.03
},
"cluster_tag_map": {
"core-product": ["Product"],
"integrations": ["Integrations"],
"use-cases": ["Use Cases"],
"ecosystem": ["Ecosystem"]
}
}
cluster_targets— your topic buckets and the share of the corpus each should hold. The names are yours; the example above is a placeholder. They must sum to ~1.0. This is the primary balancing axis.format_targets— post-shape mix (universal; the defaults above are sensible for a how-to blog). Secondary axis.cluster_tag_map— maps each cluster to the CMS tag(s)syncuses to classify live posts.cadence— how many posts/day and the UTC windows they fall into.target_platform/author— passed straight through toseo-blog-writer.
Status model — the dedup gate
The backlog is the single source of truth for what topics are taken. Every topic flows through three statuses:
| Status | Meaning | Set by |
|---|---|---|
queued |
Researched and ready to write | add, or blog-topic-research --append |
in-progress |
Writer has started; publish not yet complete | seo-blog-writer at start (auto-creates the row if missing) |
done |
Uploaded to the CMS in any state — draft, scheduled, or published | seo-blog-writer the moment the publish call returns 2xx |
Plus killed for topics intentionally dropped (kept so research doesn't re-add them).
This vocabulary is the entire dedup mechanism. The picker filters on status == "queued" only — it never needs a title-similarity match at pick time. As long as the writer locks at start and flips at publish, the picker cannot pick a topic that's already in flight or already shipped. If the writer crashes mid-flow, revert the orphan row to queued (with a last_error note) so the topic re-enters the pool.
Data files
backlog.json lives at skills/blog-editorial-calendar/backlog.json. Schema for a queued entry:
{
"topics": [
{
"id": "self-host-on-a-vps",
"topic": "Self-hosting \x3Cproduct> on a $5 VPS: complete 2026 setup walkthrough",
"cluster": "core-product",
"format": "how-to-automate",
"priority": 1,
"status": "queued",
"tags": ["Tutorial", "Product"],
"notes": "anchor post - long, link-worthy",
"added_at": "2026-01-04",
"published_slug": null,
"published_at": null,
"research_proof": {
"demand_signals": [
{"type": "github_issue", "url": "https://...", "evidence": "verbatim issue title"},
{"type": "keyword_data", "url": "dataforseo:labs/keyword_overview", "evidence": "\"self host \x3Cproduct> vps\" search_volume=210/mo, keyword_difficulty=12"}
],
"primary_sources": ["https://docs..."],
"keywords": ["primary keyword", "lsi 1", "lsi 2"],
"problem_summary": "1-2 factual sentences in writer-voice. Empty string if not derivable.",
"confirmed_fixes": [{"kernel": "short fix phrase", "source": "https://..."}],
"version_context": "v1.65+ or null",
"question_variants": ["paraphrase 1", "paraphrase 2"]
}
}
]
}
The research_proof blob is written by blog-topic-research --append and carries the writer a vetted scaffold so its research step telescopes. It is stripped on done-flip (see mark-done) so the file stays small across hundreds of posts.
Field domains:
| Field | Allowed values |
|---|---|
cluster |
any key in config.json → cluster_targets |
format |
how-to-fix, how-to-connect, how-to-automate, x-vs-y, what-is, use-case, listicle, migration, release-recap |
status |
queued, in-progress, done, killed |
priority |
integer; lower = pick sooner; default 100 (tiebreaker only) |
Preflight: auto-sync (every invocation)
Before running the requested command, refresh the local index of what's live on your CMS, then reconcile the backlog against it. The writer's platform adapter already knows how to list posts — reuse that. Write the result to state/published-index.json (gitignored): one row per post with slug, title, status, published_at, tags. All three CMS states — published, scheduled, draft — count as "this topic is taken".
# Platform-neutral: ask seo-blog-writer's configured adapter to list posts.
# (Ghost example — substitute your platform's list endpoint.)
# GET /ghost/api/admin/posts/?limit=all&fields=id,slug,title,status,published_at&include=tags
After the index refreshes, run the sync reconciliation. If the list call fails (credentials missing, network error), print sync skipped: \x3Creason> and continue against the stale index — status is fine on stale data, and the writer's start-of-run lock catches any race the index would have.
Write-through: when the writer flips a backlog row to done at publish time, it should also upsert the new post into state/published-index.json so the index stays consistent without waiting for the next preflight.
Command: next — dispatch
Three modes, detected in this order:
- Scan args for
morning/afternoon/evening(incl. plurals) → slot mode. - Else if
--draftis present → draft mode (write, leave as a draft). - Otherwise → auto-schedule mode (the default).
| Input | Result |
|---|---|
next |
1 post, scheduled in the next free slot |
next 4 |
4 posts across the next slots |
next 1 --draft |
1 post, left as a draft |
next today evening |
1 post in tonight's evening window |
next 3 mornings |
3 posts in the next 3 morning slots |
Never silently fall back from slot mode to auto-schedule. If a slot keyword is present and date parsing fails, abort with a clear error.
Auto-schedule mode (default)
Walks the cadence forward from the latest scheduled (or, if none, published) post. With posts_per_day: 2 and the two default windows, slots alternate morning → evening → morning → evening, every day. For each of N picks:
- Compute the next slot timestamp (parser below).
- Run scoring to pick one topic. If the queue is empty, trigger backlog refill.
- Mark it
in-progressand save (belt-and-suspenders; the writer also locks at its start). - Invoke
/seo-blog-writer "\x3Ctopic>" --target \x3Cplatform> --publish-at \x3Ciso> --author "\x3Cauthor>". - The writer flips the row to
done, setspublished_slug+published_atwhen the publish call returns 2xx. Nomark-doneneeded in the happy path. - On failure the writer reverts the row to
queuedwithlast_errorand the loop stops — don't grind through the backlog while one topic is broken.
Cadence reference parser
Prints N ISO-8601 UTC timestamps, chronological, one per line.
python3 - "\x3CN>" \x3C\x3C'PY'
import datetime, json, pathlib, random, sys
N = int(sys.argv[1])
base = pathlib.Path("skills/blog-editorial-calendar")
cfg = json.loads((base / "config.json").read_text())
SLOTS = [tuple(s) for s in cfg["cadence"]["slots_utc"]] # [(sh,sm,eh,em), ...]
now = datetime.datetime.now(datetime.timezone.utc)
data = json.loads((base / "backlog.json").read_text())
done_pub = [t for t in data["topics"]
if t.get("status") == "done" and t.get("published_at")]
def window(day, idx):
sh, sm, eh, em = SLOTS[idx]
start = datetime.datetime(day.year, day.month, day.day, sh, sm, tzinfo=datetime.timezone.utc)
end = datetime.datetime(day.year, day.month, day.day, eh, em, tzinfo=datetime.timezone.utc)
return start, end
if done_pub:
latest = max(t["published_at"] for t in done_pub)
last_dt = datetime.datetime.fromisoformat(latest.replace("Z", "+00:00"))
# find which slot the last post fell in, then advance one
idx = max((i for i, (sh, *_ ) in enumerate(SLOTS) if last_dt.hour >= sh), default=0)
if idx + 1 \x3C len(SLOTS):
cur_day, slot_idx = last_dt.date(), idx + 1
else:
cur_day, slot_idx = last_dt.date() + datetime.timedelta(days=1), 0
else:
cur_day, slot_idx = now.date(), 0
stamps, guard = [], 0
while len(stamps) \x3C N and guard \x3C 365 * len(SLOTS) + len(SLOTS):
guard += 1
start, end = window(cur_day, slot_idx)
if end > now:
floor = max(start, now + datetime.timedelta(minutes=2))
span = max(0, int((end - floor).total_seconds()))
stamps.append(floor + datetime.timedelta(seconds=random.randint(0, span)))
slot_idx += 1
if slot_idx >= len(SLOTS):
slot_idx = 0
cur_day += datetime.timedelta(days=1)
if len(stamps) \x3C N:
sys.exit(f"could not fit {N} slots within a year")
for t in stamps:
print(t.isoformat().replace("+00:00", "Z"))
PY
Draft mode
Same loop, but invoke /seo-blog-writer "\x3Ctopic>" --draft instead of --publish-at. No slot timestamp is reserved.
Slot mode
Parse explicit windows (today evening, 7 May morning, mornings, etc.), sort timestamps ascending, then run the same pick → write loop per slot. If a singular slot ends in the past at run time, abort before writing anything; plural slots auto-skip past windows.
Scoring
Two-stage: cluster first (primary balancing axis), then format inside the chosen cluster (secondary axis). Both targets come from config.json.
python3 - "\x3CN>" \x3C\x3C'PY'
import json, pathlib, sys
N = int(sys.argv[1])
base = pathlib.Path("skills/blog-editorial-calendar")
cfg = json.loads((base / "config.json").read_text())
CLUSTER_TARGET = cfg["cluster_targets"]
FORMAT_TARGET = cfg["format_targets"]
data = json.loads((base / "backlog.json").read_text())
topics = data["topics"]
done = [t for t in topics if t["status"] == "done"]
queued = [t for t in topics if t["status"] == "queued"]
c_counts = {c: 0 for c in CLUSTER_TARGET}
f_counts = {f: 0 for f in FORMAT_TARGET}
for t in done:
c_counts[t["cluster"]] = c_counts.get(t["cluster"], 0) + 1
f_counts[t["format"]] = f_counts.get(t["format"], 0) + 1
total = sum(c_counts.values())
c_deficit = {c: CLUSTER_TARGET[c] - (c_counts[c] / max(total, 1)) for c in CLUSTER_TARGET}
f_deficit = {f: FORMAT_TARGET[f] - (f_counts.get(f, 0) / max(total, 1)) for f in FORMAT_TARGET}
recent = sorted([t for t in done if t.get("published_at")],
key=lambda t: t["published_at"], reverse=True)
last_cluster = recent[0]["cluster"] if recent else None
last_format = recent[0]["format"] if recent else None
picks = []
for _ in range(N):
c_score = {c: c_deficit[c] - (0.10 if c == last_cluster else 0) for c in CLUSTER_TARGET}
avail = {c: [t for t in queued if t["cluster"] == c and t not in picks] for c in CLUSTER_TARGET}
chosen = None
for c in sorted(CLUSTER_TARGET, key=lambda c: c_score[c], reverse=True):
if not avail[c]:
continue
def key(t):
fmt = t["format"]
return (-(f_deficit.get(fmt, 0) - (0.05 if fmt == last_format else 0)), t.get("priority", 100))
chosen = sorted(avail[c], key=key)[0]
break
if not chosen:
break
picks.append(chosen)
last_cluster, last_format = chosen["cluster"], chosen["format"]
print(f"Current mix ({total} done):")
for c in CLUSTER_TARGET:
pct = (c_counts[c] / max(total, 1)) * 100
print(f" {c:16s} {c_counts[c]:3d} ({pct:4.1f}% / target {CLUSTER_TARGET[c]*100:.0f}%) deficit {c_deficit[c]*100:+5.1f}pp")
print(f"\
Next {len(picks)} pick(s):")
for i, pk in enumerate(picks, 1):
print(f" {i}. [{pk['cluster']:14s}/{pk['format']:14s}] {pk['topic']} (id={pk['id']})")
PY
The -0.10 (cluster) and -0.05 (format) penalties stop the picker from stacking the same cluster/format two posts in a row. priority is only a tiebreaker.
Backlog refill
If scoring returns no pick — queued is empty across every cluster — pause and invoke /blog-topic-research 10 --append, which validates candidates and prompts for confirmation before writing them as queued rows. Then re-run scoring and continue. Refill fires at most once per next N invocation; if the queue empties again in the same call, halt with a shortfall message rather than re-prompting. This is a fallback, not a routine pre-fetch — keep the queue ahead with proactive research runs.
Command: status
Print queued / in-progress / done counts per cluster and format, current-vs-target mix, age of the oldest queued item, and count of stale (>30-day) queued items. Reuses the counting code from scoring.
Command: add
Validate cluster is a key in config.json, format is in the format set, and id is unique (kebab-case from the topic, stop-words stripped, ≤60 chars). Append and save. Refuse if any existing row already has that id.
import re
STOP = {"the","a","an","for","with","in","to","of","on","and","or","is","are"}
slug = "-".join(t for t in re.findall(r"[a-z0-9]+", topic.lower()) if t not in STOP)[:60].rstrip("-")
Command: mark-done
In the happy path the writer flips the row automatically. This is the manual fallback (post made directly in the CMS, or a writer that crashed after publish but before the flip). Find by id or published_slug, set status = "done", and strip the heavy fields so the backlog stays lean.
Done-row schema (only these kept): id, topic, cluster, format, tags, status, published_slug, published_at. Removed on flip: priority, notes, added_at, research_proof, last_error.
python3 - "\x3Cid-or-slug>" "\x3Cactual-slug>" \x3C\x3C'PY'
import json, pathlib, datetime, sys
key, slug = sys.argv[1], sys.argv[2]
p = pathlib.Path("skills/blog-editorial-calendar/backlog.json")
data = json.loads(p.read_text())
KEEP = {"id","topic","cluster","format","tags","status","published_slug","published_at"}
hit = next((t for t in data["topics"] if t["id"] == key or t.get("published_slug") == key), None)
if not hit:
sys.exit(f"not found: {key}")
hit["status"] = "done"
hit["published_slug"] = slug
hit["published_at"] = hit.get("published_at") or datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00","Z")
for k in list(hit):
if k not in KEEP:
del hit[k]
p.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\
")
print(f"marked done: {hit['id']} -> {slug}")
PY
Command: sync
Reconcile the backlog against state/published-index.json (refreshed in preflight). Auto-runs before every command.
- For each live post, match a backlog row by
published_slug, else by Jaccard token overlap ≥0.7 against the title (slug match is authoritative). - Matched + stale (not
done, or slug differs) → setdone, fillpublished_slug+published_at, strip heavy fields. - Matched +
killed→ surface, don't auto-revive. - Unmatched live post → auto-import as a
donerow, derivingclusterfrom the primary tag (viacluster_tag_map),formatheuristically from the title (vs→x-vs-y;what is→what-is;fix/error→how-to-fix;connect/integrate→how-to-connect; default →how-to-automate). If no tag matches a cluster, setcluster: "?"and surface for manual fix. - Backlog
donerows whose slug is in none of the live sets → surface as missing (probably deleted in the CMS).
Diff-only output: ~ updated, + auto-imported, ? ambiguous, - missing. Empty diff → sync: backlog matches CMS.
What this skill does NOT do
- Does not draft posts. It delegates the whole writing pipeline to
seo-blog-writer. It's a scheduler around that, not a replacement. - Does not pick the platform or author. Both come from
config.jsonand pass through to the writer untouched. - Does not weight format above cluster. Cluster mix is primary; format is secondary, applied within the chosen cluster.
- Does not run more than one loop at a time. The
in-progressstatus is the lock; a secondnextwhile one is in flight should detect it and refuse. - Does not touch git. Treat each
add/mark-done/nextlike a normal source edit; you commit. - Does not refill silently. When the queue empties, refill calls
blog-topic-research, which shows candidates and asks before writing them. Fires at most once pernext N.
One-command summary
/blog-editorial-calendar next [N] # pick -> write -> schedule into the next cadence slots
/blog-editorial-calendar next [N] --draft # pick -> write -> leave as a draft
/blog-editorial-calendar next [N] \x3Cslot>[, \x3Cslot>...] # pick -> write -> schedule each explicit slot
- Load
backlog.json+config.json. Bucket queued/done by cluster and format. - Compute deficit vs your target weights.
- Penalize the most-recently-done cluster (−0.10) and format (−0.05) to avoid stacking.
- Pick the highest-scoring cluster with queued items, then the topic whose format has the largest deficit;
prioritybreaks ties. If the queue is empty, refill once viablog-topic-research. - Compute the next slot timestamp (auto-schedule), use the explicit slot (slot mode), or skip timestamping (draft mode).
- Lock to
in-progress, invokeseo-blog-writer; it flips the row todoneon a 2xx publish. On failure it reverts toqueuedand the loop halts. - For N>1, repeat with the just-picked cluster/format treated as "most recent" and advance the cadence.
- 确保已安装 OpenClaw(本地或 Docker 部署)
- 在对话框中输入安装命令:
/install blog-editorial-calendar - 安装完成后,直接呼叫该 Skill 的名称或使用
/blog-editorial-calendar触发 - 根据 Skill 的参数说明提供必要输入,即可获得结构化输出
Blog Editorial Calendar 是什么?
Run your blog like a queue, not a guessing game. This skill is the orchestration layer on top of blog-topic-research and seo-blog-writer: it keeps an evidenc... 它是一个面向 Claude Code / OpenClaw 的 AI Agent Skill 插件,目前累计下载 48 次。
如何安装 Blog Editorial Calendar?
在 OpenClaw 或 Claude Code 对话框中运行命令「/install blog-editorial-calendar」即可一键安装,无需额外配置。
Blog Editorial Calendar 是免费的吗?
是的,Blog Editorial Calendar 完全免费,采用 MIT-0 许可证,可自由下载、安装和使用。
Blog Editorial Calendar 支持哪些平台?
Blog Editorial Calendar 跨平台运行,可在任意部署了 OpenClaw / Claude Code 的环境中使用(cross-platform)。
谁开发了 Blog Editorial Calendar?
由 AutomateLab(@automatelab)开发并维护,当前版本 v1.0.0。