Blog Agent Reference: C# Implementation vs TypeScript
This document is a complete reference of the C# agents in references/agents/ — how they operate, their exact prompts, data models, orchestration logic, and retry patterns — compared against our TypeScript workers. It is intended to serve as the source of truth when reimplementing or upgrading the TypeScript blog pipeline.
1. System Overview
The C# system (references/agents/) is a .NET 8 multi-agent orchestration library built on the Microsoft Agent Framework. It is not a queue-based system — it runs as a single synchronous pipeline invoked once per blog request, managing all stages internally.
Tech stack:
- Runtime: .NET 8
- Agent framework:
Microsoft.Agents.AI1.0.0-rc3 - LLM for writing/review: Anthropic Claude Sonnet 4.6 (configurable)
- LLM for image generation: Google Gemini via
Leadmetrics.Provider.GoogleAI - Image processing:
SixLabors.ImageSharp3.1.12 - Caching: Redis via
Leadmetrics.Provider.Cache - Logging: Serilog (console + daily rolling file)
- Retry: Polly
Entry point:
var mainAgent = new BlogAgent(config, imageGenService, imageUtilities, promptBuilder, cacheManager, loggerFactory);
var result = await mainAgent.RunBlogPipelineAsync(request, siteUrl, cancellationToken);2. Agent Roles
| Agent | Class | Model | Temperature | MaxTokens | Purpose |
|---|---|---|---|---|---|
| BlogWriterAgent | BlogWriterAgent.cs | Claude Sonnet 4.6 | 0.7 | 18,000 | Initial draft + revision |
| BlogReviewAgent | BlogReviewAgent.cs | Claude Sonnet 4.6 | 0.7 | 8,000 | Quality review + image review |
| BlogImageGenerationAgent | BlogImageGenerationAgent.cs | Google Gemini | n/a | n/a | Hero image generation |
| BlogEditorAgent | BlogEditorAgent.cs | Claude Sonnet 4.6 | (config) | (config) | Edit existing blog |
| BlogCommentAnalyserAgent | BlogCommentAnalyserAgent.cs | Claude Sonnet 4.6 | (config) | (config) | Classify edit instructions |
3. Pipeline Flow
3.1 Blog Creation Pipeline (BlogAgentOrchestrator)
BlogAgent.RunBlogPipelineAsync(request, siteUrl)
│
├─ 1. GetCachedSitemapLinksAsync(siteUrl, tenantId)
│ Cache key: "sitemap:links:{tenantId}" (24-hour Redis TTL)
│ On miss: SitemapHelper.FetchSitemapUrlsForCacheAsync(siteUrl)
│ Caps at 50 URLs (random sample) to control prompt size
│
├─ 2. BuildBlogWriterInstructionsAsync(sitemapUrls)
│ Loads BlogWriterAgentInstructions.txt
│ Replaces [[existing_links]] with CSV of URL,Title
│
├─ 3. BuildBlogWriterPromptAsync(request)
│ Loads BlogWriterAgentPrompt.txt + BlogBestPracticesPrompt.txt + ImageCreationPrompt.txt
│ Replaces all {{placeholders}} with sanitized request fields
│
├─ 4. BuildBlogReviewerInstructionsAsync(blogContext, sitemapUrls)
│ Loads BlogReviewerAgentInstructions.txt
│ Replaces {{BusinessContext}} and [[existing_links]]
│
├─ 5. Instantiate agents: BlogWriterAgent, BlogReviewAgent, BlogImageGenerationAgent
│
└─ 6. BlogAgentOrchestrator.RunAsync(blogWriterPrompt, description)
│
├─ Stage 1: BlogWriterAgent.CreateBlogPostAsync(prompt)
│ → BlogResponse (JSON)
│
├─ Stage 2: Review-revision loop (max 3 iterations, configurable)
│ for iteration = 1 to maxReviewIterations:
│ BlogReviewAgent.ReviewAsync(blog, description, iteration)
│ → BlogReviewResult { is_approved, review_summary, feedback_items[] }
│ if is_approved: BREAK
│ if iteration < max:
│ BlogWriterAgent.ReviseBlogPostAsync(blog, reviewResult)
│ → Revised BlogResponse
│ if iteration == max: proceed with current draft (log warning)
│
└─ Stage 3: Image generate-review loop (max 3 iterations)
imagePrompt = blog.ImageGenerationPrompt ?? "Professional editorial photo: {blog.Title}"
BlogImageGenerationAgent.GenerateImageAsync(imagePrompt, blog.Slug)
→ ImageGenerationResult
for imgIteration = 1 to maxReviewIterations:
BlogReviewAgent.ReviewImageAsync(blog.Title, image)
→ BlogReviewResult { is_image_approved, image_feedback }
if is_image_approved: BREAK
if imgIteration < max:
enhancedPrompt = "{imagePrompt}\n\nAdditional requirements: {image_feedback}"
BlogImageGenerationAgent.GenerateImageAsync(enhancedPrompt, blog.Slug)
On image generation failure: CreatePlaceholderImage() — blog is never blocked
Output: OrchestratedBlogResult { blog, image, totalReviewIterations, reviewHistory, activityLog }3.2 Blog Edit Pipeline (BlogEditorOrchestrator)
A separate pipeline invoked via BlogAgent.EditBlogAsync(request) for editing existing blogs based on user comments.
BlogEditorOrchestrator.RunAsync(BlogEditRequest)
│
├─ Stage 1: BlogCommentAnalyserAgent.AnalyzeEditInstructionsAsync(existingBlog, editInstructions)
│ Classifies what kind of edit is needed
│ → BlogCommentAnalyserResult { contentEditNeeded, imageEditNeeded,
│ contentInstructions, imageInstructions, summary }
│ If neither needed: return immediately with original blog unchanged
│
├─ Stage 2 (if contentEditNeeded):
│ BlogEditorAgent.EditContentAsync(existingBlog, contentInstructions)
│ → BlogEditorResponse (revised blog JSON)
│ Review-revision loop (same max iterations as creation):
│ BlogReviewAgent.ReviewEditedBlog(blog, iteration)
│ → BlogReviewResult
│ if not approved: BlogEditorAgent.ReviseContentAsync(blog, instructions, reviewFeedback)
│
└─ Stage 3 (if imageEditNeeded, after content is finalised):
if request.ExistingImageBytes provided:
BlogImageGenerationAgent.GenerateImageWithReferenceAsync(prompt, slug, referenceImageBytes)
else:
BlogImageGenerationAgent.GenerateImageAsync(prompt, slug)
Image review loop (same pattern as creation pipeline)
Image failure does NOT block — edited blog is returned without new image
Output: OrchestratedBlogEditResult { editedBlog, image, reviewHistory, contentEdited, imageRegenerated, activityLog }4. Data Models
4.1 Input: BlogRequest
public class BlogRequest
{
public string RequestId { get; set; } // Unique request identifier
public string TenantId { get; set; } // Tenant for cache key scoping
public string Topic { get; set; } // Blog topic / title hint
public string PrimaryKeywords { get; set; } // Main SEO keywords
public string SecondaryKeywords { get; set; } // Supporting keywords
public string Description { get; set; } // Priority requirements (overrides best practices)
public string BlogContext { get; set; } // Tenant business context (injected into reviewer)
public string RequiredDate { get; set; } // Target publish date
public string BlogMonth { get; set; } // Publication month
public string RequestedBy { get; set; } // Requester name
public string CompanyName { get; set; } // Company name (for context file lookup)
public int? ImageWidth { get; set; } // Hero image width (default 1200)
public int? ImageHeight { get; set; } // Hero image height (default 400)
public string WebsiteUrl { get; set; } // Used for sitemap fetching
}4.2 Output: BlogResponse (LLM produces this as JSON)
public class BlogResponse
{
[JsonPropertyName("slug")]
public string Slug { get; set; } // kebab-case, keyword-rich URL slug
[JsonPropertyName("title")]
public string Title { get; set; } // SEO title, ~70 chars, 7-9 words
[JsonPropertyName("preview_content")]
public string PreviewContent { get; set; } // 40-60 word plain-text summary, no HTML
[JsonPropertyName("content")]
public string Content { get; set; } // Full HTML fragment (no html/head/body tags)
[JsonPropertyName("tags")]
public List<string> Tags { get; set; } // 4-6 tags
[JsonPropertyName("seo")]
public BlogSeo Seo { get; set; } // title (50-60 chars), description (140-150 chars), keywords
[JsonPropertyName("faq")]
public List<FaqItem> Faq { get; set; } // Exactly 10 Q&A items (35-45 words each)
[JsonPropertyName("image_generation_prompt")]
public string ImageGenerationPrompt { get; set; } // Detailed prompt passed to image agent
}4.3 Review: BlogReviewResult (LLM produces this as JSON)
public class BlogReviewResult
{
[JsonPropertyName("is_approved")]
public bool IsApproved { get; set; } // Exits review loop when true
[JsonPropertyName("review_summary")]
public string ReviewSummary { get; set; } // Human-readable summary
[JsonPropertyName("feedback_items")]
public List<string> FeedbackItems { get; set; } // Specific actionable items for writer
[JsonPropertyName("is_image_approved")]
public bool IsImageApproved { get; set; } // Used in image review loop
[JsonPropertyName("image_feedback")]
public string ImageFeedback { get; set; } // Injected into enhanced image prompt
[JsonPropertyName("iteration")]
public int Iteration { get; set; } // Set by orchestrator, not LLM
}4.4 Image: ImageGenerationResult
public class ImageGenerationResult
{
public byte[] ImageBytes { get; set; } // Raw image data
public string SuggestedFileName { get; set; } // e.g. "ai-ocr-ports-hero.jpg"
public string AltText { get; set; } // SEO alt text from slug
public string MimeType { get; set; } // "image/jpeg" or "image/png" (detected from magic bytes)
public long FileSizeBytes { get; set; }
public string PromptUsed { get; set; } // Audit trail of what was sent to Gemini
public bool IsPromptOnly { get; set; } // true = generation failed, only prompt stored
public string PromptOnlyReason { get; set; } // Explanation when IsPromptOnly is true
}4.5 Final Output: OrchestratedBlogResult
public class OrchestratedBlogResult
{
public BlogResponse Blog { get; set; }
public ImageGenerationResult Image { get; set; }
public int TotalReviewIterations { get; set; }
public List<BlogReviewResult> ReviewHistory { get; set; }
public List<ActivityLogEntry> ActivityLog { get; set; } // Timestamped step audit trail
}5. Prompt System
5.1 File Structure
All prompts are plain .txt files. The PromptBuilder loads them from disk on first use and caches them in a ConcurrentDictionary<string, string> (session lifetime, no expiry).
PromptFiles/
├── AgentInstructions/ ← System instructions (agent identity + rules)
│ ├── BlogWriterAgentInstructions.txt
│ ├── BlogReviewerAgentInstructions.txt
│ ├── BlogEditorAgentInstructions.txt
│ ├── BlogCommentAnalyserAgentInstructions.txt
│ └── BlogEditReviewerAgentInstructions.txt
├── BlogWriterAgentPrompt.txt ← User-turn prompt (per-request)
├── BlogReviewerAgentPrompt.txt ← Review turn prompt
├── BlogRevisionPrompt.txt ← Revision turn prompt
├── BlogEditorAgentPrompt.txt ← Editor turn prompt
├── BlogEditReviewerAgentPrompt.txt ← Edit review turn prompt
├── BlogCommentAnalyserAgentPrompt.txt ← Comment analysis prompt
├── BlogImageReviewerPrompt.txt ← Image review prompt
├── BlogBestPracticesPrompt.txt ← Injected into writer prompt
└── ImageCreationPrompt.txt ← Injected into writer prompt5.2 How Placeholders Work
PromptBuilder uses simple .Replace() on placeholder tokens. Two categories:
{{CamelCase}}— scalar field substitution (topic, keywords, etc.)[[Section_Name]]— block substitution (entire prompt sections, CSV tables, etc.)
User-controlled fields (topic, keywords, description, request ID) are sanitized via PromptSecurity.SanitizePromptInput() before injection. Section blocks (best practices, business context) are injected raw.
Sitemap URL injection pattern:
Sitemap URLs are formatted as a CSV with a derived title column:
URL,Title
https://example.com/blog/ai-marketing,Ai Marketing
https://example.com/services/seo,SeoTitle is extracted from the URL path: /blog/ai-marketing → Ai Marketing. This CSV replaces [[existing_links]] in both the writer instructions and reviewer instructions.
5.3 Security: PromptSecurity
Two sanitization methods applied before any user-supplied value touches a prompt:
PromptSecurity.SanitizePromptInput(input, maxLength = 500)
// Removes: IGNORE PREVIOUS, SYSTEM:, OVERRIDE, ASSISTANT:, [INST], <s>, </s>
// Truncates to maxLength
// Case-insensitive matching
PromptSecurity.SanitizeFileName(name)
// Removes: ../, ..\, path separators, invalid filename chars
// Prevents directory traversal when loading BusinessContext/{name}.txtApplied to: Topic, PrimaryKeywords, SecondaryKeywords, RequestId, TenantId, RequiredDate, BlogMonth, RequestedBy. Not applied to Description or BlogContext (these are trusted internal fields, not user input).
6. BlogWriterAgent — Full Detail
File: src/Leadmetrics.AI.Agents.Blog/Agents/BlogWriterAgent.cs
Configuration:
- Model:
claude-sonnet-4-6 - Temperature:
0.7 - MaxTokens:
18,000 - Provider: Anthropic
System instructions (BlogWriterAgentInstructions.txt) — key rules:
You are an expert SEO blog writer. You produce complete, structured blog posts
in valid JSON where the entire blog body is a single publication-ready HTML string.
HTML CONTENT RULES:
- Write the full blog in one "content" field as a valid HTML fragment (no <html>, <head>, or <body> tags)
- Use <p> for paragraphs, <h2> for main headings, <h3> for sub-headings
- Use <ul><li> for bullet lists
- Wrap ALL internal and external links as <a target="_blank" rel="noopener" href="URL"> tags
- Key Takeaways section: <h3>Key Takeaways</h3> followed by <ul><li> items, immediately after intro
- Never use em dashes, en dashes, or emojis
JSON ESCAPING RULES (critical for valid JSON):
- NEVER use straight double quotes inside the content field — use single quotes
- All HTML attributes use single quotes: <a href='URL'> not <a href="URL">
- Put the entire "content" field on ONE CONTINUOUS LINE — no line breaks
- Response must be ONLY the JSON object, starting with { and ending with }
FAQ RULES:
- Always generate EXACTLY 10 FAQ items
- Each answer: 35-45 words
- At least 1-2 internal links per 5 FAQs, linking to pages not already in the blog
- Links must be woven naturally into sentences, not standalone CTAs
EXISTING INTERNAL LINKS:
[[existing_links]]
Only use URLs from this list. Do not invent or modify URLs.
JSON structure:
{
"slug": "string",
"title": "string",
"preview_content": "string",
"content": "string",
"tags": ["string", ...],
"seo": { "title": "string", "description": "string", "keywords": "string" },
"faq": [{"question": "string", "answer": "string"}],
"image_generation_prompt": "string"
}Per-request user prompt (BlogWriterAgentPrompt.txt) — after substitution:
Instruction:
I want to create a blog post for the business for the blog title {Topic}.
First, read the BLOG DESCRIPTION below carefully - these requirements have PRIORITY
over all other instructions and must be followed strictly.
Then, learn about the business and their goals from the BUSINESS CONTEXT below.
Finally, study the blog writing best practices from the BLOG BEST PRACTICES below.
Generate a blog post using the primary keywords {PrimaryKeywords} and secondary
keywords {SecondaryKeywords} and try to create at least [4] internal links
from the blog post using the valid URLs provided in your instructions.
STRICTLY FOLLOW meta title in range of 50-60 characters and
meta description in range of 155-158 characters.
=============================
BLOG DESCRIPTION (PRIORITY)
=============================
{Description}
=============================
BUSINESS CONTEXT
=============================
{BlogContext}
=============================
BLOG BEST PRACTICES
=============================
{BlogBestPracticesPrompt.txt content}
=============================
IMAGE BEST PRACTICES
=============================
{ImageCreationPrompt.txt content}Retry loop (3 attempts):
for (int attempt = 1; attempt <= 3; attempt++)
{
// On retry, prepend: "[IMPORTANT: Previous response was invalid. Respond with ONLY valid JSON...]"
var raw = await RunAsync(prompt, ct, new AgentRunOptions {
ResponseFormat = ChatResponseFormat.ForJsonSchema<BlogResponse>() // JSON schema mode
});
var result = JsonParsingHelper.ParseWithStrategies<BlogResponse>(raw);
if (result != null) return result;
// Save failed JSON to DebugLogs/ folder for inspection
// Wait 2 seconds before retry
}
throw new InvalidOperationException("Failed after 3 attempts");JSON parsing strategies (applied in order):
- Direct
JsonSerializer.Deserialize<T>(raw) - Strip markdown fences (
```json→ empty) and retry - Extract outermost JSON object using UTF-8 reader (finds first
{to matching})
Revision prompt (BlogRevisionPrompt.txt):
You previously produced this blog post (JSON):
{{CurrentDraft}}
The reviewer (iteration {{Iteration}}) has NOT approved it.
Review summary: {{ReviewSummary}}
Specific feedback to address:
{{FeedbackList}} ← numbered list: "1. Feedback item\n2. ..."
Return a fully revised blog post in the same JSON structure.
Address every feedback point. Do not change the overall topic or primary keywords.
Respond with raw JSON only — no markdown, no preamble.7. BlogReviewAgent — Full Detail
File: src/Leadmetrics.AI.Agents.Blog/Agents/BlogReviewAgent.cs
Configuration:
- Model:
claude-sonnet-4-6 - Temperature:
0.7 - MaxTokens:
8,000 - Provider: Anthropic
System instructions (BlogReviewerAgentInstructions.txt) — full review criteria:
The reviewer applies a strict blocking/non-blocking distinction. It only fails the blog for blocking issues that meaningfully affect SEO, readability, or content quality.
Blocking (must fix — will set is_approved: false):
- Primary keyword missing from title entirely
- Primary keyword density outside 0.8–1.5%, secondary outside 0.3–0.8%
- Introduction doesn’t mention primary keyword in first 2 lines
- Key Takeaways section missing or not immediately after introduction
- Fewer than 3 H2 subheadings
- No internal links at all
- No external links at all
- SEO title or description missing/empty
- Fewer than 10 FAQ questions
- Em dashes, en dashes, or emojis present
contentfield contains no HTML (plain text only)image_generation_promptmissing or empty- Multiple links to same destination within same domain
- Links don’t include full domain
- Internal links not from the provided sitemap list
- Any mandatory requirements from the
Descriptionfield not followed
Non-blocking (mention in summary but do NOT fail):
- Title is 10 words instead of 9
- Introduction 75 words instead of 80
- Preview content 62 words instead of 60
- SEO title 48 chars instead of 50
- SEO description 145 chars instead of 150
- FAQ answer 46 words instead of 45
- Tags count 5 or 7 instead of 4-6
- Word count 1,280 or 1,520 instead of 1,300–1,500
Review prompt (BlogReviewerAgentPrompt.txt) — per iteration:
Review the following blog draft (iteration {{Iteration}}) against all criteria
and return your verdict as JSON.
=============================
BLOG DESCRIPTION (PRIORITY)
=============================
{{Description}}
=============================
BLOG DRAFT
=============================
{{BlogDraft}} ← full BlogResponse JSON, pretty-printed
Return raw JSON only - no markdown, no preamble.Response format:
{
"is_approved": true,
"review_summary": "One paragraph summary of quality and issues",
"feedback_items": ["Specific actionable instruction 1", "..."]
}Image review prompt (BlogImageReviewerPrompt.txt):
Review the hero image for the blog post titled "{{BlogTitle}}".
Assess the image on:
- Relevance: Does it clearly relate to the blog topic?
- Quality: Is it visually professional and sharp?
- Composition: Is the subject well-framed and not awkward?
- Tone: Does it match a professional B2B/editorial style?
- Text (if included): should be large enough to read on mobile and have no spelling mistakes.
Return raw JSON only:
{
"is_image_approved": true,
"image_feedback": ""
}Image review call:
- If
image.ImageBytesis non-empty andIsPromptOnly = false: callsRunWithImageAsync(prompt, image.ImageBytes, mimeType)— sends actual image bytes to the model as a multimodal message - If image bytes are empty (placeholder case): calls
RunAsync(prompt)— text-only fallback
JSON parsing for review responses (more lenient than writer):
Uses JsonDocument.Parse() with property-by-property extraction rather than full JsonSerializer.Deserialize<T>(). Handles edge cases:
is_approvedas boolean or string"true"/"false"review_summaryas string, number, or boolean (converts to string)feedback_itemsas array of strings (filters empty items)
On parse failure: returns a safe fallback result where IsApproved = false with a single feedback item noting the malformed response — which causes the orchestrator to trigger a revision.
8. BlogImageGenerationAgent — Full Detail
File: src/Leadmetrics.AI.Agents.Blog/Agents/BlogImageGenerationAgent.cs
Image provider: Google Gemini via IImageGenService (from Leadmetrics.Provider.GoogleAI)
GenerateImageAsync(imagePrompt, blogSlug):
for attempt = 1 to 3:
imageBytes = await _imageGenService.GenerateImageFromPromptAsync(imagePrompt)
→ Gemini outputs 1024x1024 JPEG/PNG by default
if imageBytes is null or empty:
throw InvalidOperationException (triggers retry)
(extension, mimeType) = DetectFormat(imageBytes)
→ Reads magic bytes:
JPEG: starts with 0xFF 0xD8
PNG: starts with 0x89 0x50 0x4E 0x47
fileName = "{blogSlug}-hero.{extension}"
altText = "Hero image for blog post: {blogSlug with hyphens→spaces}"
resizedBytes = Resize(imageBytes, targetWidth=1200, targetHeight=400, mimeType)
→ Uses ImageSharp: ResizeMode.Crop (centered), maintains quality
return ImageGenerationResult {
ImageBytes, SuggestedFileName, AltText, MimeType,
FileSizeBytes, PromptUsed, IsPromptOnly: false
}
on exception (any):
delay = 2^(attempt-1) seconds → 1s, 2s, 4s
continue retry
throw InvalidOperationException("Failed after 3 attempts")Orchestrator error handling for image failures:
The orchestrator catches HttpRequestException, InvalidOperationException, IOException, OperationCanceledException, and any other non-fatal exception. On failure it creates a placeholder:
new ImageGenerationResult {
AltText = blogTitle,
SuggestedFileName = $"{blogSlug}-hero.jpg",
PromptUsed = imagePrompt,
IsPromptOnly = true // caller knows no bytes are available
}This placeholder is returned in OrchestratedBlogResult.Image so the caller can store the prompt and generate the image manually later. Blog delivery is never blocked by image generation failure.
GenerateImageWithReferenceAsync(imagePrompt, blogSlug, referenceImageBytes):
Same retry pattern, but calls _imageGenService.GenerateImageWithReferencesAsync(prompt, new List<byte[]> { referenceImageBytes }). Used in the edit pipeline when the user provides an existing hero image as a style reference.
Image generation prompt (constructed by BlogWriterAgent, from image_generation_prompt field):
The writer LLM produces this prompt as part of its JSON output, guided by the ImageCreationPrompt.txt best practices file, which specifies:
- Ultra-wide 21:9 aspect ratio composition, centered so 1200×400 crop keeps main subject
- File size <150 KB preferred (WebP/PNG/JPEG)
- Must relate to the blog topic — not generic stock imagery
- If text is included: large enough to read on mobile, no spelling mistakes
- No em dashes, consistent brand style
9. BlogEditorAgent and BlogCommentAnalyserAgent
9.1 BlogCommentAnalyserAgent
Runs as Stage 1 of the edit pipeline. Reads the existing blog (as JSON) and the user’s edit instructions, then classifies what kind of edit is needed.
Response shape:
{
"contentEditNeeded": true,
"imageEditNeeded": false,
"contentInstructions": "Update the introduction to mention the new product launch",
"imageInstructions": "",
"summary": "User wants content changes to the intro only"
}On parse failure or exception: defaults to contentEditNeeded: true, imageEditNeeded: false — always attempts a content edit as a safe fallback.
9.2 BlogEditorAgent
Receives the existing BlogEditorResponse (full JSON) plus contentInstructions, applies the edits, and returns a revised BlogEditorResponse. Same 3-attempt retry loop with JSON schema mode as BlogWriterAgent. On revision, combines original instructions with numbered review feedback into a single revisedInstructions string.
10. Configuration
AgentConfiguration (loaded from appsettings.json + env vars):
{
"Agent": {
"Name": "BlogWriter",
"DeploymentName": "gpt-4o-mini",
"ApiKey": "...",
"Model": "gemini-2.0-flash-exp",
"MaxTokens": 8000,
"Temperature": 0.7,
"SkillsPath": "skills",
"MaxReviewIterations": 3,
"ImageDimensions": { "Width": 1200, "Height": 400 }
}
}MaxReviewIterations (default 3) controls both the content review loop and the image review loop in both orchestrators.
Required environment variables:
| Variable | Used for |
|---|---|
ANTHROPIC_API_KEY | Claude calls (writer, reviewer, editor, analyser) |
GOOGLE_AI_API_KEY | Gemini image generation |
AZURE_OPENAI_ENDPOINT + AZURE_OPENAI_API_KEY | Optional: Azure-hosted OpenAI models |
OPENAI_API_KEY | Optional: Direct OpenAI models |
11. Reliability Patterns
HTTP Retry (Polly, in BaseAgent)
All LLM HTTP calls are wrapped in a Polly pipeline:
- 3 retry attempts
- Exponential backoff:
2^attemptseconds (2s, 4s, 8s) - Retried exceptions:
HttpRequestException,TaskCanceledException,TimeoutException
Timeout Protection (BaseAgent)
- Default per-request timeout: 12 minutes
- HTTP client timeout: 10 minutes (leaves margin for retries)
- Both timeouts are linked via
CancellationTokenSource.CreateLinkedTokenSource
JSON Parsing (JsonParsingHelper)
Three strategies applied in sequence on every LLM response:
- Direct
JsonSerializer.Deserialize<T>(raw)— fastest path - Strip markdown fences (e.g.
```json\n...\n```) and retry deserialization - Walk UTF-8 reader to find the outermost
{...}object and extract it
Before any strategy, the raw string is pre-processed to normalize unescaped newlines inside JSON strings — a common failure mode where models include literal \n in string values.
Activity Log
Every orchestration step is appended to List<ActivityLogEntry> with a timestamp, description, stage name, and status string (Started, Completed, Failed, Approved, Skipped). This log is returned in the final result for full auditability. Example entries:
[Orchestration Started]
[Blog creation started]
[Blog creation completed]
[Review iteration 1 started]
[Review iteration 1 failed: {summary}]
[Blog revision started (iteration 1)]
[Blog revision completed (iteration 1)]
[Review iteration 2 approved]
[Image generation completed (142,500 bytes)]
[Image review iteration 1 approved]
[Orchestration completed (BlogIterations: 2, BlogApproved: true, ImageApproved: true)]12. Blog Best Practices (Injected into Writer Prompt)
The full BlogBestPracticesPrompt.txt is injected into every writer call. Key rules enforced:
Structure:
- Title: 70 chars max, 7-9 words, must include primary keyword
- Introduction: 80-100 words, primary keyword in first 2 lines
- Key Takeaways: 2-3 bullets immediately after intro, before body
- Body: 3-5 H2 subheadings, paragraphs 2-4 sentences each
- Conclusion: 80-100 words, reuse primary keyword, include CTA with
<a>tag - Total word count (excluding FAQ): 1,300-1,500 words
SEO:
- Meta title: 50-60 chars, includes primary keyword
- Meta description: 140-150 chars, primary keyword at the start
- Primary keyword density: 0.8-1.5%
- Secondary keyword density: 0.3-0.8% each
- Internal links: 2-5 per 1,000 words; at least one anchored on primary keyword; distributed across intro, body, conclusion
- External links: 1-2 per 1,000 words; authoritative sources only
Tone: Professional, approachable, informative; active voice; sentences under 20 words average; no jargon unless technical audience.
Strict prohibitions: No emojis. No em dashes. No en dashes. No hyphen-connected clauses.
FAQ: Exactly 10 items. Each answer 35-45 words. Answers may contain <a> tags. At least 1-2 internal links per 5 FAQs. Links must be natural within the sentence — not standalone CTAs.
13. Comparison: C# Reference vs TypeScript Implementation
Architecture
| C# Reference | TypeScript (Ours) | |
|---|---|---|
| Pattern | Sequential in-process orchestration | Queue-based (BullMQ/Redis), separate workers |
| Entry point | Single BlogAgent.RunBlogPipelineAsync() | Multiple independent queue workers |
| Coordination | Orchestrator manages all stages internally | Dependency resolver enqueues downstream workers |
| Concurrency | Polly in-process retry | BullMQ handles retries, stalled jobs, lock renewal |
| Timeout | 12-minute per-request timeout (Polly) | 10-minute Claude adapter timeout, 12-minute BullMQ lock |
Blog Writing
| Dimension | C# Reference | TypeScript (Ours) |
|---|---|---|
| LLM | Claude Sonnet 4.6 | Claude Sonnet 4.6 (configurable via AgentConfig DB row) |
| Output format | Structured JSON (JSON schema mode) | Markdown (fields extracted by regex) |
| Title | LLM-generated, in JSON | Extracted from first # H1 |
| Slug | LLM-generated, in JSON | Programmatically derived |
| Preview/summary | preview_content field | Not generated |
| Content format | HTML fragment | Markdown |
| Tags | LLM-generated array | Not generated |
| SEO title | LLM-generated, 50-60 chars | Regex-extracted |
| SEO description | LLM-generated, primary kw at start | Regex-extracted |
| FAQ | Same LLM call, 10 items, 35-45 words each | Separate blog-faq-writer worker (Haiku model) |
| Image prompt | LLM-generated field | Not generated |
| Internal links | Live sitemap (Redis-cached 24h, up to 50 URLs) | 20 recent approved BlogPosts + 10 LandingPages from DB |
| Prompt injection protection | PromptSecurity.SanitizePromptInput() on all user fields | None |
| JSON retry | 3 attempts, 2s delay, debug file saved | Not applicable (Markdown output) |
AI Self-Review Loop — Most Significant Gap
| C# Reference | TypeScript (Ours) | |
|---|---|---|
| Review agent | Dedicated Claude Sonnet call, structured JSON output | None — human DM/client reviews |
| Loop iterations | Up to 3 (configurable), fully automated | 1 per human rejection |
| Feedback injection | BlogRevisionPrompt.txt with numbered feedback list | REVISION INSTRUCTIONS section appended to prompt |
| Blocking vs non-blocking | Strict distinction; minor deviations don’t fail | Not applicable |
| Quality gate timing | Before any human sees the draft | Human sees first draft |
Image Generation
| C# Reference | TypeScript (Ours) | |
|---|---|---|
| Blog hero image | Google Gemini, part of blog pipeline | Not implemented |
| Social images | Not implemented | Azure OpenAI GPT Image 1.5 (social-post-designer worker) |
| Image prompt source | image_generation_prompt field from writer LLM | Per-slide prompt built in worker |
| Dimensions | 1200×400 (configurable), ImageSharp resize | Platform-specific, Sharp composite |
| AI image review | Yes — up to 3 iterations with multimodal review | None |
| Failure handling | Placeholder (IsPromptOnly: true), blog not blocked | Slide skipped, partial success |
| Image retry | 3 attempts, exponential backoff (1s, 2s, 4s) | Per-slide error handling |
What TypeScript Does Better
The C# reference has no equivalents for:
- Design intelligence — rejection history, past approved styles, competitor visual differentiation, engagement bias injected into social image prompts
- Brand voice scoring — non-blocking 0–100 brand consistency score with issue breakdown (Haiku model)
- RAG / knowledge base — knowledge base searched per activity, relevant chunks injected
- Credit system — full reserve/consume/release lifecycle around every LLM call
- BullMQ resilience — stalled job recovery, configurable lock duration and renewal
- Dependency resolution — downstream workers automatically unblocked after each stage completes
- Multi-tenant isolation — every DB and queue operation is scoped to
tenantId - AgentConfig DB row — model and system prompt per agent are configurable in the database without code changes
14. Gap Summary
| Gap | Effort | Impact | Notes |
|---|---|---|---|
| AI self-review loop on blog drafts | High | High | Catches blocking quality issues before human review |
| Blog hero image generation | Medium | High | Blogs delivered without images currently |
| AI image review loop | Medium | Medium | Requires multimodal Claude or separate review model |
| Structured JSON output from writer (HTML + all fields in one call) | Low | Medium | More reliable than regex; unlocks preview_content, tags, image_generation_prompt |
| Live sitemap injection instead of DB-only pages | Low | Low–Medium | Covers static/product pages not tracked in DB |
| Prompt injection sanitization on user-controlled fields | Low | Medium | Security hardening before TypeScript blog pipeline is customer-exposed |
15. Reference Files
| Path | Purpose |
|---|---|
references/agents/src/Leadmetrics.AI.Agents.Blog/BlogAgent.cs | Pipeline entry point, sitemap caching |
references/agents/src/Leadmetrics.AI.Agents.Blog/Orchestration/BlogAgentOrchestrator.cs | Creation pipeline orchestration |
references/agents/src/Leadmetrics.AI.Agents.Blog/Orchestration/BlogEditorOrchestrator.cs | Edit pipeline orchestration |
references/agents/src/Leadmetrics.AI.Agents.Blog/Agents/BlogWriterAgent.cs | Writer: retry loop, JSON schema mode |
references/agents/src/Leadmetrics.AI.Agents.Blog/Agents/BlogReviewAgent.cs | Reviewer: content + image review, lenient JSON parsing |
references/agents/src/Leadmetrics.AI.Agents.Blog/Agents/BlogImageGenerationAgent.cs | Gemini image gen, magic-byte detection, ImageSharp resize |
references/agents/src/Leadmetrics.AI.Agents.Blog/Agents/BlogEditorAgent.cs | Editor agent |
references/agents/src/Leadmetrics.AI.Agents.Blog/Agents/BlogCommentAnalyserAgent.cs | Edit intent classifier |
references/agents/src/Leadmetrics.AI.Agents.Blog/PromptBuilder.cs | Template loading, placeholder replacement, path traversal protection |
references/agents/src/Leadmetrics.AI.Agents.Blog/PromptFiles/AgentInstructions/BlogWriterAgentInstructions.txt | Full writer system prompt |
references/agents/src/Leadmetrics.AI.Agents.Blog/PromptFiles/AgentInstructions/BlogReviewerAgentInstructions.txt | Full reviewer system prompt with blocking/non-blocking criteria |
references/agents/src/Leadmetrics.AI.Agents.Blog/PromptFiles/BlogWriterAgentPrompt.txt | Writer user-turn template |
references/agents/src/Leadmetrics.AI.Agents.Blog/PromptFiles/BlogReviewerAgentPrompt.txt | Review turn template |
references/agents/src/Leadmetrics.AI.Agents.Blog/PromptFiles/BlogRevisionPrompt.txt | Revision turn template |
references/agents/src/Leadmetrics.AI.Agents.Blog/PromptFiles/BlogImageReviewerPrompt.txt | Image review turn template |
references/agents/src/Leadmetrics.AI.Agents.Blog/PromptFiles/BlogBestPracticesPrompt.txt | Full SEO + content best practices |
references/agents/src/Leadmetrics.AI.Agents.Blog/PromptFiles/ImageCreationPrompt.txt | Image generation guidelines |
references/agents/src/Leadmetrics.AI.Agents.Blog/Models/ | All input/output data models |
references/agents/src/Leadmetrics.AI.Agents.Blog/Constants/ | Per-agent temperature and MaxTokens |
packages/agents/src/workers/blog-writer.worker.ts | TypeScript blog writer |
packages/agents/src/workers/blog-faq-writer.worker.ts | TypeScript FAQ worker |
packages/agents/src/workers/social-post-designer.worker.ts | TypeScript social image worker |