Content at volume: batches & briefings
Most of Nukipa's content gets written one post at a time: you give the writer a brief, it researches and drafts, you review and publish. That's fine for a handful of posts. It doesn't scale to "publish three posts a week, indefinitely, and don't repeat yourself."
Two mechanisms cover the volume case:
- Batches — a user-triggered run that ideates a set of posts from your current signals, filters out anything that overlaps what you've already published, and fans the writer out across all of them. Use it to fill a one-to-two-week editorial period in a single request.
- Briefing policies — standing rules that produce drafts on a schedule (or on demand, or when a condition is met). Use them for recurring cadences: a daily news roundup, a weekly digest, "draft a post whenever a high-severity CVE lands."
Neither path publishes anything. Both produce drafts, and nothing goes live without a human clicking Publish. The two paths reach that draft state differently, though: a batch post that writes successfully is flipped to review, while a briefing only ever creates a draft and hands the post to the blog-post writer (more on that split below).
Batches: ideation → generation
A batch is one orchestrator job (cms.generate-batch) that runs a four-stage pipeline and tracks its own progress on the cms.batch_runs row, which is on the realtime publication so the dashboard shows a live progress strip.
queued → ideating → writing → done
Kicking one off
Via the assistant / MCP:
cms_create_batch({
workspace_id: "<tenant>",
period_start: "2026-07-01",
period_end: "2026-07-14",
target_posts: 6,
steering: "Lean into the agentic-procurement angle; we have a launch mid-July.",
campaign_id: null
})
Or POST /batches with the same body. The call returns 202 immediately with the new batch_runs row and the enqueued job id — the work happens in the background. Poll cms_get_batch (or GET /batches/:id) to watch status and summary fill in.
Every field is optional except the workspace. If you omit the period, it defaults to the next 7 days starting today. If you omit target_posts, it defaults to 3.
[!NOTE]
steeringis free text appended to the ideator's prompt. It's how you bias a batch toward a theme or an upcoming event without writing per-post briefs.campaign_idscopes the batch to a campaign — itswriting_style_addendumandprofile_addendumget merged on top of the company context, and every generated post is tagged with thatcampaign_id.
The post-count cap
You cannot ask for arbitrary volume. The HTTP API caps target_posts server-side regardless of what the UI or assistant sent. Two things happen, in order:
- The request is validated.
POST /batchesis gated by a Zod schema withtarget_posts: z.number().int().positive().max(12). A value of 13 or higher is rejected with a400 BadRequest— it is not silently clamped. (The assistant's own blog-batch tool advertisesmaximum: 12for the same reason, so it can't construct an over-12 request in the first place.) - Within the valid 1–12 range, the period tightens it further:
target = min(requested, ceil(weeks * 3), 12)
So a one-week period allows at most 3 posts; two weeks, 6; and the absolute ceiling is 12 posts per batch. weeks is derived from the period length (ceil(days / 7), minimum 1). This is the anti-spam policy: roughly three posts per week, and never more than a dozen at once.
Stage 1 — signals-fed ideation
Before proposing anything, the batch resolves your company context (profile, industry, products, ICP, USP, writing style) and pulls a signals snapshot from the signals service. The context bundle is fetched via a parallel path — a hardcoded path-to-key map, not the per-post resolvePostContext (which needs a post id the batch doesn't have yet) — but it reads the same context documents the single-post writer reads. The signals snapshot comes from the signals service's /context/bundle endpoint (full route /signals/context/bundle). It contains:
| Signal stream | What it is |
|---|---|
briefings |
Pre-prioritised content recommendations — the "so what" of the signals pipeline. The ideator is told to strongly prefer these. |
trends |
Recent trends + news scored for relevance to your company. |
keywords |
Verified keywords with volume, competition index, and an opportunity score. |
gsc |
Search Console queries already ranking ~5–15 ("knocking on page 1"). |
competitors |
Competitor posts from the last 14 days. |
gaps |
Prompt-test gaps — prompts where competitors get cited and your domain doesn't. |
Signal fetching is best-effort: if the signals service is down or a stream is empty, the batch proceeds with whatever subset came back. The summary.signals_used field on the run records which streams were actually available (availability), so you can tell after the fact whether a batch ran on a full picture or a thin one.
The ideator is a single LLM call (default claude-sonnet-4-6, override with CMS_BATCH_IDEATOR_MODEL) that returns strict JSON: a list of ideas, each with a title, a topic bucket, a one-to-two-sentence angle, a suggested publish date inside the period, and — critically — an evidence array. The prompt enforces a hard rule:
Every idea MUST cite at least ONE entry in 'evidence'. No signal, no proposal.
Ideas with no evidence, or a missing/non-string title, are dropped on parse. If nothing usable comes back, the batch fails with error.code = 'no_ideas'. If the ideator's response isn't parseable JSON at all, it fails with error.code = 'ideator_invalid_json'.
Stage 2 — the uniqueness pre-filter
The prompt also tells the ideator not to duplicate recent work: the batch passes it anti-references — up to 40 posts published or updated in the last 90 days (CMS_BATCH_ANTI_REF_DAYS), with their titles and topics. That's a soft contract; the model is asked to differentiate and to list any adjacent posts under similar_posts.
The hard floor runs after ideation, before anything is persisted. Each proposed angle (title + angle) is embedded and searched against the cms_post mirror in the context wiki. If the top match's cosine similarity is ≥ 0.85 (CMS_BATCH_UNIQUENESS_THRESHOLD), the angle is rejected as a near-duplicate and never becomes a post.
soft → anti-references in the prompt (the LLM tries to avoid overlap)
hard → vector pre-filter at cosine ≥ 0.85 (the safety net)
Rejections are recorded in summary.uniqueness_rejected — title, score, and the post it overlapped — so you can see why a batch produced fewer posts than you asked for.
[!TIP] The pre-filter degrades to a no-op when there's no context service (
SERVICE_CONTEXT_URLunset) or no embeddings exist yet (a cold tenant). In that case only the soft anti-references apply, and the batch still runs. The threshold of 0.85 was tuned to catch paraphrases without flagging "a genuinely different angle on the same topic" as overlap — but it's a similarity heuristic, not a human read, so checkuniqueness_rejectedif a batch comes back unexpectedly small.
If every proposed angle gets rejected, the batch fails with error.code = 'all_proposals_overlap' — the signal to refine your steering or wait for fresher signals.
Stage 3 — materialise drafts
Survivors are inserted as cms.posts rows in status='draft', each carrying the batch_run_id. A few things happen here:
- The ideator's
topicstring (e.g. "GEO", "Agentic") is resolved to acms.foldersrow — found if it exists, created if not. Case-insensitive, so "GEO" and "geo" collapse to one folder. - Each post's
scheduled_foris set from the idea'ssuggested_publish_atif it falls inside the period; otherwise the posts are spread evenly across the period at 09:00. (This sets the field — it does not schedule them to publish; they're still drafts.) - An interim
summaryis written (ideas_count,signals_used,anti_refs,uniqueness_rejected,ideator_model) and the run flips tostatus='writing'.
If the post insert itself fails, the batch settles to failed with error.code = 'post_insert_failed'.
Stage 4 — fan-out writing
The batch now runs the same per-post writer used for single posts, with bounded concurrency (3 by default, CMS_BATCH_WRITER_CONCURRENCY). Each writer gets a composed briefing built from its idea — the angle, topic, the signals that motivated it, and a note to differentiate from the adjacent posts the ideator flagged. Writer settings passed in the batch call (reasoning effort, length, etc.) are forwarded to each writer.
Each writer reports 0–100% progress; the batch maps that into its overall 30→95% band, weighted by how many posts have finished. A post that writes successfully flips draft → review.
Failure recovery
Volume work fails partially, so the recovery model is built around settling state cleanly rather than all-or-nothing:
- Per-writer isolation. A single writer that throws leaves its post in
draftand incrementswritten.failed; its siblings carry on. The batch settles todoneif at least one writer succeeded, otherwisefailed(error.code = 'all_writers_failed'). - Orchestrator wrap. The whole pipeline is wrapped so any uncaught throw settles
batch_runstofailedfirst (witherror.code = 'handler_threw'when no more specific code was set), then re-throws so pg-boss still applies its retry/dead-letter policy. Before this existed, a throw mid-pipeline left the row stuck inideatingorwritingforever. - Orphan sweeper. The try/catch above only fires when the worker throws. A worker that dies silently — SIGKILL, OOM, terminal closed in dev — never reaches it, and would leave
batch_runsstuck. A separate sweeper catches that. It runs once on every CMS boot and then every ~2 minutes (CMS_BATCH_SWEEP_INTERVAL_MS, default 2 min) while the process is alive. Anybatch_runsrow still inqueued | ideating | writingwhose most recent timestamp is older than 5 minutes (CMS_BATCH_STALE_AFTER_MS) is settled tofailedwitherror.code = 'orphaned'. So a worker that dies mid-run is cleared within a couple of minutes without needing a restart — the UI's progress strip flips and you can re-trigger.
The codes a batch_runs row can settle failed with: ideator_invalid_json, no_ideas, all_proposals_overlap, post_insert_failed, all_writers_failed, handler_threw (generic catch-all), and orphaned (set by the sweeper). The final summary records written_ok, written_failed, ideas_count, anti_refs, uniqueness_rejected, and the ideator model — enough to audit most runs without reading logs.
[!WARNING] A "done" batch can still contain failed posts.
doneonly means at least one writer succeeded. Always checksummary.written_failed: failed posts sit indraftwith nothing written, and you'll want to delete or re-run them.
Briefing policies: standing content rules
Where a batch is a one-shot you trigger, a briefing policy is a stored rule the platform evaluates on its own. Each policy says: when to fire, what context to pull, and how to draft. A cron sweep (cms.briefings-sweep, every 5 minutes — */5 * * * *) checks which policies are due and enqueues a per-policy fire (cms.briefings-fire) that creates the drafts.
Create one with cms_create_briefing_policy or POST /briefings/policies:
cms_create_briefing_policy({
workspace_id: "<tenant>",
name: "Daily news roundup",
slug: "daily-news",
trigger_kind: "cron",
trigger_config: { expression: "0 7 * * *" },
generator_config: {
selector: "daily_top_topics",
selector_options: { max_topics: 5 },
language: "de"
}
})
Trigger kinds
trigger_kind is one of three values:
trigger_kind |
Fires when | next_run_at |
|---|---|---|
cron |
The cron expression matches. The sweep enqueues a fire each time it's due. | Computed from trigger_config.expression (UTC). |
on_demand |
Only when you explicitly fire it (cms_fire_briefing / POST /:id/fire). |
Always null — never self-fires. |
conditional |
A cron expression makes it eligible, then a fire_when gate must also pass. |
Computed from the expression, same as cron. |
For cron and conditional, trigger_config.expression is required and validated as a real cron expression at write time (cron syntax). A bad expression is rejected with a 400.
The sweep advances next_run_at itself after enqueuing (recomputed from the cron expression), so a policy doesn't re-fire every minute while a slow fire is still running. on_demand policies have their next_run_at cleared back to null. To fire any policy right now, cms_fire_briefing sets next_run_at = now() so the next sweep picks it up.
Two generation paths: router vs selector
When a policy fires, cms.briefings-fire produces drafts one of two ways, branched on whether generator_config.selector is set.
Router path (default — no selector). The fire calls the context router with the policy's context_query, assembles one briefing string from the returned records / documents / graph relations, and drafts generator_config.post_count copies (default 1, capped at 5) — all sharing that same briefing. This is the right shape for a "weekly digest" where several posts should draw on the same input. Titles come from generator_config.title_template (supports {{name}}, {{slug}}, {{date}}, {{period}}).
Selector path (generator_config.selector = "daily_top_topics"). Instead of one shared bundle, a selector returns N distinct topics, and the fire drafts one post per topic, each with its own topic-specific briefing. This is the "3–5 fresh articles a day, each on a different story" workflow. Only daily_top_topics exists today.
router → one context bundle → N copies, same briefing (digest-style)
selector → N distinct topics → 1 post per topic (daily-news-style)
In both paths the fire creates empty posts in status='draft' and enqueues cms.generate-blog-post for each. Unlike the batch orchestrator — which explicitly flips its posts to review after a successful write — briefingsFire does not promote the post itself. It hands off to the blog-post worker, and that worker owns the post's resting status. So draft is the initial state every briefing post is created in; where it ends up depends on the blog-post writer, not on the fire.
Drafts are attributed to the tenant's authors by stable round-robin (sorted by name); a tenant with no registered authors gets author_id = null and the public index simply omits the byline. The run is recorded in cms.briefing_runs with post_ids, job_ids, and context_snapshot (the assembled bundle or selector output, for audit). List runs with cms_list_briefing_runs — every fire writes a row, including skips and failures.
[!WARNING] A briefing run can report
succeededand still produce fewer posts than itspost_count(router path) or topic count (selector path) implied. Per-draftcreatePost/ enqueue failures are logged with a warning and swallowed — they do not fail the run. This is the briefing equivalent of the batch's "donewith failures" caveat: checkpost_idson the run against what you expected.
A fire can also terminate before drafting anything, marking the briefing_runs row failed with one of:
| Failure | When |
|---|---|
selector_failed |
The daily_top_topics selector threw. |
router_failed |
The context router call threw (router path). |
router_unavailable |
The context router returned null (router path). |
(A selector that returns zero topics is not a failure — see below.)
The daily-top-topics selector
This is the only selector implemented, and it's worth understanding because it's how the platform turns a pile of ingested RSS items into a small set of non-redundant posts.
Its job: from everything ingested in the last 24 hours, surface 3–5 distinct, fresh topics — so a daily policy drafts one post per story instead of five near-identical posts on the same news. The algorithm:
- Candidates. Pull
context.documentswithkind='page'andsource_id IS NOT NULL(i.e. ingested from a feed, not a system doc) created in the lastwindow_hours(default 24). - Embed. Use each candidate's first chunk embedding. RSS items are short, so that's usually the whole item.
- Dedup against published work. Pull the last
mirror_days(default 30) ofcms_postmirror chunks. Drop any candidate whose max cosine similarity to a published post is ≥dedup_threshold(default 0.80) — "we already covered this." - Cluster. Greedily group survivors; a candidate joins the existing cluster it's closest to if cosine ≥
cluster_threshold(default 0.65), else starts a new cluster. One cluster = one story = one post. - Score + cap. Score each cluster by
size × novelty(novelty decays as the freshest member ages). Sort, take the topmax_topics(default 5, hard max 10). - Emit. Per cluster: a representative title (the newest doc), an evidence list (every doc in the cluster, with source URLs), and a handful of extracted keywords used for the writer's topic field and image search.
The topic briefing leads with the news, lists the evidence URLs explicitly, and instructs the writer to synthesise across the sources rather than paraphrase one — then verify and deepen with its own web_search and search_context.
[!NOTE] The selector never throws and returns
[]when there are no candidates, everything dedups out, or the tenant has no embeddings yet. An empty result is not a failure — the fire records the run asskipped(failure_message: "selector returned 0 topics") and the policy lives on. Quiet news days are normal and must not break the cadence. Within-run dedup is implicit: one cluster becomes one post, so a single fire can't produce two posts on the same story.
A few honest limits on the selector as it stands:
- Source authority is a placeholder. The scoring formula has an
authorityterm, but it's hardwired to1.0— all sources are weighted equally for now. The hook for per-source authority weights exists but isn't wired. - Keyword extraction is naive. Lowercase, strip punctuation, drop an English+German stopword list, count, take the top 5. It's a noise filter for image queries, not an NLP pipeline. The writer rewrites whatever it gets.
- Clustering is greedy and order-dependent. Fine at this scale (hundreds of docs, single-digit clusters) and stable given a stable input order, but it's not a globally optimal clustering.
Conditional triggers — thin, be honest
Conditional policies are the least-developed path. A conditional policy fires on its cron schedule only if a trigger_config.fire_when gate passes, evaluated by the sweep before it enqueues. Today exactly two predicate kinds are implemented:
{ kind: "new_records",
record_type: "cve",
filter: { cvss_score: { ">=": 7 } },
min_count: 1,
since: "last_fire" } // or { hours: 24 }, or an ISO string
{ kind: "always" } // never skips — useful for testing
new_records asks the context router how many records of record_type matching filter have appeared since the cutoff; if that's below min_count, the fire is skipped (with the reason recorded on a briefing_runs row). Anything else — any other kind — returns unsupported fire_when.kind and skips. There is no UI for building these predicates; you write the JSON by hand.
There's also a rough edge to know about: after a successful conditional fire, the worker resets new_items_since_last_brief = 0 across all of the tenant's ingestion sources, not just the ones the predicate matched. (The reset runs only on the success path, after drafts are created — a skip in the sweep does not touch the counters.) So conditional policies sharing a tenant can interfere with each other's counters. Treat conditional triggers as a working-but-early primitive — fine for a single "fire on high-severity CVEs" policy, not yet something to build a dozen interacting rules on.
When to use which
| You want… | Use |
|---|---|
| To fill the next 1–2 weeks with a varied set, right now | A batch (cms_create_batch) |
| Posts grounded in your current SEO / competitor / trend signals | A batch — it's the only path that reads the signals snapshot |
| A daily post per fresh news story from your RSS feeds | A daily_top_topics briefing policy on a cron |
| Several posts off one shared context bundle on a schedule | A router-path briefing policy (post_count > 1) |
| A draft only when something specific lands (e.g. a CVE) | A conditional briefing policy (early — JSON predicates only) |
| A one-off draft from a stored rule, on your command | An on_demand policy + cms_fire_briefing |
Batches and briefings don't overlap in machinery: batches read signals and run their own ideator + uniqueness pre-filter; briefings read the context router or a selector. The shared piece is the writer at the end — both fan out the same per-post cms.generate-blog-post, and both leave you drafts to review.
FAQ
Do batches or briefings ever publish automatically?
No. Both produce drafts. Batch posts that write successfully are flipped to review; briefing posts are created in draft and the blog-post worker owns where they settle from there. Publishing is always a separate, human step.
Why did my batch produce fewer posts than I asked for?
A few reasons: the server capped target_posts to min(requested, weeks×3, 12); the ideator returned fewer ideas because the signals snapshot was too thin (it's told to return fewer rather than pad); the uniqueness pre-filter rejected near-duplicates; or some writers failed (the batch settles done if at least one succeeded). Check summary.uniqueness_rejected, summary.ideas_count, and summary.written_failed on the run.
My daily briefing shows "skipped" runs. Is it broken?
No. The daily_top_topics selector returns nothing when there's no fresh, non-duplicate content in the last 24 hours — that's a skipped run, not a failure. The policy keeps running and fires again the next day.
Can I change the uniqueness or dedup thresholds?
The batch uniqueness floor (0.85) is an env var (CMS_BATCH_UNIQUENESS_THRESHOLD), so it's per-deployment, not per-call. The selector's dedup_threshold (0.80) and cluster_threshold (0.65) are per-policy via generator_config.selector_options.
What happens if a deploy interrupts a running batch?
The orphan sweeper settles any batch stuck in a non-terminal state for more than 5 minutes to failed (error.code = 'orphaned'), so the progress strip clears and you can re-trigger. It runs on boot and every ~2 minutes thereafter, so it also catches a worker that died silently without a restart. Individual posts already written stay in review.