The Blog Agent (Dashboard → AI → Blog Agent, admin-only, requires the AI Assistant feature) takes a writing brief and produces a finished post. It reads what the site has already published so it doesn't repeat itself, writes in the site's own voice from the AI Knowledge Brief, and then either files the draft in the editorial review queue or publishes it and reports what it did.
It is the first surface in the CMS where AI does work unattended — nobody is watching the screen when it runs. Most of the design below follows from that one fact.
Its voice and its guardrails come from the site's AI Knowledge (Dashboard → AI → Knowledge): the curated Brief — identity, voice, audience, differentiators, and the "Never say" list — plus per-topic Notes, shared by every AI surface in the CMS.
Assigning a task
| Field | Meaning |
|---|---|
| What should it write about? | The brief. Concrete brings back better work than a topic word — the placeholder shows the shape ("A post explaining what to check on a standby generator before storm season, aimed at homeowners who have never had one serviced"). |
| Write into | Which content type receives the post. Any type with a rich-text field qualifies, so this is not blog-only — the task stores a type_slug. |
| When it is done | Send it for approval (default) or Publish it. See below — this is the important choice. |
| Also generate a featured image | Optional, default off. Only shown when an AI image provider is configured, since it could otherwise only ever produce a post with a note explaining why there is no image. |
| Anything the image should show? | Optional direction for that image. Empty is the "let the agent decide" choice. |
| Start after | Optional. Empty means it runs within a few minutes; a time holds the task until then. |
Assigned tasks appear under Recent tasks with their status (Queued / Writing… / Awaiting approval / Published / Failed), the agent's own notes about what it chose, a Read it link to the resulting post, and Run now / Try again.
The two modes
Send it for approval lands the post as a pending content item with submitted_by / submitted_at set, and calls EditorialWorkflow::recordSubmitted() — producing exactly the same review queue, badge, counter and admin notification that a human author's submission does. Nothing reaches the public site until a person approves it. In this mode the agent is told to use its notes to explain the angle it took, anything in the brief it interpreted, and any fact it was unsure of and deliberately left out.
Publish it goes live immediately and reports afterwards by email (BlogAgentPublishedNotification), quoting the agent's notes. In this mode the agent is told the post will be public with nobody reading it first, and must not leave placeholders, TODOs, or bracketed instructions behind.
Why the approval gate is the editorial workflow and not
laravel/ai'sApprovable. AnApprovablesuspends a run until a human resumes it — right for the admin assistant widget, meaningless on cron where there is nobody to resume it. Routing approval through the existing editorial workflow also means the review queue, notifications and permissions an install already has apply unchanged, instead of the agent inventing a second approval concept.
Guardrails
- It cannot invent facts about the business. No prices, credentials, guarantees, statistics, named customers, or dates that weren't given to it. Where the brief implies a claim it can't support, it writes around it and says so in its notes.
- The Brief's
forbidden_claimsare hard constraints. This agent publishes to the open internet under the client's name, in verticals (legal, medical, financial, contracting) where a wrong claim is a compliance problem, so the Site Knowledge "Never say" list is enforced here rather than treated as a style hint. - The model's HTML is sanitized, not trusted. Output passes through
GeneratedHtml(app/Support/Html/), an allowlist sanitizer — the prose twin of the site generator'sRowAuthoringLinter. The page builder trusts a Manager's raw HTML because a human chose it; a model's output is not a human's intent. Unknown tags unwrap (keeping their text);script/style/iframe/svgare dropped whole, because unwrapping those would paste their source into the article. - It runs as the person who assigned it. The runner sets the authenticated user to the assigning admin for the duration (restored in a
finally), so the agent can never exceed what that admin could do by hand and the activity log names a real person. Thefinallyis load-bearing: LazyCron executes inside a visitor's request, so a leftover admin identity would leak into that response. - A post with no body fails the task rather than saving. An unresolvable field mapping (missing type definition, or a type with no rich-text field) used to write a bodyless post silently; a bodyless article reads as "the agent generated nothing", which is worse than not writing it.
The featured image
Off by default: image generation is the expensive call, and in publish mode an unreviewed picture goes onto the public site under the client's name, where a wrong-looking one is far more conspicuous than a bland paragraph.
- The agent writes the image prompt, even when direction was given. It alone knows the post it just wrote; a bare "make it wintery" sent to an image model loses the subject entirely. A direction becomes a requirement on the prompt it composes, not a replacement for it.
- Empty direction is a choice, not a missing value — which is why
image_directionstays null rather than becoming an empty string. One optional field also avoids the invalid state a radio pair would allow (pick "follow my direction", write nothing). - The image may never depict the business itself — no invented storefront, premises, staff, uniforms, vehicles, signage, or logo. This is the photographic twin of the forbidden-claims rule: a reader takes a photo as evidence, so an invented shopfront on a real company's blog is a false claim about them whatever the caption says. Images are illustrative — a scene, a subject, a detail belonging to the topic.
- It is generated BEFORE the content item is created, and passed to the create as
featured_media_id. Attaching it afterwards would put a published post on the site, let the response cache store it without its image, and send the "here is what went live" email — all in the seconds the image was still generating. - A failed image never fails the post. Losing a finished article because an image provider was rate-limited is a bad trade; instead the reason is appended to the task's notes ("No image was generated: …"), so the card says why rather than quietly showing no image.
- The shape comes from the layout that will render it, not from a fixed number. The shipped detail layouts disagree:
content-detail-image-sidebarframes the image square in a sidebar,-articleand-photo-leadrun it 16:9 across the content column,-hero-splitis 4:5.FeaturedImageSpotreads the type's configured default detail layout — the one a record that has not chosen gets, which is the only answer available here since the post does not exist yet — and takes thewidths+aspect-*its featured-image tag declares. Generating one fixed shape crops the subject out of half of them. (The sparkles button on an existing post's edit screen passes that post's owndetail_layoutinstead, since by then there is a record to ask.) - The result is saved to the media library under AI Generated via the same
ImageSpotFillerpath the site generator and the MCP image tool use. Alt text comes from the agent in the same reply — it describes what was rendered, and costs no extra call.
Redoing it
Redo image on the task card takes a note on what to change ("no people", "shot in daylight"). The note adjusts the prompt the agent wrote rather than replacing it — a bare tweak sent to an image model has no subject — which is why the prompt is stored on the task, and why the modal shows it. A second redo builds on the first, since that is what tweaking twice in a row means.
It is offered whenever the task asked for an image and produced a post, including when the image FAILED: the first real read of an image is when you see it, and the retry belongs in the same place as the adjustment. A failed redo leaves the existing image alone and says why. The previous image stays in the media library — it may be in use elsewhere, and deleting belongs to the library's own unused-media cleanup.
How it runs
blog-agent:run is registered on LazyCron at a 300-second interval, so it executes inside an ordinary visitor's request on installs with no queue worker. Consequences:
- One task per tick by default (
--limit). A generation is 10–40 seconds of socket wait; draining a backlog in one tick would hold a visitor's PHP-FPM worker open for minutes. - Tasks are a table, not queued jobs (
blog_agent_tasks). There is no queue worker to rely on, and a job payload is invisible to the admin who assigned it — the task row is what the dashboard lists. - A run is claimed before it executes, and a claim older than
BlogAgentRunner::STALE_RUN_MINUTES(15) is presumed dead and may be re-claimed. The window is well past a normal generation on purpose: reclaiming a merely-slow run spends a second generation and, in publish mode, can put two posts on the site. - "Run now" can lose the race and says so. A LazyCron tick can claim the task in the seconds between the page rendering the button and the click; the second runner refuses rather than double-spending, and the UI reports "It is already writing that one — this page was a moment out of date."
blog-agent:run --task={id} runs one task by id, ignoring its schedule — the same path as the dashboard's Run now / Try again.
The agent itself
BlogAgent (app/Support/Ai/BlogAgent.php) is deliberately not Conversational: it runs with nobody watching, so there is no transcript to resume and no one to answer a clarifying question. It gets one shot per task, capped at #[MaxSteps(6)].
It has one tool, list_recent_posts, and is instructed to call it first — so it can avoid repeating a covered topic and match how existing posts are titled. It returns a single JSON object (title, subtitle, body, meta_description, notes, plus image_prompt / image_alt when a featured image was asked for); notes is what surfaces on the task card and in the publish email. A task that did not ask for an image is never told those two keys exist, and an image volunteered anyway is not generated.
A reply that isn't JSON fails the task and quotes the model back in the error, because the interesting cases — it answered in prose, it refused, the reply was cut off mid-JSON — are otherwise indistinguishable from each other. The full reply is logged.
Voice and grounding come from SiteKnowledge::forPrompt(SCOPE_CONTENT, SCOPE_SEO) — the same shared layer every other AI surface reads, so the agent cannot disagree with the chatbot or the SEO remediator about who the business is.
Dashboard card
A Blog Agent stat card shows outstanding tasks (queued + running) with a subline that leads with failures when there are any, since a failed task is the only state needing a person. Feature- and admin-gated to match the page it links to.
Like every dashboard card, it only appears automatically on installs that have never customised their dashboard. Where a widget set has been saved, add it from Customize.
Related
- AI Knowledge — identity, voice, and the forbidden-claims list this agent obeys
- Editorial Workflow — the review queue a
review-mode draft lands in - AI Usage & Costs — where the agent's spend shows up