Build Your First GitHub Copilot Agent Skill

Agents, MCP & Agentic DevelopmentAcademy lesson 81Cluster 7 · Lesson 6 of 13Intermediate15 min readVersion-sensitive
Published
Updated
Last technically verified
Build Your First GitHub Copilot Agent SkillAgents, MCP & Agentic Development6Intermediate/github-copilot/agents/build-agent-skill/

The previous lesson explained what skills are. This one builds a complete, repository-ready example: a skill describing how a project writes HTTP API tests, with reference material, a template, and a validation script that actually runs.

Every file is shown in full, laid out exactly as it would sit in a real project. The example targets a FastAPI service tested with pytest and httpx; the shape transfers to any stack, and the parts worth copying are the structural decisions rather than the Python.

Pick something worth documenting

The best first skill is a document you have already half-written — an onboarding note, a wiki page, the explanation you give the same way every time someone joins.

Good candidates share three properties.

The procedure is stable. Content that changes weekly goes stale faster than anyone maintains it, and a confidently wrong skill is worse than none.

The details are arbitrary. Which fixture to use, what the naming convention is, which of two equivalent approaches you picked. Arbitrary decisions are unguessable, which makes writing them down high-value.

Getting it wrong is common. If people already make the same mistake repeatedly, a skill has something concrete to prevent.

API testing hits all three in most codebases. There is always a client fixture someone builds by hand instead. There is always an authentication helper that gets reimplemented. And there is always a category of test — the tenant boundary case, in this example — that everyone forgets.

The layout

api-testing-skill/
.github/skills/api-testing/
├── SKILL.md                          the entry point
├── reference/
│   ├── patterns.md                   parametrisation, fixtures, boundaries
│   └── incidents.md                  real bugs and the test that catches each
├── templates/
│   └── test_endpoint.py              the skeleton to copy
└── scripts/
    └── check-endpoint-coverage.py    read-only coverage check

The split matters. SKILL.md is what gets read first, so it holds the essentials and points at the rest. Long-form material lives in reference/, where it is available when relevant without being carried always.

Write the frontmatter first

---
name: api-testing
description: How this project writes HTTP API tests with pytest and httpx.
  Use when adding or changing tests for FastAPI endpoints, or when a
  request needs authentication, tenant scoping or error-path coverage.
license: MIT
---

name is lowercase with hyphens and matches the directory. description is doing the real work: it names the technologies (pytest, httpx, FastAPI) and the trigger conditions (adding tests, authentication, tenant scoping, error paths). Those are the words that will appear in a matching request.

Write the body

Start with the layout convention, because it answers the first question anyone has.

## Layout

One test module per route module, mirroring the source tree.

    src/routes/users.py   ->  tests/routes/test_users.py

Then the fixture, with the reason it matters — the reason is what lets the agent generalise to cases you did not anticipate:

## The client fixture

Always use the `client` fixture from `tests/conftest.py`. It wires an
`httpx.AsyncClient` against the app with the test database already
migrated. Never construct a client inline — an inline client skips the
teardown that rolls back the transaction, and the next test sees your
rows.

That last sentence is the difference between a rule and knowledge. “Always use the fixture” is a rule that gets followed until something makes it inconvenient. “Because an inline client leaks rows into the next test” is a fact that keeps applying.

Then the checklist that defines completeness:

## What every endpoint test must cover

1. The happy path.
2. Missing or invalid authentication.
3. A tenant boundary case — data belonging to another tenant must not
   appear.
4. Every error branch that has an explicit `raise` in the route.

Coverage of 1 and 2 only is the most common gap in review.

And a “what not to do” section, which is consistently the highest-value part of any skill:

## What not to do

- Do not assert on the full response body. Assert on the fields the
  endpoint is responsible for; whole-body assertions break whenever an
  unrelated field is added.
- Do not use `time.sleep`. If a test needs to wait, it is testing
  something that should be awaited.
- Do not mock the database. The fixture gives you a real one.

The shape of the script

The checker is worth reading even if you never write Python, because its structure is the reusable part.

It parses the source rather than searching it with a regular expression. Route decorators are found by walking the abstract syntax tree and looking for a decorator call whose attribute is one of the HTTP verbs. A regular expression over the source would find the same lines most of the time and would also find them inside strings, comments and disabled code. For a check that people are meant to trust, most of the time is not good enough.

It reports two distinct problems. A route module with no test module at all is one finding. A test function that never asserts a status code is another — and the second catches a specific real failure, the test that exercises an endpoint and asserts only on the body, passing happily while the endpoint returns 500.

It exits non-zero when it finds anything, which makes it usable in CI without modification.

And it takes its directories as arguments with sensible defaults, so it works unmodified in a project laid out differently.

Add the reference material

reference/patterns.md holds the long forms — parametrised error tables, the tenant boundary shape, the pagination envelope, a table of available fixtures. This is content that would bloat SKILL.md and is genuinely useful when someone is writing that specific kind of test.

reference/incidents.md is the file most teams skip and should not. Four real failures, each with the gap that allowed it:

## Cross-tenant leak in the search endpoint

`GET /api/search` filtered by tenant in the SQL for the primary table but
not in the join to `attachments`. Attachment filenames from other tenants
appeared in results.

**The missing test:** a tenant boundary case for every endpoint that
joins.

Nothing else in the skill is as persuasive as that. A rule says what to do; an incident says what happens when you do not, and it is the content no model could have generated.

Add the template

templates/test_endpoint.py is a skeleton with one section per required case and a deliberate NotImplementedError in the boundary test:

# 3. Tenant boundary -------------------------------------------------


async def test_is_tenant_scoped(client, as_tenant, make_user):
    # Create a row under one tenant, read as another, assert absence.
    raise NotImplementedError("Write the boundary case before merging.")

The failure is intentional. A skeleton whose sections all pass invites shipping the skeleton. One that fails loudly until the case is written does not.

Add a script that checks

A skill that can validate is more valuable than one that describes validation, because the description depends on the agent checking correctly and the script does not.

scripts/check-endpoint-coverage.py parses route modules with ast, finds decorated handlers, and reports two things: route modules with no corresponding test module, and test functions that never assert a status code.

Practical example

check-endpoint-coverage.py

Report endpoints with no test module and tests that assert no status code.

Status
Tested implementation
Runtime
Python 3.12.3 (standard library only)
Command
python3 check-endpoint-coverage.py src/routes tests/routes
Result
MISSING tests/routes/test_users.py — src/routes/users.py defines 2 endpoint(s) / NO STATUS tests/routes/test_health.py::test_health_shape never asserts a status code / 2 finding(s). Exit code 1.
Run on
August 25, 2026

It was run against a fixture project containing two route modules and one test module, with a known-missing test file and a known assertion-free test. Both were reported, and adding the missing file removed that finding on the next run — a positive and a negative control rather than one run that printed something.

Why this content and not other content

It is worth being explicit about what was left out, because the omissions are the design.

No explanation of what a test is. The model knows. Every sentence spent on general knowledge is a sentence not spent on something it could not have guessed.

No pytest tutorial. Same reason. The skill assumes competence and supplies context.

No aspirations. The skill describes how the project tests today, not how someone would like it to. A skill that documents an intended future produces output that does not match the codebase, and the mismatch reads as the agent being wrong.

Nothing a linter already enforces. If your formatter fixes it, documenting it adds nothing — and a document that disagrees with the tooling is actively harmful, because one of them is going to be followed.

No praise for the codebase. “We follow clean architecture principles” is not actionable. “Route handlers do not touch the database directly; they call a service in src/services/” is.

The residue after those cuts is short, and short is the point. A skill that is three screens long will be skimmed by the humans who maintain it, and a skill nobody maintains goes stale.

Adapting it to your stack

The Python is incidental. Four decisions carry over to any language.

One entry point that fits on a screen. Whatever SKILL.md becomes, it should be readable in a minute and should say where the detail lives.

A checklist that defines done. “Every endpoint test must cover these four cases” is the sentence that changes output most, because it gives completeness a definition instead of leaving it to judgement.

A template with a deliberate failure. Whatever your language, a skeleton that passes as-is invites shipping the skeleton.

A read-only checker. Every ecosystem has enough of a parser or a grep to answer “does every X have a corresponding Y”. It does not need to be clever; it needs to run and to report rather than fix.

Install and test it

Copy the directory into .github/skills/api-testing/ in your repository, or ~/.copilot/skills/api-testing/ while drafting.

Then verify it actually works, which means checking three separate things.

Does it load? Ask for something the skill covers and look for its fingerprints — the fixture name, the four-case checklist, the file layout.

Copilot promptTesting whether the skill loadedAgent mode or CLI

Add tests for the new endpoints in src/routes/invoices.py.

If the result uses as_tenant, mirrors the source path, and includes a tenant boundary case, the skill loaded. If it produces generic pytest with a hand-built client, it did not — check the directory path first, before rewriting anything.

Does it change the output? Run the same request with the skill removed and compare. If the two results are indistinguishable, the skill is adding tokens and nothing else, and the content is too generic.

Does the script run? Run it yourself. A validation script nobody has executed is a claim, not a check.

Measuring whether it helped

The honest question after building a skill is whether output improved, and it is answerable without ceremony.

Keep three requests you care about. Realistic ones — the tests you actually need written, phrased the way you actually phrase them.

Run them with the skill absent and present. Same repository, same wording. The comparison is the measurement.

Look for the specifics, not the quality. Whether the output is good is a judgement call that varies with mood. Whether it used as_tenant instead of building headers by hand is a fact you can check in two seconds, and it is the thing the skill was for.

Count what you had to fix. If you are still correcting the same thing after adding the skill, that correction is the content the skill is missing. Add it and run the three requests again.

This loop converges quickly — usually two iterations. It is also the only reliable way to tell a skill that works from one that merely exists, and the difference is invisible from the file itself.

What to do when it does not fire

Wrong location. This is the most common cause by a very wide margin, and worth eliminating before you consider any other explanation. Confirm the path against the documented discovery locations, and confirm the file is named exactly SKILL.md.

Description too narrow. If it matches when you use its exact words and not otherwise, add the synonyms people use. “Endpoint”, “route”, “API test” and “handler” may all refer to the same thing in your team’s speech.

Frontmatter invalid. name must be lowercase with hyphens, and normally matches the directory it sits in. A name with spaces or capitals, or one that disagrees with the directory, is a plausible reason for silent non-loading — and silent is the operative word, since nothing reports the problem.

Competing content. A broad instruction file or another overlapping skill can win. Sharper descriptions resolve this better than longer ones — and if two of your skills genuinely cover neighbouring ground, saying in each description what it is not for fixes selection more reliably than any amount of extra detail about what it is.

Sharing it with the team

A skill in your home directory helps one person. Getting it into the repository is the step that makes it worth the effort, and it is a normal pull request with two unusual review questions.

Is every specific still true? The fixture names, the file paths, the helper functions. A skill that references a fixture renamed last month is confidently wrong in a way that is hard to notice, because the output looks reasonable.

Does the team agree this is the procedure? Writing a skill makes one person’s version authoritative. If there is genuine disagreement about how the project tests, the pull request is the place to resolve it — and resolving it is worth more than the skill.

After it lands, two habits keep it alive. Update it in the same pull request that changes what it describes, so the document and the code move together. And when someone gets something wrong that the skill covers, treat that as a signal about the skill rather than about the person: either the content is missing, or the description did not match the situation they were in.

Extending it

Once one skill works, the pattern generalises. Reasonable second skills for the same project: how migrations are written, how the deployment pipeline is structured, how errors and logging are handled, how the public API is versioned.

Two cautions from teams that went further.

Do not write a skill per module. If the content is about one module, it belongs in that module’s documentation. Skills describe procedures, not areas.

Do not put the same content in two places. If a rule already lives in your instructions file, referencing it from the skill is fine and restating it is not. The restated copy will be the one that goes stale, and nobody will know which version is current.

Do not fork one skill into variants. Two skills that are eighty percent the same will diverge, and the divergence will be invisible. Either merge them or make the difference explicit in both descriptions.

Common questions

Does the skill need all four directories? No. A single SKILL.md is a valid skill and is the right starting point. Add reference/ when the entry point gets long, templates/ when people copy the same thing repeatedly, and scripts/ when you have a check worth running.

Should the script be executable? Making it executable is a convenience. Whether the agent may run it without asking is a separate decision governed by allowed-tools, and leaving it out means running the script stays deliberate.

What if my project has no conventions to document? Then it has undocumented conventions, which is different. The exercise of writing them down is usually worth more than the skill — and the disagreements it surfaces are the interesting part.

Can a skill contain a whole style guide? It can, and it should not. A style guide that only loads sometimes is a style guide that is sometimes not applied. Rules you need every time belong in custom instructions.

Where should the skill live while I am still writing it? A personal location — ~/.copilot/skills/ or ~/.agents/skills/ — so you can iterate through the bad first draft without anyone else adopting it. Move it into the repository once the content has stopped changing every time you use it.

How long should SKILL.md be? Short enough that the people maintaining it read it in full when they change it. In practice that is a screen or two.

A second skill, in outline

Once the first works, the second is quicker. A sketch of one worth building in most projects.

The subject: how this project handles errors and logging.

Why it qualifies: the conventions are arbitrary (which exception types, which log level, what goes in the message), the procedure is stable, and people get it wrong in the same way repeatedly.

What goes in SKILL.md: the exception hierarchy and when to use each type; which errors are logged and which are raised; what must never appear in a log line; the structured-logging fields this project expects.

What goes in reference/: the full list of error codes and their meanings; the long form of the structured-logging schema.

What goes in templates/: the shape of a service-layer function that handles its own errors correctly.

What goes in scripts/: a check that finds bare except: clauses and log calls that interpolate values which might be personal data.

The description: “How this project raises, handles and logs errors. Use when adding error handling, writing a log line, or reviewing a change that touches either.”

That skill takes an hour, encodes something every codebase has opinions about, and pays for itself the first time it stops a bare except from reaching review.

Next

Custom agents covers the container skills most often pair with. Later in this cluster, the DevOps agent combines an agent profile, a skill and instructions into one coherent configuration — which is where this stops being a single file and starts being a system.

Sources

Every version-sensitive claim on this page was checked against first-party documentation. Only sources actually used are listed.

Primary sources