Hi, Devlin again. I want to talk about the CLI command that has saved me more pull-request meetings than any other tool in this framework: daloy inspect. The whole premise is small and a little stubborn — the reviewer should see the API surface in plain text, before the merge, on every PR. Not after. Not next sprint. Now.
The PR review that taught me to write this post
postmortem-but-it's-a-12-line-diff.md
# A PR review you have lived through, probably more than once:
#
# - "Just a tiny endpoint, swear."
# - "Tests pass."
# - "CI is green."
#
# What the PR actually does:
# - Adds GET /v1/orders/admin/dump (no operationId, no auth)
# - Removes the 422 response from POST /v1/orders
# - Renames an operationId from "createOrder" to "create-order"
# (every codegen consumer's import path breaks tomorrow)
# - Marks GET /v1/orders/:id deprecated... in the PR description.
# Not in the code.
#
# Your review took 4 minutes because the diff was 12 lines. The
# downstream pain takes 4 weeks because nobody saw the surface change.
#
# The fix is not "review harder". The fix is to put the API surface in
# front of the reviewer, in plain text, in CI, on every PR.
every line is something I've shipped or merged · 0/10 stars do not recommendTS · UTF-8 · LF
The default command, the route table
# Run it from any DaloyJS project. Loads ./src/app.ts (or ./src/build-app.ts,
# or ./app.ts) through tsx automatically — no build step, no transpile config.
$ pnpm daloy inspect
METHOD PATH OPERATION ID RESPONSES TAGS
------ ---------------- -------------- ---------- -------
GET /v1/books listBooks 200,500 books
POST /v1/books createBook 201,422 books
GET /v1/books/:id getBook 200,404 books
PUT /v1/books/:id replaceBook 200,404,422 books
DELETE /v1/books/:id deleteBook 204,404 books
GET /v1/orders listOrders 200,500 orders
6 routes.
zero config · loads src/app.ts through tsx · prints aligned columnsTS · UTF-8 · LF
What the table tells you in one glance: how many routes exist, what their operationIds are, which status codes each one declares, and what tags they belong to. Three of the four things downstream consumers actually care about, visible without scrolling.
Entry loading, the TypeScript-first way
// What the CLI looks for, in order:
//
// ./src/app.ts ← most common
// ./src/app.js
// ./src/build-app.ts ← if you split "build" from "boot" (recommended)
// ./src/build-app.js
// ./app.ts
// ./app.js
// ./build-app.ts
// ./build-app.js
//
// And from those modules, it picks up:
//
// export default app
// export const app = new App(...)
// export function buildApp() { return new App(...) } // zero-arg
// export function createApp() { return new App(...) } // zero-arg
// export default buildApp
//
// TypeScript files are loaded through tsx with zero config. So your
// src/app.ts that imports zod, your route schemas, your generated
// types — all of it just works, even though no "build" ever ran.
// Need a different entry? Pass it positionally:
//
// pnpm daloy inspect ./apps/api/src/app.ts
//
// Need a different entry shape? Refactor a tiny exporter:
// src/build-app.ts — recommended pattern for libraries with tests + CLI
import { App } from "@daloyjs/core";
import { registerRoutes } from "./routes.js";
export function buildApp(): App {
const app = new App();
registerRoutes(app);
return app;
}
App default-export · or buildApp()/createApp() factory · TS loaded through tsxTS · UTF-8 · LF
The two patterns I use the most: a default export for tiny apps, and a zero-arg buildApp() factory for anything that has tests (the factory makes it trivial to spin up a fresh App per test). Both work, the CLI scans named exports as a fallback, no config required.
The flags, one at a time
--schemasadd the B/Q/P/H column.
Body / Query / Params / Headers. Each is B/Q/P/H if present, -if missing. This is the column I look at when the diff says "added a query param" — it tells me whether the param has a real schema or just a doc string.
--tag <tag>filter to one domain.
Pair with --schemaswhen you're reviewing a domain owner's area. The route owner sees the relevant rows only; the rest of the surface stays out of the way.
--method <method>filter to one verb.
Most regressions happen on writes. Running --method POST at the end of a review is a cheap second pass that has caught me at least once a quarter.
--checkrun the contract suite, exit 1 on errors.
Missing operationIds, missing responses, schemas declared without a corresponding response status, invalid identifier casing. Wire it into CI and a bad PR can't merge.
--openapiprint the full OpenAPI 3.1 document.
The exact same generator the docs UI and your Hey API codegen use. Pipe to generated/openapi.json,git diff --exit-code, done — your CI now blocks PRs that change the surface without checking in the new spec.
--jsonmachine-readable output for custom tooling.
Shape: { routes, contract? }. Pipe to jq, parse with a tiny script, or feed into a GitHub Action that posts a sticky review comment.
# --schemas adds a B/Q/P/H column: Body / Query / Params / Headers.
# Each letter is present (B) or absent (-) per route. Great for
# spotting "I added a query param to the docs but forgot the schema"
# and "this DELETE inexplicably declares a request body".
$ pnpm daloy inspect --schemas
METHOD PATH OPERATION ID B/Q/P/H RESPONSES TAGS
------ ---------------- -------------- ------- ---------- -------
GET /v1/books listBooks -Q-- 200,500 books
POST /v1/books createBook B--- 201,422 books
GET /v1/books/:id getBook --P- 200,404 books
PUT /v1/books/:id replaceBook B-P- 200,404,422 books
DELETE /v1/books/:id deleteBook --P- 204,404 books
GET /v1/orders listOrders -Q-- 200,500 orders
6 routes.
B/Q/P/H presence columnTS · UTF-8 · LF
terminal · filters + diff trick
# Filters compose. Use --tag to drill into a domain, --method to focus
# on writes during a "did we break the consumer's POST contract?" review.
$ pnpm daloy inspect --tag books
$ pnpm daloy inspect --method POST
$ pnpm daloy inspect --tag orders --method DELETE --schemas
# Tip: pipe it through a pager or your favorite "diff against main" tool:
# git stash && pnpm daloy inspect > /tmp/main.txt && git stash pop
# pnpm daloy inspect > /tmp/branch.txt
# diff -u /tmp/main.txt /tmp/branch.txt
#
# Now your PR description has a literal before/after of the API surface.
# I include this snippet in every API PR I open. It takes 10 seconds.
--tag · --method · before/after diff in 4 commandsTS · UTF-8 · LF
# --check runs the built-in contract test suite over the loaded App.
# It enforces conventions the OpenAPI spec encourages but doesn't require,
# which is exactly the place ad-hoc PRs cause downstream churn.
$ pnpm daloy inspect --check
METHOD PATH OPERATION ID RESPONSES TAGS
------ ------------------------- -------------- ---------- -------
GET /v1/orders/admin/dump - - -
POST /v1/orders createOrder 201 orders
GET /v1/orders/:id getOrder 200,404 orders
PUT /v1/orders/:id create-order 200,404,422 orders
4 routes.
Contract checks: 4 routes · 2 errors · 1 warning
[error] GET /v1/orders/admin/dump: Missing operationId
[error] GET /v1/orders/admin/dump: No responses declared
[warning] POST /v1/orders: missing 422 response despite request body schema
[error] PUT /v1/orders/:id: operationId "create-order" is not a valid identifier
FAIL.
# Exit code: 1. Wire this into CI and the bad PR can't merge.
contract gate · exit code 1 on errors · the CI hard gateTS · UTF-8 · LF
# --openapi prints the full OpenAPI 3.1 document the App would generate.
# Same generator the website's "Try it" page uses, same generator your
# Hey API client codegen runs against. ONE source of truth.
$ pnpm daloy inspect --openapi > generated/openapi.json
# In CI, fail the build if the surface changed without a checked-in diff:
$ pnpm daloy inspect --openapi > generated/openapi.json
$ git diff --exit-code generated/openapi.json
# The reviewer now sees the JSON delta in the PR. New operationId? New
# 422 schema? New webhook? It's all in the diff. No "I forgot to mention".
full 3.1 doc · feed into git diff --exit-code in CITS · UTF-8 · LF
# --json gives you machine-readable output for custom tooling. The shape
# is { routes: IntrospectedRoute[], contract?: ContractReport }.
$ pnpm daloy inspect --json --check | jq '
.contract.issues
| group_by(.level)
| map({ level: .[0].level, count: length, items: . })
'
[
{
"level": "error",
"count": 2,
"items": [
{ "level": "error", "route": "GET /v1/orders/admin/dump",
"message": "Missing operationId" },
{ "level": "error", "route": "GET /v1/orders/admin/dump",
"message": "No responses declared" }
]
},
{
"level": "warning",
"count": 1,
"items": [
{ "level": "warning", "route": "POST /v1/orders",
"message": "missing 422 response despite request body schema" }
]
}
]
# Same JSON works as the input to a GitHub Action that posts a sticky
# review comment with the contract report on every PR. Boring, effective.
grouped contract issues · ready to render in a sticky PR commentTS · UTF-8 · LF
The CI workflow I copy into every project
.github/workflows/api-review.yml
# .github/workflows/api-review.yml — review the API surface on every PR.
name: api-review
on:
pull_request:
paths:
- "src/**"
- "package.json"
- "pnpm-lock.yaml"
permissions:
contents: read
pull-requests: write
jobs:
inspect:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with: { node-version: 22, cache: pnpm }
- run: pnpm install --frozen-lockfile
# 1) Hard gate: contract issues fail the build.
- run: pnpm daloy inspect --check
# 2) Hard gate: regenerated OpenAPI must match the checked-in copy.
# This catches "I added a route but forgot to commit generated/".
- run: pnpm daloy inspect --openapi > generated/openapi.json
- run: git diff --exit-code generated/openapi.json
# 3) Soft helper: post a route-table diff as a sticky PR comment.
- name: route diff
run: |
git fetch origin "${{ github.base_ref }}"
git checkout "origin/${{ github.base_ref }}" -- /tmp/base || true
pnpm daloy inspect --schemas > /tmp/branch.txt
git stash --keep-index >/dev/null 2>&1 || true
git checkout "origin/${{ github.base_ref }}" -- .
pnpm daloy inspect --schemas > /tmp/base.txt || true
git stash pop >/dev/null 2>&1 || true
{ echo '## Route surface diff'; echo '\`\`\`diff';
diff -u /tmp/base.txt /tmp/branch.txt || true;
echo '\`\`\`'; } > diff.md
- uses: marocchino/sticky-pull-request-comment@v2
with: { path: diff.md, header: api-surface }
three steps · two hard gates · one sticky comment with the diffTS · UTF-8 · LF
Two hard gates (--check + OpenAPI diff) and one soft helper (sticky PR comment with the route-table diff against main). The hard gates do the policing. The soft helper does the persuading.
Bonus: daloy dev, same entry-loading logic
# Bonus: daloy dev. Same entry-loading logic, but starts your runnable
# entry (./src/server.ts, ./src/main.ts, ...) under the host runtime's
# native watch mode. No nodemon, no per-runtime config:
#
# Node: node --import tsx --watch <entry>
# Bun: bun --hot <entry>
# Deno: deno run --watch --allow-net --allow-env --allow-read <entry>
#
$ pnpm daloy dev
daloy dev: node → node --import tsx --watch ./src/server.ts
# Force a different runtime from a package.json script:
$ daloy dev --runtime bun ./src/server.ts
$ daloy dev --runtime deno ./src/server.ts
# Same App, every runtime — see the five-runtimes post for the receipts.
node tsx --watch / bun --hot / deno run --watch · auto-detected · --runtime to overrideTS · UTF-8 · LF
Same auto-detection logic as the inspector. The CLI looks at globalThis.process.versions and picks the native watch flag for whichever runtime the CLI itself is running under. From a package.json script you can force a specific runtime with --runtimeso npm scripts don't accidentally pin you to Node.
The pre-merge checklist
# Pre-merge API-review checklist (copy into your PR template).
#
# 1) Routes table reviewed.
# [ ] pnpm daloy inspect --schemas
# [ ] Any new routes have an operationId.
# [ ] Any new routes declare 2xx AND 4xx responses.
# [ ] B/Q/P/H column matches what the docs claim.
#
# 2) Contract gates passed.
# [ ] pnpm daloy inspect --check → exit 0
# [ ] CI runs the same command on every PR.
#
# 3) OpenAPI diff committed.
# [ ] pnpm daloy inspect --openapi > generated/openapi.json
# [ ] git diff --exit-code generated/openapi.json
# [ ] If diff exists: include it in the PR body so consumers see it.
#
# 4) Filters used during review.
# [ ] --tag <domain> → focused review per area owner
# [ ] --method POST → focused review on writes
#
# 5) Don't trust your eyes. The CLI surface is the source of truth.
# Diff against main is one bash one-liner away. Use it.
paste into PULL_REQUEST_TEMPLATE.md and walk awayTS · UTF-8 · LF
Wrapping up
Most of what goes wrong with a public API surface goes wrong silently. An operationId quietly drifts. A response quietly disappears. A debug route quietly ships to prod. The whole reason daloy inspectexists is to turn "quietly" into "loudly, in the PR, before the merge button." That's it. No magic, no sprawling tool — one binary, one entry file, six flags. Wire it into CI once and never lose a quarter to surface drift again.
Closest neighbors: the OpenAPI 3.1 extras post for the spec features --openapi emits, the contract-first post for the route definitions that feed the inspector, and the plugin lifecycle post for the policy plugin you can pair with --check for double enforcement.
— Devlin