DEV Community

Santosh Ghoghwani
Santosh Ghoghwani

Posted on Originally published at Medium

How to Build an Autonomous Blog Writer with CrewAI and Python

Unsplash

Those who have just started writing, and even experienced writers, face this problem on a day-to-day basis.

They sit in front of a laptop screen for a couple of hours before rage-quitting over

  • what to write,
  • how to write it,
  • which research papers are the best,
  • and how to format the post.

The main problem is that writing requires patience, research, and good structure before it can be considered a solid piece of information.

To overcome this, we are going to learn how to automate the task and reduce the effort and hassle of writing.

We will also make the process more interesting, even for experienced writers.

Disclaimer: Think of AI as an assistant, not a replacement for your effort.

Please don't use these tools to spam blogs created purely for monetary gain.

What is CrewAI

CrewAI is an open-source framework and an enterprise platform used to build multi-agent AI systems that orchestrate and collaborate on a complex task, where each agent has a defined role for handling different parts of a job.

You can consider CrewAI as an organisation, agents as the employees of that organisation, and tools as the skill set of those agents.

Main Components of CrewAI

  • Crew: A crew is a team of agents specialised in a single task. It can comprise a single agent or multiple agents, depending on the use case. Just as an organisation can have multiple departments, CrewAI can have multiple crews.
  • Agents: Agents are the employees responsible for completing a specialised task, such as research, drafting, editing, etc.
  • Tools: Tools are the helping hands of agents and carry out certain tasks, such as web search, API calls, etc.

Prerequisites

  • AI API keys (paid) or Ollama local setup (free)
  • A code editor (VS Code, Cursor, or similar)
  • Concepts of generative AI and prompt engineering
  • CrewAI basics
  • Python basics

LLM Setup

CrewAI requires the configuration of an LLM, so gather the API keys and required details from your LLM provider and write them down in a .env file later, in the format below.

AZURE_OPENAI_API_KEY=your-azure-key
AZURE_OPENAI_ENDPOINT=https://your-resource-name.openai.azure.com/
AZURE_OPENAI_API_VERSION=2024-10-21
AZURE_OPENAI_DEPLOYMENT=your-deployment-name
Enter fullscreen mode Exit fullscreen mode

For my development, I am going to use Ollama, a free, open-source tool for running LLMs locally.

You can use it too, and refer to my blog, How I Built My Own Voice Agent Prototype Using Ollama, ElevenLabs & Whisper?, for setting up Ollama on your local system.

OLLAMA_MODEL=llama3.1:8b
OLLAMA_BASE_URL=http://localhost:11434
OTEL_SDK_DISABLED=true
Enter fullscreen mode Exit fullscreen mode

Python Setup

Once your LLM credentials are written down securely, we can proceed with setting up Python on our system.

Python is a programming language used for the development of many kinds of coding projects, and AI/ML is one of them.

You can download it by visiting the official website.

After downloading and setting up Python on your system, create an empty folder and open it inside your code editor.

Make sure you have installed the Python extension in the editor for better and faster development and debugging.

With that, the Python setup is complete.

To set up CrewAI, we need to install the dependencies of CrewAI in our project using the uv package manager.

Open the terminal and install the uv package manager using the command below.

#Windows
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"

#Linux
curl -LsSf https://astral.sh/uv/install.sh | sh

#MacOS
curl -LsSf https://astral.sh/uv/install.sh | sh
Enter fullscreen mode Exit fullscreen mode

Once the package manager has installed, restart your terminal and verify it using the below command.

if the command fails, there might be an issue with the installation process

uv --version
Enter fullscreen mode Exit fullscreen mode

After installing the package manager, use the following command to set up the boilerplate of your project.

uv init
Enter fullscreen mode Exit fullscreen mode

After initialising, you need to install a few dependencies, so use the following command to download CrewAI and other libraries.

uv add crewai ddgs requests beautifulsoup4 python-dotenv markdown xhtml2pdf python-docx pyyaml
Enter fullscreen mode Exit fullscreen mode

CrewAI Setup

Once Python and all its libraries are in place, we can start building our project using CrewAI.

Use the following command to set up your blog writing flow.

crewai create
Enter fullscreen mode Exit fullscreen mode

After running the command, you will be prompted with a form where you need to provide basic information about the project you want to build with CrewAI.

I will cover each approach in a different blog.

For our use case, we are going to create a flow named Blogwriting.

What would you like to create?

    crew         A team of AI agents working together
  → flow         A deterministic workflow with full control over agents and crews
    tool         A custom tool for the CrewAI Tool Repository
    skill        An agent skill with instructions and optional assets
    template     A remote project template from the CrewAI gallery


Name of your flow › BlogWriting
Enter fullscreen mode Exit fullscreen mode

Once your CrewAI foundation is created, you will see a similar folder structure in your code editor.

AutonomousBlogWriting/
 .venv/                        
 .gitignore
 .python-version               
  main.py                       
  pyproject.toml                
  README.md
  uv.lock
  blogwriting/                 
      .env                      
      .gitignore
       pyproject.toml            
       README.md
       src/
         blogwriting/
             __init__.py
             main.py           
             crew.py 
             crews/content_crew        
                 config/
                     agents.yaml   
                     tasks.yaml 
                 content_crew.py  
             tools/
                 __init__.py
                 custom_tool.py  
Enter fullscreen mode Exit fullscreen mode

Our first task is to set up the environment variables, so go to AutonomousBlogWriting/blogwriting/.env and paste your LLM credentials in the format I showed you above.

Code

Now that every element is in place, we are going to start combining all those things to build our project.

If you get stuck during this process, I recommend you fork my Git repo to get a better understanding of the code

GitHub — Autonomous Writer Git Repository

Config

To build an organisation, we first need employees who are specialists in their respective domains, so go to AutonomousBlogWriting/blogwriting/src/blogwriting/crews/content_crew/config and start updating the YAML files.

agents.yaml — In this file, you need to define all the agents (employees) you want to work with in your organisation.

Each agent has three main components:

  • role — The title of your agent
  • goal — The responsibilities of your agent
  • backstory — short summary of how your agent will perform its task

We are going to create four agents: researcher, writer, editor, and formatter.

You can always refer to my Git repo for reference, as I have defined all four agents in my YAML file.

tasks.yaml — In this file, we are going to define all the respective tasks your agents will perform to generate the expected output.

An agent can have a single task or multiple tasks, depending on the use case.

We are going to create four tasks, one for each agent's goal.

Similar to agents, tasks also have three main components:

  • description — A detailed structure of the steps the agent has to take to generate the desired results
  • expected_output — The format in which the agent will generate the result. It can be normal text, a markdown file, code, etc.
  • agent — The name of the agent you want this task to be assigned to.

Tool

Agents require tools to fetch details from the internet, make API calls, fetch data from databases, and much more.

We need to create a web search tool that lets us find the most relevant information about the topic the user is searching for.

For this, we are going to create a custom tool inside AutonomousBlogWriting/blogwriting/src/blogwriting/tools and name it web_search_tool.py.

It helps us fetch a dictionary of URLs related to the topic and then read the details from those URLs to create a new blog as the output.

from typing import Type

import requests
from bs4 import BeautifulSoup
from crewai.tools import BaseTool
from ddgs import DDGS
from pydantic import BaseModel, Field


class WebSearchInput(BaseModel):
    query: str = Field(..., description="The search query, e.g. 'latest trends in RAG'")
    max_results: int = Field(5, description="Number of results to return (1-10)")


class WebSearchTool(BaseTool):
    name: str = "web_search"
    description: str = (
        "Search the web for up-to-date information. Returns title, URL and snippet "
        "for each result. Use it to research topics and collect sources."
    )
    args_schema: Type[BaseModel] = WebSearchInput

    def _run(self, query: str, max_results: int = 5) -> str:
        max_results = max(1, min(int(max_results), 10))
        try:
            results = DDGS().text(query, max_results=max_results)
        except Exception as e:
            return f"Search failed: {e}. Try rephrasing the query or retry shortly."
        if not results:
            return "No results found. Try a broader or differently worded query."
        return "\n\n".join(
            f"[{i}] {r.get('title', '').strip()}\n"
            f"URL: {r.get('href', '')}\n"
            f"Snippet: {r.get('body', '').strip()}"
            for i, r in enumerate(results, 1)
        )


class PageReaderInput(BaseModel):
    url: str = Field(..., description="Full URL of the page to read (include https://)")
    max_chars: int = Field(4000, description="Maximum characters of page text to return")


class PageReaderTool(BaseTool):
    name: str = "read_webpage"
    description: str = (
        "Fetch a web page and return its main text. Use on a promising URL from "
        "web_search when the snippet isn't enough."
    )
    args_schema: Type[BaseModel] = PageReaderInput

    def _run(self, url: str, max_chars: int = 4000) -> str:
        try:
            resp = requests.get(
                url, timeout=15,
                headers={"User-Agent": "Mozilla/5.0 (compatible; BlogFlowBot/1.0)"},
            )
            resp.raise_for_status()
        except Exception as e:
            return f"Could not fetch {url}: {e}"
        soup = BeautifulSoup(resp.text, "html.parser")
        for tag in soup(["script", "style", "nav", "footer", "header", "aside", "form"]):
            tag.decompose()
        text = " ".join(soup.get_text(separator=" ").split())
        return text[:max_chars] + ("... [truncated]" if len(text) > max_chars else "")
Enter fullscreen mode Exit fullscreen mode

LLM and Crew

Everything around the project is set up.

Now we have to build our crew and flow, then merge them into main.py.

Start your Ollama server and create a file named config.py inside AutonomousBlogWriting/blogwriting/src/blogwriting.

import os

from crewai import LLM
from dotenv import load_dotenv

load_dotenv()


def get_llm(temperature: float = 0.4) -> LLM:
    return LLM(
        model=f"ollama/{os.getenv('OLLAMA_MODEL', 'llama3.1:8b')}",
        base_url=os.getenv("OLLAMA_BASE_URL", "http://localhost:11434"),
        temperature=temperature,
    )
Enter fullscreen mode Exit fullscreen mode

We are going to call our LLM from this file and set it in our content_crew.py file, where we are going to define our agents and tasks in CrewAI and call them based on the agent role and task description.

Below is our content_crew.py file. You need to update it in AutonomousBlogWriting/blogwriting/src/blogwriting/crews/content_crew/content_crew.py.

from pathlib import Path

import yaml
from crewai import Agent, Crew, Process, Task

from config import get_llm

CONFIG_DIR = Path(__file__).parent / "config"
AGENTS = yaml.safe_load((CONFIG_DIR / "agents.yaml").read_text(encoding="utf-8"))
TASKS = yaml.safe_load((CONFIG_DIR / "tasks.yaml").read_text(encoding="utf-8"))


def run_step(task_key: str, inputs: dict, tools=None, temperature=0.4, max_iter=None) -> str:
    task_cfg = dict(TASKS[task_key])
    agent_key = task_cfg.pop("agent")          # "agent: researcher" -> look up in agents.yaml

    extra = {"max_iter": max_iter} if max_iter else {}
    agent = Agent(
        config=AGENTS[agent_key],
        tools=tools or [],
        llm=get_llm(temperature),
        allow_delegation=False,
        verbose=True,
        **extra,
    )
    task = Task(config=task_cfg, agent=agent)
    crew = Crew(agents=[agent], tasks=[task], process=Process.sequential, verbose=True)
    return str(crew.kickoff(inputs=inputs).raw).strip()   # fills {topic}, {draft}, etc.
Enter fullscreen mode Exit fullscreen mode

Exporters

exporters.py is a file that we are going to use to store the LLM response in our desired format, such as Markdown, DOCX, PDF, etc.

Create a new file named exporters.py in AutonomousBlogWriting/blogwriting/src/blogwriting.

For now, this file supports a limited number of formats that the LLM response can be converted to.

If you need more, you can update this file according to your needs.

import re
from pathlib import Path
from typing import Callable, Dict, Tuple

import markdown as md_lib

Meta = Dict[str, str]

CSS = """
@page { size: A4; margin: 2cm; }
body { font-family: Helvetica, Arial, sans-serif; font-size: 11pt; line-height: 1.5; color: #222; }
h1 { font-size: 22pt; margin-bottom: 4pt; }
h2 { font-size: 15pt; margin-top: 18pt; }
h3 { font-size: 12pt; }
a { color: #1a5fb4; }
code, pre { font-family: Courier; font-size: 9.5pt; background-color: #f2f2f2; }
blockquote { margin-left: 12pt; color: #555; }
table { border-collapse: collapse; }
td, th { border: 1px solid #bbb; padding: 4pt; }
"""


def split_front_matter(text: str) -> Tuple[Meta, str]:
    m = re.match(r"^---\s*\n(.*?)\n---\s*\n(.*)$", text.strip(), re.S)
    if not m:
        return {}, text.strip()
    meta: Meta = {}
    for line in m.group(1).splitlines():
        if ":" in line:
            k, v = line.split(":", 1)
            meta[k.strip().lower()] = v.strip().strip("'\"")
    return meta, m.group(2).strip()


def slugify(text: str) -> str:
    return re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")[:60] or "blog-post"


def _html_doc(meta: Meta, body: str) -> str:
    html_body = md_lib.markdown(body, extensions=["extra", "sane_lists", "tables", "fenced_code"])
    title = meta.get("title", "Blog Post")
    return (
        f"<html><head><meta charset='utf-8'><title>{title}</title>"
        f"<style>{CSS}</style></head><body>{html_body}</body></html>"
    )


def _to_md(meta: Meta, body: str, path: Path, raw: str) -> None:
    path.write_text(raw.strip() + "\n", encoding="utf-8")


def _to_html(meta: Meta, body: str, path: Path, raw: str) -> None:
    path.write_text(_html_doc(meta, body), encoding="utf-8")


def _to_pdf(meta: Meta, body: str, path: Path, raw: str) -> None:
    from xhtml2pdf import pisa

    with open(path, "wb") as f:
        status = pisa.CreatePDF(src=_html_doc(meta, body), dest=f, encoding="utf-8")
    if status.err:
        raise RuntimeError("PDF generation failed")


def _to_txt(meta: Meta, body: str, path: Path, raw: str) -> None:
    text = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r"\1 (\2)", body)
    text = re.sub(r"^#{1,6}\s*", "", text, flags=re.M)
    text = re.sub(r"(\*\*|__|`)", "", text)
    path.write_text(text.strip() + "\n", encoding="utf-8")


_INLINE = re.compile(r"(\*\*.+?\*\*|`.+?`|\*.+?\*)")


def _add_runs(par, text: str) -> None:
    text = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r"\1 (\2)", text)
    for part in _INLINE.split(text):
        if not part:
            continue
        if part.startswith("**") and part.endswith("**") and len(part) > 4:
            par.add_run(part[2:-2]).bold = True
        elif part.startswith("`") and part.endswith("`") and len(part) > 2:
            par.add_run(part[1:-1]).font.name = "Courier New"
        elif part.startswith("*") and part.endswith("*") and len(part) > 2:
            par.add_run(part[1:-1]).italic = True
        else:
            par.add_run(part)


def _to_docx(meta: Meta, body: str, path: Path, raw: str) -> None:
    from docx import Document

    doc = Document()
    buf: list = []

    def flush():
        if buf:
            _add_runs(doc.add_paragraph(), " ".join(buf))
            buf.clear()

    in_code = False
    for line in body.splitlines():
        if line.strip().startswith("```"):
            flush()
            in_code = not in_code
            continue
        if in_code:
            par = doc.add_paragraph()
            par.add_run(line).font.name = "Courier New"
            continue
        s = line.strip()
        if not s:
            flush()
        elif (h := re.match(r"^(#{1,6})\s+(.*)", s)):
            flush()
            doc.add_heading(h.group(2), level=min(len(h.group(1)) - 1, 4))
        elif re.match(r"^[-*+]\s+", s):
            flush()
            _add_runs(doc.add_paragraph(style="List Bullet"), re.sub(r"^[-*+]\s+", "", s))
        elif re.match(r"^\d+[.)]\s+", s):
            flush()
            _add_runs(doc.add_paragraph(style="List Number"), re.sub(r"^\d+[.)]\s+", "", s))
        elif s.startswith(">"):
            flush()
            _add_runs(doc.add_paragraph(style="Intense Quote"), s.lstrip("> "))
        elif re.match(r"^(-{3,}|\*{3,})$", s):
            flush()
        else:
            buf.append(s)
    flush()
    doc.save(path)


EXPORTERS: Dict[str, Tuple[str, Callable]] = {
    "markdown": (".md", _to_md),
    "md": (".md", _to_md),
    "html": (".html", _to_html),
    "pdf": (".pdf", _to_pdf),
    "docx": (".docx", _to_docx),
    "txt": (".txt", _to_txt),
}

SUPPORTED_FORMATS = sorted(set(EXPORTERS) - {"md"})


def export(markdown_text: str, fmt: str, out_dir: str, fallback_title: str = "blog post") -> Path:
    fmt = fmt.lower().strip()
    if fmt not in EXPORTERS:
        raise ValueError(f"Unsupported format '{fmt}'. Choose from: {', '.join(SUPPORTED_FORMATS)}")
    ext, writer = EXPORTERS[fmt]
    meta, body = split_front_matter(markdown_text)
    out = Path(out_dir)
    out.mkdir(parents=True, exist_ok=True)
    path = out / f"{slugify(meta.get('title', fallback_title))}{ext}"
    writer(meta, body, path, markdown_text)
    return path
Enter fullscreen mode Exit fullscreen mode

Flow and Main

This is the easiest and last part of our project.

To bring your code live, you just have to create a sequential flow, because there is no separate routing, and every time a query is run, we need to go through a defined number of steps, calling our agents one by one without any loops or conditions.

In AutonomousBlogWriting/blogwriting/src/blogwriting, create a new file named flow.py and paste the code below for the sequential flow of your crew

import re
from pathlib import Path

from crewai.flow.flow import Flow, listen, start
from pydantic import BaseModel

from crews.content_crew.content_crew import run_step
from exporters import export
from tools.web_search_tool import PageReaderTool, WebSearchTool


class BlogState(BaseModel):
    topic: str = ""
    audience: str = "general readers"
    tone: str = "conversational and informative"
    word_count: int = 900
    output_format: str = "markdown"      # markdown | html | pdf | docx | txt
    output_dir: str = "output"
    research_notes: str = ""
    draft: str = ""
    edited_post: str = ""
    final_markdown: str = ""
    output_path: str = ""


def _clean_markdown(text: str) -> str:
    m = re.match(r"^```(?:markdown|md)?\s*\n(.*)\n```\s*$", text.strip(), re.S)
    return (m.group(1) if m else text).strip()


class BlogFlow(Flow[BlogState]):
    def _save_step(self, name: str, text: str) -> None:
        d = Path(self.state.output_dir) / "intermediate"
        d.mkdir(parents=True, exist_ok=True)
        (d / f"{name}.md").write_text(text, encoding="utf-8")

    @start()
    def research(self):
        s = self.state
        s.research_notes = run_step(
            "research_task", s.model_dump(),
            tools=[WebSearchTool(), PageReaderTool()], temperature=0.2, max_iter=10,
        )
        self._save_step("1_research", s.research_notes)

    @listen(research)
    def drafting(self):
        s = self.state
        s.draft = run_step("writing_task", s.model_dump(), temperature=0.7)
        self._save_step("2_draft", s.draft)

    @listen(drafting)
    def editing(self):
        s = self.state
        s.edited_post = run_step("editing_task", s.model_dump(), temperature=0.3)
        self._save_step("3_edited", s.edited_post)

    @listen(editing)
    def formatting(self):
        s = self.state
        raw = run_step("formatting_task", s.model_dump(), temperature=0.1)
        s.final_markdown = _clean_markdown(raw)
        path = export(s.final_markdown, s.output_format, s.output_dir, fallback_title=s.topic)
        s.output_path = str(path)
        print(f"Final output written to: {path}")
        return s.output_path
Enter fullscreen mode Exit fullscreen mode

In the above file, we have created some variables that contain predefined values, and topic is the only variable that is not optional.

To change these variables at runtime, we are going to add arguments in our main file, such as word count, tone, etc.

Below is the main.py file, which lets you run your crew.

import argparse
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).parent / "src"))

from flow import BlogFlow  # noqa: E402
from exporters import SUPPORTED_FORMATS  # noqa: E402



def main() -> None:
    p = argparse.ArgumentParser(description="Autonomous blog-writing Flow (CrewAI + Ollama)")
    p.add_argument("--topic", required=True, help="Blog topic")
    p.add_argument("--audience", default="general readers")
    p.add_argument("--tone", default="conversational and informative")
    p.add_argument("--words", type=int, default=900, help="Target word count")
    p.add_argument("--format", default="markdown", choices=SUPPORTED_FORMATS + ["md"],
                   help="Output file format")
    p.add_argument("--output-dir", default="output")
    p.add_argument("--plot", action="store_true", help="Save a flow diagram (flow.html) and exit")
    args = p.parse_args()

    flow = BlogFlow()
    if args.plot:
        flow.plot("blog_flow")
        return

    result = flow.kickoff(inputs={
        "topic": args.topic,
        "audience": args.audience,
        "tone": args.tone,
        "word_count": args.words,
        "output_format": args.format,
        "output_dir": args.output_dir,
    })
    print(f"\nDone -> {result}")


if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

All our files are in place. All we have to do is open our terminal and run main.py to generate amazing ideas.

python main.py --topic "C++ vs Rust: which is faster?" --format pdf
Enter fullscreen mode Exit fullscreen mode

With this, we have come to the end of our blog. If you are stuck somewhere, have any opinions, or want me to cover a specific topic, the comments section is open.

Feel free to drop your thoughts.

Thank you for reading!


For more such content, visit my Medium blog.

Top comments (0)