Skip to content

Building a Claude Code Plugin for Your API Stack

Posted on:July 6, 2026

Welcome, Developer đź‘‹

I already wrote about the difference between Claude Code plugins and skills a while back. That post stayed conceptual on purpose. This one isn’t. I’m going to build an actual plugin, end to end, for a stack most of you are running right now: Node.js, TypeScript, NestJS, Docker, Vitest, Supertest, and GitHub Actions for CI.

The trigger for this was watching my team of engineers and QAs write the same reminders into Claude Code sessions over and over. Use the module structure this way. Test controllers like this, not like that. Don’t forget the multi-stage Dockerfile. That’s tribal knowledge, and tribal knowledge that lives only in people’s heads (or scattered across old PR comments) is exactly what a plugin should hold instead.


Plugin vs Skill, Quickly

If you haven’t read the other post, here’s the short version.

A skill is a SKILL.md file. Instructions for one job. Claude either picks it up automatically when the context matches its description, or you call it directly with /skill-name.

A plugin is the container. It bundles one or more skills, plus optionally agents, hooks, MCP servers, and even LSP servers, into a single installable package with a manifest. Skills are the content. Plugins are the distribution format.

So when I say “build a Claude Code plugin for your NestJS API,” what I actually mean is: build a small collection of skills that encode how your team writes this kind of API, and package them so anyone on the team gets all of them with one install command instead of five people copy-pasting Markdown files into their .claude/skills/ folder.


Where Does CLAUDE.md Fit?

Before we build anything, one question I know you’re going to have: doesn’t project context already go in CLAUDE.md? Where’s the line?

The difference is not what they hold, it’s when they load. CLAUDE.md is read on every session and sits in the context window the whole time. A skill loads only when its description matches what you’re doing right now. So they’re not competing, they’re for different jobs.

The rule I follow: CLAUDE.md holds the small, always-true facts about the project. What the app is, the package manager, the one or two conventions that apply to literally every task. Skills hold the detailed playbooks that only matter some of the time.

Take the api-testing skill you’ll see below. It’s around 150 lines of config, examples, and gotchas. That is exactly the kind of thing you do not want loaded into every session, including the ones that never touch a test. In CLAUDE.md it would burn tokens all day for nothing. As a skill, it shows up only when I’m actually writing tests.

Short version: if it’s true for every task and fits in a few lines, CLAUDE.md. If it’s a task-specific playbook with real depth, a skill, and a plugin once you want to share a set of them.


What the Plugin Will Contain

For a Node/TypeScript/NestJS API with Docker, Vitest, Supertest, and GitHub Actions, four skills cover most of what comes up daily:

  1. nestjs-conventions: module structure, dependency injection, DTOs, guards, interceptors, pipes
  2. api-testing: unit tests with Vitest, integration tests with Supertest, what to mock and what not to
  3. docker-build: multi-stage Dockerfile patterns for a Nest app, image size, non-root user
  4. ci-pipeline: the GitHub Actions workflow, lint, test, build, and image push, in the right order

The plugin structure looks like this:

weldev-nest-api-toolkit/
├── .claude-plugin/
│   └── plugin.json
├── skills/
│   ├── nestjs-conventions/
│   │   └── SKILL.md
│   ├── api-testing/
│   │   └── SKILL.md
│   ├── docker-build/
│   │   └── SKILL.md
│   └── ci-pipeline/
│       └── SKILL.md
└── README.md

One important detail that trips people up: only plugin.json goes inside .claude-plugin/. Everything else, skills/, agents/, hooks/, sits at the plugin root, not nested inside .claude-plugin/.


Step 1: The Manifest

Every plugin needs a manifest at .claude-plugin/plugin.json. This is the metadata Claude Code uses to identify your plugin and namespace its skills.

{
  "name": "weldev-nest-api-toolkit",
  "description": "Conventions, testing patterns, Docker setup, and CI pipeline for our NestJS API stack",
  "version": "1.0.0",
  "author": {
    "name": "Dan Castro"
  }
}

The name field matters more than it looks. It becomes the namespace prefix for every skill in the plugin. With weldev-nest-api-toolkit as the name, the testing skill becomes /weldev-nest-api-toolkit:api-testing. That namespacing is what lets multiple plugins coexist without two teams’ skills colliding on the same name.

version is optional, but I’d set it explicitly. If you leave it out and distribute the plugin through git, Claude Code uses the commit SHA instead, which means every single commit counts as a new version. Fine for a solo experiment, annoying once other people depend on it.


Step 2: Write the Skills

Each skill is a folder under skills/ with a SKILL.md inside. The folder name becomes the skill name. Every file has the same shape: a frontmatter block with a name and a description, then the actual guidance in plain Markdown.

One thing worth calling out on the description field before we get into them: it’s not documentation for humans, it’s the trigger Claude uses to decide when to load the skill automatically. Write it the way you’d explain the skill’s purpose to a new hire in one sentence, and include a few phrases someone might actually type, like “adding tests” or “reviewing a Dockerfile”.

Here are all four, in full. These are the versions I actually run after cleaning up a few things that didn’t survive contact with a real project.

nestjs-conventions

The one that keeps module structure consistent so every feature looks the same no matter who wrote it.

---
name: nestjs-conventions
description: NestJS module structure, dependency injection, DTOs, and cross-cutting concerns (guards, interceptors, pipes) for our API. Use when creating a new module, adding an endpoint, wiring providers, or reviewing NestJS structure.
---
 
# NestJS Conventions
 
Target: NestJS 11, TypeScript 5.x, Node 22 LTS.
 
## Module structure
 
Organize by feature, not by technical layer. Each feature owns its controller, service, DTOs, and entities.
 
```text
src/
├── main.ts
├── app.module.ts
├── common/            # cross-cutting only: filters, guards, interceptors, pipes, decorators
├── config/            # configuration factories and validation
└── modules/
    ├── users/
    │   ├── dto/
    │   ├── entities/
    │   ├── users.controller.ts
    │   ├── users.service.ts
    │   └── users.module.ts
    └── auth/
        ├── dto/
        ├── guards/
        ├── strategies/
        ├── auth.controller.ts
        ├── auth.service.ts
        └── auth.module.ts
```
 
Rules:
 
- Domain code lives inside its feature module. Only truly shared, business-agnostic code goes in common/. If a helper needs to know what a "user" or an "order" is, it does not belong in common/.
- Keep DTOs next to the module that owns them.
- Export a provider from a module only when another module actually consumes it.
 
## Dependency injection
 
- Inject through the constructor with private readonly. Do not instantiate services with new inside other services.
- Depend on abstractions for anything external (database, HTTP clients, queues). Put ORM/repository code behind a provider that speaks domain language.
- Controllers coordinate HTTP, services own business logic. A controller should not orchestrate a multi-step write directly; that belongs in a service that owns the unit of work.
- Prefer one operation per service method (use-case style) over a single service with fifteen unrelated methods.
 
## DTOs and validation
 
Every request body and query gets a DTO validated with class-validator.
 
```ts
// dto/create-user.dto.ts
import { IsEmail, IsString, MinLength } from "class-validator";
 
export class CreateUserDto {
  @IsEmail()
  email: string;
 
  @IsString()
  @MinLength(8)
  password: string;
}
```
 
Enable validation globally in main.ts:
 
```ts
app.useGlobalPipes(
  new ValidationPipe({
    whitelist: true, // strip properties not in the DTO
    forbidNonWhitelisted: true, // reject requests with unknown properties
    transform: true, // coerce payloads to DTO instances and primitive types
  }),
);
```
 
whitelist: true matters for security: without it, unexpected fields pass straight through to your service. Whatever global pipes and filters main.ts sets, the integration tests must set the same ones, or tests validate the wrong behavior.
 
## Cross-cutting concerns
 
- Guards for authentication and authorization.
- Interceptors for response shaping, logging, and timing. No business logic in an interceptor.
- Pipes for validation and transformation. ValidationPipe covers most cases.
- Exception filters for consistent error responses. Register a global filter so every error leaves the API in the same shape.
 
## Configuration
 
- Use @nestjs/config with a typed configuration factory. Validate environment variables at startup so a missing var fails fast on boot, not at first request.
- Split dev/staging/prod differences inside config factories, not with scattered if (env === 'prod') branches.
 
## Checklist for a new endpoint
 
1. DTO for the request, validated with class-validator.
2. Service method holding the business logic, dependencies injected.
3. Controller method that is thin: validate, delegate, return.
4. Correct HTTP status via @HttpCode() where the default is wrong.
5. Guard applied if the route is protected.
6. Tests: see the api-testing skill.

api-testing

This is the one I spent the most time on, because the naive version is quietly broken. More on that right after the code.

---
name: api-testing
description: Write unit tests with Vitest and integration tests with Supertest for NestJS controllers and services. Use when adding tests, setting up a module's test suite, reviewing coverage, or debugging failing NestJS tests.
---
 
# API Testing Conventions
 
Stack: Vitest, Supertest, @nestjs/testing. NestJS 11, Node 22.
 
## Required setup (read this first)
 
Vitest transforms TypeScript with esbuild by default, and esbuild does not emit decorator metadata. NestJS resolves constructor dependencies from that metadata, so a NestJS test suite run through plain Vitest fails at DI resolution ("Nest can't resolve dependencies..."). This is the single most common reason NestJS plus Vitest "doesn't work."
 
Fix it by transforming with SWC instead.
 
Install:
 
```bash
npm i -D vitest unplugin-swc @swc/core @types/supertest supertest
```
 
reflect-metadata must be a regular dependency (not devDependencies) and be imported once at the app entry point.
 
vitest.config.ts:
 
```ts
import swc from "unplugin-swc";
import { defineConfig } from "vitest/config";
 
export default defineConfig({
  test: {
    globals: true,
    root: "./",
  },
  plugins: [
    // SWC preserves emitDecoratorMetadata; esbuild does not.
    swc.vite({ module: { type: "es6" } }),
  ],
});
```
 
If you use tsconfig path aliases, add vite-tsconfig-paths to plugins, before swc.vite.
 
tsconfig.json must have both decorator options on, or SWC has nothing to preserve:
 
```json
{
  "compilerOptions": {
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  }
```
 
}
 
## Unit tests (Vitest)
 
Test services and providers in isolation. Mock every injected dependency.
 
- One describe per class, one it per behavior (not per method).
- Never touch a real database or external service in a unit test. If a test needs Prisma or TypeORM, it is an integration test.
- Use Test.createTestingModule only when the class actually depends on the DI container. Otherwise instantiate directly with mocks passed to the constructor. It is faster and the failure output is clearer.
 
```ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ConflictException } from '@nestjs/common';
import { UsersService } from './users.service';
 
describe('UsersService', () => {
  let service: UsersService;
  const repo = { findByEmail: vi.fn(), create: vi.fn() };
 
  beforeEach(() => {
    vi.clearAllMocks();
    service = new UsersService(repo as any);
  });
```
 
it('rejects a duplicate email', async () => {
repo.findByEmail.mockResolvedValue({ id: '1' });
await expect(
service.create({ email: 'a@b.com', password: 'password123' }),
).rejects.toThrow(ConflictException);
expect(repo.create).not.toHaveBeenCalled();
});
});
 
## Integration tests (Supertest)
 
Boot the full app, then drive it over HTTP.
 
```ts
import { Test } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import request from 'supertest';
import { AppModule } from '../src/app.module';
 
describe('Users (e2e)', () => {
  let app: INestApplication;
 
  beforeAll(async () => {
    const moduleRef = await Test.createTestingModule({
      imports: [AppModule],
    }).compile();
 
    app = moduleRef.createNestApplication();
    // Mirror main.ts exactly, or tests pass for the wrong reason.
    app.useGlobalPipes(
      new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }),
    );
    await app.init();
  });
```
 
afterAll(async () => {
await app.close();
});
 
it('POST /users creates a user', async () => {
await request(app.getHttpServer())
.post('/users')
.send({ email: 'a@b.com', password: 'password123' })
.expect(201);
});
 
it('POST /users rejects an invalid payload', async () => {
await request(app.getHttpServer())
.post('/users')
.send({ email: 'not-an-email' })
.expect(400);
});
});
 
Rules:
 
- Apply the same global pipes, filters, and interceptors the real app uses.
- Test the request/response contract: status codes, response shape, validation errors, auth guards. Business-logic edge cases stay in the unit suite.
- Use a disposable test database or reset the schema between test files. Shared mutable state across test files is the top cause of flaky Supertest suites. If you see order-dependent failures, that is the first place to look.
- Always await app.close() in afterAll, or the process hangs and CI times out.
 
## Naming and structure
 
- Unit test files sit next to the source: users.service.spec.ts.
- Integration test files live under test/, named after the resource: test/users.e2e-spec.ts.
- Every new endpoint needs at least one integration test for the happy path and one for a validation failure.
 
## Scripts
 
```json
"scripts": {
  "test": "vitest run",
  "test:watch": "vitest",
  "test:e2e": "vitest run test/**/*.e2e-spec.ts",
  "test:cov": "vitest run --coverage"
}
```

Here’s the part I got wrong the first time and want to save you from. If you wire up Vitest against a NestJS project and just start writing tests, they fail at dependency resolution with a “Nest can’t resolve dependencies” error, even though your code is fine. The reason is that Vitest uses esbuild, and esbuild throws away the decorator metadata NestJS relies on to know what to inject. The fix is unplugin-swc, which swaps in SWC as the transformer so that metadata survives. Miss that one line of config and nothing else in the skill works.

docker-build

Multi-stage build, non-root, and the signal-handling detail that everyone forgets until a deploy hangs.

---
name: docker-build
description: Multi-stage Dockerfile and .dockerignore for a production NestJS API, small image, non-root user, correct signal handling, health check. Use when writing or reviewing a Dockerfile, containerizing the API, or debugging image size or container startup.
---
 
# Docker Build (NestJS, production)
 
Goal: a small, reproducible, non-root image that starts fast and shuts down cleanly. A naive FROM node:22 plus COPY . . image is about 1GB; the multi-stage Alpine build below lands around 150 to 200MB.
 
## Multi-stage Dockerfile
 
```dockerfile
# syntax=docker/dockerfile:1
 
# ---------- build ----------
# Pin a specific patch in real deployments (e.g. node:22.11.0-alpine) so a
# Node patch release is never introduced silently on a rebuild.
FROM node:22-alpine AS build
WORKDIR /app
 
# Copy lockfile first so the dependency layer only rebuilds when deps change.
COPY package.json package-lock.json ./
RUN npm ci
 
COPY tsconfig*.json nest-cli.json ./
COPY src ./src
RUN npm run build
 
# ---------- production dependencies ----------
FROM node:22-alpine AS prod-deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
 
# ---------- runtime ----------
FROM node:22-alpine AS runner
ENV NODE_ENV=production
WORKDIR /app
 
# tini reaps zombies and forwards SIGTERM so NestJS shuts down gracefully.
RUN apk add --no-cache tini
 
# Run as the unprivileged node user that the official image already ships.
COPY --chown=node:node --from=prod-deps /app/node_modules ./node_modules
COPY --chown=node:node --from=build /app/dist ./dist
COPY --chown=node:node package.json ./
USER node
 
EXPOSE 3000
 
# Requires a /health endpoint (see @nestjs/terminus).
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
  CMD wget --quiet --tries=1 --spider http://localhost:3000/health || exit 1
 
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "dist/main.js"]
```
 
## .dockerignore
 
Without this, node_modules, .git, and .env get baked into the image and invalidate the build cache on every change.
 
```text
node_modules
dist
npm-debug.log
.git
.gitignore
.env
.env.*
*.md
.github
.vscode
coverage
Dockerfile
.dockerignore
```
 
## Why each choice
 
- npm ci, not npm install: deterministic, honors the lockfile, fails if package.json and the lockfile disagree.
- Separate prod-deps stage: the runtime image gets production dependencies only. TypeScript, the Nest CLI, and test tooling never reach the final layer.
- Non-root USER node: a compromised process runs unprivileged. The official Node image already provides this user.
- NODE_ENV=production: many libraries skip dev-only work when it is set.
- tini as PID 1: node (and worse, npm start) as PID 1 does not forward SIGTERM properly, so docker stop waits, then kills the process mid-request. tini (or docker run --init) fixes graceful shutdown. Pair it with app.enableShutdownHooks() in main.ts.
- CMD node dist/main.js, not npm run start:prod: one fewer process in the tree and clean signal handling.
 
## Gotchas
 
- Alpine uses musl, not glibc. Prebuilt native modules (bcrypt, sharp, node-canvas) can break. Either compile from source or switch the base to node:22-slim (Debian-slim, glibc, about 250MB).
- Cross-architecture builds. Building on Apple Silicon (arm64) and deploying to x86_64 without --platform=linux/amd64 produces an image the target can't run. Use docker buildx in CI.
- Pin the base image in production so Node patch releases are deliberate, not silent.

ci-pipeline

The GitHub Actions workflow, ordered so the cheap checks fail before the expensive ones start.

---
name: ci-pipeline
description: GitHub Actions workflow for the NestJS API, lint, unit tests, build, integration tests, and image push in the right order. Use when creating or reviewing a CI workflow, adding a pipeline stage, or debugging a failing GitHub Actions run.
---
 
# CI Pipeline (GitHub Actions)
 
Order, fastest-failing first: lint, then unit tests, then build, then integration tests, then image build and push. Cheap checks gate expensive ones, so a lint error never waits on a Docker build.
 
## Workflow
 
```yaml
# .github/workflows/ci.yml
name: CI
 
on:
  push:
    branches: [main]
  pull_request:
 
# Cancel superseded runs on the same ref (e.g. rapid pushes to a PR).
concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true
 
jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: npm run lint
      - run: npx tsc --noEmit # type errors fail here, not at build time
 
  unit:
    runs-on: ubuntu-latest
    needs: lint
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: npm run test:cov
 
  integration:
    runs-on: ubuntu-latest
    needs: unit
    services:
      postgres:
        image: postgres:16-alpine
        env:
          POSTGRES_USER: test
          POSTGRES_PASSWORD: test
          POSTGRES_DB: test
        ports: ["5432:5432"]
        options: >-
          --health-cmd "pg_isready -U test"
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
    env:
      DATABASE_URL: postgres://test:test@localhost:5432/test
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: npm run test:e2e
 
  docker:
    runs-on: ubuntu-latest
    needs: integration
    # Only build and push the image after a merge to main.
    if: github.ref == 'refs/heads/main'
    permissions:
      contents: read
      packages: write # required to push to GHCR
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: |
            ghcr.io/${{ github.repository }}:latest
            ghcr.io/${{ github.repository }}:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
```
 
## Why each choice
 
- cache: npm on setup-node caches the npm store keyed on the lockfile. Do not hand-roll actions/cache for this.
- npm ci in every job for a reproducible install from the lockfile.
- tsc --noEmit in lint catches type errors early and cheaply. SWC-based builds skip type checking, so without this a type error surfaces later, or never.
- needs: chaining enforces the fail-fast order.
- services: postgres gives integration tests a real database on localhost:5432, health-gated so tests don't start before it's ready. Match production's major version.
- if: github.ref == 'refs/heads/main' on the image job builds and pushes only after merge, not on every PR.
- permissions: packages: write is required for GHCR pushes; the default token is read-only for packages otherwise.
- cache-from/to: type=gha reuses Docker layer cache across runs.

Four skills is the point where this stops being a toy. Together they cover how a feature is structured, how it’s tested, how it’s packaged, and how it ships, which is most of what my team of engineers and QAs actually repeats day to day.


Step 3: Test It Locally

Before sharing anything, run the plugin from its own directory with --plugin-dir:

claude --plugin-dir ./weldev-nest-api-toolkit

This loads the plugin for that session only, no marketplace, no install step. Try a skill:

/weldev-nest-api-toolkit:api-testing

Made a change to a SKILL.md? Run /reload-plugins instead of restarting the whole session. It reloads skills, agents, hooks, and any MCP or LSP servers the plugin defines.

You can stack multiple --plugin-dir flags if you’re testing more than one plugin at a time. Recent Claude Code versions (2.1.128 and up) also let --plugin-dir point at a local .zip of the plugin. If the zip lives at a URL instead, like a CI build artifact, use --plugin-url for that.


Step 4: Where to Store It

This depends on who else needs it.

Solo, single project: keep it in the repo, at the root or under tools/weldev-nest-api-toolkit/, and point --plugin-dir at it, or better, add it to the project’s .claude/settings.json so it loads automatically for anyone who clones the repo.

Across your own projects: claude plugin init scaffolds a plugin directly into ~/.claude/skills/, and Claude Code loads it automatically on every session from there, no install step, no marketplace.

Shared with the team or the community: this is where a marketplace comes in. A marketplace is just a git repository with a .claude-plugin/marketplace.json file listing the plugins it offers. Push your plugin’s repo, add a marketplace.json pointing at it, and anyone on the team adds your marketplace once:

/plugin marketplace add your-org/deelvw - nest - api - toolkit;

To keep it private to your company, host that repository privately. Nothing about the process changes, Claude Code uses your existing git credentials.


Step 5: Install and Use It

Once a marketplace is added, installing is one line:

/plugin install weldev-nest-api-toolkit@your-org
/reload-plugins

From here, the skills work two ways.

Automatic. Claude reads the description field on every skill in every enabled plugin and pulls in whichever one fits the current task, no invocation needed. Ask it to “add a test for the new orders endpoint” and it should reach for api-testing on its own, because that’s exactly the phrase the description was written to match.

Manual. Call a skill directly with its namespaced name:

/weldev-nest-api-toolkit:ci-pipeline

Useful when you want the skill applied regardless of what Claude might infer from context, or when a skill is intentionally invocation-only. Setting disable-model-invocation: true in a skill’s frontmatter turns off the automatic path entirely and makes it callable only by name, which is worth doing for anything disruptive or expensive that shouldn’t fire without you asking for it.

Check what’s active any time with /plugin, under the Installed tab. Disable without uninstalling using /plugin disable weldev-nest-api-toolkit@your-org, useful if a skill is misfiring and you need to isolate it without losing the install.


Conclusion

The plugin itself isn’t the point. The point is that “how we build APIs here” stops living in Slack threads and onboarding docs nobody reads past week one, and starts living in something Claude actually loads and applies. Once it’s packaged, updating the convention for the whole team is a git push and a version bump, not five separate conversations.

Start with one skill that captures the thing your team repeats most often. Add the rest as they come up. You don’t need all four on day one.

Stay focused, Developer!