Blog Writer
[Live] ·
agent__blog-writer· Claude Sonnet 4.6
Writes a complete, SEO-optimised long-form blog post from an approved content brief, producing publication-ready Markdown with internal links, structured headings, and a meta description.
Overview
| Function | Write a full SEO-optimised long-form blog post from an approved content brief |
| Type | Worker — Content |
| Model | Claude Sonnet 4.6 |
| Queue | agent__blog-writer |
| Concurrency | 1 |
| Timeout | 10 min |
| Lock duration | 12 min |
| Tools | None (allowedTools: []) |
| Max turns | 1 |
| Est. cost / task | ~$0.31 |
| Plan | Free+ |
Triggers
| Trigger type | When | Who initiates |
|---|---|---|
| Activity Planner dispatch | After Content Brief Writer completes (and optionally Research Note Writer completes) — dependsOn both upstream jobs | Activity Planner |
| Human on-demand | User clicks “Write post” in DM Portal content tab or Dashboard blog management screen | Tenant admin / DM reviewer |
| Scheduled / cron | Not applicable — blog writing is always triggered by a brief or manual request | — |
Input
interface BlogWriterInput {
tenantId: string;
campaignId?: string;
contentBrief: {
workingTitle: string;
angle: string;
primaryKeyword: string;
secondaryKeywords: string[];
searchIntent: 'informational' | 'navigational' | 'transactional' | 'commercial';
recommendedOutline: string; // H2/H3 outline from Content Brief Writer
internalLinkTargets?: string[]; // page URLs or slugs from Website Content RAG
competitorUrls?: string[]; // top-ranking URLs to differentiate from
};
researchNotes?: string; // Markdown from Research Note Writer — optional but improves quality
targetWordCount: number; // from brief, typically 1200–2500
publishTarget: 'wordpress' | 'webflow' | 'other';
}Output
interface BlogWriterOutput {
title: string;
metaDescription: string; // 140–160 characters
slug: string; // URL-safe, keyword-first
bodyMarkdown: string; // full post body — H2/H3 structure, no H1 (title handles that)
internalLinksUsed: string[]; // URLs or slugs of pages linked within the body
estimatedReadTime: number; // minutes, calculated at 200 wpm
wordCount: number;
seoScore: number; // 0–100 composite: keyword density, heading structure, meta quality, internal links
}Sample output excerpt
**Title:** Why Your Google Ads Aren't Converting (And How to Fix It in 7 Steps)
**Meta description:** Most Google Ads campaigns bleed budget on the wrong keywords. Here are
the 7 fixes that consistently turn underperforming campaigns around — no agency required.
**Slug:** google-ads-not-converting-fixes
---
## Why Most Google Ads Campaigns Underperform
The average Google Ads account wastes 76% of its budget on irrelevant clicks. That's not a
fringe statistic — it's the median finding across audits of 500 SMB accounts conducted by
WordStream in 2024. If your cost-per-lead has been climbing and your conversion rate hasn't
budged, you're almost certainly in that majority.
The good news: the causes are predictable and the fixes are systematic.
Before we get into the seven steps, it's worth understanding *why* this happens. Google's
default campaign settings are optimised for Google's revenue, not yours. Broad match keywords,
auto-applied recommendations, and Smart Campaigns all expand your reach — and your spend —
without expanding your results.
## Step 1: Audit Your Search Term Report Weekly
Your search term report is the most valuable report in Google Ads, and most advertisers check
it monthly at best. It shows you exactly what people typed before clicking your ad — and in
most accounts, at least 30% of those terms have nothing to do with your business.
**What to do:** Open Search terms → filter for the last 30 days → sort by cost → add any
irrelevant term as a negative keyword immediately. Do this every Monday morning. It takes
12 minutes and pays for itself in days.
> "We cut a client's wasted spend from $4,200/month to $800/month in six weeks using nothing
> but weekly negative keyword management." — [Internal case study, Acme Agency, 2025]How It Works
-
Load brief and research context. The content brief (title, angle, keyword, outline) and optional research notes are injected into the system prompt. Research notes — if present — are treated as the primary factual source; the agent writes to the outline using those notes as scaffolding.
-
Pre-load RAG context (worker code). Before Claude is invoked, the worker runs a single
search()call with query"{activityLabel} blog post"andtopK: 6. Results fromclient_docs,website_content, andpublished_contentare injected into the prompt as aKNOWLEDGE BASE CONTEXTsection. This is not a tool call — the context is pre-loaded before execution begins. If no chunks are found, the worker logs a warning and continues without RAG context. -
Draft the post. Write the full post body following the approved H2/H3 outline. The intro must hook within 100 words, each section must have a clear value statement, and the conclusion must include a CTA. Prohibited words from tenant settings are filtered at generation time.
-
Generate metadata. After the body is complete, generate the
title,metaDescription, andslug— each optimised for the primary keyword. Meta description is written to maximise click-through from SERPs, not to summarise the article. -
Calculate SEO score and read time. Run post-generation checks: keyword in title, keyword in first 100 words, keyword density 0.5–2.0%, ≥1 internal link, meta description 140–160 chars, H2 structure present. Score 0–100, with per-check breakdown available in logs.
System Prompt
You are an expert SEO content writer working for a digital marketing agency. Your job is to
write a complete, publication-ready long-form blog post for a client based on an approved
content brief and research notes.
CLIENT CONTEXT:
{{CLIENT_CONTEXT}}
TENANT SETTINGS:
{{TENANT_SETTINGS}}
KNOWLEDGE BASE CONTEXT (competitor angles, internal links, brand facts):
{{RAG_CONTEXT}}
CONTENT BRIEF:
{{BRIEF}}
RESEARCH NOTES:
{{RESEARCH_NOTES}}
Your post must:
1. Open with a hook that earns the reader's attention within 100 words — a striking statistic,
a counterintuitive claim, or a sharp problem statement. Do not start with "In today's..."
2. Follow the H2/H3 outline in the brief exactly — headings become the article's structure
3. Use the primary keyword naturally in: the title, the first 100 words, at least one H2, and
the meta description. Target keyword density of 0.5–2.0% — never keyword-stuffed
4. Include facts and examples from the research notes. Cite sources inline in parentheses
5. Weave in 2–3 internal links to the pages identified in the RAG context — link naturally,
never forced
6. Match the client's brand voice exactly as described in the client context
7. Write to the target word count (±10%) — do not pad with filler; every paragraph must earn
its place
8. Close with a clear, low-pressure CTA that matches the post's search intent
9. Do not mention competitor brand names unless the brief explicitly calls for comparison content
10. Avoid all prohibited words listed in tenant settings
After the body, output the title, meta description (140–160 chars), and slug on separate lines.
Then output a JSON block with: wordCount, internalLinksUsed[], estimatedReadTime, seoScore.
Write for humans first. SEO second. A post that no one reads cannot rank.Skills Injected
| Skill file | Purpose |
|---|---|
client-context-file.md | Always injected — company, brand voice, audience, competitors |
long-form-writing-guide.md | Blog post structure, intro/hook patterns, SEO writing rules, CTA formats |
seo-content-rules.md | Keyword placement, heading hierarchy, meta description formula, internal linking rules |
long-form-writing-guide.md — content
# Long-Form Writing Guide
## Post Structure
Every blog post must follow this skeleton. Deviate only when the brief explicitly calls for it.
### Opening (100–200 words)
- Lead with a hook: a specific statistic, a provocative question, or a sharp problem statement
- Establish the problem or opportunity in 1–2 sentences
- State what the reader will get from the article (the "payoff promise")
- Do NOT start with "In today's digital world", "Are you looking for...", or any variant of
"Welcome to our guide"
### Body sections (follow the H2/H3 outline)
- Each H2 section = one complete idea, fully explained
- Open each H2 with a 1–2 sentence orientation paragraph before diving into detail
- Use numbered lists for steps/processes; bullet lists for features/options; prose for arguments
- Use short paragraphs (2–4 sentences). One idea per paragraph.
- Break walls of text with: subheadings, lists, pull quotes, or bold key statements
- Include at least one concrete example per major section (named, specific, not hypothetical)
### Conclusion (100–150 words)
- Summarise the 2–3 core takeaways — do not introduce new ideas
- Close with a CTA that matches search intent:
- Informational intent → "Subscribe for more guides like this" or "Download the checklist"
- Commercial/transactional intent → "Book a free audit" or "Start your free trial"
- Never use "Contact us today" as a standalone CTA — it is too generic
## Tone and Voice
- Match the brand voice in the client context exactly
- Default to: clear, direct, specific. Avoid corporate jargon and filler phrases
- Passive voice: use sparingly. Active voice moves faster
- Contractions: use them (they're, you'll, it's) unless brand voice is formally professional
## SEO Writing Rules
- Primary keyword in: title, first 100 words, at least one H2, meta description
- Do not repeat the exact primary keyword phrase more than once every 200 words
- Use semantic variations: synonyms, related terms, question forms
- Alt text for any images: descriptive, keyword-informed, under 125 chars
- Internal links: 2–3 per post, anchor text is descriptive (never "click here")
## Prohibited Patterns
- Do not open sentences with "Additionally,", "Furthermore,", or "In conclusion,"
- Do not use the phrase "dive deep" or "deep dive"
- Do not end sections with "Let's explore..." or "Read on to find out..."
- Do not use em dashes in a way that mimics AI-generated paddingRAG Usage
Mechanism: Pre-loaded (worker code runs search() before Claude starts — not a tool call).
| Dataset | Included | Purpose |
|---|---|---|
client_docs | ✅ | Brand voice, product details, case studies |
website_content | ✅ | Existing site context, service pages |
published_content | ✅ | Past posts — prevents duplication; maintains style continuity |
competitor_content | ❌ | Not used by blog-writer |
Query: "{activityLabel} blog post" — one query, topK: 6, searched across all three permitted datasets in parallel.
Fallback: If the search fails or returns 0 chunks, execution continues without RAG context (warn logged).
HITL Gates
Blog posts pass through two sequential human approval gates before they can be published.
Status flow: dm_review → client_review → client_approved → (manual publish step)
Gate 1 — DM reviewer (POST /dm/v1/blog/:id/dm-approve)
- DM reviewer reads the full Markdown post in the DM Portal blog screen.
- Approve: Post moves to
client_review. Client tenant admin is notified. - Reject: DM submits feedback text. Post is re-queued with
wakeReason: "rejection"andreviewerFeedbackinjected into the prompt. The BlogPostversionincrements on the next generation.
Gate 2 — Client (Dashboard server action)
-
Client tenant admin sees the post under “Needs Approval” in Dashboard → Blog Posts.
-
Approve: The dashboard calls the
clientApprovePostserver action inapps/dashboard/src/app/(dashboard)/blog/actions.ts— it does NOT call the REST endpointPOST /tenant/v1/blog/:id/client-approve. The server action must update three records:BlogPost.status→"client_approved"Activity.status→"done"Deliverable.status→"approved"viadb.deliverable.updateMany({ where: { blogPostId: postId }, data: { status: "approved" } })
Without the
Deliverablesync the Deliverables page shows “Done: 0” even after the client approves. -
Reject: Post returns to
dm_reviewwith the client’s rejection note. The DM team decides whether to re-run the agent or edit manually.
Guardrails
| Rule | Enforcement |
|---|---|
| Word count within ±10% of target | Post-generation check; if outside range, self-correct by expanding or trimming before returning output |
| Meta description 140–160 characters | Hard character count check; regenerate meta if outside range |
| Primary keyword present in title | String match; fail SEO score check if absent |
| Primary keyword present in first 100 words | String match on first 100 words of body |
| Minimum 1 internal link in body | Check internalLinksUsed[] length; warn if empty |
| No prohibited words from tenant settings | Filter prohibitedWords[] against full body text; flag any matches before returning output |
| No invented statistics or fabricated claims | Research notes are the only factual source; any statistic not in research notes or RAG context must be flagged with “[VERIFY]” inline |
| Slug is URL-safe and keyword-first | Regex validation; auto-corrected if invalid characters present |
Tenant Settings Used
| Setting | How it’s used |
|---|---|
brandVoice | Injected into system prompt — controls tone, formality, sentence style throughout the post |
companyName | Used in CTAs and any first-person company references |
targetAudience | Informs reading level, example types, and assumed knowledge level |
prohibitedWords[] | Filtered from the generated body; any match triggers a warning in output |
preferredKeywords[] | Checked against body text — agent attempts to naturally include preferred terms |
industry | Scopes RAG competitor research queries to the correct niche |
Cost Profile
| Avg input tokens | ~3,000 new + ~8,000 cache read (prompt caching active) |
| Avg output tokens | ~16,500 (full blog post + metadata, ~3,000–4,000 words) |
| Avg duration | ~3.5 min |
| Est. cost / task | ~$0.31 |
Performance & Tuning (Apr 2026)
Blog posts generate ~16.5K output tokens — one of the largest outputs in the pipeline. Tuning history:
| Change | Before | After | Reason |
|---|---|---|---|
timeoutSec | 300s | 600s | Actual runs take 3–4 min; 5-min timeout had no headroom and caused cascading retries |
lockDuration | 360s | 720s | Must exceed timeout to prevent stall detection mid-run |
allowedTools | (unset — all tools) | [] | Pure text generation; all context is pre-loaded; unexpected web searches add turns and latency |
maxTurnsPerRun | (unset) | 1 | Enforce single-turn generation; belt-and-suspenders with allowedTools: [] |
Why the retries piled up: The dependency resolver (packages/agents/src/lib/dependency-resolver.ts) enqueues all dependency-unblocked jobs with attempts: 6 and exponential backoff. Before the timeout was fixed, each 5-min timeout immediately triggered a retry, causing the same job to consume 30 minutes of queue time across 6 attempts.
Token counting note: The execution queue’s input-token count shows only the prompt-cache hits + new tokens combined (fixed Apr 2026 in extractUsage). The input_tokens field from Claude Code CLI reports only uncached new tokens (typically 3); the bulk of the prompt (~8K tokens) is served from cache and reported separately as cache_read_input_tokens.
Error Handling
| Error | Response |
|---|---|
| RAG returns no Published Content results | Proceed without duplicate check; log “No published content found — duplicate check skipped” |
| RAG returns no Client Documents results | Proceed without brand facts; post uses only brief and research notes; note “No client documents found — verify product claims manually” in output |
| RAG returns no Website Content results | Proceed without internal links; set internalLinksUsed: []; SEO score penalised accordingly |
| Word count target is below 600 or above 5000 | Fail job with validation error: “Word count target out of range (600–5000)“ |
| Post fails guardrail after 2 self-correction attempts | Return best attempt with failing guardrail checks documented in output; create HITL record for manual review |
Content brief is missing primaryKeyword | Fail job with validation error before any LLM call |
| Job exceeds 10-minute timeout | Save partial draft with note on where generation stopped; create HITL record for manual completion |