Skip to Content
AgentsExisting CodeBlog Agent Reference: C# Implementation vs TypeScript

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.AI 1.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.ImageSharp 3.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

AgentClassModelTemperatureMaxTokensPurpose
BlogWriterAgentBlogWriterAgent.csClaude Sonnet 4.60.718,000Initial draft + revision
BlogReviewAgentBlogReviewAgent.csClaude Sonnet 4.60.78,000Quality review + image review
BlogImageGenerationAgentBlogImageGenerationAgent.csGoogle Geminin/an/aHero image generation
BlogEditorAgentBlogEditorAgent.csClaude Sonnet 4.6(config)(config)Edit existing blog
BlogCommentAnalyserAgentBlogCommentAnalyserAgent.csClaude 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 prompt

5.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,Seo

Title is extracted from the URL path: /blog/ai-marketingAi 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}.txt

Applied 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):

  1. Direct JsonSerializer.Deserialize<T>(raw)
  2. Strip markdown fences (```json → empty) and retry
  3. 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
  • content field contains no HTML (plain text only)
  • image_generation_prompt missing 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 Description field 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.ImageBytes is non-empty and IsPromptOnly = false: calls RunWithImageAsync(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_approved as boolean or string "true"/"false"
  • review_summary as string, number, or boolean (converts to string)
  • feedback_items as 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:

VariableUsed for
ANTHROPIC_API_KEYClaude calls (writer, reviewer, editor, analyser)
GOOGLE_AI_API_KEYGemini image generation
AZURE_OPENAI_ENDPOINT + AZURE_OPENAI_API_KEYOptional: Azure-hosted OpenAI models
OPENAI_API_KEYOptional: 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^attempt seconds (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:

  1. Direct JsonSerializer.Deserialize<T>(raw) — fastest path
  2. Strip markdown fences (e.g. ```json\n...\n```) and retry deserialization
  3. 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# ReferenceTypeScript (Ours)
PatternSequential in-process orchestrationQueue-based (BullMQ/Redis), separate workers
Entry pointSingle BlogAgent.RunBlogPipelineAsync()Multiple independent queue workers
CoordinationOrchestrator manages all stages internallyDependency resolver enqueues downstream workers
ConcurrencyPolly in-process retryBullMQ handles retries, stalled jobs, lock renewal
Timeout12-minute per-request timeout (Polly)10-minute Claude adapter timeout, 12-minute BullMQ lock

Blog Writing

DimensionC# ReferenceTypeScript (Ours)
LLMClaude Sonnet 4.6Claude Sonnet 4.6 (configurable via AgentConfig DB row)
Output formatStructured JSON (JSON schema mode)Markdown (fields extracted by regex)
TitleLLM-generated, in JSONExtracted from first # H1
SlugLLM-generated, in JSONProgrammatically derived
Preview/summarypreview_content fieldNot generated
Content formatHTML fragmentMarkdown
TagsLLM-generated arrayNot generated
SEO titleLLM-generated, 50-60 charsRegex-extracted
SEO descriptionLLM-generated, primary kw at startRegex-extracted
FAQSame LLM call, 10 items, 35-45 words eachSeparate blog-faq-writer worker (Haiku model)
Image promptLLM-generated fieldNot generated
Internal linksLive sitemap (Redis-cached 24h, up to 50 URLs)20 recent approved BlogPosts + 10 LandingPages from DB
Prompt injection protectionPromptSecurity.SanitizePromptInput() on all user fieldsNone
JSON retry3 attempts, 2s delay, debug file savedNot applicable (Markdown output)

AI Self-Review Loop — Most Significant Gap

C# ReferenceTypeScript (Ours)
Review agentDedicated Claude Sonnet call, structured JSON outputNone — human DM/client reviews
Loop iterationsUp to 3 (configurable), fully automated1 per human rejection
Feedback injectionBlogRevisionPrompt.txt with numbered feedback listREVISION INSTRUCTIONS section appended to prompt
Blocking vs non-blockingStrict distinction; minor deviations don’t failNot applicable
Quality gate timingBefore any human sees the draftHuman sees first draft

Image Generation

C# ReferenceTypeScript (Ours)
Blog hero imageGoogle Gemini, part of blog pipelineNot implemented
Social imagesNot implementedAzure OpenAI GPT Image 1.5 (social-post-designer worker)
Image prompt sourceimage_generation_prompt field from writer LLMPer-slide prompt built in worker
Dimensions1200×400 (configurable), ImageSharp resizePlatform-specific, Sharp composite
AI image reviewYes — up to 3 iterations with multimodal reviewNone
Failure handlingPlaceholder (IsPromptOnly: true), blog not blockedSlide skipped, partial success
Image retry3 attempts, exponential backoff (1s, 2s, 4s)Per-slide error handling

What TypeScript Does Better

The C# reference has no equivalents for:

  1. Design intelligence — rejection history, past approved styles, competitor visual differentiation, engagement bias injected into social image prompts
  2. Brand voice scoring — non-blocking 0–100 brand consistency score with issue breakdown (Haiku model)
  3. RAG / knowledge base — knowledge base searched per activity, relevant chunks injected
  4. Credit system — full reserve/consume/release lifecycle around every LLM call
  5. BullMQ resilience — stalled job recovery, configurable lock duration and renewal
  6. Dependency resolution — downstream workers automatically unblocked after each stage completes
  7. Multi-tenant isolation — every DB and queue operation is scoped to tenantId
  8. AgentConfig DB row — model and system prompt per agent are configurable in the database without code changes

14. Gap Summary

GapEffortImpactNotes
AI self-review loop on blog draftsHighHighCatches blocking quality issues before human review
Blog hero image generationMediumHighBlogs delivered without images currently
AI image review loopMediumMediumRequires multimodal Claude or separate review model
Structured JSON output from writer (HTML + all fields in one call)LowMediumMore reliable than regex; unlocks preview_content, tags, image_generation_prompt
Live sitemap injection instead of DB-only pagesLowLow–MediumCovers static/product pages not tracked in DB
Prompt injection sanitization on user-controlled fieldsLowMediumSecurity hardening before TypeScript blog pipeline is customer-exposed

15. Reference Files

PathPurpose
references/agents/src/Leadmetrics.AI.Agents.Blog/BlogAgent.csPipeline entry point, sitemap caching
references/agents/src/Leadmetrics.AI.Agents.Blog/Orchestration/BlogAgentOrchestrator.csCreation pipeline orchestration
references/agents/src/Leadmetrics.AI.Agents.Blog/Orchestration/BlogEditorOrchestrator.csEdit pipeline orchestration
references/agents/src/Leadmetrics.AI.Agents.Blog/Agents/BlogWriterAgent.csWriter: retry loop, JSON schema mode
references/agents/src/Leadmetrics.AI.Agents.Blog/Agents/BlogReviewAgent.csReviewer: content + image review, lenient JSON parsing
references/agents/src/Leadmetrics.AI.Agents.Blog/Agents/BlogImageGenerationAgent.csGemini image gen, magic-byte detection, ImageSharp resize
references/agents/src/Leadmetrics.AI.Agents.Blog/Agents/BlogEditorAgent.csEditor agent
references/agents/src/Leadmetrics.AI.Agents.Blog/Agents/BlogCommentAnalyserAgent.csEdit intent classifier
references/agents/src/Leadmetrics.AI.Agents.Blog/PromptBuilder.csTemplate loading, placeholder replacement, path traversal protection
references/agents/src/Leadmetrics.AI.Agents.Blog/PromptFiles/AgentInstructions/BlogWriterAgentInstructions.txtFull writer system prompt
references/agents/src/Leadmetrics.AI.Agents.Blog/PromptFiles/AgentInstructions/BlogReviewerAgentInstructions.txtFull reviewer system prompt with blocking/non-blocking criteria
references/agents/src/Leadmetrics.AI.Agents.Blog/PromptFiles/BlogWriterAgentPrompt.txtWriter user-turn template
references/agents/src/Leadmetrics.AI.Agents.Blog/PromptFiles/BlogReviewerAgentPrompt.txtReview turn template
references/agents/src/Leadmetrics.AI.Agents.Blog/PromptFiles/BlogRevisionPrompt.txtRevision turn template
references/agents/src/Leadmetrics.AI.Agents.Blog/PromptFiles/BlogImageReviewerPrompt.txtImage review turn template
references/agents/src/Leadmetrics.AI.Agents.Blog/PromptFiles/BlogBestPracticesPrompt.txtFull SEO + content best practices
references/agents/src/Leadmetrics.AI.Agents.Blog/PromptFiles/ImageCreationPrompt.txtImage 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.tsTypeScript blog writer
packages/agents/src/workers/blog-faq-writer.worker.tsTypeScript FAQ worker
packages/agents/src/workers/social-post-designer.worker.tsTypeScript social image worker

© 2026 Leadmetrics — Internal use only