Context Architecture

Agent Context Across Many Repositories: Carry Contracts, Leave the Rest

David Guzenburg/ / 8 min read

An agent in one service cannot read another. It infers the API shape from your call site and variable names, and produces a client that compiles and is wrong.

multi-repomicroservicescontractscontext files

The problem a monorepo does not have

Nested context files solve the monorepo case cleanly: one repository, one tree, a file per package, precedence by proximity. None of that applies when your services live in fourteen separate repositories.

An agent working in payments-service has no way to read orders-service. It cannot see the shared protobuf definitions unless they are vendored. It does not know that the API it is calling changed last week. Every cross-service fact has to be carried across a boundary the tooling does not cross.

Three things worth carrying, and three that are not

Carry across reposLeave local
Contracts — the shape of what crosses the wireBuild and test commands
Ownership — who to ask, who reviewsLanguage-specific style
Global prohibitions — secrets, protected pathsFrozen directories, local traps

The left column is small and stable. The right column is large and volatile. Attempts at "one context file for the whole organisation" fail because they try to carry the right column, which then goes stale in thirteen places at once.

Contracts are the only thing that must be shared

The failure mode specific to multi-repo work is an agent inventing the shape of another service's API. It has never seen the response, so it infers one from the call site and the variable names, and produces client code that compiles and is wrong.

The fix is that the contract has to exist somewhere the agent can read without leaving the repository.

payments-service/
  AGENTS.md
  contracts/
    orders-service.openapi.yaml     ← vendored, generated, read-only
    inventory-service.proto
    events/order-placed.schema.json
  src/
## Cross-service contracts
- `contracts/` holds the CURRENT interface of every service we call.
  Generated by `make contracts` from the contract registry.
  Read these instead of guessing at request or response shapes.
- Never hand-edit anything in `contracts/`. To change an interface,
  change it in the owning service and regenerate here.
- If a contract looks wrong, it may be stale. Check the timestamp in
  `contracts/.generated` before assuming the other service is broken.

Vendoring contracts is unfashionable and it is the mechanism that works. The agent can read a file; it cannot query a registry it has no credentials for. The freshness problem is real and is a build-step problem, which is a category of problem teams already know how to solve.

Staleness cuts the other way

A vendored contract that is three months old is worse than none, because the agent will trust it. Regenerate in CI, fail the build when the vendored copy differs from the registry, and put the generation timestamp somewhere the agent will see it.

Sharing the rules without duplicating them

The rules that genuinely apply everywhere — commit format, secret handling, protected paths — are worth having in one place. Copy-pasting them into fourteen files guarantees drift.

Two mechanisms, both ordinary:

A generated section. Keep the shared block in a platform repository and stamp it into each service's context file with a script that CI also runs, so a modified copy fails the build.

# AGENTS.md — payments-service

## Service-specific
- Test: `go test ./... -short`
- `internal/ledger/` is append-only. Never modify a written entry.

<!-- BEGIN ORG RULES — generated, do not edit -->
- Conventional Commits. Branch: `type/short-description`.
- Secrets from env only, via `internal/config`. Never inline.
- Never edit anything under `contracts/` or `*.pb.go`.
<!-- END ORG RULES -->

A submodule or package. Heavier, and appropriate when the shared material is larger than a few lines. The trade is that submodules are one more thing to keep current, and teams are bad at that.

Ownership is the cheapest thing to write down

The question an agent cannot answer in a multi-repo world is "who owns this". It is also the question a human most needs answered, and one line per dependency covers it.

## Services we depend on
- orders-service     @orders-team    #orders-support
- inventory-service  @platform       #platform
- notifications      DEPRECATED — do not add new calls.
                     Use the events topic instead.

The third line is the valuable one. An agent adding a call to a deprecated service produces code that works today and is on a deletion list. Nothing in the code says so.

The versioning question

A vendored contract raises an obvious question: which version? The answer shapes how much trouble the arrangement causes.

VendorAgent seesProblem
Latest publishedWhat the producer intendsMay not be deployed yet
What is deployed to productionRealityLags the producer's repo
Both, labelledThe gapSlightly more to generate

The third is worth the extra effort in any organisation where deploys are not continuous. An agent that knows a field exists in the contract but is not yet live writes the consumer defensively, which is what you wanted anyway.

generated:  2026-05-17T09:14:02Z
registry:   contracts.internal/v2

orders-service      published v4.2.0   deployed v4.1.3
inventory-service   published v2.0.1   deployed v2.0.1
notifications       published v1.8.0   deployed v1.8.0  DEPRECATED

Generating the vendored copy

The generation step is worth building properly, because a hand-maintained contracts/ directory rots within a month. Declare the dependencies and extract only the interface surface.

{
  "service": "mobile-app",
  "dependencies": [
    {
      "name": "auth-service",
      "path": "../auth-service",
      "contracts": ["api/v1/auth.proto"],
      "models": ["AuthTokenRequest", "TokenResponse"]
    },
    {
      "name": "payments",
      "path": "../payment-gateway",
      "contracts": ["docs/openapi.json"],
      "endpoints": ["/v2/payments/charge"]
    }
  ],
  "budget": { "max_external_tokens": 4096 }
}

The models and endpoints keys are the important part. Without them you vendor the whole schema, which for a mature service is thousands of lines describing endpoints this consumer never calls. Naming what you actually use turns a 40k-token contract into a 400-token one.

import json, re, sys
from pathlib import Path

def proto_messages(path: Path, wanted: list[str]) -> str:
    # only the named messages, not the whole file
    src = path.read_text(encoding="utf-8")
    out = []
    for name in wanted:
        m = re.search(rf'^(message|enum)\s+{re.escape(name)}\s*\{{.*?^\}}',
                      src, re.S | re.M)
        out.append(m.group(0) if m else f"// MISSING: {name}")
    return "

".join(out)

def openapi_paths(path: Path, wanted: list[str]) -> dict:
    spec = json.loads(path.read_text(encoding="utf-8"))
    paths = spec.get("paths", {})
    return {p: paths[p] for p in wanted if p in paths}

def build(cfg_path: Path) -> dict:
    cfg = json.loads(cfg_path.read_text(encoding="utf-8"))
    root = cfg_path.parent.parent
    out = {"service": cfg["service"], "generated": None, "external": {}}

    for dep in cfg["dependencies"]:
        base = (root / dep["path"]).resolve()
        entries = []
        for rel in dep["contracts"]:
            f = base / rel
            if not f.exists():
                entries.append({"file": rel, "error": "not found"})
            elif f.suffix == ".proto":
                entries.append({"file": rel, "type": "protobuf",
                                "content": proto_messages(f, dep.get("models", []))})
            elif f.suffix in (".json", ".yaml", ".yml"):
                entries.append({"file": rel, "type": "openapi",
                                "content": openapi_paths(f, dep.get("endpoints", []))})
        out["external"][dep["name"]] = entries
    return out

if __name__ == "__main__":
    cfg = Path(sys.argv[1])
    data = build(cfg)
    dest = cfg.parent / "external-contracts.json"
    dest.write_text(json.dumps(data, indent=2), encoding="utf-8")
    print(f"wrote {dest}")
Never vendor implementation

Extract interface only — .proto messages, OpenAPI path objects, type declarations. Pulling in the other service's handler code doubles your token cost and teaches the agent implementation details it will then couple to. The contract is the boundary; that is the point of having one.

Regenerate automatically

A generation step people have to remember is a generation step that stops happening. Hook it to something that already occurs:

#!/bin/bash
# Refresh vendored contracts whenever we pull.
[ -f .ai/workspace.config.json ] &&   python3 tools/build_context.py .ai/workspace.config.json

Then a CI check that regenerates and fails if the committed copy differs. That combination — automatic locally, enforced in CI — is what keeps the vendored copy honest without anyone thinking about it.

Budget the external context

Cross-repo contracts compete with the local code for window space, and they should lose. A ceiling in the config, enforced at generation time, keeps that true:

import tiktoken
enc = tiktoken.get_encoding("cl100k_base")

n = len(enc.encode(json.dumps(data)))
limit = cfg["budget"]["max_external_tokens"]
if n > limit:
    print(f"external context is {n} tokens, limit {limit}.", file=sys.stderr)
    print("Narrow the 'models' and 'endpoints' lists.", file=sys.stderr)
    sys.exit(1)

When it trips, the fix is to narrow what you declared rather than to raise the ceiling. A consumer needing 8k tokens of another service's contract is usually a consumer reaching too far into that service.

Working across two repositories at once

Occasionally a change genuinely spans services — adding a field that the producer emits and the consumer reads. The instinct is to open both repositories in one session. Resist it slightly.

1. Contract first, in the owning repo.
   Change the schema. Regenerate. Merge. Release.

2. Producer, in its own session.
   Emit the new field. Old consumers ignore it. Merge.

3. Consumer, in its own session, after the producer is deployed.
   Read the new field. Handle its absence for in-flight messages.

This is ordinary backwards-compatible rollout discipline, and it happens to be exactly what makes the work tractable for an agent: each step is one repository, one context, one reviewable change. A session holding two repositories produces a change that cannot be deployed in either order.

What not to build

The tempting solution is a central service that assembles context across repositories on demand — an index of every service, queryable by the agent. Some organisations need this. Most do not, and building it early is a mistake for two reasons.

It becomes a single point of staleness: one system that must know about every repository, maintained by whoever built it, silently wrong when a team restructures. And it solves a problem that vendored contracts and a twelve-line ownership list already solve for a fraction of the effort.

Build it when you have measured that the cheap version is failing, not before.

Takeaway

Carry three things across repository boundaries — contracts, ownership and global prohibitions — and leave everything else local. Vendor the contracts so the agent can read them without credentials, and fail the build when they go stale. Sequence cross-service changes one repository at a time rather than holding two in one session.

Keep reading
Context Architecture

Scoping Agent Context in Monorepos: Nested Files and Precedence

How to split AGENTS.md across a monorepo so each package carries its own rules, why precedence follows proximity, and how to verify your tooling actually reads nested files.

Context Architecture

Why Duplicating Your README Into AGENTS.md Makes Agents Worse

Copying project overview into a context file measurably degrades agent performance. The three mechanisms behind it, and a discoverability test for deciding what to cut.

Context Architecture

Context File Size and Token Budget: Measuring What You Can Afford

Why the context window is a budget rather than a container, how position affects which rules actually influence behaviour, and a concrete token budget for root and package files.

Context Architecture

The AGENTS.md Specification: Anatomy of a Repository Context File

What belongs in an AGENTS.md file, the six sections that carry the weight, why short files outperform comprehensive ones, and where context files stop working.

← Token Budget in Multi-File Refactoring: Sequencing Over Squeezing  ·  Declaring Off-Limits Paths: Generated Code, Migrations and Secrets →

All context architecture articles  ·  Every article