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/<name>whenexpose.httpis set. - A WebMCP page tool — registered for in-browser agents when
expose.webmcpis set.
Every projection runs the same pipeline, so business rules never diverge between transports:
input validation → middleware chain → run() → output validationRegister 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.
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
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:
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:
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 <Form> 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:
import { Form } from "@pracht/core";
<Form capability="notes.create" onCapabilityResult={(result) => setStatus(result)}>
<input name="title" />
<button type="submit">Create note</button>
</Form>;Mutations keep the page honest automatically: capabilities are effect-classed, so after any successful non-read call from the browser (callCapability or <Form capability>) 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:
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 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
exposeis never reachable over the network. - Exposure requires a complete contract —
pracht verifyfails for exposed capabilities missing a description, schema, or effect class. destructivecapabilities are gated by a server-verified confirmation flow and cannot be exposed to agent projections — see 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.txtwith 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.
pracht inspect capabilities
# notes.search read http,webmcp /api/capabilities/notes/search
# notes.create write http /api/capabilities/notes/createComing 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; for unit, E2E, and WebMCP testing patterns, see the Testing recipe.