Current Workflow Structure
Baseline architecture before any upgrades are applied.
Campaign Trigger (Webhook POST /campaign-orchestrator)
└─► Extract Campaign Data (Set node — normalizes fields)
└─► Porter Strategy (Anthropic — Claude 3.5 Sonnet)
├─► Ogilvy Copy (Anthropic)
├─► Warhol Creative (Anthropic)
├─► Vee Social (Anthropic)
├─► Burnett Ads (Anthropic)
└─► Merge All Outputs (5-input Merge/append)
└─► Hemingway QA (Anthropic — 6-point QA + 35-pt Conversion Score)
└─► Compile Final Output (Set node — assembles package)
└─► Save to Supabase (table: campaign_orchestrations)
└─► Telegram Notification
Input Fields at Extract Campaign Data
$nowHemingway QA Scoring System
6-Point Checklist: Brand Voice, Accuracy, Formatting, CTA Clarity, Completeness, Conversion Effectiveness
Conversion Score: 7 dimensions × 5 pts = 35 pts total
- 30–35: Ship
- 25–29: Minor fixes
- 20–24: Revision required
- <20: Rewrite / Escalate
LLM-as-Judge Retry Loop
Enforce QA verdicts structurally — not just advisorily.
Problem Being Solved
Currently, Hemingway QA runs once and its output flows directly to Compile Final Output regardless of what verdict it returns. If Hemingway scores the package at 18/35 (Rewrite) or flags REJECT on a critical lens, the system still compiles and saves.
The QA score is advisory, not structural. There is no enforcement mechanism.
Design: Where the Retry Loop Goes
The retry loop inserts two new nodes between Hemingway QA and Compile Final Output:
Hemingway QA
└─► [NEW] QA Score Parser (Code node)
├─► IF score >= 25 AND status == APPROVED → Compile Final Output (existing path)
└─► IF score < 25 OR status == REVISE/REJECT → [NEW] Revision Dispatcher (Code node)
├─► Re-runs failing agent(s) with injected QA critique
└─► Loops back to Merge All Outputs → Hemingway QA (up to N retries)
└─► After max retries → Compile Final Output with qa_escalated: true flag
Node 1: QA Score Parser (Code — JavaScript)
Parses Hemingway's raw text output to extract structured judgment fields. Position: canvas [1232, 200] — immediately after Hemingway QA.
const qaText = $('Hemingway QA').item.json.content;
// Extract OVERALL QUALITY SCORE (1-10)
const scoreMatch = qaText.match(/OVERALL QUALITY SCORE[:\s]+(\d+)/i);
const qualityScore = scoreMatch ? parseInt(scoreMatch[1]) : 0;
// Extract APPROVAL STATUS
const statusMatch = qaText.match(/APPROVAL STATUS[:\s]+(APPROVED|REVISE|REJECT)/i);
const approvalStatus = statusMatch ? statusMatch[1] : 'REJECT';
// Extract Conversion Score (X/35)
const conversionMatch = qaText.match(/Conversion Score[:\s]+(\d+)\/35/i);
const conversionScore = conversionMatch ? parseInt(conversionMatch[1]) : 0;
// Extract which agents were flagged for revision
const failingAgents = [];
if (qaText.includes('Porter') && qaText.toLowerCase().includes('revise')) failingAgents.push('porter');
if (qaText.includes('Ogilvy') && qaText.toLowerCase().includes('revise')) failingAgents.push('ogilvy');
if (qaText.includes('Warhol') && qaText.toLowerCase().includes('revise')) failingAgents.push('warhol');
if (qaText.includes('Vee') && qaText.toLowerCase().includes('revise')) failingAgents.push('vee');
if (qaText.includes('Burnett') && qaText.toLowerCase().includes('revise')) failingAgents.push('burnett');
// Retry counter
const retryCount = ($('QA Score Parser').item?.json?.retryCount ?? 0);
return [{ json: {
qualityScore,
approvalStatus,
conversionScore,
failingAgents,
passes: approvalStatus === 'APPROVED' && conversionScore >= 25,
retryCount,
qaFeedback: qaText,
...($('Hemingway QA').item.json)
}}];
Output Fields
Node 2: Retry Gate (IF node)
Position: [1344, 200] — immediately after QA Score Parser.
Condition Logic:
IF ($json.passes === true) OR ($json.retryCount >= 2)
- TRUE branch → Compile Final Output (proceed normally)
- FALSE branch → Revision Dispatcher (re-run failing agents)
Max 2 retries prevents infinite loops. After 2 cycles, compile with qa_escalated: true flag and send a distinct Telegram alert.
Node 3: Revision Dispatcher (Code — JavaScript)
On the FALSE branch of Retry Gate. Re-invokes only the specific failing agents (not all agents) with QA critique injected as context.
Revised Prompt Injection Pattern
REVISION CONTEXT — Round {{ retryCount + 1 }}:
Your previous output was reviewed by Hemingway QA and requires revision.
QA CRITIQUE:
{{ qaFeedback }}
FAILING LENS(ES): {{ failingAgents.join(', ') }}
---
ORIGINAL BRIEF:
{{ campaignBrief }}
[... rest of original prompt ...]
INSTRUCTION: Address every issue flagged by QA above. Do not repeat
previous output — produce a revised version that resolves each cited problem.
Loop-Back Path
Revision Dispatcher → (re-runs failing agent nodes with augmented prompts) → Merge All Outputs (only new outputs replace old) → Hemingway QA (re-evaluates) → QA Score Parser (retryCount incremented) → Retry Gate (checks passes OR retryCount >= 2)
Escalation Handling
When retryCount >= 2 and passes === false, Compile Final Output receives:
{
"qa_escalated": true,
"qa_escalation_reason": "Failed to reach score ≥25/35 after 2 retry cycles",
"final_conversion_score": 18,
"final_approval_status": "REJECT"
}
Telegram update needed: Detect qa_escalated: true and send: "⚠️ Campaign package escalated — QA score below threshold after 2 retries. Manual review required."
Canvas Positioning Summary
The existing Hemingway QA → Compile Final Output direct connection is severed and replaced with Hemingway QA → QA Score Parser → Retry Gate → [branch].
Intent-Based Routing
Stop running all 5 agents on every request — route by campaign intent.
Problem Being Solved
Currently, every request hits the same 5-agent parallel pipeline regardless of campaign type. This is wasteful:
- A research run doesn't need Burnett to write ad copy
- An email campaign doesn't need Warhol for creative direction
- An SEO brief doesn't need Vee Social for Instagram captions
Routing by intent saves tokens, reduces latency, and improves quality by eliminating off-topic agent noise.
Design: Where the Intent Classifier Goes
Extract Campaign Data
└─► [NEW] Intent Classifier (Anthropic — claude-3-5-haiku, fast + cheap)
└─► [NEW] Intent Router (Switch node)
├─► social_content → Social Content Sub-Workflow
├─► email_campaign → Email Campaign Sub-Workflow
├─► ad_creative → Ad Creative Sub-Workflow
├─► seo_brief → SEO Brief Sub-Workflow
├─► research_run → Research Run Sub-Workflow
└─► general → Existing Full Pipeline (fallback)
Node 1: Intent Classifier (Anthropic — Haiku)
Model: claude-3-5-haiku-20241022 — classification call, not generative. Fast and cheap (~$0.0002/call).
Canvas position: [336, 384] — between Extract Campaign Data and Porter Strategy.
System Prompt
You are a campaign intent classifier. Your only job is to read a campaign brief and return a single classification label. Return ONLY one of these exact strings — nothing else: - social_content - email_campaign - ad_creative - seo_brief - research_run - general Classification rules: - social_content: Social media posts, platform content, organic social strategy, LinkedIn/Instagram/Facebook/TikTok content creation - email_campaign: Email newsletters, drip sequences, nurture flows, subject lines, email copy - ad_creative: Paid media — Facebook Ads, Google Ads, display creative, ad copy, landing page copy for ad campaigns - seo_brief: Blog posts, article writing, keyword strategy, content for search, organic search optimization - research_run: Market research, competitor analysis, audience analysis, industry reports, discovery work (no content deliverables) - general: Spans multiple channels, full campaign packages, or doesn't clearly fit one of the above Do not explain your reasoning. Return only the label.
Alternative: Code-Node Regex Fallback (faster / cheaper)
const brief = $json.campaignBrief.toLowerCase(); if (/\b(instagram|linkedin|facebook|tiktok|social|post|reel|story)\b/.test(brief)) return 'social_content'; if (/\b(email|newsletter|drip|nurture|subject line|open rate)\b/.test(brief)) return 'email_campaign'; if (/\b(ad|ads|paid|ppc|google ads|facebook ads|meta ads|cpc|cpm|landing page)\b/.test(brief)) return 'ad_creative'; if (/\b(seo|blog|article|keyword|search|rank|organic|serp)\b/.test(brief)) return 'seo_brief'; if (/\b(research|analyze|competitor|market|discovery|report|audit)\b/.test(brief)) return 'research_run'; return 'general';
Recommendation: Use LLM classifier as primary; add Code fallback if LLM call times out.
Node 2: Intent Router (Switch node)
Canvas position: [448, 384] — Porter Strategy shifts right. Match expression: {{ $('Intent Classifier').item.json.content.trim().toLowerCase() }}
Intent-to-Agent Mapping
Implementation Options
Standardized Sub-Workflow Output Contract
Every sub-workflow must return this JSON structure so the main workflow's terminal nodes work unchanged:
{
"campaign_id": "uuid",
"client_name": "string",
"original_brief": "string",
"intent": "social_content | email_campaign | ad_creative | seo_brief | research_run | general",
"porter_strategy": "string | null",
"ogilvy_copy": "string | null",
"warhol_creative": "string | null",
"vee_social": "string | null",
"burnett_advertising": "string | null",
"hemingway_qa": "string",
"qa_score": "number",
"qa_status": "APPROVED | REVISE | REJECT",
"created_at": "ISO8601 timestamp",
"budget": "number"
}
Fields not produced by a given intent are set to null — Supabase handles nullable columns.
Combined Future State
Both upgrades applied — full target architecture.
Campaign Trigger (Webhook POST)
└─► Extract Campaign Data
└─► [NEW] Intent Classifier (Haiku)
└─► [NEW] Intent Router (Switch)
├─► social_content → Execute: Social Content Sub-Workflow
│ (Porter → Vee → QA Score Parser → Retry Gate ↺)
├─► email_campaign → Execute: Email Campaign Sub-Workflow
│ (Porter → Ogilvy[email] → QA Score Parser → Retry Gate ↺)
├─► ad_creative → Execute: Ad Creative Sub-Workflow
│ (Porter → Ogilvy → Warhol → Burnett → QA Score Parser → Retry Gate ↺)
├─► seo_brief → Execute: SEO Brief Sub-Workflow
│ (Porter → Ogilvy[seo] → QA Score Parser → Retry Gate ↺)
├─► research_run → Execute: Research Sub-Workflow
│ (Porter[research] → QA Score Parser → Retry Gate ↺)
└─► general → Existing Full Pipeline
(Porter → All 4 Agents → Merge → Hemingway QA → QA Score Parser → Retry Gate ↺)
↓
Compile Final Output
↓
Save to Supabase
↓
Telegram Notification
Implementation Sequence
When Robert gives the go-ahead — build in this order.
social_content (most common) and general (existing pipeline extracts cleanly).qa_escalated flag and intent field for context-aware alerts.⛔ Do NOT implement without Robert's explicit go-ahead on each step.
Open Questions for Robert
Decisions needed before implementation begins.
2 as the default. More retries = higher quality floor, but also higher latency and token cost. Could make this configurable via the webhook payload (max_retries: 3).null? The hybrid approach saves cost at scale — regex covers ~80% of cases.Execute Workflow? Standalone is more flexible for direct testing. Execute-only keeps the entry point cleaner.