add-capabilities
Skill106 starsChanged 7 months agoMITView on GitHub
Expose an app operation as a typed pracht capability served over HTTP, WebMCP, and remote MCP, with `agents` trust config, typed clients, and `pracht eval` scenarios. Use for "add a capability", "expose this to agents", "add an MCP tool", "add WebMCP", "serve remote MCP", "make my app agent-callable".
- Installs packages
In this repository33 other files it gives its agents.
- AGENTS.mdAGENTS.md
- add-authSkill
- add-contentSkill
- add-dbSkill
- add-i18nSkill
- add-imagesSkill
- add-observabilitySkill
- add-openapiSkill
- audit-a11ySkill
- audit-agent-surfaceSkill
- audit-authSkill
- audit-bundlesSkill
- audit-csrfSkill
- audit-depsSkill
- audit-headersSkill
- audit-islandsSkill
- audit-loadersSkill
- audit-redirectsSkill
- audit-secretsSkill
- audit-seoSkill
- audit-shellsSkill
- configure-isgSkill
- migrate-nextjsSkill
- pracht-debugSkill
- pracht-deploySkill
- pracht-scaffoldSkill
- pracht-test-apiSkill
- pre-deploySkill
- scaffold-e2eSkill
- scaffold-testsSkill
- tune-render-modeSkill
- typed-routesSkill
- upgrade-prachtSkill
The file
Show its rules and text
We split this skill into the rules it gives an agent
16 rules. About 0 are common, 1 is distinctive, and 15 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈1
Distinctive · estimate
This author's own choices. Worth a look.
15
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 32
Not a pracht app? Use createCapabilityHost().
- Very specific: line 123
Only a subset of JSON Schema is accepted: type, properties, required, additionalProperties, items (single schema), enum, const, minimum, …
- Very specific: line 198
Add auth and /mcp becomes an OAuth 2.0 protected resource: pracht publishes RFC 9728 metadata at /.well-known/oauth-protected-resource, …
- Very specific: line 221
Put the module in src/server/ and default-export the verifier function.
- Very specific: line 226
If the same suffix exists in more than one registry directory, lookup rejects it as ambiguous.
- Very specific: line 234
pracht is not an authorization server: Do not offer to implement token issuance, refresh, or dynamic client registration — those belong to …
- Very specific: line 338
Prefer a loader + invokeCapability() for data a page needs on load.
- Very specific: line 340
After a successful non-read call the route's data revalidates automatically (revalidate: false opts out).
- Very specific: line 370
If agents.mcp.auth protects the endpoint, add scenario-level "mcpHeaders": { "authorization": "Bearer …" }.
- Very specific: line 389
Keep an HTTP or remote MCP projection for agents outside the browser.
- Very specific: line 396
Never expose a destructive capability over webmcp.
- Very specific: line 400
Never widen a schema (drop required, open additionalProperties, raise a maximum) without saying so — pracht plan reports it as a widening …
- Very specific: line 403
Keep expose and effect inline.
- Very specific: line 405
Treat expose.webmcp as eligibility, not activation.
- Very specific: line 408
Put authentication, authorization, and rate limiting in named middleware — the framework ships no rate limiting, no write-idempotency …
- Distinctive: line 414
Never register an app-wide approval endpoint or UI without your own authorization — who may approve is an application decision.
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Pracht Add Capabilities
A capability is a protocol-neutral operation. Every projection runs the same pipeline:
input validation → named middleware chain → run() → output validationRegistration is opt-in and private by default; loaders and API routes are never inferred as capabilities, and an app with none ships no dispatch surface.
Not a pracht app? Use createCapabilityHost(); see
https://pracht.resynapse.dev/docs/standalone-capabilities.
Step 1: Decide the contract before writing code
Settle these with AskUserQuestion when the request is vague:
- Name — dot-separated segments (
notes.search); this is the agent-visible identity and the MCP tool name (dots become underscores). - Effect —
read,write, ordestructive. This drives confirmation gating, client revalidation, and MCP annotations. Classify honestly. - Exposure — private (omit
expose),http,webmcp(requireshttp),mcp(mcp: { scopes }limits the tool to OAuth tokens with those scopes). - Router — manifest apps use a
defineApp({ capabilities })key; pages apps auto-discoversrc/capabilities/, where each module declaresname: "notes.search"(or takes its file stem) and the name must map back to its file with dots as hyphens (notes-search.ts). Step 4 covers the rest. - Authorization — which named middleware runs, and whether the endpoint
requires a verified agent (
agentPolicy: "require").
Exposure matrix the runtime, defineCapability(), and pracht verify all
enforce:
| Effect | http | webmcp | mcp |
read / write | yes | yes (needs http) | yes (needs agents.mcp) |
destructive | yes — always confirmation-gated | rejected | yes — needs agents.mcp.destructive and a registered approval store |
Step 2: Install and scaffold
create-pracht does not add the package, because an app without capabilities
should not carry it:
npm install @pracht/capabilities
pracht generate capability --name notes.search --effect read --expose http,webmcp \
--description "Find notes whose title or body matches the query."The generator writes and registers src/capabilities/notes-search.ts.
--description is required with --expose; the MCP generate_capability tool
has the same contract.
Dispatch answering 500 internal_error while pracht inspect capabilities
prints unreadable means the package is missing.
Step 3: Write the capability
// src/capabilities/notes-search.ts
import { defineCapability, type CapabilityRunArgs } from "@pracht/capabilities";
import { searchNotes } from "../server/notes-store.ts";
interface SearchInput {
query: string;
limit: number;
}
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",
middleware: ["auth"], // names from the app manifest
expose: { http: true, webmcp: true },
// webmcp: { untrustedContent: true } advertises untrustedContentHint for
// page tools whose results carry user-generated content.
// agentPolicy: "require", // verified Web Bot Auth agents only —
// // never combine with webmcp: page-tool
// // calls are unsigned and would always 401
async run({ input, context, request, signal }: CapabilityRunArgs<SearchInput>) {
return { notes: searchNotes(input.query, input.limit) };
},
});Schema rules that bite:
- Only a subset of JSON Schema is accepted:
type,properties,required,additionalProperties,items(single schema),enum,const,minimum,maximum,minLength,maxLength,default, plustitleanddescription.oneOf,anyOf,allOf,$ref,pattern,format, and tupleitemsthrow at definition time — a keyword the validator would ignore could widen what an exposed capability accepts. - Inputs and outputs are JSON data only.
File,Blob,Date,Map,undefined, and cycles are rejected — keep uploads in API routes. exposeandeffectmust be inline literals.inputandoutputmay be Standard Schema + Standard JSON Schema validators such as Zod 4; pracht derives the supported JSON subset server-side and runs the validator. Async validation, defaults, and transforms work without adding it to WebMCP.- MCP exposure additionally requires both schemas rooted at
type: "object". - Annotate
run()withCapabilityRunArgs<Input>so TypeScript still infers the output;defineCapability<Input>alone leaves the outputunknown.
Step 4: Register it, and configure agents only if needed
// src/routes.ts
import { defineApp, route } from "@pracht/core";
export const app = defineApp({
capabilities: {
"notes.search": () => import("./capabilities/notes-search.ts"),
},
agents: {
// Verified agent identity (public keys — safe in the manifest).
webBotAuth: { policy: "observe", directories: ["https://signature-agent.cloudflare.com"] },
// Destructive prepare/commit tuning.
confirmation: { ttlSeconds: 120 },
// Remote MCP endpoint; without this, `expose.mcp` serves nothing.
mcp: { serverInfo: { name: "notes", version: "1.0.0" }, instructions: "…" },
},
routes: [
route("/notes", "./routes/notes.tsx", {
// expose.webmcp makes it eligible; this makes it active on this page.
capabilities: ["notes.search"],
}),
],
});Pages apps have no manifest: the same capabilities come from
src/capabilities/ and the same agents object is export const agents in
src/pages/_app.config.ts. Activate WebMCP tools on each page that needs them:
// src/pages/notes.tsx
export const CAPABILITIES = ["notes.search"];CAPABILITIES must be an inline array of non-empty registered names and cannot
appear on _app or 404. In a manifest, group capability lists are additive.
Unknown names, capabilities without expose.webmcp, and activation on
hydration: "none" routes are rejected. Initial hydration registers the matched
route's set; every committed client navigation replaces it, so never assume a
tool exposed on one page persists globally.
In dev, the tab's pracht_page_tools WebMCP tool lists the tools active on
the route and why a declared capability is not one.
Each agents sub-option is independent — add only what the app uses. Web Bot
Auth policy: "require" gates capability HTTP endpoints (not pages or API
routes) with 401 agent_required; agentPolicy: "require" on a capability
fails closed even when webBotAuth is unconfigured.
Authenticating the MCP endpoint (agents.mcp.auth)
agents: { mcp: {} } alone serves an open endpoint — anyone who can reach
the URL can call every expose.mcp tool, and authentication is whatever the
capability's named middleware does with the forwarded Authorization header.
That is fine for a public read surface and wrong for anything scoped to a user.
Add auth and /mcp becomes an OAuth 2.0 protected resource: pracht publishes
RFC 9728 metadata at /.well-known/oauth-protected-resource, answers
unauthenticated calls with the WWW-Authenticate challenge MCP hosts follow,
and calls your verify module. This is what makes a real host (Claude, a
ChatGPT connector) able to connect at all.
mcp: {
serverInfo: { name: "notes", version: "1.0.0" },
auth: {
resource: "https://app.example.com/mcp", // absolute; token audience
authorizationServers: ["https://auth.example.com"],
scopesSupported: ["notes.read", "notes.write"],
requiredScopes: ["notes.read"], // optional per-request gate
verify: () => import("./server/mcp-token.ts"), // server-only module
},
},Rules to hold the user to:
verifyis a module reference, never an inline function. The manifest is bundled into the client; a JWKS client in it would ship to every visitor. Put the module insrc/server/and default-export the verifier function. It must live undersrc/server/,src/middleware/, orsrc/capabilities/— those are the only directories the build globs into the module registry, and a verifier anywhere else is never loadable, so every/mcprequest 401s forever.pracht verifyerrors on that, but do not create the file elsewhere. If the same suffix exists in more than one registry directory, lookup rejects it as ambiguous; use a root-relative reference such as() => import("/src/server/mcp-token.ts").- Security option names are exact. Unknown keys under
agents.mcpandagents.mcp.authare rejected instead of ignored; do not work around the error with casts. The MCP path must also differ from every explicit API route path, orpracht verifyrejects the graph and the runtime fails closed with 500 before the API handler can bypass MCP's gates. - pracht is not an authorization server. Do not offer to implement token
issuance, refresh, or dynamic client registration — those belong to the
user's identity provider. Write
verifywith their library (joseworks on Workers and Vercel Edge) and bindaudienceto theresourcevalue, or a token minted for another service on the same issuer is accepted. - It fails closed.
null, a throw, or a malformed principal all give401 invalid_token; a missing required scope gives403 insufficient_scope. Challenges advertiserequiredScopes. The verifier gets a request clone; reading its body is safe. - The principal is
context.tokenAuth— a frozen{ subject, scopes?, clientId?, claims? }, alongsidecontext.agent. Use it in named middleware andrun()for authorization; the framework only authenticates. Nested calls retain the transport-verified principal. Capture it separately when audit events must identify the account. resourcemust be the endpoint's real deployed URL: absolute, free of query/fragment and exactly matching the deployed MCP path, including the app base (https://app.example.com/app/mcp). Use HTTPS outside loopback.resolveApp()andpracht verifyreject malformed values and scope strings; mismatched authenticated request URLs redirect to the canonical resource.- The bare
/.well-known/oauth-protected-resourcepath is reserved for discovery and cannot be used asmcp.path. Production adapters route both metadata forms ahead of copied static files. pracht plansnapshots the OAuth policy separately from the endpoint path. Removingauthor a required scope, or trusting another authorization server, is a guard weakening even when/mcpitself did not move.
See https://pracht.resynapse.dev/docs/mcp-tools#oauth-letting-a-real-host-connect for the metadata document and the verify recipe.
Step 5: Destructive capabilities
destructive (delete, publish, pay, send, change access) may be exposed over
http and mcp, never webmcp, and every dispatch is gated:
- Set
PRACHT_CONFIRMATION_SECRETin the server environment (or callsetCapabilityConfirmationSecret()from@pracht/core/server). Without it, calls fail closed with403 confirmation_unavailableandpracht verifyfails — verify reads the environment, so the variable must be set even when the app registers the secret programmatically. - A call without a token answers
409 confirmation_requiredwith a token bound to principal + capability + canonical input + expiry. - The commit repeats the call with byte-identical input plus the confirmation header.
Be honest about what this buys, and say so to the user
(https://pracht.resynapse.dev/docs/agent-trust): stateless HMAC cannot prevent replay inside the TTL,
the calling agent can hand the token back to itself, and without Web Bot Auth
or setCapabilityApprovalPrincipalResolver() both phases run as "anonymous".
Register a CapabilityApprovalStore for exactly-once commits, and
confirmation: { mode: "human" } for a real human decision — that mode needs
an end-user identity (a principal resolver, or agents.mcp.auth on remote MCP)
or the server refuses to start, and refuses destructive calls without a store;
a Web Bot Auth key alone never counts.
createSqlApprovalStore({ execute }) from @pracht/core/server is the
first-party durable store — one implementation for Postgres, Cloudflare D1, and
SQLite/Turso. Pass a parameterized-query function and run the migration from
the Agent Trust page; use dialect: "postgres" for $1 placeholders.
createMemoryApprovalStore() is for tests only. A non-SQL backend needs atomic
conditional writes (Durable Objects, Redis — not Cloudflare KV).
Destructive over remote MCP
Off by default. To serve one:
agents: { mcp: { destructive: true } }indefineApp().- Register an approval store from a server entry or a capability module, so it
exists before the graph is served — a token handed to the committing agent
must be consumable exactly once. The endpoint refuses to serve at all when
the store,
PRACHT_CONFIRMATION_SECRET, or (in human mode) any resolvable principal is missing;pracht verifywarns when it cannot find the registration in the configured source directories. - The flow is unchanged; only the channel differs. Prepare answers
isError: truewith the token in_meta["io.pracht/error"], and the commit repeatstools/callwith identicalargumentsplus_meta["io.pracht/confirmation"].
Nested invokeCapability() under an MCP tool still refuses destructive callees
unless the served tool is itself a destructive capability that already cleared
prepare/commit.
Step 6: Call it
// Server: loaders, API routes, middleware — works for private capabilities too.
import { invokeCapability } from "@pracht/core/server";
const result = await invokeCapability("notes.search", { query: "roadmap" }, { request, context, signal });// Browser: generated, typed, http-exposed names only.
import { capabilities, useCapability } from "virtual:pracht/capabilities";
const result = await capabilities.notes.search({ query: "roadmap" });Unresolvable in TS? Add "@pracht/vite-plugin/virtual" to tsconfig types.
// One contract for the human form and the agent tool.
<Form capability="notes.create" onCapabilityResult={(result) => { /* … */ }}>
<input name="title" />
<button type="submit">Create</button>
</Form>- Prefer a loader +
invokeCapability()for data a page needs on load;useCapability()dispatches on interaction, never during render. - After a successful non-
readcall the route's data revalidates automatically (revalidate: falseopts out). - Capability modules are server-only: importing one from client code is a build
error, because nothing would strip
run()and its database client out of the browser bundle.
Step 7: Types, inspection, and proof
pracht typegen # emits src/pracht-capabilities.d.ts
pracht inspect capabilities --json
pracht verify --json # contract, exposure, and projection checks
pracht eval --start "pracht preview"Once the declaration exists the compiler rejects unknown names, bad input,
browser calls to private capabilities, destructive calls without
prepare/confirm, and computed names (assert as HttpCapabilityName).
Re-run pracht typegen --check in CI.
pracht eval runs JSON scenarios against the live app and exits 1 on a failed
expectation. Steps can reference earlier results
($steps[0].error.confirmationToken); a scenario-level signAs block signs
every step as a verified agent.
A scenario targets HTTP by default; scenario-level "transport": "mcp" runs
the same steps over the remote MCP endpoint (initialize, then one
tools/call per step, names mapped notes.search → notes_search). Write one
of each for any expose.mcp capability — passing over HTTP does not prove an
MCP host can reach it.
If agents.mcp.auth protects the endpoint, add scenario-level
"mcpHeaders": { "authorization": "Bearer …" }; it applies to initialize and
every later request, and step-level headers.authorization overrides it for
one call. Inject test tokens in CI. Expectations are portable: expect.status
is the capability dispatch status on both transports.
Three MCP limits fail loudly: a step for a capability without expose.mcp, a
step header other than authorization (the projection forwards nothing else),
and a destructive step whose app has not enabled agents.mcp.destructive with
an approval store. For an exposed destructive MCP tool, confirm completes the
same prepare/commit round trip as HTTP, with the token in the call's
_meta["io.pracht/confirmation"].
createCapabilityTestHost() from @pracht/core covers the same pipeline in
unit tests, without a server.
WebMCP: names must fit the draft's 1–128 ASCII [a-zA-Z0-9_.-] grammar;
Chrome advises 30 chars/name, 500/tool description, 150/parameter description,
and 1.5K/result. pracht verify checks static limits; bound outputs yourself.
Keep an HTTP or remote MCP projection for agents outside the browser. An
effective agentPolicy: "require" always 401s unsigned page-tool calls.
To audit what the whole agent surface exposes, run /audit-agent-surface.
Rules
- Never expose a
destructivecapability overwebmcp; expose it overmcponly withagents.mcp.destructiveand a durable approval store, and say so to the user. Never reclassify a destructive operation aswriteto escape the confirmation gate. - Never widen a schema (drop
required, openadditionalProperties, raise amaximum) without saying so —pracht planreports it as a widening of the agent-reachable surface for a reason. - Keep
exposeandeffectinline; reuse a Standard JSON Schema validator forinput/outputwhen the app already has one. - Treat
expose.webmcpas eligibility, not activation. Add the capability to only the manifest routes/groups or PagesCAPABILITIESexports where an in-page agent should see it. - Put authentication, authorization, and rate limiting in named middleware —
the framework ships no rate limiting, no write-idempotency helper, and no
result-size budget. Bound outputs with a
limitinput and a schemamaximum. - Design
writeinputs to be safely repeatable; agents retry, and onlydestructivecalls are token-gated. - Never register an app-wide approval endpoint or UI without your own authorization — who may approve is an application decision.
- Re-run
pracht typegenafter changing a schema, name, exposure, or route activation, andpracht verifybefore committing.
$ARGUMENTS
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

