Building a Multi-Agent AI for Company LinkedIn Pages โ€” Part 2: Building the Orchestrator Agent
DEV Community

Building a Multi-Agent AI for Company LinkedIn Pages - Part 2: Building the Orchestrator Agent

The Orchestrator Agent

In the previous article, I broke down the problem into independent responsibilities instead of expecting one agent to do everything. If every agent is independent of each other, how do they communicate with each other? Who decides what runs next? That's where the Orchestrator Agent acts like a traffic controller, managing how agents pass context to each other and maintaining the order in which they run.

linkedin-agent/
โ”œโ”€โ”€ agents/
โ”‚   โ”œโ”€โ”€ orchestrator.py
โ”‚   โ”œโ”€โ”€ research.py
โ”‚   โ”œโ”€โ”€ examples.py
โ”‚   โ”œโ”€โ”€ critic.py
โ”‚   โ”œโ”€โ”€ brief.py
โ”‚   โ”œโ”€โ”€ hook.py
โ”‚   โ”œโ”€โ”€ draft.py
โ”‚   โ””โ”€โ”€ writing_critic.py
โ”œโ”€โ”€ core/
โ”‚   โ”œโ”€โ”€ llm.py
โ”‚   โ”œโ”€โ”€ models.py
โ”‚   โ””โ”€โ”€ utils.py
โ”œโ”€โ”€ data/
โ”‚   โ””โ”€โ”€ chroma/
โ”œโ”€โ”€ main.py
โ”œโ”€โ”€ api.py
โ”œโ”€โ”€ requirements.txt
โ””โ”€โ”€ .env

Each agent has its own file. The core folder has:

  • llm.py wraps all interactions with Ollama through a single call_llm() function.
  • models.py stores shared Pydantic schemas used across agents.
  • utils.py has function clean_topic() to remove extra spaces from topic user has entered.
  • main.py runs the agent pipeline and api.py has the FastAPI server.

The Orchestrator Agent has one job: understand the topic, classify it, decide the best content angle, and return structured output for the Research Agent. For every LLM call we are making, we need a SYSTEM_PROMPT (the prompt we write to define how the agent has to work) and a user_message which has all context from previous agents. So, let's start by writing a SYSTEM_PROMPT for the Orchestrator Agent.

SYSTEM_PROMPT = """
You are the orchestrator agent for WanderMesh.
Your job is to analyze a topic and return:
- domain
- content_type
- angle
- reasoning

Rules:
- Return only valid JSON.
- Choose one domain from a predefined list.
- Choose one content type.
- Choose one content angle.
- Explain your reasoning briefly.
"""

I've shortened the prompt for readability. The production version also includes domain-specific categories, output constraints, and a few-shot example to improve classification consistency. Rather than asking the model an open-ended question, I constrained its output to a fixed structure. That made the downstream pipeline far more predictable.

One issue I ran into early was that the model didn't always return valid JSON. Sometimes it wrapped the response in Markdown code fences, sometimes it produced trailing commas, and occasionally it returned smart quotes instead of standard JSON quotes. Since every downstream agent expected valid JSON, I needed a preprocessing step before parsing the response. While testing with smaller local models like gemma2:2b, I found they occasionally ignored formatting instructions and returned malformed JSON despite being explicitly asked for valid JSON.

To help fix the JSON format, I wrote the strip_json_fences() function. If you're still getting comfortable with Python, I highly recommend CS50 Python. I learned a lot by reading the notes first and then implementing everything myself.

def strip_json_fences(raw: str) -> str:
    raw = raw.strip()
    if raw.startswith("``` json"):
        raw = raw.removeprefix("```json").strip()
    elif raw.startswith("```"):
        raw = raw.removeprefix("```").strip()
    if raw.endswith("```"):
        raw = raw.removesuffix("```").strip()
    raw = re.sub(r',\s*]', ']', raw)
    raw = re.sub(r',\s*}', '}', raw)
    raw = raw.replace('\u201c', '\\"').replace('\u201d', '\\"')
    raw = raw.replace('\u2018', "'").replace('\u2019', "'")
    return raw

Even valid JSON isn't enough. The model might forget a field, rename one, or return an unexpected structure. Before handing the output to the next agent, I wanted to validate that it matched exactly what the pipeline expected. Further reading: If you're new to Pydantic, the official documentation on Models and Fields is an excellent place to understand the validation features used here.

Now let's create a Pydantic Object for the Orchestrator Agent. For now, I'm only validating the structure, not restricting the possible values. That keeps the orchestrator flexible while the rest of the pipeline is still evolving.

class OrchestratorOutput(BaseModel):
    domain: str
    angle: str
    reasoning: str
    content_type: str

Now let's write the main function for classifying a topic. Before writing the code, let's fundamentally break down what this function should do.

Topic
 โ”‚
 โ–ผ
 clean_topic()
 โ”‚
 โ–ผ
 call_llm()
 โ”‚
 โ–ผ
 strip_json_fences()
 โ”‚
 โ–ผ
 json.loads()
 โ”‚
 โ–ผ
 Pydantic Validation
 โ”‚
 โ–ผ
 OrchestratorOutput

It should take topic as input and return a Pydantic Object as output for the Research Agent to use. The topic should first be cleaned using clean_topic(), removing extra spaces if any and checking if the user hasn't entered an empty string. Then we pass the cleaned topic and SYSTEM_PROMPT to the call_llm() function, and then remove JSON fences from it using strip_json_fences() to return valid JSON. Finally, the JSON is parsed and validated. If anything goes wrong, the function returns None instead of passing invalid data further down the pipeline. As a standard practice, we parse JSON and validate the parsed JSON in try and except blocks to handle errors if something breaks.

def classify_topic(topic: str) -> OrchestratorOutput | None:
    cleaned_topic = clean_topic(topic)
    raw_response = call_llm(SYSTEM_PROMPT, cleaned_topic)
    refined_response = strip_json_fences(raw_response)
    try:
        parsed = json.loads(refined_response)
    except json.JSONDecodeError:
        print("LLM did not return valid JSON:")
        print(raw_response)
        return None
    try:
        return OrchestratorOutput.model_validate(parsed)
    except ValidationError as e:
        print("LLM returned JSON but it doesn't match expected structure:")
        print(e)
        return None

At this point, the orchestrator can consistently classify a topic and return structured output that every downstream agent can rely on. In the next article, I'll build the Research Agent, the component responsible for gathering factual information before any content is generated.

Github Repo: https://github.com/Manav-N4/linkedin-agent#linkedin-agent

Comments

No comments yet. Start the discussion.