Fastify API skill for Codex
Schema-first Fastify with deliberate encapsulation. Codex builds Fastify services around the plugin tree: shared infrastructure wrapped with fastify-plugin, feature routes kept encapsulated, and every route carrying body and response schemas that validate input and strip unlisted fields on output. It picks the correct hook phase for auth and authorization, handles Fastify 5 decorator rules, and tests through app.inject so suites run offline.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- fastify-api Installs to ~/.agents/skills/fastify-api/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install fastify-api The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project fastify-api Installs to .agents/skills/fastify-api/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/fastify-api.zipunzip -o fastify-api.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) fastify-api PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $fastify-api, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Builds Fastify 5 services the way the framework intends - encapsulated plugins with fastify-plugin only where sharing is needed, JSON Schema or TypeBox route schemas for validation and fast serialization, typed decorators, lifecycle hooks in the right phase, setErrorHandler, pino logging, and inject-based tests. Use when creating a Fastify API, adding routes, plugins or auth hooks, migrating from Express or Fastify 4, or when a decorator is undefined in a route, response fields go missing, or throughput is lower than expected.
Use it when
- Creating or extending a Fastify API with plugins, hooks and schemas
- Decorators or hooks are missing in some routes due to encapsulation
- Migrating from Express or from Fastify 4 to Fastify 5
- Response fields go missing or throughput needs to improve
Use something else when
- The project is an Express app and is staying on Express: use express-api
- You want a framework with DI modules and decorators on top of Fastify: use nestjs-modules
How to check the result
Tests pass through app.inject, printPlugins shows shared plugins at the root and feature hooks scoped to their routes, and each route has a response schema for its success status.
The mistake it prevents
Registering a shared plugin without fastify-plugin, so its decorator exists only inside that plugin's own context and routes elsewhere see undefined.
Done well means: every route declares schemas for its input and its responses, shared
resources live in plugins with an explicit encapsulation decision, hooks run in the
phase that matches their job, and the app is built by a function that tests call with
app.inject() without opening a port.
Inspect first
- Read
AGENTS.md, thenpackage.json: Fastify major (v5 requires Node 20+), type provider in use (TypeBox, Zod, json-schema-to-ts),@fastify/autoload, test runner. - Map the plugin tree:
rg -n "register\(|fastify-plugin|fp\(" src. - Find decorators:
rg -n "decorate(Request|Reply)?\(" src. - Check how the server listens: in containers it must bind
host: "0.0.0.0"; Fastify defaults tolocalhost.
Layout
src/
app.ts # export async function buildApp(opts): FastifyInstance
server.ts # buildApp() + listen + close-with-grace
plugins/ # shared infra, wrapped with fastify-plugin: db, auth, config
routes/
orders/
index.ts # routes for /orders, encapsulated
schemas.ts
With @fastify/autoload, plugins/ loads first and routes/ folders become prefixes.
Encapsulation decisions
Each register() creates a child context. Decorators, hooks and plugins registered
inside it are invisible to siblings and parents.
| You are writing | Wrap in fastify-plugin? | Why |
|---|---|---|
| DB pool, config, auth decorator used app-wide | Yes | Must be visible to all routes |
Auth onRequest hook for one route group | No, register inside that group | Keeps public routes public |
| A feature’s routes | No | Encapsulation keeps its hooks local |
Third-party @fastify/* plugin | Already wrapped where needed | Register it at the level that should see it |
“fastify.db is undefined” in a route almost always means the DB plugin was not
wrapped with fastify-plugin or was registered in a sibling context.
// plugins/db.ts
import fp from "fastify-plugin";
import pg from "pg";
declare module "fastify" { interface FastifyInstance { db: pg.Pool } }
export default fp(async (app) => {
const pool = new pg.Pool({ connectionString: app.config.DATABASE_URL });
app.decorate("db", pool);
app.addHook("onClose", async () => { await pool.end(); });
}, { name: "db", dependencies: ["config"] });
Routes with schemas (TypeBox)
import { Type, type FastifyPluginAsyncTypebox } from "@fastify/type-provider-typebox";
const Order = Type.Object({ id: Type.String(), qty: Type.Integer(), status: Type.String() });
const orders: FastifyPluginAsyncTypebox = async (app) => {
app.addHook("onRequest", app.authenticate); // only this context
app.post("/", {
schema: {
body: Type.Object({ sku: Type.String({ minLength: 1 }), qty: Type.Integer({ minimum: 1 }) },
{ additionalProperties: false }),
response: { 201: Order },
},
}, async (req, reply) => {
const order = await app.orders.create(req.user.id, req.body); // req.body is typed
return reply.code(201).send(order);
});
};
export default orders;
- Fastify 5 requires full JSON Schema objects for
body,querystring,paramsandheaders(type: "object"withproperties); the v4 shorthand is gone. - Response schemas compile to a fast serializer and strip properties not in the schema.
This prevents leaking
password_hash, and it is also why a newly added field “disappears” until the schema is updated. - Fastify’s default Ajv options coerce types (
"5"becomes5), apply defaults, and useremoveAdditional: true: withadditionalProperties: false, unknown fields are silently stripped, not rejected. To return 400 on unknown fields instead, passajv: { customOptions: { removeAdditional: false } }to the Fastify factory. - Use
app.addSchema({ $id: "Order", ... })and{ $ref: "Order#" }for shared shapes, and@fastify/swaggerto publish them as OpenAPI.
Hooks: pick the phase
| Hook | Use for |
|---|---|
onRequest | Auth, request ids, early rejection (body not parsed yet, cheapest) |
preParsing | Transforming the raw body stream (rare) |
preValidation | Logic that needs the parsed body before schema validation |
preHandler | Authorization that needs validated params/body (e.g. ownership checks) |
preSerialization | Wrapping payloads (envelopes); not called for strings/streams |
onSend | Headers on every response, payload already serialized |
onResponse | Metrics; response already sent |
onError | Observing errors (cannot change the response; use the error handler) |
onClose (app) | Releasing pools and clients on shutdown |
Async hooks must not also call done. Return or throw; never both styles in one hook.
Decorators
app.decorateRequest("user", null); // reference types must start as null
app.addHook("onRequest", async (req) => { req.user = await verify(req.headers.authorization); });
Fastify 5 throws if you decorate the request with an object or array default, because
it would be shared across requests. Use null and assign per request. Declare the type
via module augmentation (interface FastifyRequest { user: User | null }).
Errors
app.setErrorHandler((err, req, reply) => {
if (err.validation) {
return reply.code(422).send({ error: { code: "validation_failed", details: err.validation } });
}
const status = err.statusCode && err.statusCode < 500 ? err.statusCode : 500;
if (status === 500) req.log.error({ err }, "unhandled");
return reply.code(status).send({ error: { code: err.code ?? "internal", message: status === 500 ? "Internal server error" : err.message } });
});
app.setNotFoundHandler((req, reply) => reply.code(404).send({ error: { code: "not_found" } }));
Throw app.httpErrors.notFound() (from @fastify/sensible) or errors with a
statusCode property. Error handlers are encapsulated too: a child context can override
the parent’s.
Async handler rules
- Return the payload, or call
reply.send()andreturn reply. Doing both, or callingsendwithout returning in an async handler, logs “Reply was already sent”. - Returning
undefinedfrom an async handler without sending is an error; returnreply.code(204).send()for empty responses.
Server and shutdown
import closeWithGrace from "close-with-grace";
const app = await buildApp({ logger: { level: process.env.LOG_LEVEL ?? "info" } });
await app.listen({ port: Number(process.env.PORT ?? 3000), host: "0.0.0.0" });
closeWithGrace({ delay: 10_000 }, async ({ err }) => {
if (err) app.log.error({ err }, "closing after error");
await app.close(); // runs onClose hooks
});
Behind a proxy, set trustProxy to the hop count or subnet, not true.
Testing
import { test } from "node:test";
import assert from "node:assert/strict";
test("POST /orders validates qty", async (t) => {
const app = await buildApp({ logger: false });
t.after(() => app.close());
const res = await app.inject({ method: "POST", url: "/orders", headers: auth, payload: { sku: "a", qty: 0 } });
assert.equal(res.statusCode, 422);
});
inject exercises the full lifecycle (hooks, validation, serialization) with no
network, so it works in a sandbox with networking disabled.
Migrating from Express
- Replace middleware with hooks or plugins;
@fastify/expressor@fastify/middieexist only as a temporary bridge. req.body/req.query/req.paramskeep their names;resbecomesreplywithcode()instead ofstatus().- Move
express.json()limits to Fastify’sbodyLimitoption (default 1 MiB).
Performance notes
- Response schemas matter more for throughput than handler micro-optimizations.
- Keep
loggeratinfoin production;debugwith request serializers is costly. - Measure with
autocannonagainst a production build before and after changes (installing it needs network). Compare p99 latency, not only requests per second.
Verification
Type-check, run tests, then print the route and plugin trees to confirm encapsulation:
await app.ready();
console.log(app.printRoutes());
console.log(app.printPlugins());
Check that every route in printRoutes() has a response schema for its success status
(grep schema: in route files), and that an unauthenticated request to a protected
group returns 401 while /healthz stays open.
Gotchas
- Registering a plugin after
listen()orready()throws; build the whole tree first. reply.rawwrites bypass hooks and serialization; avoid it outside streaming.app.registerorder matters for dependencies; declaredependenciesinfastify-pluginoptions so a missing one fails at boot.- Fastify 5 removed
reply.getResponseTime()(usereply.elapsedTime) and the variadiclisten(port, host)signature.
Output template
## Fastify change: <summary>
- Plugins: <name, wrapped with fastify-plugin? why>
- Routes: <method path, body/response schemas>
- Hooks: <hook, context, purpose>
- Tests: <inject cases>
- printRoutes/printPlugins checked: yes/no What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Fastify API"
short_description: "Schema-first Fastify with deliberate encapsulation"
default_prompt: "$fastify-api Add an authenticated orders module to our Fastify app with TypeBox schemas" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.