AI Automation
Four AI Workflows We Wire Into Payload for Editorial Teams (and the One We Refuse to Ship)
Content teams keep asking us to 'add AI to the CMS.' That framing buys nothing. Here are the four Payload automations that actually move editorial velocity — and the one we refuse to ship, with the wiring shape and token math for a team publishing 40 articles a month.

*Four Payload hooks earn their keep. One popular request burns trust faster than it saves hours. Here is how we draw that line.*
The brief lands in our inbox about twice a month, and the wording barely changes: *we want to add AI to our CMS*. Sometimes it is from a Head of Content who watched a competitor ship AI-generated localised pages. Sometimes it is from a CTO who has been asked to justify why the editorial team still needs three full-time writers. Either way, the framing buys nothing. Add AI to the CMS is not a unit of work — it is a wish, and wishes do not cost-justify.
What does cost-justify, on Payload CMS specifically, is four narrow automations wired into hooks with a human sign-off gate. For a content team publishing 30–60 articles a month, the four together shave roughly 15–25 editor-hours per week, replace one or two SaaS subscriptions (DeepL, Yoast-style SEO plugins, sometimes a separate alt-text tool), and — this is the part operators underestimate — give the editor-in-chief a faster red-pen pass instead of a slower one. Token cost on Claude Sonnet 4 lands somewhere between €40 and €110 a month at that volume. We have shipped this shape on enough Payload projects now that the wiring is boring, which is the goal.
There is also one request we now decline outright — fully autonomous publishing. We will explain why at the bottom, with the two incident shapes that taught us. First, the boundary that makes the other four safe.
The boundary: AI drafts into a field, humans publish
Every workflow below obeys the same rule. The model writes into a draft field — never the canonical field, never `_status: 'published'`, never a side-effect that hits a customer. An editor sees the draft, accepts or rejects it, and the published value is whatever the human approved. This is one line in a Payload field config, and it is the difference between a tool editors trust and a tool editors disable in week two.
Workflow 1: Translation on draft save
Payload's localization config gives you per-locale fields out of the box. The pattern we ship: on `afterChange` of the source-locale draft, fan out a translation job per target locale, write the result into the corresponding locale's draft field, and surface a diff view inside the admin so the editor can accept, reject, or edit before publishing. No locale ever auto-publishes.
// collections/Articles/hooks/translateOnDraftSave.ts
import type { CollectionAfterChangeHook } from 'payload'
import { translationQueue } from '@/lib/queues'
const TARGET_LOCALES = ['de', 'fr', 'hr'] as const
export const translateOnDraftSave: CollectionAfterChangeHook = async ({
doc,
previousDoc,
req,
operation,
}) => {
if (req.locale !== 'en') return doc
if (doc._status === 'published') return doc
const bodyChanged =
operation === 'create' ||
JSON.stringify(doc.body) !== JSON.stringify(previousDoc?.body)
if (!bodyChanged) return doc
for (const locale of TARGET_LOCALES) {
await translationQueue.add('translate-article', {
articleId: doc.id,
sourceLocale: 'en',
targetLocale: locale,
revision: doc.updatedAt,
}, { jobId: `${doc.id}:${locale}:${doc.updatedAt}` })
}
return doc
}Three details that matter in production. First, the `jobId` is deterministic on `(articleId, locale, updatedAt)` — re-saves do not stack up redundant translations, and BullMQ dedupes them at the queue level. Second, we compare the Lexical body shape, not the rendered HTML — formatting noise should not retrigger a 4,000-token translation. Third, the job writes through Payload's Local API with `draft: true` and `overrideAccess: true`, so the editor's permissions are not what gates the bot's write — the field is.
Workflow 2: SEO meta and social cards
The honest version of this: most editors do not write good meta descriptions, and most CMS plugins that promise to fix it generate slop. Claude Haiku is roughly the right tool — fast, cheap, and good enough that the editor's job becomes red-pen, not blank page. The wiring: an `afterChange` hook on the article collection regenerates `seoTitle`, `seoDescription`, and `socialCard` text whenever the body materially changes, into draft-only meta fields the editor confirms before publish.
The non-obvious part is the *materially changes* check. If we regenerate on every keystroke, we burn tokens and overwrite the editor's manual tweaks. We diff the Lexical body's text content (not its node structure) and only fire when the character delta crosses ~80 chars or when the H1 changes. On a 40-article-per-month team, this drops regeneration calls by 70–80% versus a naive `on every save`.
// collections/Articles/hooks/regenerateSeoMeta.ts
import type { CollectionAfterChangeHook } from 'payload'
import { extractPlainText } from '@/lib/lexical'
import { claudeHaiku } from '@/lib/ai'
const SIGNIFICANT_DELTA = 80
export const regenerateSeoMeta: CollectionAfterChangeHook = async ({
doc,
previousDoc,
req,
}) => {
const prevText = extractPlainText(previousDoc?.body)
const nextText = extractPlainText(doc.body)
const delta = Math.abs(nextText.length - prevText.length)
const titleChanged = doc.title !== previousDoc?.title
if (delta < SIGNIFICANT_DELTA && !titleChanged) return doc
if (doc.seo?.locked) return doc // editor's manual edits win
const generated = await claudeHaiku.generateMeta({
title: doc.title,
body: nextText.slice(0, 4000),
})
await req.payload.update({
collection: 'articles',
id: doc.id,
data: {
seo: {
titleDraft: generated.title,
descriptionDraft: generated.description,
socialCardDraft: generated.social,
},
},
draft: true,
overrideAccess: true,
})
return doc
}Note the `seo.locked` flag. The editor can pin the meta — once they have edited it manually, we stop regenerating. This is the kind of detail that decides whether the tool lasts past month three. We learned it the hard way on a Payload + Claude rollout where editors started leaving angry Slack messages about the bot rewriting their carefully tuned descriptions every time they fixed a typo. The hook now respects the lock; the angry messages stopped.
Workflow 3: Alt text on upload
Accessibility teams stop nagging when alt text is generated at upload time, queued through BullMQ, and surfaced as a draft field on the media collection. We use Claude Sonnet with vision, not Haiku — Haiku hallucinates objects in product photos often enough that the editor ends up rewriting most of them, which defeats the point. Sonnet's alt text is accepted as-is roughly 70% of the time on the projects we have measured.
The hook lives on the `media` collection's `afterChange`, fires only on new uploads (not on edit), and pushes to a queue rather than blocking the upload response. The editor sees the asset appear immediately in the admin; the alt text fills in five to ten seconds later. If the model fails, the asset still uploads — the alt field stays empty and the editor writes it manually. Never block the upload on the model.
Workflow 4: Internal link suggestions
This is the one most teams ask for in the wrong shape. They want the bot to *insert* internal links. We refuse to insert them and instead surface suggestions as a custom admin component beside the editor. The data layer is pgvector over embeddings of every published article, refreshed on publish. When an editor opens an article, we run a similarity query against the current draft's body and show the top five candidate articles with the paragraph they would best fit into. The editor decides.
-- migrations/20250x_add_article_embeddings.sql
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE article_embeddings (
article_id UUID PRIMARY KEY REFERENCES articles(id) ON DELETE CASCADE,
locale TEXT NOT NULL,
embedding VECTOR(1536) NOT NULL,
body_hash TEXT NOT NULL,
refreshed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX article_embeddings_ivfflat
ON article_embeddings
USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 100);
CREATE INDEX article_embeddings_locale_idx
ON article_embeddings (locale);The `body_hash` column matters: we only re-embed when the published body actually changes, which keeps embedding costs near zero for a steady-state archive. On a 600-article corpus refreshed on publish, total OpenAI text-embedding-3-small spend was under €3 a month across the projects we have measured.
The one we refuse: autonomous publishing
Two incident shapes, both from Payload projects in the last 18 months. First shape: a content team wired an agent to generate localised landing pages and publish them on a nightly cron. Three weeks in, the agent generated a page whose H1 confidently misnamed a competitor's product and stated a pricing claim the team had explicitly never made. The page lived for four days before anyone noticed. The legal email arrived on day six.
Second shape: an SEO team wired automatic meta regeneration on every save, no lock field, no diff gate. The bot overwrote a campaign landing page's hand-tuned meta the night before the campaign launched. Click-through dropped roughly 40% in the first 24 hours before the team noticed and rolled back. The savings from the automation were obliterated by the lost traffic in a single incident.
Token economics at 40 articles per month
Rough numbers from the projects we ship, against Anthropic's published pricing as of late 2024:
Translation (Claude Sonnet 4, 3 target locales, ~3k tokens in / ~3k out per article) → €25–€45 per month at 40 articles.
SEO meta (Claude Haiku, ~2k tokens in / ~200 out, debounced) → €2–€5 per month.
Alt text (Claude Sonnet vision, ~150 images per month at ~1k tokens each) → €8–€15 per month.
Internal link embeddings (OpenAI text-embedding-3-small, refresh on publish) → under €3 per month at 600 published articles.
Total → roughly €40–€70 per month on Claude Sonnet, or €25–€40 on Haiku-where-possible. Compare to a DeepL Pro seat (€20/mo per editor) plus a Yoast-style plugin (€99–€229/year) plus manual alt-text labour.
The wiring shape: hooks vs jobs vs admin components
Three seams, picked deliberately. Hooks for the trigger (`afterChange` on articles, `afterChange` on media) — they fire in Payload's transaction context and have full access to the doc. Jobs for the model call (BullMQ on Redis) — model latency must never block the editor's save. Admin components for human-in-the-loop UI (the diff view, the internal-link suggester) — Payload's custom field components let you mount React in the right place without forking the admin.
The mistake we see most often: people put the model call directly in the hook. The save spinner spins for eight seconds, the editor refreshes, the hook fires twice, the queue eats the duplicate but the editor's flow is already broken. Hooks queue. Jobs call. Admin shows.
Rollout sequence we recommend
Month 1 — Ship workflow 2 (SEO meta). Lowest risk, fastest editor-visible win, calibrates the team on the draft-gate pattern.
Month 2 — Ship workflow 3 (alt text) and workflow 1 (translation, one target locale only). Measure acceptance rate per workflow before adding locales.
Month 3 — Ship workflow 4 (internal links) once you have 200+ published articles to embed. Below that volume the suggestions are too sparse to be useful.
Measure — Acceptance rate (editor accepts vs rewrites vs rejects), editor-hours per article, token spend. If acceptance on any workflow falls below 50% after two weeks, the prompt or the model choice is wrong — fix it before expanding.
If you are sizing a Payload build and want to see the patterns we wire on every editorial project — hooks, access control, AI workflows, editorial UX — See how we ship Payload CMS for content teams.
If you are scoping AI in your CMS and want a sharp read on which of the four to wire first for your team's volume and risk profile — Send us your editorial workflow — we will tell you which workflow pays back in week two and which one to defer.
What we ship by default on every Payload + content-team engagement now: the SEO meta hook with a lock field, the alt-text job on media uploads, and the translation queue scaffolded behind a feature flag so the team can switch on locales when editorial bandwidth catches up. The internal-link suggester goes in once the archive earns it. Autonomous publishing goes nowhere. The boundary is the product.
// After the call
Questions operators ask next
Does this pattern work with Payload's draft/publish system, or do we need custom status fields?
It uses Payload's native draft system. All four workflows write with `draft: true` through the Local API, so the generated values land on the draft version and the published version is untouched until an editor confirms. No custom status fields needed — this is one of the reasons we standardised on Payload over CMSes that bolt drafts on later.
How do you handle webhook retries and idempotency on the translation queue?
BullMQ jobs use a deterministic `jobId` of `(articleId, locale, updatedAt)`, which dedupes at the queue level. If a job fails mid-flight, BullMQ retries with exponential backoff up to three attempts; on final failure we write a `translationError` field on the article so editors see the failure in the admin rather than silent staleness. We never auto-retry the model call inside the hook itself.
What happens to editor manual edits when the meta regenerator fires again?
Each meta field has a `locked` companion boolean. The moment an editor manually edits the meta, the admin component sets `locked: true` and the hook skips regeneration for that field. The lock clears only if the editor explicitly unlocks it. Without this, editors abandon the tool by week three — we learned this on a rollout where the bot kept overwriting tuned campaign meta.
Is Claude Sonnet 4 actually necessary, or can everything run on Haiku?
Translation and alt text need Sonnet — Haiku's translation quality dips noticeably on idiomatic content and its vision model hallucinates objects in product photos often enough that the savings disappear in rewrite labour. SEO meta and short summaries run fine on Haiku. Splitting models by workflow drops monthly spend roughly 40% versus all-Sonnet.
Can the internal-link suggester run without pgvector if we are already on Postgres?
Technically yes — you can store embeddings as `float8[]` and compute cosine similarity in application code. We do not recommend it past ~500 articles. pgvector with an ivfflat or hnsw index keeps similarity queries under 20ms at 10k rows; the array approach drifts into hundreds of milliseconds and the admin component feels sluggish. Adding the extension is a one-line migration.
How long does it take to wire all four workflows on an existing Payload project?
On a Payload v3 project with a healthy collection structure and Redis already in the stack: roughly 3–5 weeks for one engineer, including the admin component for link suggestions and the diff view for translations. Add 1–2 weeks if Redis and the queue worker need to be provisioned. The SEO meta workflow alone ships in under a week and is usually how we sequence the engagement.
Pull quote
AI drafts into a field. Humans publish. Reverse that order and you will spend the savings explaining the apology email.