Editorial automation (a Directus Flows answer)
A no-code "when X happens, do Y" layer for editorial workflows (#342) — Kiln's answer to Directus Flows, without an embedded scripting runtime. It's pure Elixir over the primitives Kiln already runs in production: the content state machine (the triggers), Oban (the executor), and PubSub / MTA / cache (the reactions).
The asymmetry
Automation platforms bolt on a JavaScript/visual scripting engine. Kiln doesn't need one: publishing already emits editorial events (the same funnel the webhook system uses), Oban already runs isolated, retried background jobs, and the MTA / PubSub / cache are already wired. Automation is a thin, admin-managed rule model on top.
Using it
Manage rules at /editor/automation (admin-only). Above the builder,
Start from a recipe offers common rules ready-made ("Email me when
something is published", "Create a task when content goes stale", "Announce
new posts on social media", …). A recipe only fills in the builder: you finish
what it can't know (which network, whose inbox) and add the rule as usual, and
nothing is saved until you do (KilnCMSWeb.AutomationLive.Recipes).
The builder is numbered steps:
- When this happens — a content type (
post, a dynamic type's name, Tasks, or any) and an event, grouped as editorial changes (published,unpublished,updated,in_review,returned_to_draft), tasks (assigned,overdue) and content health (health_overdue/health_expired). A task event scoped to a content type — or a content event scoped to Tasks — can never fire, and the builder says so. - Do this — one reaction (below), picked from cards grouped as Notify people, Review & follow-up and Keep the site fresh.
- Set it up — the reaction's settings, as ordinary fields generated from
the
configtable below (KilnCMSWeb.AutomationLive.ConfigFields). API clients and seeds still writeconfigas JSON. - Name it — optional. While you build, the form reads the rule back as one sentence ("When Post content is published, email [email protected]."); a rule saved with no name is named by that sentence, and keeps following it when the rule is edited until you type a name of your own. The rules list shows each rule's sentence under its name.
- Try it — optional, on both the add and the edit form. Pick a recent
piece of content and the builder shows what the rule would do to it: the
email with its subject and body rendered, the social post text and how many
accounts would post it, the task with its assignee, due date and note — or
why nothing would happen (an open review task already covers it, a
newsletter skips translations, nobody can hold the task). It follows the
rule as you edit it and sends, posts and saves nothing. The effects come
from
KilnCMS.Automation.RuleWorker.preview/4, which sits beside the real reaction and shares its templating, defaults and checks, and the event payload from that event's own builder — socreate_taskon a publish honestly previews "nobody to assign": only the health events carry the author. The four intelligence reactions are described, not run: running them costs what the rule costs, andsuggest_metadatamay send the page off-site.
Reactions
| Action | What it does | config |
|---|---|---|
send_email | Deliver an email via the MTA | to, subject, body (templated) |
broadcast | Phoenix.PubSub broadcast {:automation_event, event, payload} | topic (default "automation") |
invalidate_cache | Bust the record's content cache (+ sitemap/llms) | — |
create_task | Raise an editorial Task on the record, assigned to its author (falling back to assignee_id). Idempotent per {content, kind} | assignee_id, due_in_days (1–365, default 7), note (templated) |
reindex | Re-fire the record (refreshes artifacts + search indexes) | — |
newsletter | Send the published document to subscribers (#376) | segment_id (omit = all confirmed), subject (defaults to the title) |
flag_duplicates | Email near-duplicate findings for the document (#377) | to |
suggest_tags | Email semantic tag suggestions for the document (#377) | to |
suggest_links | Email internal-link suggestions for the document (#377) | to |
suggest_metadata | Email proposed seo_title / seo_description / seo_keywords (#377) | to, allow_egress |
social_post | Announce the publish on Bluesky / Mastodon (#497) — see social-posting.md | provider (required), template |
send_email subject/body and templates support {{title}}, {{slug}},
{{id}}, {{type}}, {{event}} (each HTML-escaped).
social_post is the one reaction that cannot be undone
Every other reaction writes somewhere the operator controls — an inbox, a cache,
an index. social_post writes to a public timeline in front of an audience, and
a duplicate cannot be taken back.
So it is the one reaction that is at most once rather than at least once: it
claims a ledger row before it posts, never retries an ambiguous outcome, and
runs with Oban retries off. The cost is that a genuinely lost announcement stays
lost until someone looks at /editor/social. See
social-posting.md.
The intelligence reactions suggest, and never write
The last four rows compute something about the document and email the findings. None of them touches the record, and that is a design decision rather than an unfinished one.
Generated metadata lands in <meta> tags on the public site, so a successful
prompt injection through a document body buys SEO cloaking on your own domain.
Human-in-the-loop is the primary control against that; the output constraints
in KilnCMS.Seo.Draft (no newlines, no HTML, no URLs, hard truncation) are only
the second layer. A reaction that wrote seo_description unattended on a state
transition would delete the primary control.
So automation makes the computation unattended — nobody has to remember to open the panel and ask — while accepting a value stays a click in the editor, where a human sees it before the public does.
Two consequences worth knowing:
They don't retry — including when the email fails. An unconfigured generator, an exhausted budget bucket, a provider outage or a greylisting relay is logged and dropped. Retrying a nice-to-have suggestion five times per document spends real tokens to tell an editor something they can ask for directly, and an Oban retry re-runs the generation, not just the send.
The SEO draft budget is shared with the editor panel, but people keep a reserve. The org ceiling is
KilnCMS.Seoper_org_limit(default 200/hour) and every caller draws on it. Asuggest_metadatarule is keyed into its own per-rule bucket on top, so one runaway rule can't starve the others — note that bucket's size is the sameper_user_limitthat sizes every editor's, so there is no separate knob for how fast one rule may run.Because it runs unattended, it also stops once the org has spent
unattended_shareof the window (default0.5), counting every caller's spend. At the defaults an editor clicking "Suggest with AI" always has at least 100 of the 200 available. Without that, a busy day on a*.updated → suggest_metadatarule ended with every editor getting a rate-limit error caused by a rule they can't see and — since this page is admin-only — can't inspect.Reading the shared counter is deliberate: automation's room shrinks as editors work, and a rule can be refused having spent nothing itself. That is the priority order this is for. Set
unattended_share: 0.0to keep automation off this budget entirely, or1.0for the old shared-bucket behaviour. Scope rules toin_reviewrather thanupdated, and raiseper_org_limitif you mean to run them broadly.This covers the SEO draft budget only.
flag_duplicatesandsuggest_tagshave their own bucket — see below.flag_duplicatesandsuggest_tagsspend a separate embedding budget (#1076). Both reachKilnCMS.Search.Related, which computes an embedding per block for a document that has none stored, andsuggest_tagsadditionally embeds every taxonomy tag whose name has no stored vector yet — once per tag, not per call, since #1085 persists it intag_embeddings. That isconfig :kiln_cms, KilnCMS.Searchembedding_per_user_limit(default 60/minute) /embedding_per_org_limit(default 600/hour) — higher than the SEO draft budget because a single local embedding is far cheaper than an LLM completion, andsuggest_tagscan spend one unit per untagged taxonomy tag in a single call.embedding_unattended_share(default0.5) is the same #943 reserve asunattended_shareabove, so a rule can't leave an editor's own "duplicates & tags" panel rate-limited by something it can't see. A rule scoped toin_reviewis exactly the case that computes rather than reads — a bulk move to that state now stops spending embeddings once the reserve is hit, instead of running unbounded.Bulk tag review shares this reserve (
/editor/organize, #1596). An editor's review run is attended, but it is the multiplied caller this reserve exists for, so it charges as an unattended caller under the editor's own id: it stops once the org has spentembedding_unattended_shareof its window, and can never take the half an editor's per-document panel relies on. Two consequences: withembedding_unattended_share: 0.0, bulk review of anything that needs computing is off too (the page says so, and published documents with stored vectors still review, since they cost nothing); and once rules have used the background share, a review run stops with a message naming automation, while the per-document panel keeps working. Each run spends at most one per-user window (embedding_per_user_limit, 60 by default) and sizes every document to the room left before charging, so the reserve check (spent >= ceiling, notspent + units) is never overshot by more than one document's cost — in practice not at all.suggest_metadataneeds"allow_egress": truewhen the configured model provider is off-site (KilnCMS.Seo.egress?/0). The editor panel is one person deciding to spend one request; a rule is every matching document, forever, with nobody watching — a different posture than the one an operator agreed to when they pointed the panel at a third-party provider. Opting in per rule keeps a later provider switch from silently turning the publish pipeline into an outbound feed. On-prem (ollama/vllmon a loopback or private endpoint) needs no opt-in, because nothing leaves.
suggest_links needs no such flag: both its legs are local — pgvector over
content this deployment already indexed, falling back to Postgres full-text —
so it works on a default install with semantic search off. It emits paths to
paste, not insertions; see KilnCMS.Seo.Links for why server-side insertion
into the block tree is ruled out.
HTTP / Slack notifications are deliberately not an automation action — that's exactly what the signed, SSRF-safe Webhooks feature does. Automation complements it with the reactions webhooks can't do.
Examples
When post.published → send_email {"to": "[email protected]", "subject": "Live: {{title}}"}
When *.published → reindex
When *.updated → invalidate_cache
When page.unpublished → broadcast {"topic": "site:page"}
When post.in_review → suggest_links {"to": "[email protected]"}
When post.in_review → suggest_metadata {"to": "[email protected]"}How it works
- Trigger. Every editorial event funnels through
KilnCMS.Webhooks.dispatch/2(<type>.published/.unpublished/.updated). It callsKilnCMS.Automation.handle_event/2, which finds the enabled rules that match and enqueues oneKilnCMS.Automation.RuleWorkerper rule.handle_event/2never raises — a rule problem can't break the publish that triggered it. - Execute. Each
RuleWorkerjob loads its rule and performs the reaction, off-request, isolated, and retried by Oban. A slow email or a failing reaction affects neither the content action nor the other rules. - Payload. Reactions receive the same serialized content map webhooks get
(
KilnCMS.CMS.ContentSerializer), so it has the title, slug, id, state,audience,lockedand the block tree. Note the asymmetry: a broadcast forwards that whole map, so a PubSub consumer can checkaudience/lockedthe way a webhook subscriber does (#1014) — but the email and webhook reactions render from a fixed variable whitelist (title,slug,id,type,event), and rule matching is trigger + content type only. There is no condition surface on which to say "only public documents", so a rule that forwards content outward forwards gated content too.
Modules: KilnCMS.Automation (domain + executor), KilnCMS.Automation.Rule
(the admin-managed resource), KilnCMS.Automation.RuleWorker (the reactions),
KilnCMSWeb.AutomationLive (the no-code builder).
Scope & follow-ons
Phase-1 slice:
Triggers are the webhook lifecycle events:
published/unpublished/updated, plus the review-workflow transitionsin_review/returned_to_draft(#375) —submit_for_reviewandreturn_to_draftemit<type>.in_review/<type>.returned_to_draftthrough the same webhook funnel, so both rules ("onin_review→ notify") and webhook subscriptions can react to them.One reaction per rule. Multi-step flows (do A then B) are modeled today as several rules on the same trigger; a sequenced multi-action rule is a follow-on.
Reaction set covers email / broadcast / cache / reindex / newsletter (#376 — "on
published→ send to segment X", deduped per {rule, content, publish revision} on the campaign ledger, so re-fires never double-send), plus the editorial-intelligence reactions (#377):flag_duplicates,suggest_tags,suggest_linksandsuggest_metadatapair naturally with thein_reviewtrigger as lightweight review gates — silent when nothing is found, and inert rather than broken when the capability they need (semantic search, a configured generator) isn't there. All four suggest and never write; see above for why that boundary is the point rather than a limitation.What remains outside the CMS is an LLM acting through the MCP surface with tools it can call. These reactions take strings and return strings, and the
KilnCMS.Search.Related/KilnCMS.Seo.Linksseams are what such an agent would consume.Pending-duplicate dedupe: a re-fired duplicate event collapses onto the still-queued job for the same {rule, event, document}; an event arriving while the first job runs or retries is never dropped.
Config is entered as JSON, and validated against the selected action at save time (
KilnCMS.Automation.Validations.ActionConfig). A required key that is missing, a value of the wrong type, and a key the action does not recognize are all refused beside the field rather than accepted and warned about at runtime — a rule that cannot work should not be able to sit in the list looking enabled.ActionConfig.shapes/0is the single description of what each reaction accepts, and the admin form renders its per-action key hint by reading that table — so the hint beside the field cannot drift from what the save will allow.Two mistakes it exists to catch:
"allow_egress": "true"(the string — every other key in that textarea is one, and the runtime gate correctly fails closed on it), and a missingtoonsend_emailor on any of the four intelligence reactions, all of which deliver by email and nothing else.Rules written before this validation existed are not re-checked, and AshAdmin writes the attribute directly, so the executor keeps its own guards. Per-action structured form fields are still a UI refinement.