Lesson 22: Implementing the Foundations of the Writing Workflow
- Scoping the Writing Workflow Architecture
- Modeling our Domain Entities as Context for LLMs
- The ContextMixin Interface
- The Article Guideline Entity
- The Research Entity
- The Secret Sauce of Writing Professional Articles: The Writing Profiles
- The Article Examples Entity
- Unifying LLM Calls
- Generating Media Items Using the Orchestrator-Worker Pattern
- The Node, Toolkit, and ToolCall Abstractions
- The Orchestrator: MediaGeneratorOrchestrator
- The ToolNode Abstraction
- The Worker: MermaidDiagramGenerator
- End-to-End Example of Calling the Orchestartor-Worker Module
- Working With Multimodal Inputs
- Understanding the Anatomy of a Prompt
- Generating the Article
- Reviewing Context Engineering Good Principles
- Running the Writing Workflow End-To-End
- Conclusion
- Practicing Ideas
- References
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:
- 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.
- Media Generation: Using the Orchestrator-Worker pattern, we analyze content requirements and spin up specialized workers (tools) to generate assets such as Mermaid diagrams.
- Article Generation: We aggregate the context and the generated media into a well-structured system prompt and pass it to the
ArticleWriternode to produce the final draft.
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 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.
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.
We load the guideline using a dedicated loader.
After we convert it to context using the to_context() method, it looks like this:
Let’s see how this looks in practice. We load the research file and convert it to context.
It outputs:
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.
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.
Let’s inspect the raw content of the ArticleProfile.
It outputs the raw markdown instructions:
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>).
It outputs:
If we do the same for the StructureProfile, the XML tag changes automatically, ensuring the LLM can distinguish between different types of instructions.
It outputs:
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 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.
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.
It outputs:
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.
- First, we define the imports and a mapping to handle API keys for different models.
- Then, we implement the factory function itself. It handles the
FakeModelfor testing, merges the default configuration with any overrides, and initializes the chat model using LangChain’sinit_chat_model.
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.
- Here we define the
SupportedModelsenum and theModelConfigclass.
- Finally, we define
DEFAULT_MODEL_CONFIGSto provide sensible defaults for specific models.
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:
It outputs:
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.
- ToolCall: A simple
TypedDictrepresenting a request from the LLM to execute a tool. It contains the tool’s name, arguments, and a unique ID.
- 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.
- 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.
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.
- 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):
- Next, we define the
MediaGeneratorOrchestratorclass, inheriting from theNodeabstraction, initializing it with the proper context, and binding the tools from thetoolkit. 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.
- Finally, we implement the
ainvokemethod that calls the LLM and returns a list of tool calls labeled as 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.
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.
- First, we define the imports and the Pydantic structured output model:
- Next, we define the specialized prompt template (we added only part of it!):
Diagram Types & Examples
Process Flow
... ← MORE EXAMPLES!!!
Syntax Rules
- Node Labels: Use square brackets
[Label]for rectangular nodes - Decisions: Use curly braces
{{Decision}}for diamond shapes - Databases: Use
[(Label)]for cylindrical database shapes - Circles: Use
((Label))for circular nodes - Arrows: Use
-->for solid arrows,-.->for dotted arrows - Labels on Arrows: Use
-->|Label|for labeled connections - Subgraphs: Use
subgraph "Title"andendto group elements - Comments: Use
%%for comments - ERD Entities: Use
ENTITY_NAME {{ field_type field_name }}format - ERD Relationships: Use
||--o{{,||--||,}}o--||for different cardinalities - Class Definitions: Use
class ClassName {{ +type attribute +method() }}format - Class Relationships: Use
<|--(inheritance),-->(association),--*(composition)
Formatting Rules
ALWAYS wrap node labels in double quotes "..." to prevent parsing errors:
WRONG (causes parse errors):
CORRECT (always use quotes):
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. """
- We implement the
ainvokemethod to generate the diagram and handle potential errors.
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:
Which outputs:
Next, we call each generated tool call as follows:
Which outputs the results of the tools, which in our use case are the final diagrams:
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:
When we use it in practice, we combine the system prompt with image URLs from the research:
This produces a list of dictionaries where each image URL is preceded by a text marker indicating it’s part of the <research> context:
Key Design Decisions:
- 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. - 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.
- 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:
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 Prompting 101 | Code w/ Claude)
Here are the 10 most essential components of a prompt’s anatomy:
- Task Context: Who is the LLM? (e.g., “You are Brown, a professional writer...")
- Tone Context: How should it sound?
- Background Data: The research and domain entities.
- Detailed Task Description & Rules: Specific rules and constraints.
- Examples: Few-shot examples (our
ArticleExamples). - Conversation History: Previous interactions (if applicable).
- Immediate Task Description or Request: The specific user request (the
ArticleGuideline). - Thinking Step-by-Step: Instructions to plan before writing (Chain of Thought).
- Output Formatting: Rules for Markdown, citations, and related elements.
- 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.
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.
- Role & Task: We start by defining the persona and the high-level objective.
- Character Profile: We inject the character profile to anchor the identity.
- 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.
- Examples: Next, the examples:
- Profiles: We inject the specific writing profiles (Tonality, Terminology, Mechanics, Structure).
- Media Items: We enhance the generated media items with exceptional prompt engineering to guide their placement throughout the article.
- 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.
- 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.
- 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.
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.
The article guideline looks something like this:
- 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.
It outputs the identified jobs:
- Now we execute all the identified jobs in parallel to generate the actual media items.
It outputs the final generated diagrams:
- Finally, we instantiate the
ArticleWriterwith all loaded context and generated media items, run it to produce the final article.
It outputs the final article content:
Image 1: A simple LLM workflow flowchart.
AI Agents
...