Back to Blog
AI & DevelopmentFebruary 25, 202510 min read

A Monorepo Structure for AI Agents That Will Not Become Chaos in 6 Months

DR

Doug Ribeiro

10 min read

The first agent you ship is always a script. The second agent is a copy of the first with different tools. The third agent is where things go wrong: someone forks one of the earlier scripts, the dependencies drift, the logging is inconsistent, and a quarter later you have five agents in four repos with no shared code.

If you know you are going to build more than one agent, set up the monorepo before you build the second one. Not before the first one. There is a real cost to premature structure, and I am not going to pretend otherwise. But the window between agent one and agent two is when you pay the least and save the most.

The Stack Assumption

This post assumes Angular on the frontend, Python for the agent runtime, Docker for local dev. That is the stack I default to for my own projects. If you are Node/TypeScript end to end, most of this translates directly. If you are Go or Rust on the backend, the structure is the same, the tooling choices differ.

Why Python for the agent itself. The AI SDKs (Anthropic, OpenAI) are more mature in Python, the tool ecosystem around vector stores and embedding pipelines is Python-first, and a ReAct loop reads better in a language where a tool is just a function and a dict is just a dict. I keep the frontend in Angular because that is where I build every product, and the dashboard for observing agent runs is a real Angular app.

The Layout

text
my-agents/
├── apps/
│   ├── dashboard/              # Angular app, agent run viewer
│   └── api/                    # Python FastAPI, dashboard backend
├── agents/
│   ├── sonar_bridge/           # First agent
│   │   ├── agent.py
│   │   ├── tools.py
│   │   ├── playbook.py
│   │   └── tests/
│   ├── docs_drift/             # Second agent
│   └── bug_triage/             # Third agent
├── libs/
│   ├── base_agent/             # The shared ReAct loop
│   ├── memory/                 # pgvector client, Postgres schema, Redis client
│   ├── tools/                  # Reusable tools: github, slack, sonar, jira
│   └── telemetry/              # Logging, cost tracking, evaluation
├── infra/
│   ├── docker-compose.yml      # postgres, redis, sonarqube for local dev
│   ├── migrations/             # Alembic for Postgres schema
│   └── k8s/                    # deployment manifests if you need them
├── knowledge/                  # Markdown knowledge base (Obsidian-compatible)
│   ├── conventions/
│   ├── runbooks/
│   └── decisions/
├── pyproject.toml              # Workspace root, points to libs and agents
├── package.json                # For the dashboard
└── docker-compose.yml

Three zones. apps/ is for user-facing code: the dashboard and its API. agents/ is for individual agents, each one a small package. libs/ is shared code that every agent depends on. Nothing else belongs at this level.

The Base Agent

libs/base_agent/ is the most important piece of the whole structure. It contains the ReAct loop, the memory access patterns, the cost tracking, and the telemetry. Every agent in agents/ imports from it and defines only what is specific to its domain: the playbook (system prompt), the tool list, the escalation rules.

python
# libs/base_agent/agent.py
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Callable
from libs.memory import MemoryStack
from libs.telemetry import Telemetry

@dataclass
class AgentConfig:
    name: str
    version: str
    playbook: str
    tools: list[Callable]
    confidence_threshold: float = 0.7
    max_iterations: int = 20
    scratchpad_window: int = 6

class BaseAgent(ABC):
    """Owns the loop. The three hooks below are what each agent supplies."""

    def __init__(self, config: AgentConfig, memory: MemoryStack, telemetry: Telemetry):
        self.config = config
        self.memory = memory
        self.telemetry = telemetry

    @abstractmethod
    def _plan(self, trigger: dict, context: str | None,
              scratchpad: list) -> tuple[str, dict | None]:
        """One model call. Returns (thought, action), or action=None when done."""

    @abstractmethod
    def _execute(self, action: dict) -> dict:
        """Run one tool from config.tools and return the observation."""

    @abstractmethod
    def _decide(self, scratchpad: list) -> dict:
        """Turn the finished scratchpad into the run outcome."""

    def run(self, trigger: dict) -> dict:
        run_id = self.telemetry.start_run(self.config, trigger)

        similar = self.memory.vector_search(trigger, top_k=5)
        context = self.memory.summarize(similar) if similar else None

        scratchpad = []
        for iteration in range(self.config.max_iterations):
            thought, action = self._plan(trigger, context, scratchpad)
            if action is None:
                break
            observation = self._execute(action)
            scratchpad.append((thought, action, observation))
            # The scratchpad is what blows the context budget on a long
            # run. Compress the older entries instead of carrying 20
            # raw observations into the next plan call.
            if len(scratchpad) > self.config.scratchpad_window:
                scratchpad = self.memory.compress(
                    scratchpad, keep_last=self.config.scratchpad_window
                )

        outcome = self._decide(scratchpad)
        self.telemetry.end_run(run_id, outcome)
        self.memory.write(run_id, outcome)
        return outcome

BaseAgent owns the loop and nothing else. _plan, _execute and _decide are abstract on purpose: _plan is the model call, _execute is the tool dispatch, _decide turns the finished scratchpad into an outcome. The concrete implementation of those three lives once in libs/base_agent/, so a new agent is a config object and a playbook, around 30 lines. Note the compression step inside the loop. Twenty iterations of raw thought, action and observation is how you get a token bill you did not plan for.

python
# agents/sonar_bridge/agent.py
from libs.base_agent import ReActAgent, AgentConfig
from libs.memory import MemoryStack
from libs.telemetry import Telemetry
from libs.tools import sonar, github
from .playbook import PLAYBOOK

def build() -> ReActAgent:
    return ReActAgent(
        config=AgentConfig(
            name="sonar_bridge",
            version="0.1.0",
            playbook=PLAYBOOK,
            tools=[
                sonar.get_issues_for_pr,
                github.read_file_range,
                github.comment_on_pr,
            ],
            confidence_threshold=0.85,
        ),
        memory=MemoryStack.default(),
        telemetry=Telemetry.default(),
    )

The Dashboard Actually Matters

Angular is doing real work here. You need a place to see every agent run, filter by outcome, diff decisions across versions, and replay failures. That is not a nice-to-have, it is how you debug an agent that decided to escalate three times in a row last night.

The Python API under apps/api/ is a thin FastAPI wrapper that reads from the same Postgres the agents write to. No magic, no duplication. The dashboard is a dumb client of the same data your agents already produce.

Local Dev in One Command

yaml
# docker-compose.yml at the root
services:
  postgres:
    image: pgvector/pgvector:pg16
    environment:
      POSTGRES_PASSWORD: local
      POSTGRES_DB: agents
    ports: ["5432:5432"]

  redis:
    image: redis:7-alpine
    ports: ["6379:6379"]

  sonarqube:
    image: sonarqube:community
    ports: ["9000:9000"]

  api:
    build: ./apps/api
    depends_on: [postgres, redis]
    environment:
      DATABASE_URL: postgres://postgres:local@postgres/agents
      REDIS_URL: redis://redis:6379
    ports: ["8000:8000"]

  dashboard:
    build: ./apps/dashboard
    depends_on: [api]
    ports: ["4200:4200"]

docker-compose up gives anyone who clones the repo a complete working environment: Postgres with pgvector, Redis, SonarQube, the API, and the Angular dashboard. No fiddling with local Postgres installs, no "works on my machine" on day one.

The Knowledge Directory

knowledge/ is a folder of markdown files. Not a feature. Not a plugin. Just markdown. The team edits it directly, or points an Obsidian vault at it. An indexer in libs/memory/ syncs the content into pgvector on changes, and every agent that needs a playbook or a convention reads from the same source.

I separate the knowledge base from the code deliberately. Business knowledge changes independently from agent code. You want non-engineers to edit knowledge/ without touching agents/. Git still versions it. Reviews still happen. But the boundary is clear.

What This Buys You

  • New agent = new folder in agents/, 30 lines of Python. No infrastructure debate.
  • Every agent produces the same shape of telemetry. The dashboard does not need per-agent code.
  • Memory, tools, and evaluation logic are shared. A bug fix in libs/ fixes every agent at once.
  • Local dev is one command. Clone to running stack with no setup document to follow.
  • Production deploy is the same container image pattern for every agent. One pipeline, many agents.

Where I Draw the Line

Not everything belongs in the monorepo. If an agent has a different blast radius, say it can write to a production system while the rest only comment on pull requests, it gets its own repo and its own release cadence. If an agent is experimental and I am not sure it will survive the month, I prototype it outside and move it in once it has proven value.

The monorepo is for the production agents. The stable ones. The ones your team depends on. Everything else can start as a script and get promoted once it earns its place.

A shared base agent and a consistent telemetry layer are worth more than any individual agent. That is the code that makes the fifth agent easy to ship.

References

MonorepoArchitectureAngularPythonAI Agents