# pracht > A full-stack Preact framework built on Vite with hybrid rendering (SSG, SSR, ISG, SPA) and a unified data-loading model. --- # Adapters > Adapters are thin layers that translate between a platform's native request handling and pracht's Web Request/Response interface. pracht ships adapters for Cloudflare Workers, Vercel Edge Functions, and Node.js. ## Architecture Every adapter follows the same request flow: ``` Platform request (Node / CF / Vercel) → Convert to Web Request → Is this a static asset? → Yes: serve from dist/client/ → Is this a prerendered page? → Yes: serve static HTML (Node checks ISG staleness) → Delegate to handlePrachtRequest() → Convert Web Response back to platform response ``` Adapters also preserve route and shell document headers for prerendered HTML so static SSG/ISG responses match dynamic document responses. --- ## Cloudflare Workers Deploy to Cloudflare's global edge network. Static assets are served from the `ASSETS` binding, dynamic routes are handled by the Worker, and regenerated ISG HTML is stored in the Workers Cache API with `ASSETS` as the build-time fallback. ### Setup ```ts [vite.config.ts] import { defineConfig } from "vite"; import { pracht } from "@pracht/vite-plugin"; import { cloudflareAdapter } from "@pracht/adapter-cloudflare"; export default defineConfig({ plugins: [pracht({ adapter: cloudflareAdapter() })], }); ``` ```json [package.json] { "dependencies": { "@pracht/core": "*", "@pracht/adapter-cloudflare": "*" } } ``` ### Build output Running `pracht build` with the Cloudflare adapter emits: ``` dist/ client/ // static assets served via ASSETS binding assets/ index.html // SSG pages server/ server.js // Worker bundle ``` Prerendered HTML receives document headers from the generated `_pracht/headers.json` asset. Keep your `wrangler.jsonc` in the project root so you can add bindings without the build overwriting them. ### Exporting Durable Objects and other primitives Wrangler discovers Durable Objects, Workflows, Queues, and similar primitives from named exports on the Worker entry. Point the adapter at a dedicated module that re-exports them: ```ts [vite.config.ts] import { defineConfig } from "vite"; import { pracht } from "@pracht/vite-plugin"; import { cloudflareAdapter } from "@pracht/adapter-cloudflare"; export default defineConfig({ plugins: [ pracht({ adapter: cloudflareAdapter({ workerExportsFrom: "/src/cloudflare.ts", }), }), ], }); ``` ```ts [src/cloudflare.ts] export { Counter } from "./workers/counter.ts"; ``` Keep the matching bindings and migrations in `wrangler.jsonc`. ### WebSockets Cloudflare is the one adapter that can serve WebSocket upgrades, because a Durable Object can own a connection for longer than a request. Serve the handshake from an [API route](/docs/api-routes#websockets) and forward it to the object: ```ts [src/api/ws.ts] import type { BaseRouteArgs } from "@pracht/core"; export async function GET({ context, request, url }: BaseRouteArgs) { if (request.headers.get("upgrade") !== "websocket") { return new Response("Expected a WebSocket upgrade", { status: 426 }); } const { CHAT_ROOM } = context.env as { CHAT_ROOM: DurableObjectNamespace }; const room = url.searchParams.get("room") ?? "lobby"; return CHAT_ROOM.get(CHAT_ROOM.idFromName(room)).fetch(request); } ``` ```ts [src/workers/chat-room.ts] import { DurableObject } from "cloudflare:workers"; export class ChatRoom extends DurableObject { override async fetch(request: Request) { const { 0: client, 1: server } = new WebSocketPair(); this.ctx.acceptWebSocket(server); // hibernation-aware return new Response(null, { status: 101, webSocket: client }); } override webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) { for (const peer of this.ctx.getWebSockets()) peer.send(String(message)); } } ``` Pracht returns the `101` exactly as the handler produced it — copying it would drop the `webSocket` handle, since that property is a Cloudflare extension to `ResponseInit` rather than part of the fetch standard. Upgrades work in `pracht dev` too, because workerd serves dev for this adapter. Cross-origin upgrades are rejected by default: browsers do not apply CORS to WebSocket, so the check that guards mutations guards handshakes as well. ### Accessing Cloudflare bindings The `env` object is passed through to your loaders and API routes via the context: ```ts // src/routes/dashboard.tsx export async function loader({ context }: LoaderArgs) { // context.env is the Cloudflare env object const user = await context.env.DB.prepare("SELECT * FROM users WHERE id = ?") .bind(userId) .first(); return { user }; } ``` ### Deploy ```sh pracht build npx wrangler deploy ``` --- ## Vercel Edge Functions Deploy using Vercel's Build Output API v3. SSG pages are served from the static file system; SSR and ISG routes go through the Edge Function. ### Setup ```ts // vite.config.ts import { vercelAdapter } from "@pracht/adapter-vercel"; pracht({ adapter: vercelAdapter() }) // package.json "@pracht/adapter-vercel": "*" ``` Static prerendered routes receive document headers through the generated Build Output `headers` config. ### Build output ``` .vercel/ output/ config.json // routes, rewrites, headers static/ // SSG pages served from the filesystem functions/ render.func/ // Edge Function for SSR/API routes and webhook bridge pricing.func/ pricing.prerender-config.json ``` ### Deploy ```sh pracht build npx vercel deploy --prebuilt ``` --- ## Node.js Run pracht as a standard Node.js HTTP server. The adapter handles static file serving, ISG stale-while-revalidate, request translation, and the generated `dist/server/server.js` entry boots the production server directly. Prerendered HTML receives document headers from `dist/server/headers-manifest.json`. ### Setup ```ts // vite.config.ts import { nodeAdapter } from "@pracht/adapter-node"; pracht({ adapter: nodeAdapter() }) // package.json "@pracht/adapter-node": "*" ``` ### Deploy ```sh pracht build node dist/server/server.js // Server listening on http://localhost:3000 ``` ### WebSockets Node's `http.Server` delivers upgrade requests to its `upgrade` event rather than to the request handler, so a handshake never reaches pracht. Attach a WebSocket server to the same HTTP server instead — the generated entry exports `handler`, and only starts a server of its own when run as the process entrypoint: ```js import { createServer } from "node:http"; import { WebSocketServer } from "ws"; import { handler } from "./dist/server/server.js"; const server = createServer(handler); const wss = new WebSocketServer({ noServer: true }); server.on("upgrade", (req, socket, head) => { // Check req.headers.origin yourself — this bypasses pracht entirely, so // pracht's same-origin protection does not apply. wss.handleUpgrade(req, socket, head, (ws) => wss.emit("connection", ws, req)); }); server.listen(3000); ``` --- ## Context Factory Adapters inject platform-specific values into loaders and API routes via a context factory. With generated entries, point the adapter at a module that exports `createContext`: ```ts [vite.config.ts] nodeAdapter({ createContextFrom: "/src/server/context.ts" }); cloudflareAdapter({ createContextFrom: "/src/server/context.ts" }); vercelAdapter({ createContextFrom: "/src/server/context.ts" }); ``` ```ts [src/server/context.ts] // Node: inject a database pool export function createContext({ request }: { request: Request }) { return { db: pool, ip: request.headers.get("x-forwarded-for"), }; } // Cloudflare receives { request, env, executionContext }. // Vercel receives { request, context }. ``` The context object is available as `args.context` in every loader, middleware, and API route handler. --- ## Writing a Custom Adapter A custom adapter exports a factory function that returns a `PrachtAdapter` object: ```ts import type { PrachtAdapter } from "@pracht/vite-plugin"; export function myAdapter(): PrachtAdapter { return { id: "my-platform", serverImports: 'import { handlePrachtRequest, resolveApp, resolveApiRoutes } from "@pracht/core";', createServerEntryModule() { return ` export default async function handle(request) { return handlePrachtRequest({ app: resolvedApp, registry, request, apiRoutes, clientEntryUrl: clientEntryUrl ?? undefined, cssManifest, jsManifest, }); } `; }, }; } ``` At the runtime level, an adapter also typically needs to: 1. Accept a platform request and convert it to a Web `Request` 2. Check for static assets -- serve files from `dist/client/` with appropriate headers 3. Check for prerendered pages -- serve SSG/ISG HTML (with staleness checking for ISG when the platform supports it) 4. Delegate dynamic requests to `handlePrachtRequest()` from `pracht` 5. Convert the Web `Response` back to the platform's response format 6. Provide a context factory for platform-specific values 7. Export an entry module generator for the Vite plugin > [!INFO] > See the source of `@pracht/adapter-cloudflare` or `@pracht/adapter-node` in the monorepo for a concrete reference implementation. --- # Agent Skills > pracht ships 28 Claude Code skills for scaffolding, auditing, testing, and deploying apps. They are published at stable URLs with a signed discovery manifest, seeded into new apps by create-pracht, and pair with the built-in MCP server. ## What Ships Every skill is a single `SKILL.md` — frontmatter (`name`, `version`, `description`, `allowed-tools`) plus an action-oriented body — that Claude Code loads from `.claude/skills//SKILL.md` and invokes with `/`. The catalog covers four categories: | Category | Skills | | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Framework & migration | `/pracht-scaffold`, `/pracht-debug`, `/pracht-deploy`, `/migrate-nextjs`, `/upgrade-pracht` | | Audit & review | `/audit-loaders`, `/audit-shells`, `/audit-islands`, `/audit-auth`, `/audit-csrf`, `/audit-headers`, `/audit-secrets`, `/audit-redirects`, `/audit-deps`, `/audit-bundles`, `/audit-seo`, `/audit-a11y`, `/tune-render-mode`, `/pre-deploy` | | Testing scaffolds | `/scaffold-tests`, `/scaffold-e2e`, `/pracht-test-api` | | App primitives | `/add-auth`, `/add-db`, `/add-i18n`, `/add-observability`, `/typed-routes`, `/configure-isg` | The source of truth lives in the repo's [skills/ directory](https://github.com/JoviDeCroock/pracht/tree/main/skills), with per-skill descriptions in [skills/README.md](https://github.com/JoviDeCroock/pracht/blob/main/skills/README.md). Instead of globbing `src/`, the skills read the resolved app graph via `pracht inspect routes|api|build --json`, so they account for groups, inheritance, and both routers. --- ## Discovery Endpoint The skills are published following the [agent skills discovery RFC](https://github.com/cloudflare/agent-skills-discovery-rfc). A well-known manifest lists every skill with a canonical URL and a SHA-256 digest of its source: ```sh curl https://pracht.resynapse.dev/.well-known/agent-skills/index.json ``` ```json { "$schema": "https://agentskills.io/schema/v0.2.0/index.json", "skills": [ { "name": "audit-csrf", "type": "claude-skill", "description": "Verify CSRF posture on forms and mutation APIs...", "url": "https://pracht.resynapse.dev/skills/audit-csrf/SKILL.md", "sha256": "…" } ] } ``` Agents landing on the home page can find the manifest without prior knowledge — it is advertised with an [RFC 8288](https://datatracker.ietf.org/doc/html/rfc8288) `Link` header: ``` Link: ; rel="agent-skills" ``` Both are emitted by a small Vite plugin ([`vite-plugin-agent-skills.ts`](https://github.com/JoviDeCroock/pracht/blob/main/examples/docs/vite-plugin-agent-skills.ts)) that reads the repo skills at build time, computes the digests, and serves each `SKILL.md` as a public asset. --- ## Manual Install Each skill is a plain Markdown file at a stable URL, so installing one into any app is a single `curl` into your `.claude/skills/` directory: ```sh mkdir -p .claude/skills/audit-csrf curl -o .claude/skills/audit-csrf/SKILL.md \ https://pracht.resynapse.dev/skills/audit-csrf/SKILL.md ``` Restart Claude Code (or start a new session) and invoke it with `/audit-csrf`. Verify a download against the manifest's `sha256` if you want integrity checking: ```sh shasum -a 256 .claude/skills/audit-csrf/SKILL.md ``` --- ## Seeded by create-pracht New apps do not need to install anything manually. `npm create pracht@latest` asks — with a yes default — whether to set up agent tooling: ``` Set up Claude Code skills + MCP? (Y/n): ``` Accepting seeds two things into the scaffold: - `.claude/skills//SKILL.md` — the full skill catalog, ready for Claude Code to discover. - `.mcp.json` — registers the `pracht mcp` server so MCP clients pick it up automatically. Pass `--agent-tools` / `--no-agent-tools` to skip the prompt in scripted runs; `--yes` includes the tooling. --- ## Relationship to the MCP Server The skills shell out to `pracht inspect ... --json`, `pracht doctor`, and `pracht verify`. The [built-in MCP server](https://github.com/JoviDeCroock/pracht/blob/main/docs/MCP.md) (`pracht mcp`) exposes the same capabilities as native tools — inspect, doctor, verify, and generate — for clients that prefer tool calls over shell access. The seeded `.mcp.json` wires it up: ```json { "mcpServers": { "pracht": { "command": "npx", "args": ["pracht", "mcp"] } } } ``` Skills and MCP tools share the same source of truth: the resolved app manifest. Use whichever fits your client — or both. --- # Agent Trust > Who is calling, may they do this, and what happened? Verified agent identity with Web Bot Auth, a prepare/commit confirmation flow for destructive operations, structured audit events, and pracht eval to prove agent flows in CI. ## Three Questions Exposing [capabilities](/docs/capabilities) to agents raises questions a schema cannot answer. The agent trust layer answers all three, and everything is opt-in — an app without `defineApp({ agents })` and without destructive capabilities pays a single property check per request. - **Who is calling?** — Web Bot Auth puts a cryptographically verified agent identity on the request context. - **May they do this?** — policy modes per app and per capability, plus a server-verified confirmation flow for destructive effects. - **What happened?** — one structured audit event per capability dispatch. --- ## Web Bot Auth: Verified Agent Identity Agents sign requests with [RFC 9421 HTTP Message Signatures](https://www.rfc-editor.org/rfc/rfc9421) and publish Ed25519 public keys in a well-known directory — the emerging standard already deployed by major CDNs. pracht implements the verifier side; configuration lives in the manifest, and keys are public, so they are safe there: ```ts [src/routes.ts] export const app = defineApp({ agents: { webBotAuth: { policy: "observe", // identify agents, serve everyone keys: [{ x: "", agent: "my-agent.example" }], directories: ["https://signature-agent.cloudflare.com"], // allowlist-only key fetching }, }, }); ``` Verification happens once per request in `handlePrachtRequest`, using only Web platform APIs — Node, Cloudflare, and Vercel share the implementation. The result surfaces everywhere: ```ts [src/capabilities/agent-whoami.ts] async run({ context }) { context.agent; // { verified: true, agentDomain, keyId } | null } ``` Verification fails closed: expired windows, uncovered components, unknown keys, or non-allowlisted directories all yield `context.agent = null`, never a partial identity. --- ## Policy Modes `"observe"` identifies agents without blocking anyone — use it to roll out and audit. `"require"` answers unsigned requests to capability HTTP endpoints with a typed `401 agent_required` envelope. The app default can be tightened per capability: ```ts [src/capabilities/agent-ping.ts] export default defineCapability({ // ... agentPolicy: "require", // this endpoint answers only verified agents }); ``` --- ## Destructive Capabilities: Prepare/Commit Capabilities declaring `effect: "destructive"` (delete, publish, pay, send) may be exposed over HTTP only, and every dispatch is confirmation-gated. Set `PRACHT_CONFIRMATION_SECRET` in the server environment; without it, destructive calls fail closed. The first call never runs the capability — it answers with a short-lived token: ```jsonc // POST /api/capabilities/notes/purge { "titlePrefix": "Old" } // → 409 { "ok": false, "error": { "code": "confirmation_required", "confirmationToken": "v1..", "expiresAt": 1735689720 } } ``` The token is an HMAC over the caller's principal (verified agent key, or `"anonymous"`), the capability name, the canonicalized input, and an expiry. Committing means repeating the call with identical input plus the `x-pracht-confirm` header — tampered, expired, different-input, or different-principal tokens are rejected with `403`, fail closed. Agent hosts cannot yet be trusted to carry this two-step flow faithfully, so destructive capabilities cannot be exposed over WebMCP — `defineCapability()`, the runtime, and `pracht verify` all enforce it. --- ## Audit Trail Every capability dispatch — HTTP or direct `invokeCapability()` — emits one structured event with the capability name, effect, transport, outcome, status, latency, and the verified agent identity (or `null`): ```ts [src/server/audit.ts] import { setCapabilityAuditHook } from "@pracht/core"; setCapabilityAuditHook((event) => log.info("capability", event)); ``` Hook exceptions are swallowed — auditing observes, it never breaks a request. --- ## pracht eval: Prove Agent Flows in CI Can an agent actually complete a task through your capabilities? `pracht eval` runs scripted scenarios against the HTTP projection and exits 1 on any failed expectation: ```jsonc [evals/notes.eval.json] { "name": "notes agent flow", "steps": [ { "capability": "notes.search", "input": { "query": "roadmap" } }, { "capability": "notes.purge", "input": { "titlePrefix": "Old" }, "expect": { "status": 409, "errorCode": "confirmation_required" } }, { "capability": "notes.purge", "input": { "titlePrefix": "Old" }, "confirm": "$steps[1].error.confirmationToken", "expect": { "ok": true, "output": { "purged": 1 } } } ] } ``` `$steps[n].` references carry values between steps — the `confirm` field above threads the prepare/commit flow through a scenario without spelling out the header name. One command runs it — `--start` launches your app, waits for it to answer, runs the scenarios, and stops it: ```sh pracht eval --start "pracht preview" # runs evals/**/*.eval.json # …or manage the server yourself: pracht preview # in another terminal pracht eval --url http://localhost:3000 ``` The [Testing recipe](/docs/recipes/testing) covers the rest of the agent-surface toolbox: unit testing the full dispatch pipeline with `createCapabilityTestHost()` — including this confirmation flow and simulated agent identities — plus Playwright patterns, faking the WebMCP API, and signing Web Bot Auth requests in tests. --- # AI-Assisted Authoring & Review > LLMs write plausible code; frameworks should make it provable. pracht turns intent into machine truth — declared constraints, a committed app-graph snapshot, semantic diffs with pracht plan, and PR reports assembled from real build output. ## Why This Exists When an agent writes a change, the interesting review question is rarely "is this valid TypeScript?" — it's "did the intent survive?" Did the new dashboard route keep the auth middleware? Did a route quietly switch from SSR to SSG? Did an API endpoint disappear? Those are app-graph questions, and pracht resolves the entire app graph — routes, render modes, shells, middleware, API endpoints — from the manifest. That makes intent checkable by machine instead of by hoping a reviewer notices: - **Constraints** declare invariants once; `pracht verify` enforces them deterministically. - **`pracht plan`** diffs the resolved graph against a base git ref, so reviewers read an intent-level changelog instead of reverse-engineering it from file diffs. - **`pracht report`** assembles the factual half of a PR description from machine truth. - **Generated smoke tests** give every scaffolded route a Playwright check for free. - **`pracht llms`** and the MCP server hand agents the framework's conventions directly. --- ## Constraints Declare invariants over the route graph in `defineApp({ constraints })`. The helpers are exported from `@pracht/core`: ```ts [src/routes.ts] import { defineApp, forbidRenderMode, requireHead, requireMiddleware, requireShell, } from "@pracht/core"; export const app = defineApp({ // shells, middleware, routes … constraints: [ requireMiddleware("/app/**", "auth"), requireShell("/app/**", "app"), forbidRenderMode("/app/**", "ssg", "isg"), requireHead("**"), ], }); ``` | Helper | Enforces | | --------------------------------------- | --------------------------------------------------------------- | | `requireMiddleware(pattern, ...names)` | Matching routes include all of the given middleware | | `requireShell(pattern, ...shells)` | Matching routes use one of the given shells | | `requireRenderMode(pattern, ...modes)` | Matching routes use one of the given render modes | | `forbidRenderMode(pattern, ...modes)` | Matching routes use none of the given render modes | | `requireHead(pattern)` | Matching routes export `head()` — directly or via their shell | Patterns match route paths segment-wise: `*` matches exactly one segment, a trailing `**` matches zero or more segments, and `"**"` on its own matches every route. Literal segments compare against the declared path, so `/blog/*` matches `/blog/:slug`. `pracht verify` evaluates constraints deterministically; violations are errors: ``` ✖ Route "/app/billing" is missing required middleware "auth" (constraint pattern "/app/**"). ``` An agent that scaffolds a new route under `/app` without the auth middleware fails verification immediately — no reviewer vigilance required. And because constraints live in the manifest, weakening one is a visible, reviewable policy change rather than a silent drift. > [!NOTE] > Constraints are evaluated for manifest apps (`defineApp`) in this release, not the pages router. --- ## The Route-Graph Lockfile `pracht plan --write` snapshots the resolved app graph to `.pracht/app-graph.json` — commit it like a lockfile: ```sh pracht plan --write git add .pracht/app-graph.json ``` From then on, `pracht plan` diffs the live graph against the snapshot committed at a base ref (default `origin/main`) and prints what actually changed at the app level: ```sh pracht plan pracht plan --base origin/release ``` ``` Pracht plan (base: origin/main) + route /pricing render=isg shell=public middleware=[] ~ route /app/billing middleware: [auth] → [auth, audit] - api /api/legacy-webhook + constraint require-middleware /app/** middleware=["auth"] ``` That is the review artifact: added, removed, and changed routes, API endpoints, and constraints — not four hundred lines of moved imports. `--json` emits the full report for tooling, and `--markdown` formats the diff for PR comments. `pracht verify` fails when the committed snapshot no longer matches the live graph, with the fix in the message: run `pracht plan --write`. So route changes can't land without the snapshot — and therefore the reviewable diff — updating alongside them. --- ## PR Reports from Machine Truth `pracht report` assembles a PR-ready markdown report from three machine-derived sections: ```sh pracht report pracht report --base origin/release --out report.md ``` - **App graph changes** — the same diff `pracht plan --markdown` produces. - **Verification** — the current `pracht verify` result, with any errors and warnings listed. - **Client JS budgets** — per-route gzip sizes versus their limits, from the last `pracht build`. Use it as the factual half of a PR description; the author (human or agent) adds the "why". The report footer marks the sections as machine-derived, so reviewers know which claims they don't need to re-check by hand. --- ## Generated Smoke Tests `pracht generate route` emits a Playwright smoke test alongside the route whenever the app has a Playwright setup (a `playwright.config.*` file or an `e2e/` directory): ```sh pracht generate route --path /blog/:slug --render ssg --shell public # → src/routes/blog-slug.tsx # → e2e/blog-slug.spec.ts ``` The test visits the route with example values for dynamic params and asserts the basics: ```ts [e2e/blog-slug.spec.ts] import { expect, test } from "@playwright/test"; test("renders /blog/:slug", async ({ page }) => { const response = await page.goto("/blog/example-slug"); expect(response?.status(), "route should serve successfully").toBeLessThan(400); await expect(page.locator("h1").first()).toHaveText("Blog Slug"); }); ``` `--test` forces the test even without a detected Playwright setup; `--no-test` skips it. The MCP `generate_route` tool accepts a matching `test` boolean. It's a floor, not a ceiling — but it means every agent-scaffolded route starts life with a failing-loudly check instead of zero coverage. --- ## Teaching the Agent: pracht llms and MCP `pracht llms` prints an embedded authoring guide for coding agents — project layout, conventions, constraints, and the verify/plan/report loop. `--write` saves it as `llms.txt` in the app root so agents working in the repo pick it up: ```sh pracht llms pracht llms --write ``` The same CLI runs as an MCP server via `pracht mcp`. Alongside the existing `inspect_routes`, `inspect_api`, `inspect_build`, `doctor`, `verify`, and `generate_*` tools, it exposes: | Tool | What it returns | | ---------- | -------------------------------------------------------- | | `get_docs` | The same authoring guide as `pracht llms` | | `plan` | The semantic app-graph diff | | `report` | The assembled markdown report | An MCP-connected agent can read the conventions, scaffold with `generate_route` (tests included), check its own work with `verify`, and summarize the change with `report` — the whole loop without shell access. --- ## The Loop in CI Run verification on every PR and post the plan as a comment: ```yaml [.github/workflows/verify.yml] name: verify on: pull_request jobs: verify: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: # pracht plan reads the snapshot committed at the base ref. fetch-depth: 0 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: node-version: 22 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm pracht verify - run: pnpm pracht plan --markdown --base origin/main > plan.md - run: gh pr comment "$PR" --body-file plan.md env: GH_TOKEN: ${{ github.token }} PR: ${{ github.event.pull_request.number }} ``` With that in place the review contract is simple: constraints hold (verify passed), the snapshot is fresh (verify passed), and the intent-level diff is sitting in the PR thread. The human review can spend its attention on whether the change is a good idea — the machine already checked whether it's the change it claims to be. --- # The Agentic Web > The web has two users now — people, and the agents acting on their behalf. pracht projects one explicit app graph to both — components for humans, typed and trust-gated tools for agents, with discovery, identity, confirmation, audit, and CI proof built in. ## The Web Has Two Users Now Today, when an AI agent needs to do something on a website — book a slot, file a ticket, buy the thing — it does what a scraper does: load the page, read the DOM, guess which ` ; ``` Enhanced submissions honor the clicked button's `formaction` and `formmethod`, so multi-action forms keep the same behavior they have with native browser submission. --- # Capabilities > Define a typed operation once and pracht projects it everywhere — direct server calls, a generated HTTP endpoint, and a WebMCP page tool for in-browser agents. Explicit, validated, and private by default. ## One Contract, Many Surfaces A capability is a typed, protocol-neutral application operation: JSON Schema input and output, an effect class (`read`, `write`, or `destructive`), optional named middleware, and a server-only `run()` function. From that single contract pracht generates: - **Direct server invocation** — `invokeCapability()` from loaders, API routes, and middleware. - **An HTTP endpoint** — `POST /api/capabilities/` when `expose.http` is set. - **A WebMCP page tool** — registered for in-browser agents when `expose.webmcp` is set. Every projection runs the same pipeline, so business rules never diverge between transports: ```text input validation → middleware chain → run() → output validation ``` --- ## Register in the Manifest Capabilities are registered in `defineApp()`, exactly like shells and middleware. Registration is deliberately opt-in — no API route or loader is ever inferred as a capability. ```ts [src/routes.ts] export const app = defineApp({ capabilities: { "notes.search": () => import("./capabilities/notes-search.ts"), "notes.create": () => import("./capabilities/notes-create.ts"), }, // shells, middleware, routes... }); ``` --- ## Define the Contract ```ts [src/capabilities/notes-search.ts] import { defineCapability } from "@pracht/capabilities"; import { searchNotes } from "../server/notes-store.ts"; export default defineCapability({ title: "Search notes", description: "Find notes whose title or body matches the query.", input: { type: "object", properties: { query: { type: "string", minLength: 1 }, limit: { type: "integer", minimum: 1, maximum: 20, default: 10 }, }, required: ["query"], additionalProperties: false, }, output: { type: "object", properties: { notes: { type: "array", items: { type: "object" } } }, required: ["notes"], }, effect: "read", expose: { http: true, webmcp: true }, async run({ input }) { return { notes: searchNotes(input.query, input.limit) }; }, }); ``` Schemas are validated by a dependency-free JSON Schema subset validator — no ajv or zod in your bundles. Unsupported keywords (`oneOf`, `$ref`, `pattern`, …) are rejected at definition time and by `pracht verify`, so an exposed capability can never silently accept more than its schema says. --- ## Call It from Anywhere Server-side — including private capabilities that have no `expose` at all: ```ts [src/routes/notes.tsx] import { invokeCapability } from "@pracht/core"; export async function loader({ request, context, signal }) { const result = await invokeCapability("notes.search", { query: "roadmap" }, { request, context, signal }); return result.ok ? result.data : { notes: [] }; } ``` From the browser — `virtual:pracht/capabilities` contains only http-exposed names, endpoints, and effect classes; capability modules never enter the client bundle: ```ts [src/islands/NoteForm.tsx] import { callCapability } from "virtual:pracht/capabilities"; const result = await callCapability("notes.create", { title }); ``` HTTP-exposed capabilities must declare `effect` as an inline `"read"`, `"write"`, or `"destructive"` string because the browser projection is generated by static analysis. Custom `expose.http.path` values must be exact same-origin pathnames beginning with `/`; protocol-relative URLs, queries, and fragments are rejected. Or declaratively — the framework's `
` posts straight to a capability, so the human form and the agent tool share one contract. Fields are coerced onto the input schema server-side, and without JavaScript the endpoint accepts the form-encoded post and redirects back: ```tsx [src/routes/notes.tsx] import { Form } from "@pracht/core"; setStatus(result)}>
; ``` Mutations keep the page honest automatically: capabilities are effect-classed, so after any successful non-`read` call from the browser (`callCapability` or `
`) the active route's loader data revalidates — no manual `revalidate()` bookkeeping. Opt out per call with `{ revalidate: false }`. Over HTTP — every response uses a typed envelope, with path-scoped validation issues an agent can act on: ```sh curl -X POST /api/capabilities/notes/search -H 'content-type: application/json' -d '{"query":"roadmap"}' # { "ok": true, "data": { "notes": [...] } } # { "ok": false, "error": { "code": "invalid_input", "issues": [{ "path": "/limit", "message": "must be <= 20" }] } } ``` And both calls above are fully typed: `pracht typegen` generates input/output types from the capability schemas into `src/pracht-capabilities.d.ts`, so `invokeCapability()` and `callCapability()` infer both sides from the capability name — no per-call generics. --- ## WebMCP: Tools for In-Browser Agents With `expose.webmcp: true`, the client runtime registers the capability as a [WebMCP](https://developer.chrome.com/docs/ai/webmcp) page tool via `document.modelContext.registerTool()` (Chrome origin trial, with the deprecated `navigator.modelContext` fallback). The tool's `execute()` dispatches through the HTTP projection, so the agent acts as the signed-in user in their tab while validation, middleware, and policy all stay server-side. The shim ships as its own chunk behind feature detection: browsers without the API never download it, apps without webmcp-exposed capabilities never reference it, and it works in both full-hydration and islands modes. --- ## Private by Default - A capability without `expose` is never reachable over the network. - Exposure requires a complete contract — `pracht verify` fails for exposed capabilities missing a description, schema, or effect class. - `destructive` capabilities are gated by a server-verified confirmation flow and cannot be exposed to agent projections — see [Agent Trust](/docs/agent-trust). - Output is validated too: a handler returning data outside its output schema produces a redacted 500, never the raw value. - HTTP-exposed capabilities are listed in the generated [`/llms.txt`](/docs/llms) with their endpoint, effect class, and description, so agents can discover them without scraping. --- ## Inspect the Graph The capability graph feeds every inspection surface: the `pracht dev` startup banner, `pracht inspect capabilities [--json]`, the `/_pracht` devtools page, the `inspect_capabilities` tool on the `pracht mcp` server, and the static checks in `pracht verify`. ```sh pracht inspect capabilities # notes.search read http,webmcp /api/capabilities/notes/search # notes.create write http /api/capabilities/notes/create ``` Coming next: a remote MCP endpoint (`/mcp`) projecting the same capabilities to out-of-browser agents, and MCP Apps UI views rendered with Preact. For the story behind the design, read [The Agentic Web](/docs/agents); for unit, E2E, and WebMCP testing patterns, see the [Testing recipe](/docs/recipes/testing). --- # CLI > The @pracht/cli package provides development, build, scaffolding, and doctor commands for your app. ## pracht dev Starts the Vite dev server with SSR middleware, HMR, and instant feedback. ```sh pracht dev # Custom port pracht dev --port 4000 # or PORT=4000 pracht dev ``` Routes are rendered server-side on each request. Changes to routes, shells, loaders, and components are reflected immediately via HMR. The startup banner prints the resolved app graph: every route with its render mode, shell, and middleware, every API endpoint with its methods, and — when the app registers any — every [capability](/docs/capabilities) with its effect class, exposure, and dispatch path. --- ## pracht build Runs a production build: client bundle, server bundle, and SSG/ISG prerendering. ```sh pracht build ``` Output: - `dist/client/` — static assets with hashed filenames - `dist/server/server.js` — server entry module - SSG routes are pre-rendered as static HTML in `dist/client/` --- After `pracht build`, Node.js targets can run the generated server with: ```sh node dist/server/server.js ``` Cloudflare and Vercel targets should use their platform tooling against the generated build output. --- ## pracht generate Framework-native scaffolding keeps route, shell, middleware, and API module conventions in one place. ```sh pracht generate shell --name app pracht generate middleware --name auth pracht generate route --path /dashboard --render ssr --shell app --middleware auth pracht generate api --path /health --methods GET,POST ``` - Manifest apps update `src/routes.ts` automatically for routes, shells, and middleware. - Pages-router apps scaffold route files into `src/pages/`. - Add `--json` when another tool or agent needs machine-readable output. `generate route` also emits a Playwright smoke test at `e2e/.spec.ts` whenever the app has a Playwright setup (a `playwright.config.*` file or an `e2e/` directory). The test visits the route with example values for dynamic params (`/blog/:slug` → `/blog/example-slug`), asserts the response status is below 400, and checks the `h1` text. `--test` forces the test, `--no-test` skips it. --- ## pracht doctor Validate the current app wiring and surface missing files or configuration drift. ```sh pracht doctor pracht doctor --json ``` The doctor command checks: - `vite.config.*` presence and `pracht()` registration - App manifest or pages-router directory wiring - Referenced shell, middleware, and route modules - Package-level CLI and adapter dependencies --- ## pracht plan Semantic app-graph diff against a base git ref. Prints added, removed, and changed routes, API endpoints, and constraints — an intent-level changelog for reviewers. ```sh # Snapshot the resolved app graph to .pracht/app-graph.json (commit it) pracht plan --write # Diff the live graph against the snapshot committed at origin/main pracht plan # Custom base ref, machine-readable, or PR-comment output pracht plan --base origin/release pracht plan --json pracht plan --markdown ``` The snapshot works like a lockfile for the route graph: `pracht verify` fails when `.pracht/app-graph.json` is stale, with the fix in the message (run `pracht plan --write`). See [AI-Assisted Authoring & Review](/docs/agent-workflow) for the full workflow. --- ## pracht report Assembles a PR-ready markdown report from machine truth: the `pracht plan` diff, `pracht verify` results, and per-route client JS budgets from the last build. ```sh pracht report pracht report --base origin/release --out report.md ``` Use it as the factual half of a PR description — the author adds the "why". --- ## pracht llms Prints an embedded authoring guide for coding agents: project layout, conventions, constraints, and the verify/plan/report loop. ```sh pracht llms # Write the guide to llms.txt in the app root pracht llms --write ``` The same guide is available from the MCP server (`pracht mcp`) via the `get_docs` tool, alongside `plan` and `report` tools and the existing `inspect_*`, `doctor`, `verify`, and `generate_*` tools. --- ## Installation The CLI is included in scaffolded projects. For existing projects, add it as a dev dependency: ```sh pnpm add -D @pracht/cli ``` Then add scripts to your `package.json`: ```json [package.json] { "scripts": { "dev": "pracht dev", "build": "pracht build", "doctor": "pracht doctor" } } ``` --- # Data Loading > pracht provides a unified data model that works across all rendering modes. Loaders fetch data on the server, API routes handle mutations, and client hooks give reactive access to route data — all with full TypeScript inference. ## Loaders A **loader** is an async function exported from a route module. It runs server-side and returns serializable data that flows into the route component. ```ts [src/routes/dashboard.tsx] import type { LoaderArgs, RouteComponentProps } from "@pracht/core"; export async function loader({ request, params, context }: LoaderArgs) { const user = await getUser(request); const projects = await context.db.projects.findMany({ userId: user.id }); return { user, projects }; } export default function Dashboard({ data }: RouteComponentProps) { // data is typed: { user: User; projects: Project[] } return (

Welcome, {data.user.name}

    {data.projects.map(p =>
  • {p.name}
  • )}
); } ``` The route component can be a function default export or a named `Component` export. Named route exports such as `loader`, `head`, `headers`, `ErrorBoundary`, and `getStaticPaths` remain separate special exports. ### LoaderArgs | Field | Type | Description | | ------- | ------------- | ---------------------------------------------------- | | request | Request | The incoming Web Request | | params | RouteParams | Dynamic URL params, e.g. `{ slug: "hello" }` | | context | TContext | App-level context from the adapter's context factory | | signal | AbortSignal | Cancellation signal for timeouts | | url | URL | Parsed URL object | | route | ResolvedRoute | Matched route metadata | ### When loaders run | Scenario | Loader runs on | | ----------------- | ---------------------------------------------------------------- | | SSG build | Build machine, once per path | | SSR request | Server, every request | | ISG initial | Build machine, then adapter runtime where supported | | SPA | Server, during client navigation fetch | | Client navigation | Server (fetched as JSON) | > [!NOTE] > Loaders **never** run in the browser. Database connections, API keys, and secrets in loader code stay server-side permanently. ### Route-state caching Client navigation fetches loader data through Pracht's route-state endpoint. By default those JSON responses use `Cache-Control: no-store`, so every navigation asks the server for fresh loader data. Use `loaderCache` in route metadata when the returned data can safely be reused by the same browser for a short time: ```ts [src/routes.ts] route("/pricing", "./routes/pricing.tsx", { render: "isg", loaderCache: 60, }); ``` A positive value sets `Cache-Control: private, max-age=` on successful route-state responses. `loaderCache: false` and `loaderCache: 0` keep `no-store` and can opt a route out of a group default. Only cache data that is safe to reuse for the configured duration in the same browser. Avoid positive `loaderCache` values for loader data that depends on the current user, permissions, session, or cookies. `loaderCache` does not change ISG `revalidate`, and it is separate from Pracht's short in-memory prefetch cache. ### Error handling Throw `PrachtHttpError` for structured error responses. Pair it with an `ErrorBoundary` export to render a fallback UI: ```ts import { PrachtHttpError } from "@pracht/core"; import type { ErrorBoundaryProps } from "@pracht/core"; export async function loader({ params }: LoaderArgs) { const post = await getPost(params.slug); if (!post) throw new PrachtHttpError(404, "Post not found"); return { post }; } export function ErrorBoundary({ error }: ErrorBoundaryProps) { return (

{error.status ?? 500}

{error.message}

); } ``` Error boundaries compose — a route boundary catches route-level errors, a shell boundary catches errors from any route in that shell, and uncaught errors bubble to the global handler. #### Custom 404 page Declare a `notFound` page in the manifest. It handles both ways a page can be missing — an unmatched URL, and a loader that cannot find what it was asked for: ```ts [src/routes.ts] export const app = defineApp({ shells: { public: () => import("./shells/public.tsx") }, notFound: { component: () => import("./routes/not-found.tsx"), shell: "public", }, routes: [...], }); ``` ```tsx [src/routes/not-found.tsx] import { useLocation } from "@pracht/core"; export function Component() { const location = useLocation(); return (

404

No page lives at {location.pathname}.

Go home
); } ``` Inside a loader or middleware, `throw notFound()` renders the same page with a 404 status: ```ts import { notFound } from "@pracht/core"; export async function loader({ params }: LoaderArgs) { const post = await getPost(params.slug); if (!post) throw notFound("Post not found"); return { post }; } ``` A route module's own `ErrorBoundary` still wins for that route. Shell-level boundaries do not intercept 404s once `notFound` is configured — "not found" is an outcome, not a failure. > [!NOTE] > The not-found page is deliberately not a route: it never matches a URL, so it cannot shadow static assets or a path you add later, and it never appears in typed routes, prefetching, or SSG output. Pages-router apps get the same behavior from `pages/404.tsx`. > [!NOTE] > Unexpected 5xx errors are sanitized by default — only `PrachtHttpError` messages are shown to users. Pass `debugErrors: true` to `handlePrachtRequest()` to see full error details during development; it is ignored when `NODE_ENV=production`. --- ## Head Metadata The `head` export controls `` content for the route. It receives the loader data as its argument: ```ts export function head({ data }: HeadArgs) { return { title: `${data.post.title} — My Blog`, meta: [ { name: "description", content: data.post.excerpt }, { property: "og:title", content: data.post.title }, { property: "og:image", content: data.post.coverUrl }, ], link: [{ rel: "canonical", href: `https://example.com/blog/${data.post.slug}` }], }; } ``` ### SEO & Open Graph Use the `meta` array to set Open Graph, Twitter Card, and other SEO tags. Because `head` receives loader data, every tag can be dynamic per page: ```ts export function head({ data }: HeadArgs) { return { title: `${data.product.name} — My Store`, meta: [ { name: "description", content: data.product.description }, { property: "og:title", content: data.product.name }, { property: "og:description", content: data.product.description }, { property: "og:image", content: data.product.imageUrl }, { property: "og:type", content: "product" }, { property: "og:url", content: `https://mystore.com/products/${data.product.slug}` }, { name: "twitter:card", content: "summary_large_image" }, { name: "twitter:title", content: data.product.name }, { name: "twitter:image", content: data.product.imageUrl }, ], link: [ { rel: "canonical", href: `https://mystore.com/products/${data.product.slug}` }, ], }; } ``` ### Structured data (JSON-LD) Include a `script` entry with `type: "application/ld+json"` for search engine structured data: ```ts export function head({ data }: HeadArgs) { return { title: data.article.title, meta: [{ property: "og:type", content: "article" }], script: [ { type: "application/ld+json", children: JSON.stringify({ "@context": "https://schema.org", "@type": "Article", headline: data.article.title, datePublished: data.article.publishedAt, author: { "@type": "Person", name: data.article.author }, }), }, ], }; } ``` ### Shell-level defaults Shells can also export `head` to set site-wide defaults. Route-level `title` overrides the shell's `title`; `meta` and `link` arrays are concatenated: ```ts // src/shells/public.tsx export function head() { return { title: "My Site", meta: [{ property: "og:site_name", content: "My Site" }], link: [{ rel: "icon", href: "/favicon.svg" }], }; } ``` --- ## Document Headers The `headers` export controls HTTP headers for the route's document response. It receives the same data-aware arguments as `head`: ```ts export function headers({ data }: HeadersArgs) { return { "content-security-policy": `default-src 'self'; img-src 'self' ${data.cdnOrigin}`, }; } ``` Headers merge with the shell's `headers` export. Route-level headers override shell headers with the same name. They apply to HTML document responses, including prerendered SSG/ISG HTML, but not API routes or route-state JSON fetches. --- ## Client Hooks ### useRouteData() Access the current route's loader data reactively. Updates automatically on navigation and revalidation. If your project runs `pracht typegen`, pass the route id and the data type is inferred from that route's loader — no generic needed: ```ts export function Component() { const data = useRouteData("dashboard"); return {data.user.name}; } ``` For projects that do not run typegen, pass the loader type explicitly as a generic instead: ```ts export function Component() { const data = useRouteData(); return {data.user.name}; } ``` ### useRevalidate() Imperatively re-run the current route's loader: ```ts export function Component() { const revalidate = useRevalidate(); return ; } ``` Manual revalidation bypasses route-state browser caching, including `loaderCache`, so refresh buttons and post-mutation reloads fetch fresh loader data. ### useNavigation() Reactive pending state for the current navigation or `` submission — the building block for global progress bars, pending buttons, and optimistic UI: ```ts import { useNavigation } from "@pracht/core"; function NavigationProgress() { const navigation = useNavigation(); if (navigation.state === "idle") return null; return