Agentic AI Engineering

Lesson 22: Implementing the Foundations of the Writing Workflow

In lessons 20 and 21, we explored the project structure and system design of Brown, our AI writing workflow. We defined the scope, architecture, and trade-offs between our previous design and the current one, which we will teach you to implement in this and upcoming lessons.

We will start by laying down the foundations for Brown, the writing workflow. We think this is an excellent exercise for learning context engineering for complex problems that require writing huge system prompts across multiple dimensions.

The core problem with using LLMs to write professional content, such as articles, is that you have to carefully guide them through respecting dozens to hundreds of criteria, starting with how the words should be chosen to how the overall content should look and what it should contain.

Since LLMs are good at generating text, you might think this is an easy problem to solve. Unfortunately, they require a lot of engineering to encode human expectations for what the final output should look and, especially, sound like. We want them to be factually accurate (not hallucinate), reflect our personal thoughts, and avoid sounding robotic.

Thus, in this lesson, we will show you how to guide the LLM to reason through massive prompts that require modelling multiple domain objects, such as writing profiles, few-shot examples, and user inputs. We will learn how to aggregate them into well-structured prompts that follow good context engineering practices.

We will also implement the orchestrator–worker pattern from scratch to dynamically generate the media items required for the final article based on natural user inputs.

Let’s start by understanding what we will implement in this lesson.

Scoping the Writing Workflow Architecture

As illustrated in Image 1, the core process of the writing workflow consists of three distinct steps:

  1. Context Loading: We gather all necessary information into memory. This includes the user’s article guidelines, research materials, a few-shot example set (our “training set”), and specific writing profiles that define our voice and style.
  2. Media Generation: Using the Orchestrator-Worker pattern, we analyze content requirements and spin up specialized workers (tools) to generate assets such as Mermaid diagrams.
  3. Article Generation: We aggregate the context and the generated media into a well-structured system prompt and pass it to the ArticleWriter node to produce the final draft.

Image 1: The three-step high-level overview of the Brown writing workflow.Image 1: The three-step high-level overview of the Brown writing workflow.

💡 If you want to see how the Brown writing workflow architecture in this lesson relates to the complete design we presented in the previous lesson, you can open the whiteboard below.

Live Whiteboard: The End-To-End Architecture of Brown: The Writing Workflow. Click here to expand.

Still, from an architectural standpoint, what we will learn in this lesson is not much. We load the context, use the orchestrator-worker to generate the media items and then pass everything to the article writer. In reality, the most essential components are based on how we modeled our domain entities, profiles, and system prompts. So, let’s see how that works.

Modeling our Domain Entities as Context for LLMs

Based on the project structure we defined in Lesson 20, our domain entities sit at the bottom of the dependency graph. These entities, such as ArticleGuideline, Research, and Profile, are the primary means by which we pass information between our Python code (Software 1.0) and the LLM (Software 3.0).

Image 2: Brown Project StructureImage 2: Brown Project Structure

To make this communication seamless, we need a standard way to convert a Python object into a string format that the LLM understands as a distinct context block. We achieve this by implementing a unified ContextMixin interface.

The ContextMixin Interface

The ContextMixin is a simple interface that forces every entity to implement a to_context() method. This method wraps the entity’s content in XML tags derived from the class name.

from abc import abstractmethod
from brown.utils.s import camel_to_snake
 
 
class ContextMixin:
    @property
    def xml_tag(self) -> str:
        return camel_to_snake(self.__class__.__name__)
 
    @abstractmethod
    def to_context(self) -> str:
        """Context representation of the object."""
        pass

This abstraction ensures consistency that every entity implements a method that maps a Python object to a context element. Whether we are passing research data or a character profile, the LLM always receives clearly delimited XML blocks. This helps the model distinguish between instructions, data, and examples and reason about them properly.

For our agent, this is simply the set of entities required to write the article: article guidelines, research, and examples.

The Article Guideline Entity

The ArticleGuideline represents the user’s intent. It contains the outline, specific instructions, and constraints for the article.

from pydantic import BaseModel
from brown.entities.mixins import ContextMixin
 
 
class ArticleGuideline(BaseModel, ContextMixin):
    content: str
 
    def to_context(self) -> str:
        return f"""
<{self.xml_tag}>
    <content>{self.content}</content>
</{self.xml_tag}>
"""

We load the guideline using a dedicated loader.

from brown. loaders import MarkdownArticleGuidelineLoader
 
guideline_loader = MarkdownArticleGuidelineLoader(uri=Path("article_guideline.md"))
article_guideline = guideline_loader.load(working_uri=SAMPLE_DIR)
 
print(article_guideline.to_context())

After we convert it to context using the to_context() method, it looks like this:

<article_guideline>
    <content>## Outline
1. Introduction: The Critical Decision Every AI Engineer Faces
2. Understanding the Spectrum: From Workflows to Agents
3. The Challenges of Every AI Engineer

Section 2: Understanding the Spectrum: From Workflows to Agents

- Generate mermaid diagram
    </content>
</article_guideline>

import re
from functools import cached_property
from pydantic import BaseModel
from brown.entities.mixins import ContextMixin
 
class Research(BaseModel, ContextMixin):
    content: str
    max_image_urls: int = 30
 
    @cached_property
    def image_urls(self) -> list[str]:
# Logic to extract and validate URLs...return urls
 
    def to_context(self) -> str:
        return f"""
<{self.xml_tag}>
    {self.content}
</{self.xml_tag}>
"""

Let’s see how this looks in practice. We load the research file and convert it to context.

from brown.loaders import MarkdownResearchLoader
 
research_loader = MarkdownResearchLoader(uri=Path("research.md"))
research = research_loader.load(working_uri=SAMPLE_DIR)
 
print(research.to_context()[:500])# Printing first 500 chars

It outputs:

<research>
	Some random research...

  [Lesson 22 Writing Workflow Architecture](https://github.com/iusztinpaul/agentic-ai-engineering-course-data/blob/main/images/l22_writing_workflow.png)

  This repository contains the Gemini CLI, a command-line AI workflow tool that connects to your
  tools, understands your code, and accelerates your workflows.
...
</research>

T****he Secret Sauce of Writing Professional Articles: The Writing Profiles

While the article guideline and research tells the LLM what to write, the profiles tell it how to write. We separate these into six distinct profiles, allowing us to have granular control over every aspect of the generation process.

These profiles are static. They won’t change for every article. You configure them once, and then use the article guidelines and research to write different articles.

We store them as Markdown files in the inputs/profiles directory, and, as before, we model them as Pydantic entities as follows.

class Profile(BaseModel, ContextMixin):
    name: str
    content: str
 
    def to_context(self) -> str:
        return f"""
<{self.xml_tag}>
    <name>{self.name}</name>
    <content>{self.content}</content>
</{self.xml_tag}>
"""
 
class CharacterProfile(Profile): pass
class ArticleProfile(Profile): pass
class StructureProfile(Profile): pass
class MechanicsProfile(Profile): pass
class TerminologyProfile(Profile): pass
class TonalityProfile(Profile): pass

Now, let’s see how we load these profiles and transform them into context. As we did for every entity, we use a specialized loader to read the markdown files from disk.

from brown.loaders import MarkdownArticleProfilesLoader
 
profiles_loader = MarkdownArticleProfilesLoader(uri=PROFILES_DIR)
article_profiles = profiles_loader.load()

Let’s inspect the raw content of the ArticleProfile.

print(article_profiles.article.content[:400])

It outputs the raw markdown instructions:

## Tonality

You should write in a humanized way, as if writing a blog article or book.

Write the description of ideas as fluid as possible...

You might ask why we created a different subclass for each profile, such as the ArticleProfile and StructureProfile. We did that to leverage the ContextMixin functionality, which allows us to use different XML tags for each.

Let’s see this in practice and call the to_context() method. It automatically wraps the content in the specific XML tag derived from the class name (ArticleProfile -> <article_profile>).

print(article_profiles.article.to_context()[:400])

It outputs:

<article_profile>
    <name>article</name>
    <content>## Tonality

You should write in a humanized way as writing a blog article or book...
    </content>
</article_profile>

If we do the same for the StructureProfile, the XML tag changes automatically, ensuring the LLM can distinguish between different types of instructions.

print(article_profiles.structure.to_context()[:400])

It outputs:

<structure_profile>
    <name>structure</name>
    <content>## Sentence and paragraph length patterns

Write sentences 5–25 words; allow occasional 30-word 'story' sentences...
    </content>
</structure_profile>

Now, let’s better understand the role of each profile.

We have four general profiles: mechanics, structure, terminology, and tonality. Then, we have two specific profiles that cover: article and character

The general ones are used to instruct the LLM on general rules of thumb for what a professional piece of content should look like, such as defining the voice, setting sentence length, and avoiding AI slop. At the same time, the specific ones are used to configure the LLM to produce our ideal article and provide a unique kick to the writing process.

Image 3: Static vs. customizable profilesImage 3: Static vs. customizable profiles

As shown in image 3, you can replace the examples, article, or character profile with your own to begin generating other article formats and different content types, such as video transcripts or social media posts, and add your own personality to the writing process.

Keeping things modular in separate Markdown files, even at the context-engineering level, makes it much easier to grow this into a full-fledged, scalable product. For example, if you introduce the concept of a user, each user can configure their own character profile, which is dynamically added to the workflow at the beginning without altering any other aspect of the implementation.

class ArticleExample(BaseModel, ContextMixin):
    content: str
 
    def to_context(self) -> str:
        return f"""
<{self.xml_tag}>
    {self.content}
</{self.xml_tag}>
"""
 
class ArticleExamples(BaseModel, ContextMixin):
    examples: list[ArticleExample]
 
    def to_context(self) -> str:
        return f"""
<{self.xml_tag}>
        {"\n".join([example.to_context() for example in self.examples])}
</{self.xml_tag}>
"""

This structure allows us to pass a list of examples, nested within <article_examples> tags. The LLM, along with the writing profiles, uses these to infer style, formatting, and the overall structure.

from brown.loaders import MarkdownArticleExampleLoader
 
examples_loader = MarkdownArticleExampleLoader(uri=EXAMPLES_DIR)
article_examples = examples_loader.load()
 
print(article_examples.to_context()[:500])

It outputs:

<article_examples>
    <article_example>
      # Lesson 3: Context Engineering
      AI applications have evolved rapidly...
    </article_example>

    <article_example>
      # Lesson 4: Structured Outputs
      In our previous lessons...
    </article_example>
</article_examples>

To return to our strategy, we used 2–3 articles from previous lessons that humans carefully curated to ensure they met all our criteria present in the writing profiles. Remember that the quality of these examples directly impacts the final performance.

For few-shot examples, it’s more important to have enough diversity than to hit a specific number. A common rule of thumb is to have 20–30 examples, but as you introduce more diversity into your examples, you can reduce the overall number of few-shot examples.

Our use case is somewhat unique. Here, an example is provided for both the entire article, from introduction to conclusion, and for each section individually. With 2–3 articles, each with around 10 sections, we reach the 20–30 example target. Thus, you can control the number of examples by adjusting the article’s length. Otherwise, adding 20 full articles as examples would explode our context window. To enhance diversity, we used articles with theoretical sections that were rich in bullet points, numbered lists, and well-structured paragraphs. We also introduced various media types, including Mermaid diagrams, images, tables, and code blocks of varying sizes.

This is a clear example of how each AI problem is unique and why you cannot rigidly follow “the book”.

As these are standalone articles, we can’t add them here, but you can open them within the inputs/examples/course_lesson directory.

Ultimately, we ensured these article examples covered as many of the requirements listed in the writing profiles as possible.

With that clarified, we can now return to our workflow implementation.

Unifying LLM Calls

Before connecting these entities into a workflow, we need a unified way to interact with LLMs. In brown/models, we implement a factory pattern to manage model instantiation and configuration [5].

The get_model function serves as our single point of entry.

  1. First, we define the imports and a mapping to handle API keys for different models.
import json
    
    from langchain.chat_models import init_chat_model
    from langchain_core.language_models import BaseChatModel
    
    from brown.config import get_settings
    from brown.models.config import DEFAULT_MODEL_CONFIGS, ModelConfig, SupportedModels
    from brown.models.fake_model import FakeModel
    
    MODEL_TO_REQUIRED_API_KEY = {
        SupportedModels.GOOGLE_GEMINI_30_PRO: "GOOGLE_API_KEY",
        SupportedModels.GOOGLE_GEMINI_25_PRO: "GOOGLE_API_KEY",
        SupportedModels.GOOGLE_GEMINI_25_FLASH: "GOOGLE_API_KEY",
        SupportedModels.GOOGLE_GEMINI_25_FLASH_LITE: "GOOGLE_API_KEY",
    }
  1. Then, we implement the factory function itself. It handles the FakeModel for testing, merges the default configuration with any overrides, and initializes the chat model using LangChain’s init_chat_model.
def get_model(model: SupportedModels, config: ModelConfig | None = None) -> BaseChatModel:
        if model == SupportedModels.FAKE_MODEL:
            if config and config.mocked_response is not None:
                if hasattr(config.mocked_response, "model_dump"):
                    mocked_response_json = config.mocked_response.model_dump(mode="json")
                else:
                    mocked_response_json = json.dumps(config.mocked_response)
                return FakeModel(responses=[mocked_response_json])
            else:
                return FakeModel(responses=[])
    
        config = config or DEFAULT_MODEL_CONFIGS.get(model) or ModelConfig()
        model_kwargs = {
            "model": model.value,
            **config.model_dump(),
        }
    
        required_api_key = MODEL_TO_REQUIRED_API_KEY.get(model)
        if required_api_key:
            settings = get_settings()
            if not getattr(settings, required_api_key):
                raise ValueError(f"Required environment variable `{required_api_key}` is not set")
            else:
                model_kwargs["api_key"] = getattr(settings, required_api_key)
    
        return init_chat_model(**model_kwargs)

We use a ModelConfig Pydantic model to define parameters such as temperature, maximum tokens, and retries. This enables us to configure different models for each node in the workflow. For example, we might use a faster, less expensive model (Gemini-2.5-Flash) for the orchestrator and a more capable reasoning model (Gemini-2.5-Pro) for the actual writing.

  1. Here we define the SupportedModels enum and the ModelConfig class.
from enum import StrEnum
    from typing import Any
    
    from pydantic import BaseModel, Field
    
    
    class SupportedModels(StrEnum):
        GOOGLE_GEMINI_30_PRO = "google_genai:gemini-3-pro-preview"
        GOOGLE_GEMINI_25_PRO = "google_genai:gemini-2.5-pro"
        GOOGLE_GEMINI_25_FLASH = "google_genai:gemini-2.5-flash"
        GOOGLE_GEMINI_25_FLASH_LITE = "google_genai:gemini-2.5-flash-lite"
        FAKE_MODEL = "fake"
    
    
    class ModelConfig(BaseModel):
        temperature: float = 0.7
        top_k: int | None = None
        n: int = 1
        response_modalities: list[str] | None = None
        include_thoughts: bool = False
        thinking_budget: int | None = Field(
            default=None,
            ge=0,
            description="If reasoning is available, the maximum number of tokens the model can use for thinking.",
        )
        max_output_tokens: int | None = None
        max_retries: int = 6
    
        mocked_response: Any | None = None
  1. Finally, we define DEFAULT_MODEL_CONFIGS to provide sensible defaults for specific models.
DEFAULT_MODEL_CONFIGS = {
        "google_genai:gemini-3-pro-preview": ModelConfig(
            temperature=0.7,
            include_thoughts=False,
            thinking_budget=1000,
            max_retries=3,
        ),
        "google_genai:gemini-2.5-pro": ModelConfig(
            temperature=0.7,
            include_thoughts=False,
            thinking_budget=1000,
            max_retries=3,
        ),
        "google_genai:gemini-2.5-flash": ModelConfig(
            temperature=1,
            thinking_budget=1000,
            include_thoughts=False,
            max_retries=3,
        ),
    }

This decoupling ensures that our application logic (the nodes) doesn’t care which specific provider or model is running under the hood. Additionally, it enables us to extend and configure the workflow with various models easily. For example, when Gemini 3.0 was released, it took us 2 minutes to migrate from version 2.5 to 3.0.

Here is how we use it in practice:

from brown.models import ModelConfig, SupportedModels, get_model
 
# Create a model instance with custom configuration
model_config = ModelConfig(temperature=0.5, max_output_tokens=100)
model = get_model(SupportedModels.GOOGLE_GEMINI_25_FLASH, config=model_config)
 
# Test it with a simple prompt
response = await model.ainvoke([{"role": "user", "content": "Say hello in one sentence!"}])
print(f"Model response: {response.content}")

It outputs:

Model response: Hello there!

Generating Media Items Using the Orchestrator-Worker Pattern

Now we will start implementing the LLM calls, prompts, tools, and everything else you have been expecting from an AI workflow. We packed each LLM call, with everything around it, into a node. The nodes are still part of the domain layer, as each node is an independent unit of work that represents a piece of our business logic. We will combine one or more nodes and entities to create a workflow. Intuitively, the node contains the business logic, while the entities mostly structure the data that’s passed around between nodes. But that’s not always the case, as an entity class can contain methods with business logic.

The Node, Toolkit, and ToolCall Abstractions

To implement the Orchestrator-Worker pattern for generating media items (e.g., diagrams) based on article guidelines, we first need to present our core abstractions for nodes.

These interfaces ensure that every component in our workflow speaks the same language.

  1. ToolCall: A simple TypedDict representing a request from the LLM to execute a tool. It contains the tool’s name, arguments, and a unique ID.
from typing import Any, Iterable, Literal, TypedDict
 
 
class ToolCall(TypedDict):
    name: str
    args: dict[str, Any]
    id: str
    type: Literal["tool_call"]
  1. Toolkit: An abstract base class that manages a collection of tools. It provides methods to retrieve tools by name or get the full list, acting as a registry for the node.
from abc import ABC, abstractmethod
 
from langchain_core.runnables import Runnable
from langchain_core.tools import BaseTool
 
class Toolkit(ABC):
    """Base class for toolkits following LangChain's toolkit pattern."""
 
    def __init__(self, tools: list[BaseTool]) -> None:
        self._tools: list[BaseTool] = tools
        self._tools_mapping: dict[str, BaseTool] = {tool.name: tool for tool in self._tools}
 
    def get_tools(self) -> list[BaseTool]:
        """Get all registered media item generation tools."""
        return self._tools.copy()
 
    def get_tool_by_name(self, name: str) -> BaseTool | None:
        """Get a specific tool by name."""
        return self._tools_mapping.get(name)
  1. Node: The base class for all workflow components. It holds the LLM instance and the toolkit. It also handles model extension (like binding tools or structured outputs) and multimodal input construction.
class Node(ABC):
    def __init__(self, model: Runnable, toolkit: Toolkit) -> None:
        self.toolkit = toolkit
        self.model = self._extend_model(model)
 
    def _extend_model(self, model: Runnable) -> Runnable:
        if isinstance(model, FakeModel):
            model = cast(FakeModel, model)
            has_mocked_response_set = bool(model.responses)
            if has_mocked_response_set is False:
                model = self._set_default_model_mocked_responses(model)
 
        return model
 
    def build_user_input_content(self, inputs: Iterable[str], image_urls: list[str] | None = None) -> list[dict[str, Any]]:
		    """Maps user inputs + image URLS to chatbot messages."""
		    
        ... # Explained later
 
        return messages
 
    @abstractmethod
    async def ainvoke(self) -> Any:
        pass

We intentionally leave the ainvoke() method empty to make this class extremely flexible and customizable, allowing it to be implemented into each concrete node as required by leveraging the self.model and build_user_input_content components.

Now, let’s see how we used these abstractions in practice by implementing the MediaGeneratorOrchestrator node.

The Orchestrator: MediaGeneratorOrchestrator

The MediaGeneratorOrchestrator analyzes the input article guidelines and research. Its job is not to generate the actual media items, but to decide what media items are needed and to delegate the task to the appropriate worker. In other words, this is the orchestrator component from the orchestrator-worker pattern, while the workers will be implemented as tools.

Let’s take a look at the implementation.

  1. We start by defining the necessary imports and the detailed system prompt that guides the orchestrator into analyzing the input guideline article and researching and reasoning what tool calls it should output (we will define the available tools in future steps):
from typing import cast
    from langchain_core.messages import AIMessage
    from langchain_core.runnables import Runnable
    from langchain_core.language_models import BaseChatModel
    
    from brown.entities import ArticleGuideline, Research
    from brown.nodes.base import Node, ToolCall, Toolkit
    
    MEDIA_GENERATOR_PROMPT = """You are an Media Generation Orchestrator responsible for analyzing article 
    guidelines and research to identify what media items need to be generated for the article.
    
    Your task is to:
    1. Carefully analyze the article guideline and research content provided
    2. Identify ALL explicit requests for media items (diagrams, charts, visual illustrations, etc.)
    3. For each identified media requirement, call the appropriate tool to generate the media item
    4. Provide clear, detailed descriptions for each media item based on the guideline requirements and research context
    
    ## Analysis Guidelines
    
    **Look for these explicit indicators in the article guideline:**
    - Direct mentions: "Render a Mermaid diagram", "Draw a diagram", "Create a visual", "Illustrate with", etc.
    - Visual requirements: "diagram visually explaining", "chart showing", "figure depicting", "visual representation"
    - Process flows: descriptions of workflows, architectures, data flows, or system interactions
    - Structural elements: hierarchies, relationships, comparisons, or step-by-step processes
    
    **Key places to look:**
    - Section requirements and descriptions  
    - Specific instructions mentioning visual elements
    - Complex concepts that would benefit from visual explanation
    - Architecture or system descriptions
    - Process flows or workflows
    
    ## Tool Usage Instructions
    
    You will call multiple tools to generate the media items. You will use the tool that is most appropriate for the media item you are 
    generating.
    
    For each identified media requirement:
    
    **When to use MermaidDiagramGenerator:**
    - Explicit requests for Mermaid diagrams
    - System architectures and workflows
    - Process flows and data pipelines  
    - Organizational structures or hierarchies
    - Flowcharts for decision-making processes
    - Sequence diagrams for interactions
    - Entity-relationship diagrams
    - Class diagrams for software structures
    - State diagrams for system states
    - Mind maps for concept relationships
    
    **Description Requirements:**
    When calling tools, provide detailed descriptions that include:
    - The specific purpose and context from the article guideline
    - Key components that should be included based on the research
    - The type of diagram most appropriate (flowchart, sequence, architecture, etc.)
    - Specific elements, relationships, or flows to highlight
    - Any terminology or technical details from the research
    
    ## Example Analysis Process
    
    1. **Scan the guideline** for phrases like:
       - "Render a Mermaid diagram of..."
       - "Draw a diagram showing..."
       - "Illustrate the architecture..."
       - "Visual representation of..."
    
    2. **For each found requirement:**
       - Extract the specific context and purpose
       - Identify what should be visualized
       - Determine the most appropriate diagram type
       - Craft a detailed description incorporating research insights
    
    3. **Call the appropriate tool** with the comprehensive description
    
    ## Input Context
    
    Here is the article guideline:
    {article_guideline}
    
    Here is the research:
    {research}
    
    ## Your Response
    
    Analyze the provided article guideline and research, then call the appropriate tools for each 
    identified media item requirement. Each tool call should include a detailed description that ensures 
    the generated media item will be relevant, accurate, and valuable for the article's educational goals.
    
    If no explicit media requirements are found in the guideline, respond with: 
    "No explicit media item requirements found in the article guideline."
    """
  1. Next, we define the MediaGeneratorOrchestrator class, inheriting from the Node abstraction, initializing it with the proper context, and binding the tools from the toolkit . Note that the Media Orchestrator class is generic, with available workers injected as tools through composability. Thus, we can easily extend it with as many media generator types as we want, which we will do when we instantiate and call the node.
class MediaGeneratorOrchestrator(Node):
        system_prompt_template = MEDIA_GENERATOR_PROMPT
    
        def __init__(
            self,
            article_guideline: ArticleGuideline,
            research: Research,
            model: Runnable,
            toolkit: Toolkit,
        ) -> None:
            self.article_guideline = article_guideline
            self.research = research
            super().__init__(model, toolkit)
    
        def _extend_model(self, model: Runnable) -> Runnable:
            model = cast(BaseChatModel, super()._extend_model(model))
            model = model.bind_tools(self.toolkit.get_tools(), tool_choice="any")
            return model
  1. Finally, we implement the ainvoke method that calls the LLM and returns a list of tool calls labeled as jobs:
async def ainvoke(self) -> list[ToolCall]:
            system_prompt = self.system_prompt_template.format(
                article_guideline=self.article_guideline.to_context(),
                research=self.research.to_context(),
            )
            user_input_content = self.build_user_input_content(
                inputs=[system_prompt],
                image_urls=self.research.image_urls
            )
            inputs = [{"role": "user", "content": user_input_content}]
            response = await self.model.ainvoke(inputs)
    
            if isinstance(response, AIMessage) and response.tool_calls:
                jobs = cast(list[ToolCall], response.tool_calls)
            else:
                jobs = []
    
            return jobs

The ToolNode Abstraction

Before we call the orchestrator and implement a concrete tool, we need one last abstraction: the layer we use to define tools and, in our case, workers.

The ToolNode abstraction inherits from the Node class. It adds the as_tool() method used to transform the current node into a LangChain tool through their StructuredTool.from_function utility function.

from langchain_core.tools import BaseTool, StructuredTool
 
class ToolNode(Node):
    def __init__(self, model: Runnable) -> None:
        super().__init__(model, toolkit=Toolkit(tools=[]))
 
    def as_tool(self) -> StructuredTool:
        return StructuredTool.from_function(
            coroutine=self.ainvoke,
            name=f"{s.camel_to_snake(self.__class__.__name__)}_tool",
        )
 
    @abstractmethod
    async def ainvoke(self) -> Any:
        pass

Through this abstraction, we can easily transform each node into a tool, allowing us to use it as either a workflow step or a tool. This way, we can quickly compose nodes between each other, without many code changes.

Now, let’s leverage this abstraction while implementing the MermaidDiagramGenerator**** node (and tool!).****

The Worker: MermaidDiagramGenerator

This node focuses on doing one thing well: generating Mermaid diagrams code. By isolating this logic, we can craft a specific prompt for diagram generation that handles edge cases, syntax rules, and custom business preferences without polluting other system prompts.

  1. First, we define the imports and the Pydantic structured output model:
from typing import cast
    from loguru import logger
    from pydantic import BaseModel, Field
    from langchain_core.runnables import Runnable
    from langchain_core.language_models import BaseChatModel
    
    from brown.entities import MermaidDiagram
    from brown.nodes.base import ToolNode
    from brown.utils.exceptions import InvalidOutputTypeException
    
    class GeneratedMermaidDiagram(BaseModel):
        content: str = Field(description="The Mermaid diagram code formatted in Markdown format as: ```mermaid\n[diagram content here]\n```")
        caption: str = Field(description="The caption, as a short description of the diagram.")
  1. Next, we define the specialized prompt template (we added only part of it!):
MERMAID_GENERATOR_PROMPT = """
    You are a specialized agent that creates clean, readable Mermaid diagrams from text descriptions.
    
    ## Task
    Generate a valid Mermaid diagram based on this description:
    <description_of_the_diagram>
    {description_of_the_diagram}
    </description_of_the_diagram>
    
    ## Output Format
    Return ONLY the Mermaid code block in this exact format:
    ```mermaid
    [diagram content here]

Diagram Types & Examples

Process Flow

graph LR
    A["Input"] --> B["Process"] --> C["Output"]
    B --> D["Validation"]
    D -->|"Valid"| C
    D -->|"Invalid"| A

... ← MORE EXAMPLES!!!

Syntax Rules

  1. Node Labels: Use square brackets [Label] for rectangular nodes
  2. Decisions: Use curly braces {{Decision}} for diamond shapes
  3. Databases: Use [(Label)] for cylindrical database shapes
  4. Circles: Use ((Label)) for circular nodes
  5. Arrows: Use --> for solid arrows, -.-> for dotted arrows
  6. Labels on Arrows: Use -->|Label| for labeled connections
  7. Subgraphs: Use subgraph "Title" and end to group elements
  8. Comments: Use %% for comments
  9. ERD Entities: Use ENTITY_NAME {{ field_type field_name }} format
  10. ERD Relationships: Use ||--o{{, ||--||, }}o--|| for different cardinalities
  11. Class Definitions: Use class ClassName {{ +type attribute +method() }} format
  12. Class Relationships: Use <|-- (inheritance), --> (association), --* (composition)

Formatting Rules

ALWAYS wrap node labels in double quotes "..." to prevent parsing errors:

WRONG (causes parse errors):

graph TD
    A[Vision Encoder (e.g., ViT)] --> B[Output];

CORRECT (always use quotes):

graph TD
    A["Vision Encoder (e.g., ViT)"] --> B["Output"]

Key formatting requirements:

  • Always use double quotes around ALL node labels: A["Label"]
  • Never use semicolons at the end of lines (they're optional and can cause issues)
  • Quote any label containing: parentheses (), commas ,, periods ., colons :, or spaces
  • Quote subgraph titles as well: subgraph "Title"

Styling Guidelines

  • Use default colors only - do not add color specifications or custom styling
  • Do not use fill:, stroke:, color: or any CSS styling properties
  • Keep diagrams clean and professional with standard Mermaid appearance
  • Focus on structure and clarity, not visual customization

Key Guidelines

  • Keep node labels concise (avoid parentheses and special characters in labels)
  • Use precise, logical flow from top to bottom or left to right
  • Keep the diagram simple and easy to understand
  • Group related elements with subgraphs when helpful
  • Maintain consistent spacing and formatting
  • Choose the appropriate diagram type for the concept being illustrated

Common Mistakes to Avoid

  • NEVER use unquoted labels - always wrap in double quotes: A["Label"]
  • NEVER use semicolons at the end of lines (causes parsing issues)
  • NEVER put parentheses () in unquoted labels - parser treats them as shape tokens
  • Don't create overly complex diagrams with too many connections
  • Avoid extremely long labels that break the visual flow
  • Never use custom colors or styling - stick to Mermaid's default appearance

Generate a clean, professional diagram that clearly illustrates the described concept using only default Mermaid styling. Remember: ALWAYS use double quotes around ALL labels and NEVER use semicolons. """


  3. We define the `MermaidDiagramGenerator` class and configure the model to use structured outputs.

```python
class MermaidDiagramGenerator(ToolNode):
        prompt_template = MERMAID_GENERATOR_PROMPT
    
        def _extend_model(self, model: Runnable) -> Runnable:
            model = cast(BaseChatModel, super()._extend_model(model))
            model = model.with_structured_output(GeneratedMermaidDiagram, include_raw=False)
            
            return model
  1. We implement the ainvoke method to generate the diagram and handle potential errors.
async def ainvoke(self, description_of_the_diagram: str, section_title: str) -> MermaidDiagram:
            """Specialized tool to generate a mermaid diagram from a text description."""
            try:
                response = await self.model.ainvoke(
                    [
                        {
                            "role": "user",
                            "content": self.prompt_template.format(
                                description_of_the_diagram=description_of_the_diagram,
                            ),
                        }
                    ]
                )
    
            except Exception as e:
                logger.exception(f"Failed to generate Mermaid diagram: {e}")
    
                return MermaidDiagram(
                    location=section_title,
                    content=f'```mermaid\ngraph TD\n    A["Error: Failed to generate diagram"]\n    A --> B["{str(e)}"]\n```',
                    caption=f"Error: Failed to generate diagram: {str(e)}",
                )
    
            if not isinstance(response, GeneratedMermaidDiagram):
                raise InvalidOutputTypeException(GeneratedMermaidDiagram, type(response))
    
            return MermaidDiagram(
                location=section_title,
                content=response.content,
                caption=response.caption,
            )

End-to-End Example of Calling the Orchestartor-Worker Module

Finally, now that we have all the LEGO pieces, let’s assemble them into the orchestrator-worker module that generates Mermaid diagrams based on the article guidelines and user research inputs.

In the workflow, the orchestrator calls the MermaidDiagramGenerator tool. The tool executes and returns the diagram. This separation of concerns allows us to easily add new workers (e.g., an ImageGenerator or ChartGenerator) without changing the orchestrator’s core logic.

Let’s see this in action:

import asyncio
 
from brown.entities.media_items import MediaItem, MediaItems
from brown.models import SupportedModels, get_model
from brown.nodes import MediaGeneratorOrchestrator, MermaidDiagramGenerator, Toolkit
 
# 1. Create worker tool
diagram_model = get_model(SupportedModels.GOOGLE_GEMINI_25_FLASH)
mermaid_generator = MermaidDiagramGenerator(model=diagram_model)
toolkit = Toolkit(tools=[mermaid_generator.as_tool()])
 
# 2. Create orchestrator
orchestrator_model = get_model(SupportedModels.GOOGLE_GEMINI_25_FLASH)
orchestrator = MediaGeneratorOrchestrator(
    article_guideline=article_guideline,
    research=research,
    model=orchestrator_model,
    toolkit=toolkit,
)
 
# 3. Run the orchestrator to identify required media items
media_jobs = await orchestrator.ainvoke()
 
print(f"Found {len(media_jobs)} media items to generate")
for i, job in enumerate(media_jobs):
    print(f"Job {i + 1}:")
    print(f"  Tool: {job['name']}")
    print(f"  Description: {job['args'].get('description_of_the_diagram', 'N/A')[:100]}...")
    print(f"  Section: {job['args'].get('section_title', 'N/A')}")

Which outputs:

Analyzing article guidelines for media requirements...
Found 2 media items to generate
Job 1:
  Tool: mermaid_diagram_generator_tool
  Description: A flowchart showing the parallelization pattern applied to our writing workflow...
  Section: The Writing Workflow Architecture
Job 2:
  Tool: mermaid_diagram_generator_tool
  Description: A sequence diagram showing the interaction between orchestrator and workers...
  Section: Orchestrator-Worker Pattern
Generating media items in parallel...
Generated 2 media items successfully!

Next, we call each generated tool call as follows:

# 4. Execute all tool calls in parallel
coroutines = []
for job in media_jobs:
    tool = orchestrator.toolkit.get_tool_by_name(job["name"])
    if tool:
        coroutines.append(tool.ainvoke(**job["args"]))

media_items: list[MediaItem] = await asyncio.gather(*coroutines)

# 5. Build MediaItems entity and display results
media_items = MediaItems.build(media_items=media_items)

# Display the generated diagramsfor media_item in media_items.media_items:
    print(f"\nSection: {media_item.location}")
    print(f"Caption: {media_item.caption}")
    print(f"Diagram Code:\n{media_item.content}")

Which outputs the results of the tools, which in our use case are the final diagrams:

Section: The Writing Workflow Architecture
Caption: A flowchart showing the parallelization pattern applied to our writing workflow.
Diagram Code:
``mermaid
graph TD
    A["Start"] --> B["Orchestrator"]
    B --> C["Worker 1"]
    B --> D["Worker 2"]
    C --> E["End"]
    D --> E
``

Section: Orchestrator-Worker Pattern
Caption: A sequence diagram showing the interaction between orchestrator and workers.
Diagram Code:
``mermaid
sequenceDiagram
    participant O as Orchestrator
    participant W1 as Worker 1
    participant W2 as Worker 2
    O->>W1: Generate diagram
    O->>W2: Generate diagram
    W1-->>O: Diagram result
    W2-->>O: Diagram result
``

Working With Multimodal Inputs

Another vital aspect to understand before we dive into the ArticleWriter node is how we model multimodal inputs, such as combining text prompts with images extracted from the research.

We handle this through the build_user_input_content() method, which is part of the Node base class. This method allows us to combine text inputs with image URLs in a structured format that multimodal models like Gemini can process.

Here’s how the method works:

class Node(ABC):
...
 
	def build_user_input_content(self, inputs: Iterable[str], image_urls: list[str] | None = None) -> list[dict[str, Any]]:
	    """Build multimodal input content with optional images."""
	    messages: list[dict[str, Any]] = []
	    if image_urls:
	        for image_url in image_urls:
	            messages.append({
	                "type": "text",
	                "text": "Use the following images as <research> context:",
	            })
	            messages.append({"type": "image_url", "image_url": {"url": image_url}})
	
	        messages.append({"type": "text", "text": input_})
	
	    return messages

When we use it in practice, we combine the system prompt with image URLs from the research:

user_input_content = orchestrator.build_user_input_content(
    inputs=["some random inputs", "some other random inputs"],
    image_urls=orchestrator.research.image_urls[:5]
)

This produces a list of dictionaries where each image URL is preceded by a text marker indicating it’s part of the <research> context:

[
    {'type': 'text', 'text': 'Use the following images as <research> context:'},
    {'type': 'image_url', 'image_url': {'url': 'https://example.com/image1.jpg'}},
    {'type': 'text', 'text': 'Use the following images as <research> context:'},
    {'type': 'image_url', 'image_url': {'url': 'https://example.com/image2.jpg'}},
    {'type': 'text', 'text': 'some random inputs'},
    {'type': 'text', 'text': 'some other random inputs'}
]

Key Design Decisions:

  1. XML Tag Context : Each image URL includes a text marker with <research> tags. Because we model context as XML tags throughout our system, this provides a unique reference ID that helps the LLM understand the source and purpose of each image.
  2. Text at the End : We explicitly place text inputs at the bottom of the message list. This avoids the “needle-in-a-haystack” problem. When you have many images, putting the critical text instructions at the end ensures the LLM focuses on them rather than getting lost in the visual content.
  3. Extensibility : This strategy can easily be extended to include other media types (documents, videos, audio) as long as they can be encoded as base64, URLs, or binary data, as we’ve seen in multimodal processing lessons.

In the MediaGeneratorOrchestrator, we use this method to pass both the system prompt and research images to the model:

user_input_content = self.build_user_input_content(
    inputs=[system_prompt],
    image_urls=self.research.image_urls
)
inputs = [{"role": "user", "content": user_input_content}]
response = await self.model.ainvoke(inputs)

This allows the orchestrator to analyze both textual guidelines and visual research content when deciding what media items to generate.

Understanding the Anatomy of a Prompt

Before we examine the ArticleWriter, we need to understand how to construct the comprehensive system prompt that incorporates all the context elements we have discussed so far in this lesson.

According to Anthropic’s research on practical context engineering [3], [4], context is a finite resource. In a nutshell, context engineering is all about curating the “smallest possible set of high-signal tokens” to achieve our desired outcome.

This means we cannot simply dump information into the prompt. We must prune it and structure it. A well-engineered prompt acts like a program, guiding the model through a logical flow of information. It establishes the role, provides the necessary data, sets constraints, and defines the output format before the model even begins generating text. This structured approach reduces hallucinations and ensures the model adheres to complex requirements, such as our writing profiles.

Image 4: The anatomy of a prompt structure, detailing the 10 key components for practical context engineering. (Source Image 4: The anatomy of a prompt structure, detailing the 10 key components for practical context engineering. (Source Prompting 101 | Code w/ Claude)

Here are the 10 most essential components of a prompt’s anatomy:

  1. Task Context: Who is the LLM? (e.g., “You are Brown, a professional writer...")
  2. Tone Context: How should it sound?
  3. Background Data: The research and domain entities.
  4. Detailed Task Description & Rules: Specific rules and constraints.
  5. Examples: Few-shot examples (our ArticleExamples).
  6. Conversation History: Previous interactions (if applicable).
  7. Immediate Task Description or Request: The specific user request (the ArticleGuideline).
  8. Thinking Step-by-Step: Instructions to plan before writing (Chain of Thought).
  9. Output Formatting: Rules for Markdown, citations, and related elements.
  10. Prefilled Response (if any): Priming the model’s output.

Note the order of each component. For example, we add the background data, which is usually the most dense, between the task context and the detailed task description and rules. Remember that, due to the needle-in-the-haystack problem, the LLM performs best at the beginning and end of the prompt. In this way, we ensure that the most critical parts of the prompt are where the LLM reasons best.

To keep this hands-on, let’s finally introduce the article writer node and explain how these prompt engineering rules apply to its system prompt.

Generating the Article

The ArticleWriter is the final node in this flow. It uses all the context we’ve prepared to generate the article.

The ainvoke method is straightforward: it formats the system prompt with all our entities and calls the model.

class ArticleWriter(Node):
		system_prompt_template = """..."""
		
    async def ainvoke(self) -> Article | SelectedText:
				# Format the system prompt with all context
        system_prompt = self.system_prompt_template.format(
            article_guideline=self.article_guideline.to_context(),
            research=self.research.to_context(),
            article_profile=self.article_profiles.article.to_context(),
            character_profile=self.article_profiles.character.to_context(),
            mechanics_profile=self.article_profiles.mechanics.to_context(),
            structure_profile=self.article_profiles.structure.to_context(),
            terminology_profile=self.article_profiles.terminology.to_context(),
            tonality_profile=self.article_profiles.tonality.to_context(),
            media_items=self.media_items.to_context(),
            article_examples=self.article_examples.to_context(),
        )
 
				# Build multimodal input (text + images from research)
        user_input_content = self.build_user_input_content(
            inputs=[system_prompt],
            image_urls=self.research.image_urls
        )
 
        inputs = [{"role": "user", "content": user_input_content}]
 
				# Generate the article
        written_output = await self.model.ainvoke(inputs)
        if not isinstance(written_output, AIMessage):
            raise InvalidOutputTypeException(AIMessage, type(written_output))
        written_output = cast(str, written_output.text)
 
        return Article(content=written_output)

The system_prompt_template is where the logic is executed. We structure it using the anatomy we just discussed. We won’t apply them one by one. As we kept saying throughout this course. You always have to adapt the recipe to your use case. Let’s see how it works.

  1. Role & Task: We start by defining the persona and the high-level objective.
system_prompt_template = """
    You are Brown, a professional human writer specialized in writing technical, educative and informational articles
    about AI. 
    
    Your task is to write a high-quality article, while providing you the following context:
    - **article guideline:** the user intent describing how the article should look like. Specific to this particular article.
    - **research:** the factual data used to support the ideas from the article guideline. Specific to this particular article.
    - **article profile:** rules specific to writing articles. Generic for all articles.
    - **character profile:** the character you will impersonate while writing. Generic for all content.
    - **structure profile:** Structure rules guiding the final output format. Generic for all content.
    - **mechanics profile:** Mechanics rules guiding the writing process. Generic for all content.
    - **terminology profile:** Terminology rules guiding word choice and phrasing. Generic for all content.
    - **tonality profile:** Tonality rules guiding the writing style. Generic for all content.
    
    Each of these will be carefully considered to guide your writing process. You will never ignore or deviate from these rules. These
    rules are your north star, your bible, the only reality you know and operate on. They are the only truth you have.
    ...
    """
  1. Character Profile: We inject the character profile to anchor the identity.
"""
    ## Character Profile
    
    To make the writing more personable, you will impersonate the following character profile. The character profile 
    will anchor your identity and specify things such as your:
    - **personal details:** name, age, location, etc.
    - **working details:** company, job title, etc.
    - **artistic preferences:** it's niche, core content pillars, style, tone, voice, etc.
    
    What to avoid using the character profile for:
    - explicitly mentioning the character profile in the article, such as "I'm Paul Iusztin, founder of Decoding AI." Use
    it only to impersonate the character and make the writing more personable. For example if you are "Paul Iusztin",
    you will never say all the time "I'm Paul Iusztin, founder of Decoding AI." as people already know who you are.
    - using the character profile to generate article sections, such as "Okay, I'm Paul Iusztin, founder of Decoding AI. 
    Let's cut through the hype and talk real engineering for AI agents." Use the character profile only to adapt the
    writing style and introduce references to the character. Nothing more.
    
    Here is the character profile:
    {character_profile}
    """
  1. Research Data: We inject the research data. Note how we explicitly state that the LLM should use only the research data to output facts and nothing else.
"""
    ## Research
    
    When using factual data to write the article, anchor your results exclusively in information from the given 
    <research> or <article_guideline> tags. Avoid, at all costs, using factual information from your internal knowledge.
    
    The <research> will contain most of the factual data to write the article. But the user might add additional information
    within the <article_guideline>. 
    
    Thus, always prioritize the factual data from the <article_guideline> over the <research>.
    
    Here is the research you will use as factual data for writing the article:
    {research}
    ...
    """
  1. Examples: Next, the examples:
"""
    ## Article Examples
    
    Here is a set of article examples you will use to understand how to write the article:
    {article_examples}
    """
  1. Profiles: We inject the specific writing profiles (Tonality, Terminology, Mechanics, Structure).
"""
    ## Tonality Profile
    ...
    {tonality_profile}
    
    ## Terminology Profile
    ...
    {terminology_profile}
    
    ## Mechanics Profile
    ...
    {mechanics_profile}
    
    ## Structure Profile
    ...
    {structure_profile}
    ...
    """
  1. Media Items: We enhance the generated media items with exceptional prompt engineering to guide their placement throughout the article.
"""
    ## Media Items
    
    Within the <article_guideline>, the user requested to include all types of media items, such as tables, diagrams, images, etc. Some of the 
    media items will be present inside the <research> or <article_guideline> tags as links. But often, we will have to generate the 
    media items ourselves.
    
    Thus, here is the list of media items that we already generated before writing the article that should be included as they are:
    {media_items}
    
    The list contains the <location> of each media item to know where to place it within the article. The location is the section title, 
    inferred from the <article_guideline> outline. Based on the <location>, locate the generated media item within the <article_guideline>, 
    and use it as is when writing the article.
    
    Replace the media item requirements from the <article_guideline> with the generated media item and it's caption. We always
    want to group a media item with it's caption.
    ...
    """
  1. The Article Profile and Guidelines: We first inject the article profile. Then we pass the ArticleGuideline, instructing the model that this represents the user’s explicit intent and overrides other general rules if there is a conflict. Also, we provide explicit instructions on how to parse the article guidelines and what key requirements to look out for. We intentionally placed the article profile close to the article guideline to ensure the attention mechanism makes connections between them.
"""
    ## Article Profile
    
    Here is the article profile, describing particularities on how the end-to-end article should look like:
    {article_profile}
    
    ## Article Guideline: 
    
    Here is the article guideline, representing the user intent, describing how the actual article should look like:
    {article_guideline}
    
    You will always start understand what to write by reading the <article_guideline>.
    
    As the <article_guideline> represents the user intent, it will always have priority over anything else. If any information
    contradicts between the <article_guideline> and other rules, you will always pick the one from the <article_guideline>.
    
    Avoid using the whole <research> when writing the article. Extract from the <research> only what is useful to respect the 
    user intent from the <article_guideline>. Still, always anchor your content based on the facts from the <research> or <article_guideline>.
    
    Always priotize the facts directly passed by the user in the <article_guideline> over the facts from the <research>. Avoid at all costs 
    to use your internal knowledge when writing the article.
    
    The <article_guideline> will ALWAYS contain:
    - all the sections of the article expected to be wrriten, in the correct order
    - a level of detail for each section, describing what each section should contain. Depending on how much detail you have in a
    particular section of the <article_guideline>, you will use more or less information from the <research> tags to write the section.
    
    The <article_guideline> can ALSO contain:
    - length constraints for each section, such as the number of characters, words or reading time. If present, you will respect them.
    - important (golden) references as URLs or titles present in the <research> tags. If present, always prioritize them over anything else 
    from the <research>.
    - information about anchoring the article into a series such as a course or a book. Extremely important when the article is part of 
    something bigger and we have to anchor the article into the learning journey of the reader. For example, when introducing concepts
    in previous articles that we don't want to reintroduce into the current one.
    - concrete information about writing the article. If present, you will ALWAYS priotize the instructions from the <article_guideline> 
    over any other instructions.
    ...
    """
  1. Reasoning: Lastly, using reasoning models, we explicitly instruct the LLM to generate the article outline internally, rather than having a different LLM call for it, as we did in the first version of Brown.
"""
    ## Article Outline
    
    Internnaly, based on the <article_guideline>, before starting to write the article, you will plan an article outline, 
    as a short summary of the article, describing what each section contains and in what order.
    
    Here are the rules you will use to generate the article outline:
    - The user's <article_guideline> always has priority! If the user already provides an article outline or a list of sections, 
    you will use them instead of generating a new one.
    - If the section titles are already provided in the <article_guideline>, you will use them as is, with 0 modifications.
    - Extract the core ideas from the <article_guideline> and lay them down into sections.
    - Your internal description of each section will be verbose enough for you to understand what each section contains.
    - Ultimately, the CORE scope of the article outline is to have an internal process that verifies that each section is anchored into the
    <article_guideline>, <research> and all the other profiles.
    - Before starting writing the final article, verify that the flow of ideas between the sections, from top to bottom, 
    is coherent and natural.
    ...
    """
  1. Chain of Thought + Immediate task description: Ultimately, through a chain of thought, we connected all the pieces and clearly instructed the LLM how to reason through the system prompt.
"""
    ## Chain of Thought
    
    1. Plan the article outline
    2. Write the article following the article outline and all the other constraints.
    3. Check if all the constraints are respected. Edit the article if not.
    4. Return ONLY the final version of the article.
    
    With that in mind, based on the <article_guideline>, you will write an in-depth and high-quality article following all 
    the <research>, guidelines and profiles.
    """

This massive prompt acts as the “brain” of our writer, ensuring it considers every constraint we’ve defined.

Based on the prompt anatomy from the previous section, we skipped the conversation history and the prefilled response because they don’t make sense in our use case, as we don’t have a conversational agent. Also, we skipped the output formatting because we expect a plain string. However, this one is usually added directly by the LLM API through the structured output configuration.

Before running everything end-to-end, we want to add a quick note on good practices for context engineering.

Reviewing Context Engineering Good Principles

You might wonder: “Why do we dump everything into one massive prompt for the writer? Why not use a chain of smaller prompts for specialized elements of the article to avoid the performance degradation due to issues such as the needle-in-the-haystack problem?”

If you remember, we tried that in the first version of the Brown architecture, which ended up being overly complex. When designing LLM-powered applications, there is always a trade-off between context isolation via specialized LLM calls (Brown V1) and a unified context that uses a single large system prompt (Brown V2).

Both approaches have pros and cons. The advantage of the unified prompt is that it’s faster, since we only need one LLM call instead of dozens. You might expect that isolating the context into a section-by-section generation strategy would increase performance, but in our tests, the results were on par.

Even worse, fragmenting the context window created many issues, because each section was not fully aware of details from the rest of the article, for example, which acronyms or concepts had already been introduced, which is critical for a good reader experience. Thus, splitting the generation into multiple LLM calls was a worse decision in almost every dimension: costs, latency, and performance.

from pathlib import Path
    from brown.loaders import (
        MarkdownArticleExampleLoader,
        MarkdownArticleGuidelineLoader,
        MarkdownArticleProfilesLoader,
        MarkdownResearchLoader,
    )
    
    # Define directories
    SAMPLE_DIR = Path("inputs/tests/01_sample_small")
    PROFILES_DIR = Path("inputs/profiles")
    EXAMPLES_DIR = Path("inputs/examples/course_lessons")
    
    # Load Guideline and Research
    guideline_loader = MarkdownArticleGuidelineLoader(uri=Path("article_guideline.md"))
    article_guideline = guideline_loader.load(working_uri=SAMPLE_DIR)
    
    research_loader = MarkdownResearchLoader(uri=Path("research.md"))
    research = research_loader.load(working_uri=SAMPLE_DIR)
    
    # Load Profiles and Examples
    profiles_loader = MarkdownArticleProfilesLoader(uri=PROFILES_DIR)
    article_profiles = profiles_loader.load()
    
    examples_loader = MarkdownArticleExampleLoader(uri=EXAMPLES_DIR)
    article_examples = examples_loader.load()

The article guideline looks something like this:

## Outline
    
    1. Introduction: The Critical Decision Every AI Engineer Faces
    2. Understanding the Spectrum: From Workflows to Agents
    3. Choosing Your Path
    4. Conclusion: The Challenges of Every AI Engineer
    
    ## Section 1 - Introduction: The Critical Decision Every AI Engineer Faces
    
    - **The Problem:** When building AI applications, engineers face a critical architectural decision early in their development process. Should they create a predictable, step-by-step workflow where they control every action, or should they build an autonomous agent that can think and decide for itself? This is one of the key decisions that will impact everything, from the product itself, such as development time and costs, to reliability and user experience.
    - Quick walkthrough of what we'll learn by the end of this lesson
    
    - **Section length:** 100 words
    
    ## Section 2 - Understanding the Spectrum: From Workflows to Agents
    ...
  1. Next, we initialize the Orchestrator-Worker system to identify what media items need to be generated. We create the worker tool, wrap it in a toolkit, and pass it to the orchestrator. Then, we run the orchestrator to identify the jobs.
from brown.models import SupportedModels, get_model
    from brown.nodes import MediaGeneratorOrchestrator, MermaidDiagramGenerator, Toolkit
    
    # 1. Create worker tool
    diagram_model = get_model(SupportedModels.GOOGLE_GEMINI_25_FLASH)
    mermaid_generator = MermaidDiagramGenerator(model=diagram_model)
    toolkit = Toolkit(tools=[mermaid_generator.as_tool()])
    
    # 2. Create orchestrator
    orchestrator_model = get_model(SupportedModels.GOOGLE_GEMINI_25_FLASH)
    orchestrator = MediaGeneratorOrchestrator(
        article_guideline=article_guideline,
        research=research,
        model=orchestrator_model,
        toolkit=toolkit,
    )
    
    # 3. Run orchestrator to identify required media items
    media_jobs = await orchestrator.ainvoke()
    
    print(f"Found {len(media_jobs)} media items to generate")
    for i, job in enumerate(media_jobs):
        print(f"Job {i + 1}:")
        print(f"  Tool: {job['name']}")
        print(f"  Description: {job['args'].get('description_of_the_diagram', 'N/A')[:100]}...")
        print(f"  Section: {job['args'].get('section_title', 'N/A')}")

It outputs the identified jobs:

Found 2 media items to generate
    Job 1:
      Tool: mermaid_diagram_generator_tool
      Description: A flowchart illustrating a simple LLM workflow. The workflow starts, an LLM call or other operation ...
      Section: Understanding the Spectrum: From Workflows to Agents
    Job 2:
      Tool: mermaid_diagram_generator_tool
      Description: A flowchart illustrating the AI generation and human verification loop. It should show an AI compone...
      Section: Choosing Your Path
  1. Now we execute all the identified jobs in parallel to generate the actual media items.
import asyncio
    from brown.entities.media_items import MediaItem, MediaItems
    
    # Execute all tool calls in parallel
    coroutines = []
    for job in media_jobs:
        tool = orchestrator.toolkit.get_tool_by_name(job["name"])
        if tool:
            coroutines.append(tool.ainvoke(**job["args"]))
    
    media_items_list: list[MediaItem] = await asyncio.gather(*coroutines)
    
    # Build MediaItems entity
    media_items = MediaItems.build(media_items=media_items_list)
    
    print(media_items.to_context())

It outputs the final generated diagrams:

<media_items>
    
    <mermaid_diagram>
        <location>Understanding the Spectrum: From Workflows to Agents</location>
        <content>```mermaid
    graph TD
        A["Start"] --> B["LLM Call / Data Operation"]
        B --> C["End"]
    ```</content>
        <caption>A simple LLM workflow flowchart.</caption>
    </mermaid_diagram>
    
    
    <mermaid_diagram>
        <location>Choosing Your Path</location>
        <content>```mermaid
    graph TD
        A["AI Generates Content/Solution"]
        B["Human Reviews and Verifies Output"]
        A --> B
        B --> A: "Refinement/Correction Feedback"
    ```</content>
        <caption>A flowchart illustrating the AI generation and human verification loop with a feedback mechanism.</caption>
    </mermaid_diagram>
    
    </media_items>
  1. Finally, we instantiate the ArticleWriter with all loaded context and generated media items, run it to produce the final article.
from brown.nodes import ArticleWriter
    
    # Initialize Writer
    writer = ArticleWriter(
        article_guideline=article_guideline,
        research=research,
        article_profiles=article_profiles,
        article_examples=article_examples,
        media_items=media_items,
        model=get_model(SupportedModels.GOOGLE_GEMINI_25_PRO)
    )
    
    # Generate
    article = await writer.ainvoke()
    print(article.content)

It outputs the final article content:

# Workflows vs. Agents: The Critical Decision Every AI Engineer Faces
    ### How to choose between predictable control and autonomous flexibility when building AI applications.
    
    When building AI applications, engineers face a critical architectural decision early in the process. Should you create a predictable, step-by-step workflow where you control every action, or build an autonomous agent that can think and decide for itself? This choice impacts everything from development time and cost to reliability and user experience. It is a fundamental decision that often determines if an AI application will be successful in production.
    
    By the end of this lesson, you will understand the fundamental differences between LLM workflows and AI agents, know when to use each, and recognize how to combine their strengths in hybrid approaches.
    
    ## Understanding the Spectrum: From Workflows to Agents
    
    To make the right choice, you first need to understand what LLM workflows and AI agents are. We will examine their core properties and how they are utilized, rather than focusing on their technical specifics.
    
    ### LLM Workflows
    
    An LLM workflow is a sequence of tasks orchestrated by developer-written code. It can include LLM calls, as well as other operations such as reading from a database or calling an API. Think of it like a recipe where each step is explicitly defined. The key characteristic is that the path is determined in advance, resulting in a deterministic or rule-based system. This gives you predictable execution, explicit control over the application's flow, and makes the system easier to test and debug. Because you control every step, you know exactly where a failure occurred and how to fix it.
    
    ```mermaid
    graph TD
        A["Start"] --> B["LLM Call / Data Operation"]
        B --> C["End"]

Image 1: A simple LLM workflow flowchart.

AI Agents

...


Open the `inputs/tests/01_sample_small` directory to see the generated `[article.md](<http://article.md/>)` and the user inputs.

Note that in this end-to-end example, we began implementing the application-layer code that glues our domain components together. It creates a clean separation between the  _logic_ of writing (the nodes) and the  _execution_ of the workflow. Also, note that infrastructure elements such as file loaders, renderers, or models do not pollute the domain objects or the overall application logic. They are just injected through composition. This means we can easily abstract the application logic through interfaces and swap implementations, such as using S3 Loaders/Renderers instead of local disk ones, or switching from Gemini to Anthropic or open-source models.

The application and infrastructure layers will become clearer when we implement the LangGraph workflows in the next lesson.

## **Conclusion**

Congratulations! In this lesson, we built the core engine of Brown, the writing workflow. We learned:

  1. **Context Engineering:** How to map domain entities to XML-structured context using `ContextMixin` , plug them into a massive system prompt and help the LLM reason through them.
  2. **Writing Profiles + Few-shot examples:** How to enforce style and voice using granular profile entities and few-shot examples.
  3. **Orchestrator-Worker:** How to write the orchestrator-worker pattern from scratch and dynamically generate media items by delegating to specialized tools.

We now have a system that can write a good first draft. Still, because the system prompt is extremely complex, the LLM cannot reason through everything at once. In our opinion, that’s natural. Ultimately, even we, as humans, need a few iterations to refine our work.

That’s why, in the next lesson (Lesson 23), we will implement the **Evaluator-Optimizer** pattern to iteratively review and edit the article. Additionally, we will integrate everything into a robust LangGraph workflow. Then, in Lesson 24, we will expose these workflows via MCP to bring humans into the loop.

### Practicing Ideas

As a practical exercise for part 4 of the course, where you have to implement your own project, here are some ideas on how you can further extend the code: 

  * **Add support for image and video generation** within the Orchestrator-Worker layer to create richer media assets.
  * **Reduce costs and latency** by caching constant inputs (like research and profiles) between LLM calls to avoid recomputing them, and by compressing the research relative to the article guideline.
  * **Extend the writer** to other media formats such as social media posts, email newsletter articles, technical documentation, or video transcripts.
  * **Add different character profiles** alongside our Paul Iusztin one to support multiple voices and personas.

### References

  1. Anthropic. (n.d.). Building effective agents. Anthropic. [anthropic.com/engineering/building-effective-agents](<https://www.anthropic.com/engineering/building-effective-agents>)
  2. Anthropic. (n.d.). Effective context engineering for AI agents. Anthropic. [anthropic.com/engineering/effective-context-engineering-fo...](<https://www.anthropic.com/engineering/effective-context-engineering-for-ai-agents>)
  3. Anthropic just revealed their internal prompt engineering template - here’s how to 10x your Claude results. (n.d.). Reddit. [reddit.com/r/PromptEngineering/comments/1n08dpp/anthrop...](<https://www.reddit.com/r/PromptEngineering/comments/1n08dpp/anthropic_just_revealed_their_internal_prompt/>)
  4. LangChain. (n.d.). Chat models. LangChain. [reference.langchain.com/python/langchain/models](<https://reference.langchain.com/python/langchain/models/>)
  5. Prompting 101 | Code w/ Claude [Video]. (n.d.). YouTube. [youtube.com/watch](<https://www.youtube.com/watch?v=ysPbXH0LpIE>)