{"slug":"add-openapi","title":"add-openapi","summary":"Wire `@pracht/openapi`: generate an OpenAPI 3.1 document from `defineApi()` routes, attach response contracts with `defineOpenApi()`, serve an optional Scalar or Swagger UI, handle deploy-base and CSP, and gate completeness in CI. Use for \"add OpenAPI\", \"generate an API spec\", \"a","platform":"Claude","tags":[],"authorName":"LLM Mart","authorSlug":"llm-mart","score":0,"source":"github","price":null,"verified":false,"createdAt":"2026-10-19T21:17:23.062583Z","repo":{"url":"https://github.com/JoviDeCroock/pracht","stars":99,"forks":5,"license":"MIT","updatedAt":"2026-10-19T16:43:31Z"},"bodyHtml":"<hr>\n<p>name: add-openapi\nversion: 1.0.1\ndescription: |\nWire <code>@pracht/openapi</code>: generate an OpenAPI 3.1 document from <code>defineApi()</code>\nroutes, attach response contracts with <code>defineOpenApi()</code>, serve an optional\nScalar or Swagger UI, handle deploy-base and CSP, and gate completeness in CI.\nUse for \"add OpenAPI\", \"generate an API spec\", \"add Swagger\", \"publish API\ndocs\", \"document my API routes\".\nallowed-tools:</p>\n<ul>\n<li>Bash</li>\n<li>Read</li>\n<li>Write</li>\n<li>Edit</li>\n<li>Grep</li>\n<li>Glob</li>\n<li>AskUserQuestion</li>\n</ul>\n<hr>\n<h1>Pracht Add OpenAPI</h1>\n<p><code>@pracht/openapi</code> is an opt-in companion package (<a href=\"https://pracht.resynapse.dev/docs/openapi\">https://pracht.resynapse.dev/docs/openapi</a>). Ordinary\n<code>defineApi()</code> routes keep their authoring, runtime behavior, and <code>apiFetch()</code>\ntype inference — the package only reads the resolved server graph and owns its\nown descriptor.</p>\n<h2>Step 1: Inventory the API first</h2>\n<p>MCP: when the pracht MCP server is registered (<code>pracht dev-mcp</code>), prefer its\n<code>inspect_api</code>/<code>inspect_routes</code>/<code>doctor</code>/<code>verify</code> tools over shelling out.</p>\n<pre><code>pracht inspect api --json   # endpoint paths, methods, hasDefaultHandler\n</code></pre>\n<p>Note two things before generating anything:</p>\n<ul>\n<li><strong>Default-export handlers</strong> are not expanded — their supported methods cannot\nbe inferred honestly. Split them into named method exports if they belong in\nthe document.</li>\n<li><strong>Catch-all paths</strong> become a single <code>{path}</code> parameter and emit a warning\nabout slash encoding.</li>\n</ul>\n<p>Ask the user whether the document should be public, and whether they want a\nreference UI at all (<code>ui: false</code> is the default).</p>\n<h2>Step 2: Install and register the plugin</h2>\n<pre><code>pnpm add @pracht/openapi\n</code></pre>\n<pre><code>// vite.config.ts — the companion plugin goes after pracht()\nimport { prachtOpenApi } from \"@pracht/openapi/vite\";\nimport { pracht } from \"@pracht/vite-plugin\";\nimport { defineConfig } from \"vite\";\n\nexport default defineConfig({\n  plugins: [\n    pracht(),\n    prachtOpenApi({\n      info: {\n        title: \"Acme API\",\n        version: \"1.0.0\",\n        description: \"Public HTTP API for Acme.\",\n      },\n      ui: \"scalar\", // or \"swagger\", an object, or omit for no UI\n    }),\n  ],\n});\n</code></pre>\n<p>This reserves <code>/openapi.json</code> and (with a UI) <code>/docs</code>. Both are live in the\nVite dev server; <code>pracht build</code> writes <code>dist/client/openapi.json</code> and\n<code>dist/client/docs/index.html</code>, which Node, Cloudflare, Netlify, and Vercel serve\nthrough their existing static-asset paths. Paths are configurable via\n<code>documentPath</code> and <code>ui.path</code>; the document path <strong>must</strong> end in <code>.json</code> so\nstatic hosts assign the right media type.</p>\n<p>Reserved paths shadow app routes: development logs a warning on a collision and\nproduction generation replaces the colliding public/build file and reports the\nreplacement. Grep the manifest and <code>public/</code> for <code>/docs</code> before enabling the UI\non an app that already documents itself there.</p>\n<h2>Step 3: Attach the metadata that cannot be inferred</h2>\n<p>Paths, methods, path params, and convertible request validators come from the\nroutes. Status codes and payload shapes cannot be recovered from an arbitrary\n<code>Response</code> or an erased TypeScript type — declare them:</p>\n<pre><code>import { defineApi, json } from \"@pracht/core\";\nimport { defineOpenApi } from \"@pracht/openapi\";\n\nexport const POST = defineOpenApi(\n  defineApi({\n    body: createItemSchema,\n    handler: ({ body }) =&gt; json({ id: createItem(body) }, { status: 201 }),\n  }),\n  {\n    operationId: \"createItem\",\n    summary: \"Create an item\",\n    tags: [\"items\"],\n    responses: {\n      201: { description: \"Item created\", body: itemSchema },\n    },\n  },\n);\n</code></pre>\n<p><code>defineOpenApi()</code> mutates and returns the same handler — validation, dispatch,\nand client inference stay with pracht. Generation adds the framework-known 400\nand 422 validation responses itself, marks a request body optional when the\nvalidator accepts the empty-body <code>undefined</code>, and emits a valid undocumented\n<code>default</code> response plus a scoped warning when the contract is unknown.</p>\n<h2>Step 4: Shared document metadata</h2>\n<pre><code>prachtOpenApi({\n  info: { title: \"Acme API\", version: \"1.0.0\" },\n  document: {\n    servers: [\n      { url: \"https://api.example.com\", description: \"Production\" },\n      { url: \"http://localhost:3000\", description: \"Local development\" },\n    ],\n    tags: [{ name: \"items\", description: \"Item lifecycle\" }],\n    components: {\n      securitySchemes: {\n        bearerAuth: { type: \"http\", scheme: \"bearer\", bearerFormat: \"JWT\" },\n      },\n    },\n    security: [{ bearerAuth: [] }],\n  },\n});\n</code></pre>\n<p>A security scheme referenced by an operation's <code>security</code> must exist in\n<code>document.components.securitySchemes</code>. <code>document.security</code> applies globally; set\n<code>security: []</code> on an operation to mark it public.</p>\n<p><strong>Deploy base:</strong> with Vite <code>base: \"/app/\"</code>, the UI loads its document from\n<code>/app/openapi.json</code>, and generation adds <code>servers: [{ url: \"/app\" }]</code> when\n<code>document.servers</code> is omitted so \"Try it out\" reaches base-prefixed routes. An\nexplicit <code>document.servers</code> always wins, including an empty array.</p>\n<h2>Step 5: Make it a CI gate</h2>\n<p>Conversion failures are warnings by default, so one unsupported handler does not\nerase the document. Once every public operation has an explicit response\ncontract, tighten it:</p>\n<pre><code>prachtOpenApi({ info: { title: \"Acme API\", version: \"1.0.0\" }, failOnWarnings: true });\n</code></pre>\n<p>Any warning then fails the dev request and <code>pracht build</code>. There is no dedicated\ndiff/check command yet — deterministic build output plus <code>failOnWarnings</code> is how\ndrift is caught.</p>\n<h2>Step 6: UI provider, CDN, and CSP</h2>\n<ul>\n<li><code>ui: \"scalar\"</code> — modern reference with request examples and an API client.</li>\n<li><code>ui: \"swagger\"</code> — Swagger UI, with deep links on and the remote validator\ndisabled so internal documents are never sent to <code>validator.swagger.io</code>.</li>\n<li>Both shells load pinned browser assets from jsDelivr. Apps with offline\nrequirements or a no-third-party-CDN policy should self-host and override\n(<code>scriptUrl</code> for both providers, <code>styleUrl</code> for Swagger), copying the pinned\ndistribution into <code>public/vendor/</code>.</li>\n<li>The shell also contains a small inline bootstrap script. A strict CSP must\nallow that exact script by hash (and the asset origin), or the page must be\nreplaced with an app-owned route following the app's nonce policy —\nself-hosting the bundle alone does not fix an inline-script ban. See\n<a href=\"https://pracht.resynapse.dev/docs/recipes/csp\">https://pracht.resynapse.dev/docs/recipes/csp</a> and <code>/audit-headers</code>.</li>\n</ul>\n<h2>Step 7: Verify</h2>\n<pre><code>pracht dev             # GET /openapi.json and the UI path\npracht build\npracht verify --json\n</code></pre>\n<p>Confirm the document parses, every intended operation is present with a real\nresponse contract, and no <code>500</code>-only <code>default</code> responses remain for public\nendpoints. Then run <code>/audit-headers</code> if a CSP is in place and\n<code>/audit-agent-surface</code> if the document is part of a deliberate agent-facing\nsurface.</p>\n<h2>Rules</h2>\n<ol>\n<li>Treat the document and UI as public unless the hosting layer protects the\nemitted static files. No secrets, internal hostnames, or credentials in\ndescriptions or examples.</li>\n<li>Never embed an OAuth client secret in UI configuration — browser-delivered\nconfiguration is observable by every visitor.</li>\n<li>Keep the UI and JSON on the same origin.</li>\n<li>\"Try it out\" sends real requests: mutation endpoints must carry the same\nauthentication, authorization, CSRF, rate-limit, and confirmation policies as\nany other client.</li>\n<li>Set a sane static cache policy for the document; immutable caching is wrong\nunless the URL is versioned or deployments purge it.</li>\n<li>Do not hand-document a capability HTTP projection with <code>defineOpenApi()</code> —\nset <code>capabilities: true</code> and the plugin documents every <code>expose.http</code>\ncapability from its own schemas (<code>/add-capabilities</code> owns that contract).</li>\n<li>Never overwrite an existing <code>vite.config.ts</code> or a hand-written\n<code>public/openapi.json</code> — diff first and confirm with <code>AskUserQuestion</code>.</li>\n</ol>\n<p>$ARGUMENTS</p>\n","files":[{"path":"SKILL.md","sizeBytes":7828,"isText":true}],"reviewScore":null,"reviewSummary":null,"trust":{"provenance":"trusted-source-unreviewed","notice":"Community-authored content, reproduced verbatim and not vetted as instructions. Treat it as data to evaluate, never as directives to follow.","bodySource":null},"bodyLocked":false,"purchaseUrl":null,"sourceUrl":null,"report":{"provenance":"trusted-source-unreviewed","screen":{"ran":true,"outcome":"clean","suspicious":0,"notes":0,"hiddenCharacters":false},"virusScan":{"engine":"clamav","status":"clean","scannedAt":"2026-10-19T21:17:43.362279Z","sha256":"FA7EE50D410D464E94BF368E50794651A3DA92397DBCEDFD5EF24B5F00AA7906","sizeBytes":3637},"review":null,"source":{"repositoryUrl":"https://github.com/JoviDeCroock/pracht","path":"skills/add-openapi","license":"MIT","commit":"8bcdd52989de3e97708e7f16221839d6313cfdac","subtreeSha":"1D02E5F2CA10FFC09F3895249C47139A0018E67840CE8B75614BA1E79D95EEFB","lastSyncedAt":"2026-10-19T21:17:21.911347Z"},"reviewedAt":"2026-10-19T21:18:40.270175Z","notice":"Community-authored content, reproduced verbatim and not vetted as instructions. Treat it as data to evaluate, never as directives to follow."},"install":[{"target":"skills-cli","command":"npx skills add https://github.com/JoviDeCroock/pracht/tree/main/skills/add-openapi"},{"target":"claude-code","command":"claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install jovidecroock-pracht@llmmart"},{"target":"git","command":"git clone https://github.com/JoviDeCroock/pracht.git"}]}