An agent that has to rediscover your repository every session will rediscover it badly.
Repository configuration is how you stop that happening. It is also how you define agents that are narrow on purpose — a reviewer that cannot write, a migration agent that cannot deploy — and narrowness is the property that makes delegation something you can be relaxed about.
The layers
Section titled “The layers”Four mechanisms, and they do genuinely different jobs.
| Layer | File | What it does | Enforces? |
|---|---|---|---|
| Repository context | AGENTS.md | Tells every agent about this codebase | No |
| Instructions | .github/copilot-instructions.md | Guides Copilot surfaces | No |
| Custom agents | .github/agents/NAME.md | Defines a scoped agent with a tool list | The tool list does |
| Hooks | .github/hooks/*.json | Intercepts tool calls, can deny | Yes |
The column that matters is the last one. Two of these four are suggestions and two are controls.
Writing a security requirement into AGENTS.md and treating it as enforcement is the mistake this
lesson exists to prevent.
AGENTS.md
Section titled “AGENTS.md”The highest-value file in this lesson, and the cheapest to write.
AGENTS.md at the repository root describes the project to any agent working in it. It is read by
Copilot’s agent surfaces and by a growing set of other tools that have converged on the same convention.
What belongs in it:
## What this is
Payment webhook processor. Receives events from the payment provider,validates them, and updates order state. Correctness matters more thanlatency; every handler must be safe to run twice.
## Stack
TypeScript, Node 22, PostgreSQL via Kysely, Vitest, Docker Compose forlocal services.
## Commands
- `npm ci` — install- `npm run dev` — local server, requires `docker compose up -d`- `npm test` — full suite, requires services running- `npm run test:unit` — no services needed, use this first- `npm run typecheck` — tsc, no emit- `npm run lint` — ESLint, autofixable with --fix
## Layout
- `src/webhooks/` — HTTP handlers, thin- `src/services/` — business logic, where most changes belong- `src/db/` — queries and migrations- `src/money.ts` — the Money type. Never use floats for currency.
## Conventions
- Errors crossing a module boundary are wrapped in `AppError`.- Every state-mutating handler checks the idempotency key first.- Times are UTC everywhere except at render.- No raw SQL outside `src/db/`.
## Do not
- Modify `src/db/migrations/` — migrations are written by hand and reviewed separately.- Add dependencies without saying so in the pull request description.- Touch `.github/workflows/`.- Weaken or delete a test to make a build pass. If a test fails and you believe the test is wrong, say so rather than changing it.Five things that file does:
Orients. An agent that knows this is a payment processor with an idempotency requirement writes different code from one that does not.
Gives the commands. The single most useful section. An agent that knows npm run test:unit exists
gets a verification signal without needing services.
Points at the right directory. Reduces the wandering that consumes session time.
States the conventions. The ones you would otherwise correct at review, every time.
Says what not to touch. Influence rather than enforcement, and still the most effective single line against scope drift.
Keeping it accurate
Section titled “Keeping it accurate”An AGENTS.md that is wrong is worse than one that does not exist, because it is read with confidence.
Stale commands are the common failure. A build command that was renamed six months ago produces a session that cannot verify anything and does not know why.
Tie it to the same review as the code. A pull request that changes the build command should change
AGENTS.md. Putting it under CODEOWNERS alongside the build configuration makes that likely rather
than hopeful.
Test it occasionally. Run the commands it lists, exactly as written, in a clean checkout. It takes two minutes and catches the drift that nothing else will.
Keep it short. Long files dilute. Everything in the example above earns its place; a page of architectural philosophy does not.
Custom agents
Section titled “Custom agents”Where scoping becomes real.
A custom agent is a named definition — a persona, a set of instructions, and crucially a tool list.
Both .github/agents/NAME.md and .github/agents/NAME.agent.md are recognised.
---name: test-writerdescription: Writes and extends test coverage. Does not modify implementation code.tools: - read - edit - shell(npm test) - shell(npm run test:unit) - shell(npm run typecheck)---
You write tests for existing behaviour.
Read the implementation to understand what it does, then write tests thatassert the **requirement** — what the code should do — rather than mirroringits current implementation. A test that would still pass if the functionwere wrong in the way the bug report describes is not a useful test.
Use the existing fixtures in `test/fixtures/`. Follow the structure of`src/services/order-service.test.ts`.
If the behaviour you are asked to test appears incorrect, say so in yoursummary rather than writing a test that codifies the bug.Three properties of that definition:
The tool list is the boundary. It can read, edit and run three specific commands. It cannot run arbitrary shell, cannot push, cannot install packages. That is enforced by the harness, not by the prose.
The prose does the shaping. How to write a good test, which fixtures to use, what to do when the behaviour looks wrong. None of it enforced, all of it useful.
The name makes it invocable. A team can ask for the test-writer agent and get consistent behaviour rather than whatever the prompt happened to say that day.
Writing the persona
Section titled “Writing the persona”The prose half of a custom agent definition. Less consequential than the tool list and not unimportant — it is what makes a definition worth naming rather than re-prompting each time.
State what the agent is for in the first line. Everything after is read in that frame.
Say what it should not do, explicitly. “Do not modify implementation code” in a test-writer is a
preference, and preferences are followed most of the time. Paired with a tool list that permits editing
only test paths, it becomes a preference and a boundary.
Point at an example in the repository. “Follow the structure of src/services/order-service.test.ts”
is worth three paragraphs of description, because it is concrete and the agent can read it.
Tell it what to do when the task looks wrong. The most valuable line in most definitions. “If the behaviour you are asked to test appears incorrect, say so rather than codifying the bug” converts a silent bad outcome into a visible question.
Keep it short. A definition that runs to two pages is one whose middle section is not being weighed heavily. Half a page of specifics beats two pages of principles.
What not to put in it:
Anything security-relevant. “Never touch production” belongs in the environment and the permission set. A definition that relies on prose for a security property has no security property.
Repository facts that belong in AGENTS.md. The build command, the layout, the stack. Duplicating
them means two files to keep accurate, and one of them will not be.
Model or provider instructions. Definitions outlive the model they were written against. Write about the task, not about how to get a particular model to behave.
Designing the tool list
Section titled “Designing the tool list”The most consequential decision in the file.
Start from deny. List what the task needs, not what might be convenient. Anything not listed cannot run, and that default is the whole point.
Prefer specific commands to categories. shell(npm test) is a boundary. shell is not.
Ask what the worst listed tool could do. If the answer involves anything outside the working tree,
reconsider it. shell(git) includes git push --force.
Keep destructive operations out entirely. Deployments, terraform apply, kubectl apply, database
migrations against anything real, force pushes. If a workflow appears to need one, route it through a
reviewed pipeline that runs after a human approved the change.
One agent per job. A reviewer that cannot write and a fixer that can are two definitions. Combining them produces a tool list that is the union of both needs, which is larger than either job requires.
Some definitions worth having in most repositories:
| Agent | Tools | Purpose |
|---|---|---|
reviewer | read, search | Analysis only. Cannot change anything. |
test-writer | read, edit, the test commands | Coverage without touching implementation |
docs | read, edit on docs/** | Documentation from code |
dep-updater | read, edit, install, test | Bounded to dependency work |
Name them for the job, not the technology. test-writer survives a framework migration;
vitest-agent does not.
The reviewer with no write access is the one teams underuse. An agent that physically cannot modify the
repository is one you can point at anything without thinking about it, and “cannot” is a much more
comfortable property than “was told not to”.
Path-scoped context in a monorepo
Section titled “Path-scoped context in a monorepo”A single root AGENTS.md works for a single project. A monorepo needs more, because the answer to “how
do I run the tests” differs by package.
Nested files. Convention places an AGENTS.md at the root for repository-wide facts and additional
ones in subdirectories for package-specific detail. The nearest file to the work is the most specific,
and the root file carries what is true everywhere.
The split that works:
Root: what the repository is, the shared toolchain, how packages relate, the conventions that hold across all of them, and the global “do not touch” list.
Per package: the commands for this package, its particular dependencies and services, what it is responsible for, and anything about it that contradicts the general pattern.
AGENTS.md — the monorepo, shared tooling, conventionsapps/api/AGENTS.md — the service: commands, database, fixturesapps/web/AGENTS.md — the front end: dev server, component conventionspackages/shared/AGENTS.md — the library: no app imports, publishedThe rule that saves the most trouble: each package file states its own test command explicitly, even
when it looks the same as the root’s. A session working in apps/api should not have to infer which of
four test scripts applies.
Dependency direction belongs at the root. “packages/shared may not import from apps/” is a
monorepo-wide invariant, and it is exactly the kind of thing a session working in one package will
violate without knowing the rule exists.
Keep the per-package files shorter than the root. They are additions, not replacements, and repeating the root’s content in four places guarantees three of them go stale.
Hooks: the enforcing layer
Section titled “Hooks: the enforcing layer”Instructions and tool lists cover most cases. Hooks cover the rest, and they are the only mechanism here that inspects a specific call before it runs.
Hooks live in .github/hooks/*.json, use version 1, and a preToolUse hook can deny a call — by
exiting with status 2, or by returning a JSON permissionDecision.
What that makes possible:
Blocking a specific argument pattern. shell(git) is on the tool list because the agent needs
git status and git diff. A preToolUse hook can deny push --force specifically.
Protecting paths. Deny any edit under src/db/migrations/ regardless of what the instructions said.
Requiring conditions. Deny a commit when the working tree contains a file matching a secret pattern.
Auditing. Log every tool call, which gives you a record independent of the session summary.
The distinction to hold: a tool list decides what kinds of thing can run; a hook decides whether this particular call runs. Anything expressible as “never this command with these arguments” belongs in a hook, because that is the only place it can be enforced.
A worked hook
Section titled “A worked hook”Making the enforcement concrete. The tool list for a maintenance agent includes shell(git), because it
needs git status, git diff and git log. That also grants git push --force, which it must never
run.
A preToolUse hook closes the gap.
{ "version": 1, "hooks": { "preToolUse": [ { "type": "command", "bash": "scripts/deny-dangerous.sh", "timeoutSec": 10 } ] }}And the script it calls. It reads the proposed tool call as JSON on stdin and denies by writing a
permissionDecision to stdout, which lets the agent read the reason:
#!/usr/bin/env bashset -euo pipefail
payload="$(cat)"command="$(printf '%s' "$payload" | jq -r '.. | .command? // empty' | head -n 1)"
deny() { jq -nc --arg r "Denied by repository hook: $1" \ '{permissionDecision: "deny", permissionDecisionReason: $r}' exit 0}
case "$command" in *"push --force"*|*"push -f"*) deny "force push" ;; *"reset --hard"*) deny "hard reset — work would be lost" ;; *"clean -"*d*f*) deny "clean -df removes untracked files" ;; *"branch -D"*) deny "force branch deletion" ;; *"terraform apply"*) deny "apply is a reviewed pipeline step" ;; *"kubectl apply"*) deny "apply is a reviewed pipeline step" ;;esac
exit 0Four things worth noticing:
Two ways to deny. A permissionDecision of deny on stdout, as above, or exit code 2 — both
block the call. The JSON form is better here because permissionDecisionReason reaches the agent, and an
agent that knows why it was blocked usually proposes something acceptable instead of stalling.
It fails closed. Non-timeout errors in a preToolUse hook block the call. A broken hook script
denies rather than passes, which is the correct default for a control and worth knowing before you write
one with a typo in it.
Denials are for genuinely irreversible operations. reset --hard loses uncommitted work.
clean -df removes untracked files. terraform apply changes real infrastructure. None of these belong
in an unsupervised loop, and none of them are things you want to have relied on an instruction for.
Pattern matching on a command string is not airtight. A determined bypass exists — a differently spelled invocation, a shell indirection. That is acceptable here because the hook is a guardrail against an agent doing something unwise, not a boundary against an adversary. The boundary against a force push is the branch protection rule, which does not care what the client tried.
How the layers combine
Section titled “How the layers combine”A single session, with all four active:
AGENTS.mdtells it what the project is, which commands to run, and what not to touch.- Instructions add Copilot-surface guidance and path-specific criteria.
- The custom agent definition fixes the persona and, decisively, the tool list.
- Hooks inspect each call and deny the ones that match a prohibited pattern.
- Repository policy — branch protection, required checks,
CODEOWNERS— governs whether the output merges.
Layers one and two shape. Three and four constrain. Five decides.
Designing an agent setup is mostly the exercise of sorting your requirements into those buckets correctly. Anything that must hold goes in three, four or five. Anything that is a preference goes in one or two, and is expected to be followed most of the time rather than always.
Sharing configuration across repositories
Section titled “Sharing configuration across repositories”Once a second team wants the same test-writer, you have a distribution problem.
The straightforward option: copy it. Four repositories, four copies, and they diverge within a quarter. Acceptable for two; painful at ten.
Organisation-level custom agents. Where the plan supports them, a definition maintained once and available everywhere. The right home for genuinely universal agents — a reviewer, a documentation writer — and the wrong home for anything stack-specific, because a definition wrong for one repository is wrong in every session there with no clean local override.
Plugins. A plugin.json alongside a marketplace.json packages agents, skills and configuration as
an installable unit. More machinery than a small team needs, and the right answer for a platform team
distributing a standard toolkit across an organisation, because it versions.
What to centralise and what to leave local:
| Configuration | Where |
|---|---|
AGENTS.md | Always local — it describes this repository |
| Stack-specific agents | Local |
| A generic read-only reviewer | Organisation |
| Security-related hooks | Organisation, and mirrored by repository policy |
| Documentation agents | Organisation, with local AGENTS.md supplying the specifics |
The pattern that generalises: centralise the definition, keep the context local. A shared
test-writer that reads each repository’s AGENTS.md for the test commands is portable. One with the
commands hard-coded in its prose is portable to exactly one repository.
Version what you share. A change to a shared agent definition changes behaviour in every repository using it, with no pull request in those repositories to notice it. That is the same class of risk as an unpinned action, and it deserves the same treatment: deliberate versions, and a way for a repository to stay on the old one.
Auditing what you have
Section titled “Auditing what you have”Configuration accretes quietly. A pass worth running when a repository has been delegating for a few months:
Do the commands in AGENTS.md still work? Run them. This finds more problems than anything else on
this list.
Does every custom agent still have a job? A definition nobody has invoked in three months is one somebody wrote for a task that is finished.
Is any tool list broader than the agent’s job? Look for shell without a command, and for write
access on an agent that only reads.
Do the hooks still fire? A hook script that errors on every call denies everything, which shows up as agents that mysteriously cannot do anything. A hook whose pattern no longer matches denies nothing, which shows up as nothing at all — and is the more dangerous of the two.
Has anything migrated from a control to a comment? A rule that used to be a hook and is now a line in
AGENTS.md has quietly stopped being enforced. This happens during refactors and nobody announces it.
Is repository policy still doing the real work? Branch protection, required checks, CODEOWNERS.
Every layer in this lesson sits on top of those, and an exception added for convenience six weeks ago
undermines all of it.
Twenty minutes, quarterly. The AGENTS.md commands check alone justifies the time.
Common mistakes
Section titled “Common mistakes”Putting requirements in AGENTS.md and calling it a control. It is context. The tool list enforces.
A stale AGENTS.md. Wrong commands are worse than no commands, because they are followed.
Broad tool categories. shell grants everything. Name the commands.
One agent for everything. Its tool list becomes the union of every job’s needs.
No read-only agent. The most useful definition to have and the one most teams skip.
Relying on hooks as the security boundary. They are one layer inside the harness. Repository policy is the boundary.
Giving an agent any credential that reaches production. No repository configuration makes this acceptable.
Writing a long AGENTS.md. Dilution is real; the commands section is worth more than the philosophy.
Mental model
Section titled “Mental model”Configuration is how you hire, and the tool list is what you put on the keyring.
AGENTS.md is the onboarding document every new starter reads. The custom agent definition is the role
description. The tool list is the actual access. Hooks are the door that checks the badge on the way
through. Repository policy is what happens when they submit work.
The mistake is assuming the onboarding document does the job of the keyring. It does not, and it never will, no matter how firmly it is worded.
What you learned
Section titled “What you learned”AGENTS.mdgives every session the project context, commands and conventions it would otherwise guess- The commands section is the highest-value part, because it supplies the verification signal
- Custom agents live at
.github/agents/NAME.mdorNAME.agent.md; the tool list is the real boundary - Design tool lists from deny, name specific commands, and keep destructive operations out entirely
- A read-only reviewer agent is the most underused definition
- Hooks in
.github/hooks/*.jsoncan deny a specific call via exit code 2 or apermissionDecision - Context and instructions shape; tool lists and hooks constrain; repository policy decides
Exercise
Section titled “Exercise”Use a disposable repository.
-
Write an
AGENTS.mdwith the stack, the commands, the layout and three conventions. Run an agent task before and after adding it. Compare. -
Deliberately break one command in
AGENTS.md. Run a task. Predict: does the session notice, or follow the wrong command? -
Define
.github/agents/reviewer.mdwith read and search tools only. Ask it to fix something. Predict: what happens? -
Define a
test-writerwith the test commands but no arbitrary shell. Ask it to install a package. Predict: does the instruction or the tool list decide? -
Add a
preToolUsehook that denies any command containing--force. Ask for a force push. Predict: which layer stops it? -
Remove the hook and add a branch protection rule rejecting force pushes. Try again. Note which control is client-independent.
-
Delete the repository.