The Monorepo Was for the Agents: Building Cross-Team Context Without a Migration

The classic argument for a monorepo goes: shared tooling, atomic cross-service refactors, build-cache reuse, no submodule pain. The classic argument against goes: migration is expensive, the tooling is finicky, and most teams do not need it.

Both arguments are correct and both are now obsolete, because there is a new argument that did not exist two years ago.

You need a monorepo because your AI coding agent needs context.

Not your humans. The humans were doing fine. The humans have IDE bookmarks and tribal knowledge and a Slack channel where someone always remembers which service owns the user-creation webhook.

The agent does not. The agent sees what is in the working directory and nothing else. If the user-creation webhook lives in service-auth and the consuming code lives in service-app, and those are separate clones on the engineer’s laptop, the agent that is asked to “fix the webhook regression” can see exactly half the problem.

This is the story of how we got the cross-team context our agents needed without committing to a full monorepo migration, using git submodules and Docker Compose and a per-service secret-encryption pattern that survives both local development and Claude Code’s cloud environment.

The client in this story is a B2B SaaS with a Rails monolith, a React dashboard, a separate React admin, and an emerging AI workflow service. Four services, four repos, four onboarding documents that were all subtly out of date. By the end of this engagement the four were addressable as one workspace, encrypted secrets shared without leaking, and agents (local Claude Code or Claude Code Web) could reason across the whole stack as if it had always been one repo.

What we did not do

We did not do a real monorepo migration. We did not move everyone to Bazel or Nx. We did not unify the build systems. We did not flatten the directory structures or rewrite history. We did not even merge package managers.

A real monorepo is expensive. The tooling has to be invested in. The team has to agree on the build orchestration. The CI pipeline has to be rebuilt. For a four-service team with one engineer leading the AI adoption, that was not a fight worth picking.

What we did instead was ship 80% of the value of a monorepo for 20% of the effort. The trick was recognizing that “value of a monorepo” had changed.

The setup

A new repo, mono-web. It contains:

The submodules are not pinned to a fixed SHA. They float on their respective default branches. Engineers cloning mono-web get every service at the version their team is currently shipping.

mono-web/
├── CLAUDE.md
├── Makefile
├── docker-compose.yml
├── repos/
│   ├── web/             (submodule → Org/web)
│   ├── dashboard/       (submodule → Org/dashboard)
│   ├── react-admin/     (submodule → Org/react-admin)
│   └── workflow-copilot/ (submodule → Org/workflow-copilot)
└── secrets/
    ├── web.enc
    ├── dashboard.enc
    ├── react-admin.enc
    └── workflow-copilot.enc

The first command an engineer runs is:

git clone --recurse-submodules [email protected]:org/mono-web.git
cd mono-web
make setup

The make setup target initializes the submodules, decrypts the secrets, drops them into each service’s .env file at runtime (never on disk in plaintext outside the encrypted blob), and brings up docker compose. Twenty minutes after a new engineer’s first day, they have all four services running locally.

The secrets pattern

Secrets are the part most submodule-based workspaces get wrong, and the part the AI angle made urgent. An agent that needs to run integration tests across services needs to know how the services authenticate with each other. But you do not want plaintext secrets in the workspace, you do not want secrets fetched from a vault at every agent invocation (slow, requires network), and you do not want different agents holding different versions of the secrets.

Our pattern is per-service encrypted secrets, sealed at rest, decrypted into the running container, never written to disk in plaintext.

# Makefile-invoked Ruby helper for editing a service's secrets safely.
require "openssl"
require "base64"

class SecretsVault
  def self.edit(service)
    master_key = ENV.fetch("MONO_WEB_MASTER_DEV_KEY")
    blob_path = "secrets/#{service}.enc"

    plaintext = decrypt(File.read(blob_path), master_key)
    tmp = Tempfile.new(["secrets-#{service}", ".env"])
    tmp.write(plaintext)
    tmp.flush

    system(ENV["EDITOR"] || "vim", tmp.path)

    encrypted = encrypt(File.read(tmp.path), master_key)
    File.write(blob_path, encrypted)
    tmp.unlink
  end

  def self.decrypt(blob, key) = OpenSSL::Cipher.new("aes-256-gcm")...  # (elided)
  def self.encrypt(plaintext, key) = OpenSSL::Cipher.new("aes-256-gcm")... # (elided)
end

The master key lives in 1Password under “Mono-Web Master Dev Key.” Every engineer who needs access pulls it out of 1Password once and sets it in their shell profile. The encrypted blobs are checked into the workspace repo. Anyone with the master key can decrypt; anyone without it cannot.

The make secrets-edit target prompts for the service, opens an editor with the decrypted contents, and re-encrypts on save. Plaintext is never written to disk outside the editor’s temp file, which is unlinked the moment the function returns.

A hook in the agent configuration blocks any tool call that would cat a .env file directly. The agent is rerouted to make check-secrets, which validates without printing.

Why this works for agents

Once mono-web exists, an agent invoked at its root can:

  1. See the full stack. A grep for “create_user” returns hits across the Rails monolith, the dashboard’s TypeScript caller, the admin UI’s GraphQL fragment, and any webhook code in the AI service.
  2. Reason across service boundaries. A ticket like “the dashboard does not show new users for thirty minutes” is now visible end-to-end: the agent can read the dashboard’s caching logic, the API route that produces the data, the underlying ActiveRecord query, and the background job that warms the cache.
  3. Run integration tests across services. With Docker Compose bringing up the full stack, the agent can run an end-to-end test that touches all four services without manual setup.
  4. Get the same context whether running locally or in the cloud. Claude Code on the engineer’s laptop and Claude Code Web see the same workspace shape. The agent does not have to be retaught the project structure depending on where it runs.

That last point is the one most teams miss. Cloud-based agents (Claude Code Web, hosted runners, CI agents) operate against a checkout of the repo. If that checkout only contains one service, the cloud agent’s context is strictly worse than the local agent’s. With mono-web, the cloud agent gets the same cross-service view as the engineer does.

The CLAUDE.md at the workspace root

The workspace CLAUDE.md is a small but high-leverage file. It is loaded into the agent’s context on every invocation. It contains:

A snippet:

## Service topology
- repos/web: Rails 7 monolith, primary backend. All persistent data, user model.
- repos/dashboard: React customer-facing dashboard. Reads from web's GraphQL API.
- repos/react-admin: Modern internal admin. Replacing legacy /admin in web.
- repos/workflow-copilot: AI workflow assistant. Talks to web via webhooks.

## Cross-service contracts
- Any change to the `User` model in web likely affects dashboard's UserProfile
  query. Check `repos/dashboard/src/graphql/user.ts` after editing
  `repos/web/app/models/user.rb`.
- New webhook events in web require corresponding listener registration in
  workflow-copilot. See `repos/workflow-copilot/mastra/src/listeners/`.

This file is short on purpose. Long context files dilute the signal. The job is not to teach the agent the codebase; the job is to point the agent at the right place to read.

What this enabled

After the workspace was in place, the teams started experiencing AI work qualitatively differently. The most common observation, paraphrased from several engineers: “the agent is no longer guessing about how the other services work, because it can just read them.”

Specifically:

Tradeoffs, including the one engineers will twitch at

I want to address the obvious objection up front, because every senior engineer reading this has it. Submodules pointing at floating default branches sound like a recipe for “it works on my machine” of a particularly bad kind: two engineers cloning the workspace at different moments get different versions of every service.

The objection is correct. We chose to accept it, with eyes open, for specific reasons.

Floating branches were a deliberate choice for the AI-context use case. The workspace’s job is to let an agent see the current shape of every service as the teams have it on dev. Pinning submodule SHAs would mean either: (a) someone has to bump the pointer on every change to every service, which is a real coordination tax for a small team; or (b) the workspace lags the services it points at, which defeats the cross-service-context purpose. The floating-branch choice is the one that keeps the agent’s view of the world current.

The “different developers see different versions” problem is real but bounded. Each engineer’s workspace reflects the world at the moment they pulled. For agent runs, that is fine: the agent is operating on the engineer’s machine against the engineer’s checkout, and the work product is reviewed normally. For shared-state work (CI, deployments, releases), nothing changes — those continue to operate against the underlying services’ real branches and tags, not against the workspace.

Submodule-pointer merge conflicts are the visible friction. Engineers updating different services bump different submodule pointers; the workspace repo accumulates these as quasi-conflicts. Our pragma is “nobody commits submodule-pointer changes to the workspace except through a scheduled refresh job.” Engineers can update their local submodules freely; only the scheduled job (twice a day, automated) writes the updated pointers back to the workspace repo’s main. This means individual engineers do not need to think about submodule pointer arithmetic at all.

A more conservative team would pin SHAs. If your environment requires reproducibility (regulated industry, long-tail support obligations, multi-environment promotion), pin submodule SHAs and update them via a scheduled bump. You pay a real coordination cost; you get a workspace whose state is exactly defined at any commit. Either choice is defensible. Choose with eyes open.

What we did not get from this setup. Cross-service build-cache reuse a real monorepo provides. Atomic-commit cross-service refactors (each service still merges through its own PR). Unified dependency management. These were the tradeoffs of choosing the 80/20 path.

The takeaway

The case for monorepos is rebuilt now. The old reasons (atomic refactors, build caching) still apply. There is a new reason that did not exist two years ago, and it is the one that gets you over the hill: your AI agents need cross-team context, and the cheapest way to give it to them is to make every service addressable from one workspace root.

You do not need a real monorepo to do this. Submodules plus Docker Compose plus a sealed-secrets pattern plus a small workspace-level CLAUDE.md get you most of the way there. Twenty percent of the migration effort. Eighty percent of the agent-context win.

If your team has agents that keep guessing about how the other services work, this is the cheapest fix you can make.