Marketing Content Agent: Built-In Brand Compliance

What This Agent Does

This agent automates a complete marketing content pipeline using four specialized AI roles working in sequence. A Campaign Planner takes a product name, description, and target audience and produces a structured brief. A Researcher builds background context on the audience. A Writer drafts three content formats: a marketing email, a blog post, and ad copy. An Editor reviews every draft against a configurable list of forbidden words and a required tone descriptor loaded from a settings file. If a draft fails, Python requests another draft until the content passes or the retry cap is hit. The Editor returns a score and reason, and Python enforces the final pass/fail rule.

The entire pipeline runs on any AI provider without changing a single line of code. Swapping providers is handled through the settings file and the LiteLLM-compatible endpoint, without changing the agent code.


Why I Built This

The standard approach to AI content generation is a single prompt producing a single draft. You review it, edit it, prompt again. The human is the compliance layer. That works at low volume but it does not scale, and it does not enforce rules consistently.

I wanted to find out what happens when you remove the human from the compliance loop and replace them with a second AI role with explicit, scored, binary instructions. The Editor either passes the draft or it does not. No partial credit. No vague guidance.

The constraint satisfaction pattern this creates is foundational to how enterprise content pipelines need to work. A marketing team at a regulated company cannot afford to have AI generate copy containing forbidden claims or off-brand language. The pipeline enforces the rules automatically or a compliance team reviews every output manually. One of those scales.


What Actually Happened

What I expected: A larger marketing pipeline using SDKs, retry wrappers, typed helper functions, and more scaffolding than the demo really needed.

What actually happened: The agent became clearer when the extra layers were removed. The current version uses only Python’s standard library. No OpenAI SDK. No LiteLLM Python package. No python-dotenv. The script loads .env itself and sends a normal HTTP request to the LiteLLM-compatible endpoint.

What broke: The editor exposed a real workflow issue during testing. One draft came back with passed=True even though the score was too low. That meant the model’s own JSON response could contradict the compliance rule.

The fix was to keep the model as the brand editor, but let Python enforce the obvious gate: a draft only passes if the score is at least 80 and no forbidden words appear.

Why this matters: The point of the agent is not just generating content. The point is building a small constraint satisfaction loop where the Writer drafts, the Editor scores, and Python enforces the pass/fail rule consistently.


The Architecture: How It Works

The pipeline has five components working in a defined sequence.

The Campaign Planner receives product details and produces a brief with three labeled sections: email angle, blog angle, and ad copy angle. This brief becomes the shared context every other role operates from.

The Researcher receives the brief and returns audience insights and positioning context. This is a pure language model call drawing on the model’s knowledge. No live data retrieval.

The Writer receives the brief, research context, and a content type parameter that switches its role. The same function handles all three formats by changing the system prompt.

The Editor is the compliance gate. It receives the draft, the forbidden words list, and the tone descriptor. It returns a structured result with three fields: pass or fail, a score from zero to one hundred, and a rejection reason. The response is parsed as JSON. If parsing fails, the draft is treated as a failure.

The Orchestrator runs the full sequence and manages the retry loop for each content type. Every attempt is logged with its score and pass/fail status. If the final attempt still fails, the editor note is printed so the operator can see why.

The key architectural concept: Constraint Satisfaction Loop

Think of it like a quality control station on a factory floor. The Writer is the production line. The Editor is the inspector at the end with a printed checklist. The inspector stamps pass or fail. If the item fails, it goes back to production with a written reason. The rules never change mid-shift. That consistency is what makes it auditable.

The Build: Step by Step

The Editor function

def edit(draft):
    raw = chat(
        "You are a brand editor. Return only JSON with passed, score, and reason. "
        "Set passed to true only when the score is 80 or higher and no forbidden "
        "words appear.",
        f"Forbidden words: {os.getenv('FORBIDDEN_WORDS', '')}\n"
        f"Required tone: {os.getenv('BRAND_TONE', 'friendly and authoritative')}\n\n"
        f"Draft:\n{draft}",
        temperature=0,
    )
    raw = raw.strip().removeprefix("```json").removeprefix("```").removesuffix("```").strip()
    try:
        result = json.loads(raw)
        score = int(result.get("score", 0))
        forbidden = [word.strip().lower() for word in os.getenv("FORBIDDEN_WORDS", "").split(",")]
        blocked = [word for word in forbidden if word and word in draft.lower()]
        return {
            "passed": bool(result.get("passed")) and score >= 80 and not blocked,
            "score": score,
            "reason": "Forbidden words found: " + ", ".join(blocked)
            if blocked
            else str(result.get("reason", "No reason provided")),
        }
    except (TypeError, ValueError, json.JSONDecodeError) as error:
        return {"passed": False, "score": 0, "reason": f"Editor JSON error: {error}"}

What this code is actually doing

This function is the compliance gate. It sends the draft to the language model with a very specific instruction: return only a JSON object with three fields and nothing else. The temperature=0.0 setting removes creative variation from the compliance check. The same rules applied the same way every time, not a creative interpretation.

The try/except block handles the case where the model ignores the JSON-only instruction and wraps its response in markdown formatting. The strip logic removes those wrappers before attempting to parse. If parsing still fails, the function returns a fail result rather than crashing the pipeline. The pipeline treats the draft as failed and requests another draft until the retry cap is reached.

The orchestration loop

for content_type in ("email", "blog", "ad"):
    print(f"\n{content_type.upper()} DRAFT")
    for attempt in range(1, retries + 1):
        draft = write_draft(content_type, brief, context)
        review = edit(draft)
        print(f"Attempt {attempt}: score={review['score']}, passed={review['passed']}")
        if review["passed"] or attempt == retries:
            print(draft)
            if not review["passed"]:
                print(f"Editor note: {review['reason']}")
            break

What this code is actually doing

This is the retry loop. For each content type it calls the Writer, passes the draft to the Editor, and checks the result. If the Editor passes it, the loop breaks and moves to the next content type. If rejected, a fresh draft is requested. If the final attempt still fails, the editor note is printed so the operator can see why.

Brand constraints loaded from the settings file

retries = int(os.getenv("MAX_EDITOR_RETRIES", "3"))

forbidden = [word.strip().lower() for word in os.getenv("FORBIDDEN_WORDS", "").split(",")]
blocked = [word for word in forbidden if word and word in draft.lower()]

What this code is actually doing

The retry cap comes from .env. The forbidden words are also read from .env, split into a list, and checked directly by Python after the model returns its editor score. That means the model can judge tone and explain the review, but Python still enforces the hard rule.

The forbidden words are stored as a comma-separated string and split into a list here. Adding or removing a forbidden word requires only editing the settings file. The tone descriptor and retry cap work the same way. Everything that controls the Editor’s behavior lives in configuration, not in the code.


Errors I Hit During This Build

The older version of this build had more infrastructure and dependency concerns than the current repo needs. The current implementation avoids that by using only the Python standard library and calling the LiteLLM-compatible endpoint directly over HTTP.

The real issue during verification was in the editor logic. The model returned a structured review where passed could be true even when the score was too low. That is a subtle but important failure because the whole point of the pipeline is enforcement.

The fix was to make Python enforce the final rule. The model can still review the draft, assign a score, and explain the reason, but Python decides whether the draft actually passes.

A draft now passes only when two things are true: the editor score is 80 or higher, and none of the configured forbidden words appear in the draft.

That makes the constraint loop stronger. The model provides judgment, but the script enforces the gate.


Why This Matters

Every marketing team using AI to generate content faces the same problem: the AI does not know your brand rules unless you enforce them in the pipeline. Telling a language model to write in a specific tone produces different output every time. Telling a second language model to score a draft against a binary pass/fail checklist and reject anything that fails produces consistent, documented output.

The pattern this agent demonstrates applies beyond marketing copy. Legal disclaimers, customer support responses, financial communications, product descriptions for regulated industries. Any domain where a human compliance reviewer currently reads AI output before it goes live is a domain where this pattern reduces that workload.

The retry loop with logged rejection reasons also creates a partial audit trail. Every draft that failed, and the reason it failed, is printed to the console. In my setup that output goes to the terminal. In a production deployment those logs would feed an observability platform, giving compliance teams visibility into not just what the AI produced but what it tried to produce and why those attempts were rejected.


Tools Used

LiteLLM (MIT): github.com/BerriAI/litellm

NosisTech Agent Engineering (MIT): github.com/nosistech/agent-engineering

(c) 2026 NosisTech LLC. Licensed under CC BY 4.0. Use freely, just credit us.

WEEKLY BUILD NOTES

One documented agent build in your inbox, every week

Real systems, real code, the errors left in. No spam, unsubscribe anytime.