Publishing: drafts, versions, scheduling & translations
Every blog post in Nukipa moves through a small set of statuses, and publishing is the moment that actually puts content on your public site. This article covers the full lifecycle: how a post goes from draft to live, how publish snapshots let you roll back, how to schedule a post for later, how translations work, and what publishing kicks off behind the scenes (an SEO push and a copy into the context wiki).
The status flow
A post row carries a status. The full set is:
| Status | What it means | How a post gets there |
|---|---|---|
idea |
A shell, no real body yet | Created by the batch ideator, or by hand |
draft |
Editable, not public | Default on create; where most editing happens, and where a single-post writer leaves its output |
review |
Drafted and awaiting a human read | The batch writer flips draft → review on success |
scheduled |
Set to publish at a future time | cms_schedule_post |
published |
Live on the public site | cms_publish_post, or the scheduler |
archived |
Retired, not public, kept around | A direct status edit |
Two distinctions matter here.
First, status is not the same as "is it public." The public site doesn't read a post's status directly — it reads the post's current_version_id pointer (more on that below). A post is public when that pointer is set, and it gets cleared the moment you unpublish. So "published" really means "has a live snapshot pointer."
Second, only some status moves are free-form. You can move a post directly between idea, draft, review, and archived with cms_update_post(status=…). The publish-lifecycle states — scheduled and published — have dedicated tools, because they touch more than the status column (snapshots, timestamps, the version pointer). You can't set status: 'published' with cms_update_post; the tool's schema only accepts ['idea','draft','review','archived'], so it rejects published and scheduled outright.
idea ──▶ draft ──▶ review ──▶ (publish) ──▶ published
▲ │ │
└──────────┘ │ unpublish / restore
archived ◀───────── any ─────────────┘
[!NOTE] The AI writer never auto-publishes.
cms_generate_post(the single-post writer) drafts the post and leaves it indraft— it writes title, body, excerpt, slug, SEO, components, sources, and facts, but doesn't touchstatus. Only the batch writer (cms_generate_batch) flips its successesdraft → review. Either way, a human reviews and then publishes.
Publishing creates a snapshot
When you publish, Nukipa doesn't just flip a flag. It takes a snapshot of the post as it stands right now and appends it to the post's version history.
Call:
cms_publish_post(post_id="…", change_summary="Added Q2 numbers")
What happens, in order:
- Read the live post plus its live components and sources.
- Insert a new row in the version history with the next
version_no(1, 2, 3…). That row copies the title, slug, body, excerpt, SEO bag, language, post type, cover, and embeds the components and sources as snapshot arrays. - Update the post:
status='published',published_at=now(),current_version_id=the new snapshot's id, clearchange_summary, and clearunpublished_at.
The public site serves the snapshot pointed at by current_version_id — never the live draft. That means you can keep editing a published post freely; readers stay on the last published snapshot until you publish again. The dashboard tracks this with a has_unpublished_changes flag (it compares the live row's modification time, including edits to components/sources/facts, against the snapshot) so the button reads "Republish" when there's a diff.
Republishing just creates the next version. Versions are append-only — publish v1, edit, publish v2, and both rows stay. Nothing is overwritten.
[!WARNING] Publishing is blocked while a widget on the post is still generating. If any
widgetcomponent is in statuspendingorgenerating,cms_publish_postreturns an error listing the offending component ids. This guards against freezing a "Widget generating…" state into the snapshot, which would render forever on the public site. Wait for the widget to settle, or regenerate it if it looks stuck.
The change_summary is a short free-text note stored on the version row and shown in the editor's history. It's optional.
Unpublishing
cms_unpublish_post(post_id="…")
This sets status='draft', clears current_version_id, and stamps unpublished_at. The version history is kept — nothing is deleted. Because the pointer is gone, the CMS public read (GET /public/posts/:slug) starts returning 404 immediately, and that's what the multi-tenant Nuxt blog serves.
[!NOTE] The 404 is immediate at the data layer. The rendered page may lag on surfaces that cache. Unpublish also fires a best-effort revalidate webhook (see "What publishing triggers" below); on
target=siteNext.js tenants the ISR/CDN copy only flushes when that webhook reaches the site. A self-hosted site that hasn't configured the webhook can keep serving a cached copy of an unpublished post until its own revalidate window expires.
Use this to pull a post offline without losing its history.
Versions and reverting
Every publish leaves a version row. You can list them, inspect one, and restore one.
cms_list_versions(post_id="…") # newest first; version_no, change_summary,
# published_at, published_by
cms_get_version(post_id="…", version_id="…") # full body + component/source snapshots
cms_revert_to_version(post_id="…", version_id="…")
cms_list_versions is the index — use it to find the version_no and id you want. cms_get_version returns the full snapshot (including the embedded components_snapshot and sources_snapshot) so you can read exactly what a restore would produce before committing.
Restore overwrites the live draft from the snapshot. It:
- Copies the snapshot's title, slug, excerpt, body, language, post type, cover, and SEO back onto the live post.
- Wipes the live components and sources, then re-inserts them from the snapshot's embedded arrays — with fresh ids, so the live tables stay independent of historical version ids.
- Sets the post back to
status='draft', clearscurrent_version_id, stampsunpublished_at, and writes the change summaryrestored from vN.
[!IMPORTANT] Reverting is never an implicit republish. After a restore the post is a draft and the public site stops serving it (the pointer is cleared) until you publish again. Restore v3, then publish, and you get v4 — the history keeps growing forward; you don't lose v3 or anything after it.
A typical rollback flow:
cms_list_versions(post_id)— find the version you want.cms_get_version(post_id, version_id)— read it to be sure.cms_revert_to_version(post_id, version_id)— the live draft now matches that snapshot.cms_publish_post(post_id)— go live again (this is v_next, not a reused number).
Scheduling a future publish
Instead of publishing now, you can set a publish time:
cms_schedule_post(post_id="…", scheduled_for="2026-07-01T09:00:00Z")
scheduled_for is an ISO 8601 datetime with a timezone (Z or a numeric offset both work). This moves the post to status='scheduled' and stores the target time. Nothing is public yet.
A cron worker (cms.scheduled-publish) runs every 5 minutes. On each tick it scans across all tenants for posts where status='scheduled' and scheduled_for <= now(), picks up to 100, and runs the exact same publish path as a manual publish — snapshot, version pointer, SEO push, context mirror. The scheduler records the version's published_by as null (cron isn't a user) and stamps a change_summary of auto-published by scheduler.
[!NOTE] Because the sweep is every 5 minutes, a post publishes at the first tick at or after
scheduled_for, not to the second. Schedule for 09:00 and it goes live somewhere in 09:00–09:05. That's the resolution; there's no per-post timer.
A few practical points:
- Same gates apply. Scheduled publish runs the real publish path, so the still-generating-widget guard applies. If a widget is mid-generation when the scheduler tries, that post's publish fails for that tick; the failure is logged and the other due posts continue.
- A stuck post retries forever. There's no give-up, backoff, or dead-letter. A post that keeps failing (e.g. a widget that's permanently stuck
generating) staysstatus='scheduled'and is re-selected and re-fails on every 5-minute sweep, indefinitely, counting against that tick's cap of 100. Nothing alerts you — if a scheduled post never goes live, check the worker logs and fix or regenerate the offending widget, or move the post back todraft. - You can edit the date without scheduling.
scheduled_foris also patchable viacms_update_post, which lets the editor save a target date while leaving the post indraft. It's thecms_schedule_postcall that actually flips the status toscheduledand arms the sweep. - To cancel, move the post back to
draft(e.g.cms_update_post(status="draft")) — the sweep only picks upscheduledrows.
Translations
A translation is a separate post in another language, linked to its source by a shared translation_group_id. The group is the unit the dashboard uses to render a language switcher and the public site uses for hreflang alternates.
Start a translation:
cms_translate_post(post_id="…", target_language="de")
This returns a placeholder post plus a job_id. The sequence:
- If the source post has no
translation_group_idyet, Nukipa mints one and stamps it on the source — this is the moment a single post becomes a translation group. - It inserts a placeholder sibling in the target language (status
draft), inheriting the source's folder, campaign, author, and cover image (the sameasset_id, so both languages show the same hero on the public blog). - It enqueues the
cms.translate-blog-postworker against that placeholder.
The worker then does an LLM round-trip and fills in the translated title, body, excerpt, SEO, and a localised slug. It also clones the components onto the target with fresh ids, translating the user-facing text per component type (URLs, asset ids, chart/data structures, and status enums are preserved verbatim), and rewrites the body's {{component:UUID}} markers to point at the new component ids. Widgets are special: they clone with status: 'pending', drop the source-language HTML, and get an auto-enqueued regeneration job so the rendered HTML comes out in the target language rather than carrying over the source HTML.
Sources and facts are shared, not duplicated. The {{cite:N}} markers index the same source list across languages — the underlying URLs don't change when you translate the prose, so there's nothing to re-translate there.
[!NOTE] If the translator produces a slug that collides with an existing post in the tenant, the worker retries the write once with the language code suffixed (e.g.
my-post-de) rather than failing the job.
To see the whole group:
cms_list_translations(post_id="…") # all siblings incl. the post itself
Language rules
A few constraints and behaviours worth knowing:
| Rule | Detail |
|---|---|
| One language per group | A translation group holds at most one post per language code. Asking for a second de in the same group returns a conflict (409). |
| Same-language refused | Translating a post into its own language is rejected. |
| Each translation is its own post | It has its own slug, its own status, and its own publish lifecycle. Publishing the English post does not publish its German sibling — you publish each one. |
| Re-translation pulls the freshest sibling | The worker translates from the most recently updated sibling that isn't the target, so re-running a translation picks up edits to the source. |
| Language codes | Both bare (de) and qualified (de-DE) codes are accepted. |
Public reads match languages with a small bit of leniency: filtering by a bare code like de matches de plus any region variant (de-DE, de-AT, de-CH, …), while a fully-qualified en-US matches exactly. Related-post listings default to the source post's own language when you don't pass one.
[!TIP] Translation quality is steerable. Before the LLM call, the worker fetches
/translation/guidelines.mdand a per-language/translation/{lang}-rules.md(e.g./translation/de-rules.md) from your context wiki. Both are optional — if absent they're skipped silently. Upload them via the Context module to enforce a brand glossary and per-language style rules across every translation. Note the service docs still list this worker as "deferred" even though the pipeline is implemented and the worker is registered live — treat translations as usable but newer than the rest.
What publishing triggers
A successful publish fires off two side effects. Both are best-effort — if either fails, the publish still succeeds. They don't block you and they don't get rolled back.
1. SEO push. Publishing enqueues a cms.seo-push job for the post. The worker resolves your verified custom domains and, per host, sends an IndexNow ping to nudge search engines to re-crawl the URL, plus a debounced (once per 24h per tenant) Google Search Console sitemap submit. Every attempt is logged to the seo.pings audit table you can inspect. Tenants with no verified custom domain have nowhere to push to — the worker records a failed ping on target indexnow with the message no_verified_domain and stops. The publish itself is unaffected, but expect that row to show as a failure in the audit table for domain-less tenants.
2. Context mirror. Publishing also POSTs a flattened copy of the post into the context wiki as a cms_post document at path /cms/posts/<slug>.md. The body is flattened for the wiki: components inlined as JSON, {{cite:N}} markers replaced with bracketed numbers, fact markers stripped, sources appended. This is what the writer agent searches when it asks "have we written about this already?" — the CMS stays the editable source of truth, and the context wiki is the search index. Unpublishing patches that same document's metadata.published to false so the overlap search filters it out.
[!NOTE] The context mirror is why the AI writer avoids re-covering topics you've already published — it's reading the mirrored copies, not the CMS directly. If a publish's mirror call fails, the post is still live; it just won't show up in overlap search until the next successful publish.
Publishing also fires a fire-and-forget revalidate webhook for the post slug and the listing page, so tenants that host their own site and configured the webhook see the change without waiting for the ISR window. It's a no-op for tenants that haven't set one up (most blog-only tenants haven't). The same webhook fires on unpublish.
FAQ
Does editing a published post change what readers see?
No. Readers stay on the last published snapshot (current_version_id). Your edits live on the draft until you republish. The dashboard shows a "Republish" button once there are unpublished changes.
Can I undo a publish?
Unpublish takes the post offline (clears the pointer, back to draft) but keeps all history. To return to an earlier content state, use cms_revert_to_version to restore that snapshot onto the draft, then publish.
Why is my published post still 404ing on the public site?
Most likely it was unpublished (pointer cleared) or restored (restore drops it back to draft). Check the status and current_version_id; republish if needed. Also confirm you're querying the right language — a bare de filter won't return an en post.
My post didn't publish at the scheduled minute. Is something broken?
Probably not. The scheduler sweeps every 5 minutes, so a 09:00 schedule publishes between 09:00 and 09:05. If it's much later than that, check whether a widget on the post was still generating — that blocks the publish, and the post will keep retrying every sweep with no automatic give-up until you fix the widget or move it back to draft.
If I translate, do I have to publish each language separately? Yes. Each translation is its own post with its own status and lifecycle. Translating creates a draft sibling; you review and publish it independently of the source.
Do translations share citations?
Yes — sources and facts are shared across the group because the {{cite:N}} markers point at the same URLs. Components, however, are cloned and translated per language with their own ids.