Agent Context Across Many Repositories: Carry Contracts, Leave the Rest
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.
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 repos | Leave local |
|---|---|
| Contracts — the shape of what crosses the wire | Build and test commands |
| Ownership — who to ask, who reviews | Language-specific style |
| Global prohibitions — secrets, protected paths | Frozen 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.
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.
| Vendor | Agent sees | Problem |
|---|---|---|
| Latest published | What the producer intends | May not be deployed yet |
| What is deployed to production | Reality | Lags the producer's repo |
| Both, labelled | The gap | Slightly 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}")
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.
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.