CLAUDE.md · git:20261109.6e70e26 · 2026-11-09 · sha256 dbf2b4a7ea10142c
CLAUDE.md git:20261109.6e70e26A
Immutable. This exact content never changes and is served at /api/v1/blob/dbf2b4a7ea10142c.
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Overview
Silkweave is a TypeScript toolkit for building MCP (Model Context Protocol) servers and CLI tools from a single set of "Actions". Define an action once, then expose it via multiple adapters (MCP stdio, MCP HTTP, Fastify REST API, tRPC, or CLI).
## Commands
```bash
pnpm build # Build all packages with tsdown (ESM output to build/)
pnpm check # Lint + typecheck all packages
pnpm clean # Clean all build outputs and turbo cache
# Run example servers (not automated tests - these start live servers)
pnpm -F @silkweave/example-core dev # Run an Action directly without an adapter
pnpm -F @silkweave/example-cli dev # CLI adapter (commander)
pnpm -F @silkweave/example-mcp stdio # MCP stdio server
pnpm -F @silkweave/example-mcp http # MCP streamable HTTP server on :8080
pnpm -F @silkweave/example-mcp http-auth # MCP HTTP with bearer token auth on :8080
pnpm -F @silkweave/example-mcp http-oauth # MCP HTTP with Google OAuth 2.1 on :8080
pnpm -F @silkweave/example-mcp http-multi-resource # One AS, N per-tenant protected resources on :8080
pnpm -F @silkweave/example-mcp cli-proxy # MCP CLI proxy client (connects to http example)
pnpm -F @silkweave/example-fastify dev # Fastify REST API with Swagger on :8080
pnpm -F @silkweave/example-trpc dev # tRPC standalone HTTP server on :8080/trpc/
pnpm -F @silkweave/example-typegen dev # Generate .d.ts from action Zod schemas
pnpm -F @silkweave/example-nestjs dev # NestJS controllers exposed as MCP tools via @Mcp, on :8080
pnpm -F @silkweave/example-nextjs dev # Next.js App Router: one action set as MCP (/api/mcp) + tRPC (/api/trpc), on :8080
pnpm -F @silkweave/example-cloudflare dev # Cloudflare Worker: stateless MCP + Google OAuth 2.1 + KV-backed store (wrangler dev on :8787)
pnpm -F @silkweave/example-skills dev # MCP HTTP server serving two Agent Skills on :8080 (try: npx silkweave skills list --url http://localhost:8080/mcp)
# Silkweave CLI (packages/silkweave - the `silkweave` npm package)
pnpm -F silkweave dev skills sync --url <mcp-url> # install/update Agent Skills from a silkweave MCP server
pnpm -F silkweave dev proxy <mcp-url> [tool ...] # universal cliProxy: any MCP server's tools as CLI commands
# AI chat example (Vite + React + useChat + tRPC subscriptions)
ANTHROPIC_API_KEY=sk-... pnpm -F @silkweave/example-ai dev
# MCP Inspector (connects to MCP stdio example via .mcp.json)
pnpm mcp
```
## Architecture
The core pattern is **Action → Adapter → Silkweave**:
- **Action** (`packages/core/src/util/action.ts`): A named operation with a Zod `input` schema, an optional Zod `output` schema, and an async `run(input, context)` function. Actions are adapter-agnostic - they receive a `Logger` via context. The `output` schema is used by the typegen and tRPC adapters to generate typed response interfaces. An optional `kind: 'query' | 'mutation'` field (default `'mutation'`) controls how the action is exposed over tRPC - queries are GET-cacheable, mutations are POST. the `@silkweave/fastify` REST adapter additionally honors three optional routing fields: `method` (`'GET' | 'POST' | 'PUT' | 'DELETE'`, default `POST` or `GET` when `kind: 'query'`), `path` (a route template that may contain `:param` placeholders, e.g. `'spaces/:spaceId/users'`), and `queryParams` (input fields read from the URL query string instead of the body, e.g. `['offset', 'limit']`). Path placeholders and query params must be keys of the input schema; the input is merged from path + query + body and validated as one (see [REST routing](#rest-routing) below). An optional `toolResult(response, context)` hook lets actions control how results are formatted as an MCP tool result (returning core's dependency-free `ToolResult` type, structurally the SDK's `CallToolResult` - since 4.0 core no longer depends on `@modelcontextprotocol/sdk`; e.g. returning embedded resources for large payloads); an optional `disposition: 'json' | 'smart' | 'structured'` field sets the MCP result format - `'json'` (the default since 3.2; was `'smart'`) ⇒ `jsonToolResult`, `'smart'` ⇒ `smartToolResult`, `'structured'` ⇒ the `output` schema becomes the tool's MCP `outputSchema` contract and the schema-**parsed** (extra-fields-stripped) result ships as `structuredContent` (+ JSON text mirror). A client `_meta.disposition` overrides `'json'`/`'smart'` but never `'structured'` (the contract is fixed at `tools/list` time). `'structured'` requires a non-streaming action with `output` - `validateActionDisposition()` throws at registration otherwise (called from `stdio()`/`mcpTransport()`/`edge()` boot paths). An optional `tags?: string[]` field carries free-form grouping labels for the MCP adapters' per-request `filterActions` to match on (no behavior in core). Actions can also return **binary/text resources** (screenshots, PDFs, JSON artifacts): declare `output: binary({ mimeType, name?, description? })` and return `resource(bytes, { mimeType, name?, description? })`, a Web-Standard `File`/`Blob`, or bare `Uint8Array`/`ArrayBuffer` bytes - every adapter detects the resource result and delivers it transport-appropriately (see [Resources](#resources-binary-results) below). An optional `annotations` field (`ToolAnnotations` - `readOnlyHint`/`destructiveHint`/`idempotentHint`/`openWorldHint`/`title`) is forwarded to MCP `tools/list`; MCP adapters derive `readOnlyHint` from `kind` (`'query'` ⇒ read-only) and merge explicit annotations over the derived base. `Action<I, O, N, K>` is generic over input/output types, the literal `name`, and `kind` - literal types are preserved through `createAction()` so the `Silkweave<Actions>` builder can thread action types to type-aware adapters like tRPC. Actions can also be **streaming**: declare a `chunk` Zod schema and an `async function*` `run` that yields chunks; adapters detect this via `isStreamingAction()` and switch to per-chunk wire delivery (see [Streaming](#streaming) below).
- **Adapter** (`packages/core/src/util/adapter.ts`): Translates actions into a specific transport. `AdapterFactory<T>` takes config options, returns an `AdapterGenerator` that takes `SilkweaveOptions` and produces an `Adapter` with `start(actions)` / `stop()`.
- **Silkweave** (`packages/core/src/lib/silkweave.ts`): Fluent builder - `silkweave(opts).adapter(generator).action(action).start()`. `Silkweave<Actions extends Record<string, Action>>` is generic over accumulated actions so `typeof server` carries action type info forward; type-aware adapters (e.g. `@silkweave/trpc`'s `InferTrpcRouter<typeof server>`) extract this for end-to-end type safety.
### Packages
| Package | Path | Description |
|---------|------|-------------|
| `@silkweave/core` | `packages/core` | Core library - actions, adapters, builder, context, logger, utilities |
| `@silkweave/auth` | `packages/auth` | Auth - **resource-server core** (spec-required, `jose`-only): bearer-token validation (expiry + issuer binding RFC 9207, audience binding RFC 8707, step-up `scope` challenge SEP-2350) + protected resource metadata (RFC 9728, with `scopes_supported`). The OAuth 2.1 **authorization-server** proxy (PKCE, refresh tokens, CIMD, dynamic client registration) + persistence stores live behind the opt-in **`@silkweave/auth/oauth`** subpath, so a pure resource server never pulls the issuer machinery into its graph. The OAuth *types* (`OAuthRequest`/`OAuthResponse`/`OAuthProvider`) stay at the root because `AuthConfig.provider` references them. **(5.1)** `AuthConfig.resourceUrl` widened to `string | ResourceResolver` for multi-resource servers - see [Multi-resource OAuth](#multi-resource-oauth-one-as-n-protected-resources) below. |
| `@silkweave/mcp` | `packages/mcp` | MCP adapters - stdio, streamable HTTP, CLI proxy. **(4.0)** The Express `http()` server + its mountable handlers (`mcpTransport`, `oauthRoutes`, `protectedResourceMetadata`, `sideloadResource`, `mcpCors`, `authMiddleware`) live behind the **`@silkweave/mcp/server`** subpath - the package root is express-free (`stdio`, `registerTools`, result helpers, `filterActions`), so a stdio-only or serverless consumer never pulls express/cors. `express` + `cors` are **optional peer deps** (needed only for `/server`). The tool-registration + result helpers are also re-exported from the express-free **`@silkweave/mcp/tools`** subpath, which web-standard adapters (`@silkweave/edge`, `@silkweave/nextjs`) import. |
| `@silkweave/cli` | `packages/cli` | CLI adapter - commander (plain `console` output, no terminal-UI dep) |
| `@silkweave/fastify` | `packages/fastify` | Fastify REST adapter - auto-generated OpenAPI/Swagger docs |
| `@silkweave/trpc` | `packages/trpc` | tRPC adapter - end-to-end type-safe procedures via `InferTrpcRouter<typeof server>`. Three host shapes: `trpc()` binds its own port, `trpcFetch()` returns a Web-Standard handler, and **(5.1)** `trpcNode()` returns a `(req, res)` handler for mounting on a `node:http` server you already own. **(5.1)** All three accept `authenticate` for cookie/custom identity. |
| `@silkweave/edge` | `packages/edge` | Web-Standard edge/serverless adapter - stateless MCP over Streamable HTTP. Pure `(Request) => Response` (no Express, Web Crypto only), so the one adapter runs on Cloudflare Workers, Vercel, Bun, Deno, Hono, and Next.js. Exposes `edge()` returning `{ adapter, handler, GET, POST, DELETE }`; serves the full OAuth surface when given `auth`. Opt-in DNS-rebinding protection via `allowedHosts`/`allowedOrigins` (forwarded to the transport), and `corsOrigin` to restrict the default wildcard `Access-Control-Allow-Origin`. A top-level try/catch maps any handler throw (malformed OAuth body, boot-failure `_ready` rejection) to a JSON error Response instead of an opaque host 500. |
| `@silkweave/nextjs` | `packages/nextjs` | Next.js App Router adapter - action-first and additive. `defineSilkweave({ actions })` projects one action set onto Next.js route handlers: `app.mcp()` (MCP tools, for agents) and `app.trpc()` (tRPC endpoint, for the frontend). Wraps `@silkweave/edge` + `@silkweave/trpc`, adding catch-all path normalization and end-to-end tRPC types. App Router only; no `next`/`react` dependency (Web-Standard `Request`/`Response`). |
| `@silkweave/nestjs` | `packages/nestjs` | NestJS adapter - the `@Mcp` and `@Trpc` method decorators expose existing controller routes as MCP tools / tRPC procedures; input schemas are reflected from the route + `@Param`/`@Query`/`@Body` decorators + `@nestjs/swagger`/`class-validator` metadata (+ optional OpenAPI doc); on a call the input is re-bound into the method's positional args (guards applied first). `@Trpc` adds output types (reflected from `@ApiOkResponse` or `@Trpc({ output })`), verb-inferred `kind`, and `async *`-as-subscription; the `mcp()`/`trpc()`/`typegen()` adapters consume the respective decorators |
| `@silkweave/typegen` | `packages/typegen` | Type generator - emits `.d.ts` interfaces from action Zod schemas as plain strings (no `typescript` runtime/peer dependency - it never type-checked, so the compiler-API AST factory + printer were dropped in favor of direct string emission; `typescript` remains a devDep for this package's own build/typecheck only) |
| `@silkweave/skills` | `packages/skills` | Skills - serve [Agent Skills](https://agentskills.io/specification) (`SKILL.md` dirs) from MCP servers: loader/validator (`resolveSkills`), per-file + aggregate sha256 digests (Web Crypto, edge-safe), `ListSkills`/`GetSkill` actions, server-instructions blurb, lockfile diff model shared with the CLI. The `skill://` resource registrar lives behind **`@silkweave/skills/mcp`** (MCP SDK is an optional peer of the root). `defineSkill()` + the `Skill`/`SkillDefinition` types live in core (the coupling seam, like `Action`); this package is an **optional peer** of `@silkweave/mcp`/`@silkweave/edge`, lazy-imported by `prepareSkills()` only when an adapter's `skills` option is set. See [Skills](#skills-serving-agent-skills-over-mcp) below. |
| `silkweave` | `packages/silkweave` | **The Silkweave CLI** (`npx silkweave`) - `skills sync|install|list|outdated|pin|unpin` (digest-verified installs into `~/.claude/skills` with a `.silkweave-lock.json`), `skills pack` (wrap a skill dir into a publishable skills-only Claude Code plugin npm package), and `proxy <url>` (universal cliProxy: any MCP server's tools as CLI subcommands, built from `tools/list` at invocation). **(5.0)** Repurposed from the former umbrella re-export package - the `silkweave/core`-style subpaths are gone; bin-only, lean deps (commander + MCP SDK client + `@silkweave/mcp/cli-proxy` internals + `@silkweave/skills`). |
| `@silkweave/ai` | `packages/ai` | Vercel AI SDK bridge - `createChatAction()` wraps `streamText` into a streaming action; `silkweaveTransport()` is a custom `ChatTransport` that adapts any subscribe-style function (typically a tRPC subscription) into the `ReadableStream<UIMessageChunk>` that `useChat` consumes |
| `@silkweave/example-*` | `examples/*` | One example per adapter package: `examples/core`, `examples/cli`, `examples/mcp`, `examples/fastify`, `examples/trpc`, `examples/typegen`, `examples/edge`, `examples/nestjs`, `examples/nextjs`, `examples/skills` (two `SKILL.md` skill dirs served via `http({ skills })` / `stdio({ skills })`). Each is a self-contained workspace package with its own `package.json`, `tsconfig.json`, and minimal inline actions. |
| `@silkweave/example-ai` | `examples/ai` | End-to-end chat app: Vite + React + `useChat` → `silkweaveTransport` → tRPC subscription → Silkweave streaming action → AI SDK `streamText`. Run with `pnpm -F @silkweave/example-ai dev` (needs `ANTHROPIC_API_KEY`). |
| `@silkweave/example-cloudflare` | `examples/cloudflare` | Deployment example (not a new adapter): the `edge()` Web-Standard adapter on a **Cloudflare Worker** - stateless MCP + **Google Workspace OAuth 2.1** with OAuth state in **Cloudflare KV** (reuses `createRedisStore` over a tiny KV adapter, since Workers have no filesystem for `createJsonStore`). App built lazily per-request because KV/secrets arrive on `env`. `wrangler.jsonc` + `.dev.vars.example`; README has from-scratch Cloudflare + Google setup. Run with `pnpm -F @silkweave/example-cloudflare dev`. |
### Adapters
| Adapter | Package | File | Transport |
|---------|---------|------|-----------|
| `stdio` | `@silkweave/mcp` | `packages/mcp/src/adapter/stdio.ts` | MCP over stdin/stdout (`StdioServerTransport`) |
| `http` | `@silkweave/mcp` | `packages/mcp/src/adapter/http.ts` | MCP Streamable HTTP (`express`, **stateless** - each `POST /mcp` builds a fresh server via the SDK v2 `createMcpHandler`, served through `toNodeHandler` from `@modelcontextprotocol/node`; protocol 2026-07-28 only incl. `server/discover`, `legacy: true` adds the SDK's stateless 2025-era leg; no `Mcp-Session-Id`, no session map, no `GET`/`DELETE` reconnect) |
| `cliProxy` | `@silkweave/mcp/cli-proxy` | `packages/mcp/src/adapter/cliProxy.ts` | MCP CLI proxy client (`commander` + `StreamableHTTPClientTransport`). Exposed from the dedicated `@silkweave/mcp/cli-proxy` subpath (not the package root) so importing the `stdio`/`http` servers never pulls the CLI client's `commander`, which is an **optional peer dep** of `@silkweave/mcp`. Auth passthrough via `headers` (record or thunk, resolved once per invocation before connecting), `requestInit`, `fetch`, `authProvider` (all forwarded to the transport); a failed connect prints a legible one-liner (auth-specific on 401/403) and root `--help`/`--version` degrade gracefully offline. Renders input fields listed in the tool's `_meta['silkweave/args']` (emitted by `registerTools()` from `Action.args`) as positional arguments in declared order - required (per JSON Schema `required`) as `<arg>`, optional as `[arg]`. Property schemas are normalized first (`normalizeProperty()`): nullable `anyOf`/`oneOf` with one non-null branch or `type: [X, 'null']` collapse to `X` (literal `null` sends `null`); unmappable shapes get a lenient `<value>` option (JSON else raw string) - it never throws, so one odd property cannot break every command. An `isError` tool result sets exit code 1; `toolTimeoutMs` (default 30 min, reset on progress) bounds each call. |
| `fastify` | `@silkweave/fastify` | `packages/fastify/src/adapter/fastify.ts` | REST API with Swagger UI via `@scalar/fastify-api-reference` |
| `trpc` | `@silkweave/trpc` | `packages/trpc/src/adapter/trpc.ts` | Standalone tRPC HTTP server (`@trpc/server/adapters/standalone`) with fully-typed `AppRouter` inference |
| `trpcFetch` | `@silkweave/trpc` | `packages/trpc/src/adapter/fetch.ts` | Fetch-compatible tRPC handler (`@trpc/server/adapters/fetch`) for Astro/Vercel/Cloudflare serverless runtimes |
| `trpcNode` | `@silkweave/trpc` | `packages/trpc/src/adapter/node.ts` | `(req: IncomingMessage, res: ServerResponse) => void` handler for mounting on a host-owned `node:http` server; returns `{ adapter, handler }`, no CORS, ready-gated with a 503 on boot failure |
| `cli` | `@silkweave/cli` | `packages/cli/src/adapter/cli.ts` | CLI via `commander` with plain `console` output |
| `edge` | `@silkweave/edge` | `packages/edge/src/adapter/edge.ts` | Stateless MCP Streamable HTTP: one boot-time SDK v2 `createMcpHandler` (per-request state found by `Request` identity in a `WeakMap`); protocol 2026-07-28 only, `legacy: true` adds the SDK's stateless 2025-era leg |
| `mcp` | `@silkweave/nestjs` | `packages/nestjs/src/adapter/mcp.ts` | Exposes existing NestJS controller routes (`@Mcp` methods, discovered via `DiscoveryService`) as MCP tools mounted on Nest's HTTP server; tool schemas reflected from route + param decorators + swagger/class-validator |
| `trpc` | `@silkweave/nestjs` | `packages/nestjs/src/adapter/trpc.ts` | Exposes `@Trpc`-decorated controller routes as tRPC procedures (httpBatch query/mutation + SSE subscription) mounted on Nest's HTTP server via `createExpressMiddleware`; reuses `@silkweave/trpc`'s `buildRouter`; guards read the real Express request (cookie/header auth) and denials carry `data.httpStatus` |
| `typegen` | `@silkweave/nestjs` | `packages/nestjs/src/adapter/typegen.ts` | Writes the `AppRouter` type (`TRPCBuiltRouter`) for every `@Trpc` procedure on boot, via `@silkweave/typegen`'s `renderTypegen`; gated to `@Trpc` actions only |
| `defineSilkweave` | `@silkweave/nextjs` | `packages/nextjs/src/lib/defineSilkweave.ts` | Next.js App Router. `defineSilkweave({ actions })` returns `app.mcp()` / `app.trpc()`, each building Next route handlers (`{ GET, POST, ... }`) by wrapping `@silkweave/edge` (MCP) and `@silkweave/trpc`'s `trpcFetch` respectively; `typeof app.Router` carries the typed `AppRouter` for the tRPC client |
| `typegen` | `@silkweave/typegen` | `packages/typegen/src/adapter/typegen.ts` | Build-time `.d.ts` generation from action Zod schemas (`allActions: true`) |
MCP adapters (`stdio`, `http`) register actions as MCP tools using `PascalCase` names (via the shared `registerTools()`). **Two integration primitives** let Silkweave drop into an existing app: the **Node** handler `mcpTransport().post` (an Express `RequestHandler`, imported from **`@silkweave/mcp/server`** since 4.0; for express / fastify / NestJS) and the **Web-Standard** handler `edge().handler` (a `(Request) => Response`, for hono / Next.js / Cloudflare / Deno / Bun - express-free). The CLI adapter uses `kebab-case` for commands and maps Zod types to CLI options/arguments; `Action.args` (input-schema keys, in order) marks fields as positional arguments - honored by the local `cli()` adapter directly and carried across the wire to `cliProxy` via the MCP tool's `_meta['silkweave/args']` (emitted by `registerTools()`, spec-legal, ignored by non-silkweave clients). The `typegen` adapter uses `allActions: true` to bypass `isEnabled` filtering and generate types for all registered actions. The `trpc` adapter registers each action as a tRPC procedure at `camelCase(action.name)`, dispatching `action.kind` to `.query()` or `.mutation()`; the exported `InferTrpcRouter<typeof server>` type extracts a fully-typed `AppRouter` for `createTRPCClient<AppRouter>()`.
### MCP SDK v2 (6.0)
Since 6.0 the MCP packages run on the **split MCP SDK v2** (`@modelcontextprotocol/server` + `/client` + `/node` + `/express`, protocol **2026-07-28** incl. `server/discover`); `@modelcontextprotocol/sdk` is gone from every package.
- **One protocol: 2026-07-28 (7.0).** Servers (`http`/`mcpTransport`/`edge`/`stdio`/nestjs `mcp()`) default to `legacy: 'reject'` - a 2025-era client gets the SDK's unsupported-protocol-version error. A `legacy: true` adapter option passes through to the SDK's own legacy support (`'stateless'` on HTTP, `'serve'` on stdio) for hosts not on 2026-07-28 yet (Claude Code negotiates 2026-07-28 by default since ~2.1.3xx but behind remote flags; older versions, v1 SDK clients). **Codex CLI <= 0.167 speaks 2026-07-28 only with `[features] mcp_2026_07_28 = true`** (off by default) - Tobias's decision (2026-11-09): servers stay strict, the Codex homes we run enable the flag. Silkweave keeps **no legacy code of its own** (edge's manual `isLegacyRequest` route was removed in 7.0, so `enableJsonResponse` applies to 2026-07-28 exchanges only). stdio: the factory may run more than once per connection (a discarded `server/discover` probe), so servers are always built inside it.
- **zod shim, consumers stay on `zod ^3.25 || ^4`.** v2 `registerTool` needs `StandardSchemaWithJSON` (`~standard.jsonSchema`, native only from zod 4.2). `standardSchema()` (`packages/mcp/src/util/schema.ts`) wraps each action schema: JSON Schema from `z.toJSONSchema(…, { target, io, unrepresentable: 'any' })`, validation delegated to the schema's own `~standard.validate` (zod semantics - defaults/transforms/coercion - exactly). No consumer zod instance reaches the SDK (which carries its own zod 4 as a regular dependency). The emitted dialect is now draft 2020-12 (v1 emitted draft-07).
- **Tool callback context** is v2's `ServerContext` (the `extra` context key): progress token / `_meta` at `ctx.mcpReq._meta`, notifications via `ctx.mcpReq.notify`, the HTTP request at `ctx.http.req` (a Fetch `Request`; `requestFromExtra()` flattens its headers to a lowercase record for the `request` context key).
- **Clients** (`cliProxy`, the `silkweave` CLI) pin `versionNegotiation: { pin: MCP_PROTOCOL_VERSION }` (`'2026-07-28'`, exported from `@silkweave/mcp/cli-proxy` - the SDK's `LATEST_PROTOCOL_VERSION` is the newest *legacy* revision, don't use it); `legacy: true` / `--legacy` switches to `'auto'`. A pin failure (`SdkError` `ERA_NEGOTIATION_FAILED`) maps to a one-liner pointing at `--legacy`.
- **Slim `serverInfo` on tool results.** The SDK stamps `_meta["io.modelcontextprotocol/serverInfo"]` (full server info incl. `description`) on every 2026-07-28 result; hosts like `@ai-sdk/mcp` hand whole results to the model, repeating the description per call. `registerTools({ serverInfo })` pre-stamps `{ name, version }` on tool results (the SDK never overwrites a handler-set value; only when `ctx.mcpReq.envelope` is set, i.e. 2026-07-28). The full description stays in `server/discover`.
- **Edge DNS-rebinding** (`allowedHosts`/`allowedOrigins`) is checked by silkweave itself with the v1 exact-match semantics (Host incl. port, full origin) - the v2 SDK helpers compare bare hostnames and would silently change existing configs.
- Era coverage: `packages/mcp/src/handlers/eras.test.ts` (Node path) and `packages/edge/src/adapter/eras.test.ts` pin the policy (2025 client rejected by default, served with `legacy: true`, slim serverInfo on modern results only). Raw JSON-RPC suites (`transport.test.ts`, edge `edge.test.ts`/`skills.test.ts`) post 2025-style requests and therefore opt into `legacy: true`; edge's `testUtil.ts` unwraps the legacy leg's SSE into JSON.
### Key Utilities (in @silkweave/core)
- `unwrap()` in `packages/core/src/util/zod.ts` - recursively unwraps Zod wrapper types (optional, nullable, default, readonly) to get the base type and metadata. Used by the CLI adapter for option generation.
- `createLogger()` / `buildLogLevels()` / `createConsoleLogger()` and the `Logger`/`LogLevel` types in `packages/core/src/util/logger.ts` (part of **`@silkweave/core`** - there is no separate logger package; the logger is the shape of the `context.get('logger')` key every action receives, so it belongs to core and adds **zero dependencies**). `createLogger()` is a structured logger that writes JSON lines to a stream (default `process.stdout`, `false` to discard), honoring a `level` threshold and `onLog`/`onProgress` callbacks; it is the default logger forked into the action context by the `stdio`/`http`/`edge` MCP adapters (`stream: false` or `process.stderr`, with `onLog` forwarding to MCP `notifications/message`). `buildLogLevels()` builds a log-level record from a single callback (used by the `fastify`/`trpc` adapters to bridge onto their host loggers).
- `createConsoleLogger()` in the same module - a zero-dependency, human-readable `console`-backed logger (each syslog level mapped to `console.log`/`info`/`warn`/`error`), used by `@silkweave/cli` and `@silkweave/mcp`'s cliProxy for terminal output. There is **no terminal-UI dependency** (`@clack/prompts` was dropped): a core-only consumer (e.g. the `@Mcp()` decorator import path in `@silkweave/nestjs`) pulls neither pino nor clack.
- `isStreamingAction(action)` in `packages/core/src/util/action.ts` - returns `true` when `action.run` is an `async function*`. Every adapter checks this at registration time to branch between buffered and streaming code paths.
- `lintActions(actions)` / `reportActionLint(actions, warn?)` in `packages/core/src/util/lint.ts` - dev-time **ecosystem guardrail** flagging agent-hostile action definitions (missing / throwaway `description`, undescribed input params) - the cheap mistakes that quietly degrade an agent's tool-use. `silkweave().start()` runs `reportActionLint` automatically (warnings to stderr via `console.warn`, safe for stdio); disable with `SilkweaveOptions.lint: false`.
- HTTP routing helpers in `packages/core/src/util/http.ts` - used by `@silkweave/fastify`. `actionMethod(action)` resolves the HTTP verb (`method` ?? `GET` for queries ?? `POST`); `methodHasBody(method)` is `true` for everything except `GET`; `pathParamNames(path)` extracts `:param` names; `validateActionRouting(action)` throws a `SilkweaveError` at registration time if a path placeholder or `queryParams` entry is not a key of the input schema; `resolveActionInput(action, { params, query, body })` merges the three sources (body base layer, then declared query params, then path params) and coerces path/query strings to the primitive each field's schema expects, returning the object to feed `action.input.parse()`.
- `ToolCallEvent` / `OnToolCall` / `emitToolCall()` in `packages/core/src/util/telemetry.ts` - the per-invocation telemetry contract. `emitToolCall()` invokes a hook **fire-and-forget** (never awaited on the call path; sync throws and async rejections logged to stderr and swallowed). MCP adapters accept `onToolCall` (option on `stdio()`/`http()`/`mcpTransport()`/`edge()`) and emit from `registerTools()` **after result formatting**, so MCP events carry `resultBytes` (serialized raw-result size) and `sideloaded` (smart embedded-resource offload); `ok` is false on a thrown error (`errorCode` = `SilkweaveError.code`, else the error's name) or an `isError` tool result. Events carry `args` - the call's input: parsed (post-zod) on success/handler-error events, the **raw offered input** on validation-failure events; **unredacted** (like `context`, which carries the raw request incl. `Authorization`) - consumers must redact/truncate before persisting. **Input-validation failures emit too** (`ok: false`, stable `errorCode: 'INVALID_ARGUMENTS'`, `durationMs: 0`): the SDK rejects an invalid `tools/call` before the handler, so `emitInvalidArguments()` (`packages/mcp/src/handlers/prevalidate.ts`, exported from root + `/tools`) pre-validates **emit-only** in `mcpTransport()`/`edge()` - same schema, `safeParseAsync` on the verbatim `params.arguments`, mirroring the SDK so no call double-emits; the request always proceeds to the SDK's native rejection (wire behavior unchanged, seam survives an SDK major swap; `stdio` not covered). `@silkweave/nestjs` wires telemetry via DI: `forRoot({ telemetry: ClassToken })` (a `SilkweaveTelemetry` provider, resolved via `ModuleRef` lazily at first event) - MCP events come from the registrar seam, tRPC events from the synthesized action wrapper in `ControllerDiscovery` (gated to the `trpc` adapter context; guard denials count as failed calls; `args` = validated per-procedure input, already de-batched) - **exactly one event per call** on either transport. tRPC input-validation failures (which fire before the resolver, so the wrapper never runs) emit from the `trpc()` adapter's `onError` seam via `buildValidationErrorEmitter()` (`packages/nestjs/src/lib/validationTelemetry.ts`, internal), discriminated by `error.cause.name === 'ZodError'` (blind spot: a controller deliberately throwing a raw `ZodError` double-counts).
- `runStreamingAction(action, input, context, onChunk?)` in `packages/core/src/util/streaming.ts` - drives a streaming action's async generator, awaiting `onChunk` for each yielded value before pulling the next (which is how transport-level backpressure - SSE drain, stdout drain, MCP notification ack - flows back to the action). Returns the buffered array of chunks; the buffered fallback is used when a client opts out of streaming (e.g. no MCP `progressToken`, or a `POST` without an SSE/NDJSON `Accept` header in Fastify).
### Streaming
A streaming action declares a `chunk` Zod schema (instead of, or alongside, `output`) and an `async function*` `run`:
```typescript
createAction({
name: 'generate-messages',
description: '...',
input: z.object({ count: z.number() }),
chunk: z.object({ index: z.number(), text: z.string() }),
run: async function* ({ count }, { logger }) {
for (let i = 0; i < count; i += 1) {
yield { index: i, text: `Message ${i}` }
}
}
})
```
Each adapter delivers chunks differently:
| Adapter | Wire format | Trigger | Fallback |
|---------|-------------|---------|----------|
| `stdio()`, `http()`, `edge()` (MCP) | `notifications/progress` with the JSON-stringified chunk in `message` and a 1-based `progress` counter | Client sends `_meta.progressToken` in the tool call | Action runs to completion, chunks are buffered and returned as the final `CallToolResult` |
| `fastify()` (REST) | `text/event-stream` (SSE: `data: <json>\n\n`, terminated by `event: done`) or `application/x-ndjson` (one JSON chunk per line) | `Accept` header matches `text/event-stream` or `application/x-ndjson` | `200 OK` with the buffered chunk array in the response body |
| `trpc()`, `trpcFetch()` | Action is registered as a tRPC `.subscription()` whose async generator yields chunks directly | Streaming action ⇒ always a subscription (regardless of `kind`) | n/a - the consumer iterates the subscription |
| `cli()` | NDJSON on stdout (one JSON chunk per line, backpressure-aware via `stdout.write` + `drain`) | Streaming action ⇒ always streamed | n/a |
**MCP and AI host visibility.** Standard MCP `notifications/progress` puts each chunk on the wire correctly, but what the host client does with those notifications is a host-side choice. Most LLM hosts today (Claude Code, Cursor, generic chat UIs) consume progress notifications for *UI rendering* - spinners, status text, progress bars - while the model still sees only the final aggregated tool result when the call returns. Chunks reach the wire; in-flight model visibility depends on whether the host surfaces them into the model's context. For per-chunk model visibility today, prefer Fastify (SSE/NDJSON) or tRPC subscriptions.
### Resources (binary results)
An action can return a **binary or text artifact** (screenshot PNG, PDF, JSON/markdown artifact, audio) instead of a JSON object. Declare the output with core's `binary()` schema helper and return a `resource()` (or a Web-Standard `File`/`Blob`, or bare `Uint8Array`/`ArrayBuffer` bytes - normalized via `toActionResource()`, with the `binary()` metadata as defaults for what the value doesn't carry):
```typescript
createAction({
name: 'screenshot',
description: 'Capture a screenshot of a URL',
kind: 'query',
input: z.object({ url: z.string() }),
output: binary({ mimeType: 'image/png' }),
run: async ({ url }) => resource(await capture(url), {
mimeType: 'image/png',
name: 'screenshot.png',
description: `Screenshot of ${url}`
})
})
```
Core primitives (`packages/core/src/util/resource.ts`): `resource()` / `ActionResource` (brand-symbol-detected value: `data: Uint8Array | string`, `mimeType`, `name?`, `description?`), `binary(meta)` (a Zod schema over resource-like values carrying `BinarySchemaMeta` defaults, detected via `isBinarySchema()`/`binarySchemaMeta()`), `toActionResource(value, defaults)` (async normalization, `undefined` for non-resource values - adapters call it on every result), `isTextMimeType()` (`text/*`, JSON, XML/SVG, JS, `+json`/`+xml` suffixes), `serializeResource()`/`deserializeResource()` (the `SerializedResource` JSON envelope: `{ kind: 'resource', mimeType, name?, description?, text? | base64? }`), and edge-safe `bytesToBase64`/`base64ToBytes`. Everything is Web-Standard (no Buffer/node imports) so it runs on edge/Workers.
Per-adapter delivery (**embed-only** - no hosted resource links):
| Adapter | Delivery |
|---------|----------|
| `stdio()`, `http()`, `edge()` (MCP) | `resourceToolResult()` maps mime-driven content blocks: `description` first as a `text` block, then raster `image/*` (png/jpeg/gif/webp) ⇒ `image` block (multimodal hosts show it to the model), `audio/*` ⇒ `audio` block, text-based mime ⇒ embedded `resource` with `text`, else embedded `resource` with base64 `blob`. A `toolResult` hook still wins; `_meta.disposition` cannot demote a resource result. `disposition: 'structured'` + `binary()` is rejected at registration (extended `validateActionDisposition()`), as is a `binary()` chunk schema. |
| `fastify()` (REST) | Raw bytes: `Content-Type` from the mime type, `Content-Disposition: inline; filename=` from `name`, `Content-Description` from `description`; OpenAPI response documents `type: string, format: binary` under the declared media type. |
| `cli()` | Piped stdout ⇒ raw bytes (`my-cli screenshot > shot.png`, status/description on stderr); interactive TTY ⇒ written to `--output <path>` (flag added for `binary()` actions) or a file named after the resource. Binary is never dumped onto a TTY. |
| `cliProxy` | Decodes `image`/`audio` blocks and non-text `blob` resources from the tool result; same pipe/`--output`/TTY behavior (`--output` added to every proxied command unless the schema claims the name). Text resources (incl. smart offloads) keep printing as text. |
| `trpc()`, `trpcFetch()` | JSON-only wire ⇒ the result ships as its `SerializedResource` envelope; `InferTrpcRouter` maps resource-like run return types to `SerializedResource` at the type level. |
| `typegen` | `binary()` outputs emit the `SerializedResource` interface literal (also in the generated `AppRouter`). |
Telemetry: a resource result's `resultBytes` is the payload byte length (not a JSON stringification) and `sideloaded` stays `false` (deliberate resources are not smart offloads). Streaming + resources don't mix in v1 (no binary chunk schemas).
> **NestJS:** `@Mcp({ resource })` / `@Trpc({ resource })` (`ResourceMetadata`: `mimeType?`/`name?`/`description?`) declare a controller route's result a resource; without the option, a reflected non-JSON `@Header('Content-Type', ...)` flips the route automatically (JSON Content-Types don't; never auto-applied to `async *` routes). The synthesized action gets `output: binary(meta)`; returned `StreamableFile`s are collected to bytes (`normalizeControllerResult()` in `packages/nestjs/src/lib/resource.ts` - explicit `type`/`disposition` options win as a named `resource()`, otherwise bare bytes let the declared defaults apply). Incompatible with `result: 'structured'` and with streaming routes (boot errors); `resource` wins over `output`.
### Skills (serving Agent Skills over MCP)
The MCP adapters can serve [Agent Skills](https://agentskills.io/specification) (`SKILL.md` directories) - versioned, digest-verified, installable via `npx silkweave skills sync`. Design tracks the MCP Skills-over-MCP working group's SEP-2640 resources baseline (full research + decisions: `plans/skills-over-mcp-exploration.md`); the draft extension methods are additionally available behind an **experimental opt-in flag** (see the SEP-2640 bullet below).
```typescript
import { defineSkill, silkweave } from '@silkweave/core'
import { http } from '@silkweave/mcp/server'
silkweave({ name: 'team-skills', version: '1.0.0', description: '...' })
.adapter(http({ port: 8080, host: 'localhost', skills: [defineSkill({ dir: './skills/commit-message' })] }))
.start()
```
- **Core seam** (`packages/core/src/util/skill.ts`): `Skill`/`SkillFile`/`SkillDefinition` types + `defineSkill()` (sync capture; exactly one of `dir` or inline `files` - the latter for filesystem-less runtimes) + `isResolvedSkill()`. Zero runtime beyond validation, kept in core so adapters need no type dependency on `@silkweave/skills`.
- **Resolution** (`packages/skills/src/util/resolve.ts`): `resolveSkills()` loads `dir` (lazy `node:fs` import - the package root stays edge-importable), parses frontmatter (`yaml`), validates the spec (name shape/length, dir-name match with `defineSkill({ name })` as the escape hatch, description 1-1024), computes per-file + aggregate `sha256:<hex>` digests (Web Crypto), rejects traversal paths (`assertSafeSkillPath`) and duplicate names. Version = `defineSkill({ version })` ?? frontmatter `metadata.version` (the spec has no first-class version field).
- **Serving** - a `skills` option on `stdio()`/`http()`/`mcpTransport()`/`edge()`, prepared once at start by `prepareSkills()` (`packages/mcp/src/handlers/skills.ts`, exported from root + `/tools`; lazy-imports `@silkweave/skills`, an **optional peer** - a legible `missing_dependency` error otherwise). Three surfaces per request: (1) **`skill://<name>/<path>` resources** via `registerSkillResources()` (`@silkweave/skills/mcp`) - the SEP-2640 baseline, plain `resources/list`/`read`; (2) **`ListSkills`/`GetSkill` tools** (`skillActions()`) - ordinary actions (tagged `silkweave/skills`) returning the digest manifest / full payload (text as `text`, binary as `base64`), so filters/auth/telemetry apply and any current client can install; (3) **server `instructions`** (`skillInstructions()`) announcing the skills (the WG-validated activation pattern - previously no adapter passed `instructions` at all). **Filter gating:** when `filterActions` drops the skill actions for a request, `SkillServing.visible()` also hides the resources + instructions - a filter that hides the tools never leaks the files via `resources/read`. `mcpTransport()` now returns `{ post, ready }`; `http()` awaits `ready` so a bad skill fails at boot, per-request consumers (nestjs `mcp()`) surface it as a 500.
- **CLI client** (`packages/silkweave/src/lib/skills/`): `sync` diffs the remote manifest against the target's `.silkweave-lock.json` (`diffSkills()`: `missing`/`outdated`/`up-to-date`/`held`/`orphaned`), installs under `<target>/<name>/` with every file re-verified against its digest and name/path revalidated client-side (a hostile server cannot escape the target), removes files a previous version tracked that the new one dropped, `--prune` removes orphans. `pin`/`unpin` hold a skill (`held` diffs are skipped; explicit `install` of a held skill errors). One server per target/lockfile (mismatch warns). Default target `~/.claude/skills`.
- **Wire contract** (`packages/skills/src/util/manifest.ts`): `SkillManifestEntry` (metadata + per-file digests - mirrors SEP-2640's listing shape) from `ListSkills`; `SkillPayload` (files with `text`/`base64`) from `GetSkill`; the CLI checks `payload.digest === manifest.digest` to catch a server changing mid-sync.
- **SEP-2640 extension (EXPERIMENTAL, opt-in)** - `skillsExtension: true` on `stdio()`/`http()`/`mcpTransport()`/`edge()` additionally serves the draft extension: `capabilities.extensions['io.modelcontextprotocol/skills']` (`{ directoryRead: false }` - `resources/directory/read` not implemented) + `skills/list` (entries `{ uri: skill://<name>/SKILL.md, frontmatter, resources: [{uri, digest}] }`) + `skills/get` by URI (`registerSkillExtension()` in `@silkweave/skills/mcp`; entry shape + `aggregateDigest()` in `packages/skills/src/util/extension.ts`). Gated by the same `visible()` filter rule as the rest of the surface. Off by default because the draft churns (the `skill://index.json` design was replaced by these methods in July 2026) - shapes may change with the draft. The **CLI is extension-aware**: `skillSource()` (`packages/silkweave/src/lib/skills/client.ts`) prefers `skills/list` + `resources/read` when the server declares the capability - so `silkweave skills sync` installs from ANY SEP-2640 server (e.g. Hugging Face MCP), not just silkweave ones; foreign listings get an aggregate digest computed client-side with the same canonical formula (`aggregateDigest` sorts SKILL.md-first internally), so lockfile identities are stable across both consumption paths. Tool path remains the fallback.
- **Claude Code marketplace bridge (Phase 3)** - public skills get the native `/plugin` experience (real versioning, `/plugin update`) via npm, no repo exposure, no registry of ours. **Pack:** `silkweave skills pack <dir...>` wraps skill dir(s) into a skills-only plugin package ready for `npm publish` - `package.json` + `.claude-plugin/plugin.json` + `skills/<name>/...` (`pluginFiles()` in `packages/skills/src/util/marketplace.ts` is the pure layout; `packSkill()` in `packages/silkweave/src/lib/skills/pack.ts` does fs + the registry check). Multiple dirs pack into ONE **multi-skill plugin** (requires `--package`; plugin name = package basename via `pluginNameForPackage()`; all skills must agree on a version). Version comes from frontmatter `metadata.version` (required); package name = `--package` ?? `metadata.npmPackage` ?? `<name>-skill`; packing **refuses an already-published version** (registry lookup; `--force` overrides, `--registry` for private registries, unreachable registry warns and proceeds); the out dir (default `dist/<name>-plugin`) is only overwritten when it is a previous pack output. **Serve:** the `skillsMarketplace` option on `http()`/`edge()`/`mcpTransport()` (`{ name?, owner, description?, registry? }`, name defaults to the server name) answers `GET /.claude-plugin/marketplace.json` (`MARKETPLACE_PATH`) with npm-sourced plugin entries for every served skill carrying an `npmPackage` (`defineSkill({ npmPackage })` ?? frontmatter `metadata.npmPackage`, threaded onto `Skill` by `resolveSkill`; document built by `marketplaceJson()`, pre-serialized onto `SkillServing.marketplace`; skills sharing an `npmPackage` collapse into one multi-skill plugin entry named `pluginNameForPackage()`). The published demo: `@silkweave/example-plugin` bundles both `examples/skills` skills. Served **unauthenticated** (like `/.well-known/` - it only points at already-public npm packages; npm sources are the only kind emitted since a URL-added marketplace can't resolve relative paths). Boot-errors when `skills` is unset or no served skill is npm-published. Consumers: `/plugin marketplace add https://<host>/.claude-plugin/marketplace.json`.
- Digests bind approval to content (supporting-file rotation is caught); they are **not signatures** - skills are code, install only from trusted servers.
### Multi-resource OAuth (one AS, N protected resources)
A server fronting N tenants needs a distinct URL per tenant (Claude clients dedupe MCP connectors **by URL**), and each URL must be its own OAuth **protected resource** with its own token audience - so a token minted for tenant A is rejected when replayed at tenant B. There is still exactly one authorization server.
- **Resource-server side** - `AuthConfig.resourceUrl` accepts a `ResourceResolver` (`(ResourceRequest, SilkweaveContext) => string | undefined`) in addition to a `string`. `packages/auth/src/resolve.ts` holds the primitives: `toResourceRequest()` (normalizes the adapter-forked `request` - Fetch `Request` / Express / Fastify / bare `IncomingMessage` - into `{ url, headers }`), `resolveResourceUrl()`, `normalizeResourceUri()` (RFC 8707 §2 canonical form: lowercased scheme/host, default port stripped, no fragment, bare-origin trailing slash stripped - the classic bug, since SDKs ship `new URL(resource)` stringifications), `protectedResourceMetadataUrl()` (RFC 9728 **path-insertion** form), and `pathResolver({ origin, match })` - the safe combinator that builds identifiers from a **configured** origin, never the request's, so a spoofed `Host` cannot steer the advertised metadata URL or the expected audience. The resolver is deliberately **synchronous** (shape/identity mapping); async policy belongs in the AS's `allowedResources`. `validateToken` resolves once per call, returns the resolved `resource` on `ValidateResult`, and derives `expectedAudience` from it. **Challenge-URL asymmetry is deliberate**: `string` configs keep the historical **append** form (load-bearing for the nestjs basePath mount, and identical to insertion for a path-less resource); resolver-resolved resources get RFC 9728's **insertion** form, which is what the SDK probes. A resolver returning `undefined` behaves exactly as an unset `resourceUrl` (challenge without `resource_metadata`, no *default* audience check; an explicit `audience` still applies) - fail-open, deliberately, because adapters guard non-resource routes too (sideload `/resource/:id`) and failing closed would 401 every sideload fetch.
- **Per-request metadata** - `resolveProtectedResourceMetadata(auth, request, context)` (`packages/auth/src/metadata.ts`) strips the well-known prefix and replays the remainder through the resolver as the as-if resource request, so a resolver never learns the prefix exists; it compares **pathnames** (proxy-robust, no `trust proxy` dependency) and returns `undefined` -> 404 for an unknown sub-resource or a resolver whose answer addresses a different path.
- **Authorization server** - `OAuthProxyConfig.allowedResources?: string[] | ((resource: string) => boolean | Promise<boolean>)` bounds the reachable `aud` space; the RFC 8707 indicator is threaded authorize -> `PendingAuthData.resource` -> `AuthCodeData.resource` -> mint, and persisted on `RefreshTokenData.resource` so rotation preserves the audience (a mismatched `resource` on refresh is `invalid_target` - a refresh token must not be a lateral move). The allow-list is re-checked **at redemption**, not only at `/authorize`, so omitting the indicator on the authorize leg cannot smuggle one in at `/token`. Exactly **one** resource per grant (repeats rejected); `body.resource` that is not a single string is rejected rather than stringified (Express `urlencoded` parses repeats to arrays behind the declared `Record<string, string>`). `iss` stays `config.resourceUrl` - the AS identity does not fragment.
- **`verifyToken`** - without `allowedResources` the historical double pin (`issuer` + `audience`) is kept **verbatim**. With it, the audience pin is replaced by an issuer-only pin plus a manual check that is deliberately *stricter*: `jose`'s `audience` option **requires the claim to be present**, so dropping it without a shape check would accept an `aud`-less token, which `validateToken` treats as a pass. The check therefore requires a **single non-empty string** `aud` (rejecting missing, empty, and array forms - the proxy never mints arrays) and then membership. The membership check is not defence against key compromise (an attacker with the key mints allowed audiences trivially); its value is **revocation** and misconfig containment. Returning `aud`/`iss` in the `AuthInfo` is the load-bearing part - it turns `validateToken`'s per-request audience binding from vacuous (today, for proxy tokens, the binding lives only inside `jwtVerify`) into the actual confused-deputy defence.
- **`aud` is not membership** - the AS mints `aud: …/tenantA` for any authenticated upstream user who asks, including one with no rights in A. `resource` is where a token may be *presented*, not what its subject may *do* there; per-tenant authorization stays the resource server's job on every call. `ValidateResult.resource` exists so that check does not have to re-parse the request URL.
- **Adapters** - the well-known mount becomes a wildcard when `resourceUrl` is a resolver: Express 5 optional-splat (`'/.well-known/oauth-protected-resource{/*resource}'`) in `http()` and nestjs, a prefix match on `validPaths` in `edge()`, and a `'/*'` route in `fastify()`. **nestjs additionally root-mounts the RFC 8414 insertion-form AS metadata** (`/.well-known/oauth-authorization-server${basePath}`) alongside its basePath append-form route: the SDK probes insertion form for a path'd issuer and never the append form, and on discovery failure falls back to root-absolute `/authorize` + `/register`, so without this AS discovery simply fails.
- Tests: `packages/auth/src/resolve.test.ts`, `metadata.test.ts`, the resolver suite in `validate.test.ts`, and `provider/proxy.test.ts` (**new** - the proxy previously had no tests; drives the full authorize -> callback -> token -> refresh -> verify flow against a stubbed upstream).
### REST routing
The `@silkweave/fastify` REST adapter maps a single input schema across the HTTP request's path, query string, and body using three optional action fields:
```typescript
createAction({
name: 'list.users',
kind: 'query', // ⇒ method defaults to GET
method: 'GET', // explicit verb (overrides the kind default)
path: 'spaces/:spaceId/users', // :spaceId resolved from the URL path
queryParams: ['offset', 'limit'], // read from ?offset=&limit= instead of the body
input: z.object({
spaceId: z.string(), // ← path param
offset: z.int().optional().default(0), // ← query param (coerced + defaulted)
limit: z.int().optional().default(10) // ← query param
}),
run: async ({ spaceId, offset, limit }) => { /* ... */ }
})
```
- **`method`** - `'GET' | 'POST' | 'PUT' | 'DELETE'`. Defaults to `POST`, or `GET` when `kind: 'query'`. An explicit `method` always wins.
- **`path`** - route template joined to the adapter's base (Fastify mounts at `/`). `:param` placeholders are matched from the URL path. When unset, the path is derived from `name` (Fastify uses the name verbatim).
- **`queryParams`** - input fields sourced from the query string on body-carrying methods. On a bodyless `GET`, *every* non-path input field is read from the query string automatically.
Field-source precedence when merging: body (base) → declared query params → path params (highest). Path/query values arrive as strings and are coerced to the schema's primitive (number/boolean/bigint). Fastify validates the result via per-source JSON Schema (AJV) route schemas (and surfaces failures as `400 validation_error`). Misconfigured routing (a `:param` or `queryParams` entry absent from the input schema) throws at registration via `validateActionRouting()`.
> **NestJS note:** `@silkweave/nestjs` does **not** use these core routing fields. It goes the other direction - reflecting a controller method's existing `@Get`/`@Post` + `@Param`/`@Query`/`@Body` decorators into a tool input schema (see [NestJS Utilities](#nestjs-utilities-in-silkweavenestjs)).
### Node host mounting + cookie identity (in @silkweave/trpc)
Silkweave's adapters came in two shapes - bind a port (`trpc`/`fastify`/`http`) or a Web-Standard `(Request) => Response` (`trpcFetch`/`edge`/`nextjs`). The third shape is the one a real Node app has: a `node:http` server that already exists, owned by something else, onto which silkweave mounts as one route among many.
- **`trpcNode(options)`** (`packages/trpc/src/adapter/node.ts`) returns `{ adapter, handler }` where `handler` is `(req: IncomingMessage, res: ServerResponse) => void`. **Why it matters beyond convenience**: an app whose browser talks tRPC and whose agent talks MCP usually needs both on **one origin**, because a browser cannot put an `Authorization` header on a WebSocket upgrade - the only credential a tab can present on an attach is a per-origin cookie. Binding another port breaks the app's own login. **No silkweave-owned Node<->Fetch bridge**: tRPC's `createHTTPHandler` already *is* one (`incomingMessageToRequest` + `writeResponse` around the same `resolveResponse` core the fetch adapter uses), so SSE flushing/backpressure/abort propagation stay upstream-maintained; the MCP SDK's `handleRequest` likewise takes plain `IncomingMessage`/`ServerResponse`. Not a pure lift of `trpc()`: a mountable handler must exist at *factory* time, so it carries the deferred-handler + ready-gate + 503 wiring `trpcFetch` has and `trpc()` lacks, while `trpc()`'s own envelope (its 404 for non-endpoint URLs, its CORS/OPTIONS wrapper) deliberately stays out - a mounted handler does not own the response headers of the server it is mounted on. Sharp edge, documented: the underlying handler **does not verify its prefix** (it slices `basePath` unconditionally), so the host must route only matching URLs into it.
- **`authenticate?: Authenticate<Req>`** on all three tRPC adapters (`packages/trpc/src/lib/createContext.ts`, chain in `resolveIdentity()`) - `auth` reads exactly one credential (a bearer token), which made cookie auth unreachable. The chain: resolver first; `null` falls through to the bearer path (so one endpoint serves a cookie-bearing browser *and* a token-bearing agent); with no `auth` configured a decline is a 401 **without** a `WWW-Authenticate` challenge (advertising OAuth discovery on a cookie endpoint misleads); a throw is mapped via `mapError` because `@trpc/server` swallows `createContext` throws and a raw one surfaces as an opaque 500. The returned `AuthInfo` lands on the **same `auth` context key** the MCP adapters fork (`transport.ts`, `edge.ts`) - that identity is the whole feature, and a test pins the parity. **It bypasses every `validateToken` check** (expiry/issuer/audience/scopes) because what it validates is not a token, and it inherits a browser CSRF threat model bearer endpoints are structurally immune to (`kind: 'query'` runs on GET; `SameSite=Lax` rides top-level GET navigations) - hence the resolver receives the whole request, so hosts can check `Origin`.
- **Deliberately declined**: a lint rule for "a model sends `''` for an omitted optional string". The premise is real, but the linter cannot see what makes it a bug - that *absence is load-bearing* (a create-vs-update discriminator) - so it would be noisy against overwhelmingly legitimate `z.string().optional()` uses and erode trust in a default-on linter. Note the decline is *strengthened* by the fix below: a linter keyed on `z.string().optional()` would flag the schema and still miss the bug, because **the bug is the tool's shape, not its schema**.
- **Action-authoring guidance for the same failure** (the first line of defence is design, not validation): **do not make an operation depend on a field's absence.** If omitting `id` means "create" and supplying it means "update", make that an explicit discriminator (`mode: z.enum(['create', 'update'])`) - a model that sends `''`, or `' '`, or a provider that strips optionality from the schema before the model ever sees it, then produces a *validation error* rather than silently taking the wrong branch. Schema tightening is the **second** line of defence and is genuinely partial: `z.string().min(1).optional()` rejects `''` but **accepts `' '`** (length 1), `z.string().trim().min(1).optional()` rejects both (verified against zod v4), and neither can help when optionality never reaches the model.
### Vercel AI SDK Integration (in @silkweave/ai)
`@silkweave/ai` bridges Vercel AI SDK's `useChat` hook to a Silkweave streaming action over tRPC subscriptions - skipping AI SDK's Data Stream Protocol entirely. The chunks `useChat` consumes are plain JS objects, so a custom `ChatTransport` doesn't need to emit the prefix-coded wire format; it just needs to produce a `ReadableStream<UIMessageChunk>` from whatever transport you choose.
- `createChatAction({ model, system?, tools?, ... })` in `packages/ai/src/chatAction.ts` - server-side helper. Wraps `streamText()` from `ai` in a Silkweave streaming action; `chunk` schema is `z.custom<UIMessageChunk>()` and `run` is an async generator that yields chunks from `result.toUIMessageStream()`. Combined with the tRPC adapter, this automatically registers as a `.subscription()` procedure.
- `silkweaveTransport(subscribe)` in `packages/ai/src/transport.ts` - client-side `ChatTransport` factory. Wraps a subscribe-style function (typically `client.chat.subscribe`) into a `ReadableStream<UIMessageChunk>` that `useChat` consumes directly. Abort signals propagate to `unsubscribe()`. `reconnectToStream` returns `null` - stream resume after disconnect is intentionally unsupported (would require server-side state we don't manage).
- `onData` is typed as `unknown` at the callback boundary because Zod's `z.custom<UIMessageChunk>()` doesn't preserve the exact union variance through tRPC's subscription type inference (`input?: unknown` vs `input: unknown`). Runtime is safe because the server only yields valid chunks; the cast lives in the transport.
- `examples/ai/` is the canonical end-to-end example: Vite + React + `useChat` → custom transport → tRPC subscription → `createChatAction` → Anthropic's Claude via `@ai-sdk/anthropic`. Server loads `ANTHROPIC_API_KEY` from `examples/ai/.env` via `dotenv`.
### tRPC Utilities (in @silkweave/trpc)
- `InferTrpcRouter<S>` in `packages/trpc/src/lib/inferRouter.ts` - type helper that extracts a `TRPCBuiltRouter` type from a `Silkweave<Actions>` instance. Maps each action to a `TRPCQueryProcedure` or `TRPCMutationProcedure` keyed by `camelCase(action.name)`, with input/output types inferred from the Zod schemas and the `run()` return type. The `Silkweave<Actions>` generic preserves literal action names and kinds through `.action()` calls, so `typeof server` carries the router shape.
- `buildRouter(actions)` in `packages/trpc/src/lib/buildRouter.ts` - runtime counterpart to `InferTrpcRouter`. Builds the tRPC router from an `Action[]` array using `initTRPC.context<TrpcHandlerContext>().create()`. Shared by both `trpc()` (standalone HTTP) and `trpcFetch()` (fetch handler).
- `createActionLogger()` / `resolveAuth()` in `packages/trpc/src/lib/createContext.ts` - shared helpers for the per-request silkweave context (logger injection and optional bearer-token validation). Used by both tRPC adapters.
- `trpcFetch(options?)` in `packages/trpc/src/adapter/fetch.ts` - returns `{ adapter, handler, GET, POST }` for Web Standard runtimes (Astro, Vercel serverless, Cloudflare Workers). The internal `_ready` promise gates the handler until `server.start()` has built the router, guarding against cold-start races. CORS must be handled by the host framework.
- `mapError(error)` in `packages/trpc/src/lib/errors.ts` - converts `SilkweaveError` (via `statusCode`), `ZodError` (to `BAD_REQUEST`), or any other thrown value to a `TRPCError` with the appropriate code.
### MCP Result Utilities (in @silkweave/mcp)
- `smartToolResult()` in `packages/mcp/src/util/result.ts` - opt-in response formatter (`disposition: 'smart'`; was the default before 3.2). Responses ≤ 4096 chars are returned as `TextContent` JSON; larger payloads are automatically split into a short text summary + base64 embedded resource to reduce LLM context bloat.
- `jsonToolResult()` / `errorToolResult()` / `handleToolError()` in `packages/mcp/src/util/result.ts` - lower-level helpers for constructing `CallToolResult` objects (`jsonToolResult` is the default formatter since 3.2). Used internally by all MCP adapters and available for custom `toolResult` hooks.
- `structuredToolResult()` in `packages/mcp/src/util/result.ts` - `structuredContent` + JSON text mirror for `disposition: 'structured'` actions. Callers must pass the output-schema-**parsed** data (zod strips extra fields), never the raw result - that is what keeps the SDK's client-side JSON-Schema validation (`additionalProperties: false`) passing by construction; a genuine mismatch degrades to an `isError` tool result naming the failing fields (exempt from SDK output validation) instead of a protocol error.
- `buildMcpExpressApp()` in `packages/mcp/src/adapter/http.ts` + `mcpTransport()` in `packages/mcp/src/handlers/transport.ts` - `buildMcpExpressApp()` wires the full Express app (MCP transport + OAuth + sideload + well-known routes) for the server-owning `http()` adapter; `mcpTransport()` returns the stateless `POST /mcp` handler and is the primitive **shared** by `http()` and `@silkweave/nestjs`'s `mcp()` (which mounts it, plus `oauthRoutes()`/`protectedResourceMetadata()`, onto Nest's HTTP server).
- `filterActions` per-request tool filtering (`packages/mcp/src/handlers/filter.ts`, option on `http()`/`mcpTransport()`/`edge()`/nestjs `mcp()`) - `(actions, request) => Action[] | Promise<Action[]>` runs before `registerTools()` on every POST (the stateless transport recomputes the tool list per request, so permission changes apply on the next `tools/list`; applies to `tools/call` too, so a client that cached a wider list is still denied). The `FilterRequest` stand-in is `{ headers, url, method, toolName? }` - `method` is the JSON-RPC method extracted by `rpcInfo()` (lets the filter skip lookups on `initialize`/`ping` and doubles as an observability tap), `toolName` is `params.name` on `tools/call`. JSON-RPC **batches are rejected** by both stateless transports (`http()` and `edge()`) with a `400` before the filter runs (batching was removed from the MCP spec, and a batch would otherwise let a later message bypass a filter keyed on the first), so `method`/`toolName` always reflect a single message. **Error semantics** (`filterErrorResponse()`): a thrown `SilkweaveError` propagates as its `statusCode` with a JSON-RPC error body, anything else maps to 500 - a throw NEVER degrades to an empty tool list. Exported from both the package root and the express-free `@silkweave/mcp/tools` subpath (edge imports it from there).
- `registerTools()` in `packages/mcp/src/handlers/registerTools.ts` - **shared by all MCP transports** (`stdio`, `http`, `edge`). Forks the per-tool-call action context with `logger`, `extra` (the SDK tool context - v2 `ServerContext` since 6.0, was v1 `RequestHandlerExtra`), and a `request` key; the resolved `auth` is inherited from the context the transport passes in. **(4.0)** the `http()` path forks the caller's `auth` into a per-request context (`authFromRequest()` reads what `authMiddleware` attached to the Express request) rather than using `AsyncLocalStorage`, so the `/tools` subpath and `@silkweave/edge` no longer import `node:async_hooks`. The `request` is a `{ headers, url, params, query, body }` stand-in built from the SDK context's HTTP request (`ctx.http.req`, headers flattened to a lowercase record; `requestFromExtra()`), surfacing the inbound tool-call HTTP headers under the same context key REST/tRPC populate - this is what lets `@silkweave/nestjs` `@UseGuards` guards read request headers over MCP. There are no path `params`/`query`/`body` on an MCP call, so those start empty (the `@silkweave/nestjs` guard layer later fills `params` from the reflected path bindings - see NestJS Utilities below). A `logStream` option keeps `stdio` logging to `false` (stdout is the protocol channel) while HTTP transports use `process.stderr`; the result is formatted by the action's `toolResult` hook or the resolved `disposition` (`_meta.disposition` > `action.disposition` > `smart`).
### NestJS Utilities (in @silkweave/nestjs)
The `@silkweave/nestjs` model is **additive controller reflection**: add `@Mcp()` (MCP) or `@Trpc()` (tRPC) to an existing controller route and it becomes a tool / procedure. Nothing is re-declared - the name/description/input are reflected from metadata the method already carries. The discovery/reflection core is transport-neutral; both decorators share it, and a method can carry both (yielding two gated actions).
- `@Mcp(options)` (`packages/nestjs/src/decorator/mcp.ts`) - method decorator marking a controller route for MCP exposure. Options (all optional): `name`, `description`, `input` (a Zod override merged over reflected fields - a raw shape `{ field: z.ZodType }` **or** a whole `z.object({ ... })`, whose `.shape` is duck-typed-unwrapped via `inputShape()`; it *adds to* reflected fields, never replaces them), `pipes: 'apply' | 'skip'`, `result: 'json' | 'smart' | 'structured'` (MCP result format - sets the synthesized action's `disposition`; a client `_meta.disposition` overrides `'json'`/`'smart'` but never `'structured'`), `output` (explicit output schema backing `result: 'structured'` - Zod type, DTO class, or raw shape; wins over `@Trpc({ output })`; `'structured'` **boot-errors without an explicit output** - reflected `@ApiOkResponse` schemas are deliberately rejected as structured contracts), `annotations` (MCP `ToolAnnotations` merged over verb-derived defaults: `@Get` ⇒ read-only + idempotent, `@Put` ⇒ idempotent, `@Delete` ⇒ destructive + idempotent, else `{ readOnlyHint: false }`), `tags` (free-form grouping labels carried on the synthesized action for the `mcp({ filterActions })` per-request filter), `args` (input fields exposed as CLI positional arguments, in order, by `cliProxy` via the tool's `_meta['silkweave/args']`; each entry must be a reflected or `input`-overridden field - boot-validated). Stored via `SetMetadata(MCP_METADATA, ...)`. A module-wide default is also available via `SilkweaveModuleOptions.defaultResult` (`'json' | 'smart'` - `'structured'` is deliberately per-method only); precedence is **client `_meta.disposition` > `@Mcp({ result })` > module `defaultResult` > `'json'`** (was `'smart'` before 3.2; threaded through `ControllerDiscovery.discover()` → each action's `disposition`).
- `@Trpc(options)` (`packages/nestjs/src/decorator/trpc.ts`) - the tRPC sibling, stored via `SetMetadata(TRPC_METADATA, ...)`. Options (all optional): `name`, `description`, `input` (same as `@Mcp`), plus tRPC-specific ones - `output` (a Zod type, DTO class, or raw shape driving the generated procedure's output type; wins over `@ApiOkResponse` reflection), `chunk` (a Zod type or DTO class typing a subscription's `async *` stream), `kind` (`'query' | 'mutation' | 'subscription'`; inferred from verb (`@Get`⇒query, else mutation) or `async *` body (⇒subscription) when unset), `pipes`. Works **without** an HTTP-verb decorator (so a verb-less route is served over tRPC/MCP but not mapped as public REST). Procedure keys are `camelCase(`${ControllerBase}.${MethodName}`)` (e.g. `Users.listBySpace` → `usersListBySpace`).
- `ControllerDiscovery` (`packages/nestjs/src/lib/controllerDiscovery.ts`) - walks every provider **and controller** via `DiscoveryService` + `MetadataScanner`, finds methods carrying `@Mcp` and/or `@Trpc`, and `reflect()`s the shared input/bindings/guards **once** per method, then emits a synthesized core `Action` per decorator present. `reflect()` also `logger.warn`s (via a `new Logger('Silkweave')`) any **unreflectable whole-DTO param** - a `@Body()`/`@Query()` that reflected to zero fields, e.g. an intersection/union TypeScript erases to `Object`/`Array` under `design:type` (the silent-drop footgun; a `({ input })` override does not recover the dropped fields). `mcpAction()` gates `isEnabled` to the `mcp` adapter; `trpcAction()` gates to `trpc`/`typegen`, infers `kind`, resolves `output` (`@Trpc({ output })` → `@ApiOkResponse` reflection via `reflectResponseSchema`/`reflectDtoSchema`) and warns (`outputDegradedFields()` over `reflectResponseFields()` + `unreflectedFields()`) when a **reflected** output field degrades to `unknown`/`unknown[]` (nested DTO / `Dto[]`); it converts thrown Nest `HttpException`s to `SilkweaveError` (so `@silkweave/trpc`'s `mapError` yields the right `TRPCError` code + `data.httpStatus`). An `async *` method becomes a streaming action (`run` is an `async function*` that applies guards then yields the method's chunks) for both targets - tRPC registers it as a `.subscription()`, MCP buffers/streams via progress.
- `reflect/response.ts` (`packages/nestjs/src/lib/reflect/response.ts`) - `reflectResponseSchema(method)` reads `@nestjs/swagger`'s `swagger/apiResponse` metadata (via the shared `successEntry()`), picks the 2xx entry's `type`, and flattens that DTO into a Zod object (array-wrapped when `isArray`); `reflectResponseFields(method)` returns the same DTO's `FieldDesc` map (no schema) so `trpcAction` can flag degraded fields; `reflectDtoSchema(dtoType)` reflects any DTO class via `reflectDtoFields`. Reflection is **one level deep** - nested DTOs / `Dto[]` degrade to `unknown`/`unknown[]` (detected by `schema.ts`'s `unreflectedFields()` and warned at boot); supply `@Trpc({ output })` a Zod schema for a precise nested shape.
- **Entry split + optional peers** (footprint) - the package has **subpath exports** so importing the root (`@silkweave/nestjs`: module + `@Mcp`/`@Trpc` decorators + discovery/reflection) pulls only `@silkweave/core` + zod (no MCP SDK, no `@trpc/server`). Each adapter is its own entry/subpath: `@silkweave/nestjs/mcp` (`mcp()`), `@silkweave/nestjs/trpc` (`trpc()`), `@silkweave/nestjs/typegen` (`typegen()`), built as separate tsdown entries (`src/{index,mcp,trpc,typegen}.ts`) that code-split the shared core. `@silkweave/mcp`, `@silkweave/trpc`, `@silkweave/typegen`, and `@trpc/server` are **optional `peerDependencies`** (like `@nestjs/swagger`/`class-validator`), so an MCP-only app never installs the tRPC stack and vice versa. `rxjs` is a (required) peer too - it must be externalized, not bundled, or guards' `isObservable()`/`instanceof` checks would break against the host's rxjs. The `typegen()` adapter additionally **lazy-`import()`s** `@silkweave/typegen` inside `register()` so the TS compiler loads only when that adapter runs.
- Reflection core (`packages/nestjs/src/lib/reflect/`) - `route.ts` composes the controller prefix + method path + verb (Nest `PATH_METADATA`/`METHOD_METADATA`) into a `:param`/`{param}` route + path-param list; `params.ts` reads `ROUTE_ARGS_METADATA` (`@Param`/`@Query`/`@Body`/...) into per-argument slots; `schema.ts` is the converter hub - a transport-neutral `FieldDesc` intermediate, a `fieldToZod()` builder (honors a `nullable` `FieldDesc` flag - sourced from `@ApiProperty({ nullable: true })` / OpenAPI `nullable` - via `.nullable()`; `class-validator` has no nullable signal so `@IsOptional` stays optional-not-nullable), per-source mappers (swagger `@ApiParam`/`@ApiProperty`, class-validator, `design:type`, OpenAPI schema), `reflectDtoFields()` for whole-DTO params, and `unreflectedFields()` (names of fields that fell back to `unknown`); `swagger.ts` reads operation-level `@ApiOperation`/`@ApiParam`/`@ApiQuery`; `openapi.ts` ingests an optional OpenAPI document (matched by verb + path, `$ref`-resolving); `classValidator.ts` lazily `createRequire`s the optional `class-validator` peer (from this package or the app cwd) and reads its metadata storage (built-ins record identity in `meta.name`, e.g. `minLength`, `isString`; `@IsOptional` in `meta.type`). Per-field merge precedence: `design:type` < class-validator < swagger decorators < OpenAPI doc < `@Mcp({ input })`.
- `rebind.ts` (`packages/nestjs/src/lib/rebind.ts`) - `invokeRebound()` reconstructs the method's positional args from the flat tool input per the discovery-time `Binding[]` plan (scalar field, whole-DTO object, path-params object, or `@Req`/`@Headers`/`@Ip` sourced from the request stand-in, plus `@Res` sourced from the forked `response`), runs parameter-bound pipes (unless `pipes: 'skip'`), then `method.apply(instance, args)`. `@Res()` resolves to the **real Express response over tRPC** (the `trpc()` adapter forks `response: res` into the context, so `@Res({ passthrough: true })` can set session cookies/headers) and to `undefined` over MCP (no HTTP response). Globally-registered `ValidationPipe`/interceptors/exception filters do **not** run - only guards and param-bound pipes. **Security caveat:** a controller that relies on a global `ValidationPipe({ whitelist: true })` to strip fields (e.g. `role`/`isAdmin`) before a guard or handler reads them will, over MCP, see the raw tool input with those fields intact (`populateRequestSlots` copies them straight from the input). Enforce such invariants with a param-bound pipe or an explicit check in the action, not a global pipe. A pipe that fails to construct (DI-dependent) is skipped with a `Silkweave` `logger.warn` rather than silently.
- `runGuards()` / `collectGuards()` / `collectGlobalGuards()` (`packages/nestjs/src/lib/guards.ts`) - `collectGuards` merges `@UseGuards()` metadata from class+method; `collectGlobalGuards` reads the app's global guards from the injected `ApplicationConfig` (`getGlobalGuards()` for `useGlobalGuards(new X())` instances + `getGlobalRequestGuards()` `.instance` for `{ provide: APP_GUARD, useClass }` DI guards) and filters them against an opt-in allow-list of classes (`SilkweaveModuleOptions.globalGuards`); `runGuards` resolves guard instances via `ModuleRef` and runs `canActivate()` against a `SilkweaveExecutionContext`. `applyGuards` (in `controllerDiscovery.ts`) resolves global guards **at call time** (APP_GUARD instances aren't populated until `app.init()`), runs them before the method/class guards, and reads `request`/`response` from the silkweave context (MCP-over-HTTP populates `request` from `extra.requestInfo`), passing `contextType: 'http'` when a request exists and `'rpc'` otherwise. A header-reading guard (`switchToHttp().getRequest().headers['x-api-key']`) sees the inbound tool-call headers; with no request it gets a header-less stand-in so it denies instead of crashing. Over **tRPC** the `request`/`response` forked into the context are the **real Express req/res** (the `trpc()` adapter's `createContext` forks them in), so a guard reading cookies/`req.user` works exactly as in REST - unlike MCP there is a genuine HTTP request. Before running guards, `applyGuards` also reconstructs `request.params`/`query`/`body` from the validated input per the reflected param sources (`requestSlotFields()` routes each input field: path/`@Param` -> `params`, `@Query` -> `query`, `@Body` -> `body`; whole-DTO `@Query()`/`@Body()` contribute all their fields), `populateRequestSlots()` filling each slot - path/query stringified to match how Express delivers them, body kept as parsed values - and only filling keys not already present (so a real REST/tRPC request's own slots are never overwritten). This makes a scope-enforcing guard reading `getRequest().params['id']` (e.g. OpenWA's `allowedSessions` API-key scoping), `req.query['…']`, or `req.body['…']` decide identically over MCP and REST - the request-fidelity contract that lets host auth run transport-transparently. The allow-list is explicit-by-class rather than "all globals" because unrelated globals (e.g. `ThrottlerGuard`, which needs a writable response) would misbehave over MCP.
### Next.js Utilities (in @silkweave/nextjs)
The `@silkweave/nextjs` model is **action-first projection** (the inverse of NestJS's reflection): Next.js route handlers carry no reflectable schema metadata, so instead of reading existing handlers, you define Silkweave Actions once and project them onto Next.js **App Router** route files. The package is a thin, Next-specific wrapper over `@silkweave/edge` (MCP) and `@silkweave/trpc` (`trpcFetch`) - it adds path normalization, ergonomic route factories, and end-to-end tRPC types. App Router only; the package has **no `next`/`react` dependency** (handlers are Web-Standard `(request: Request) => Promise<Response>`).
- `defineSilkweave({ name, description, version, actions })` (`packages/nextjs/src/lib/defineSilkweave.ts`) - returns a `SilkweaveApp` with `.mcp(options)`, `.trpc(options)`, and a type-only `Router` phantom. Generic over the actions tuple (`const Arr`), so `typeof app.Router` resolves via `InferTrpcRouter` to a fully-typed `AppRouter` for `createTRPCClient` - no manual `typeof server` plumbing. `.mcp()`/`.trpc()` each build their **own** internal `silkweave(identity).actions(actions).adapter(...)` instance (no shared-mutable-builder footgun), `void`-start it (the adapters' `_ready` promise guards cold starts), and return `{ GET, POST, ... }`.
- `buildMcpRoute()` (`packages/nextjs/src/lib/mcpRoute.ts`) - wires actions through `edge()` and wraps its handler with `rewriteRequestPath()`. Returns `{ GET, POST, DELETE, OPTIONS }` for a single optional-catch-all file `app/<basePath>/[[...slug]]/route.ts`.
- `buildTrpcRoute()` (`packages/nextjs/src/lib/trpcRoute.ts`) - wires actions through `trpcFetch()` (which strips its own `endpoint`, so no rewrite needed). Returns `{ GET, POST, OPTIONS }`; CORS is opt-in (`cors: true`) since a same-origin Next.js frontend needs none.
- `normalizeBasePath()` / `rewriteRequestPath()` (`packages/nextjs/src/lib/stripPrefix.ts`) - the key glue. `@silkweave/edge` matches **absolute** pathnames (`/mcp`, `/authorize`, `/token`, `/.well-known/...`), but a Next route is mounted under a fixed prefix. `rewriteRequestPath(request, basePath)` strips `basePath` from the incoming URL (`/api/mcp` → `/mcp`, `/api/mcp/authorize` → `/authorize`, `/api/mcp/.well-known/...` → `/.well-known/...`), reconstructing the `Request` (preserving method/headers/body, with `duplex: 'half'` for streaming bodies) so one catch-all file serves the transport + OAuth + protected-resource metadata. `basePath` **must** equal the route file's directory (no reliable way to detect the mount at module load). Recommend `runtime = 'nodejs'` + `dynamic = 'force-dynamic'` in route files.
## Tooling
> Make sure to use the `roam` MCP server when exploring the codebase.
- One `roam` command replaces 5-10 grep/read cycles. Always try roam first.
- Use `roam search` instead of grep/glob for finding symbols - it understands
definitions vs. usage and ranks by importance.
- `roam context` gives exact line ranges - more precise than reading whole files.
- After `git pull`, run `roam index` to keep the graph fresh.
- For disambiguation, use `file:symbol` syntax: `roam symbol myfile:MyClass`.
### Code Quality Metrics
**Do NOT use `roam health` as a quality metric** for this project. It penalizes
architectural patterns that are correct for a multi-package library toolkit
(adapter hubs → bottlenecks, disconnected packages → low connectivity,
public API exports → "dead" symbols).
Use these instead:
- `roam fitness` - metric thresholds + trend guards in `.roam/fitness.yaml` (CI-friendly, exit 1 on failure)
- `roam rules --ci` - custom architecture rules in `.roam/rules/` (layer violations, adapter isolation)
- `roam check-rules --profile minimal` - built-in structural rules with false-positive-prone checks excluded
- `roam complexity --threshold 15` - function-level cognitive complexity
- `roam vibe-check` - AI rot score (target: < 10)
- `roam ai-readiness` - agent-friendliness score
- `roam trends --save` - save a snapshot after each release for trend guards
### Roam in Sub-Agents
All `mcp__roam-code__*` tools are available inside sub-agents (both `general-purpose` and `Explore` types). When spawning a sub-agent for codebase exploration, include these instructions in the prompt:
> Use `mcp__roam-code__*` MCP tools for codebase exploration. Prefer roam over
> grep/glob/read - it understands symbols, call graphs, and architecture.
> Key tools: `roam_understand` (overview), `roam_context` (files for a symbol),
> `roam_search_symbol` (find by name), `roam_trace` (dependency paths),
> `roam_file_info` (file structure), `roam_impact` (blast radius).
> Use ToolSearch to find the full tool schemas before calling them.
## Code Style
- ESM-only (`"type": "module"` in package.json)
- No semicolons, single quotes, 2-space indent, no trailing commas - **enforced by `oxfmt`**, not by hand (`pnpm format`); the rules live in `.oxfmtrc.json`
- Unused vars must be prefixed with `_`
- Imports use `.js` extensions (NodeNext module resolution)
- Zod: source imports the v4 API as `from 'zod/v4'`; published packages declare **`zod@^3.25.0 || ^4.0.0`**, matching what the MCP SDK / AI SDK / zod-to-json-schema all declare. The `zod/v4` subpath exists in both 3.25+ and 4.x, so one import style serves both and consumers are never forced to migrate. **Action schemas must use the v4 API** (`zod/v4` on 3.25+, the root on 4.x): classic v3 schemas were never supported (core's `Action` type rejects them; CLI/Fastify/typegen need v4 internals), and since 6.0 `assertZodV4Schemas()` (`packages/core/src/util/zod.ts`, run by `silkweave().start()` and the MCP boot paths) rejects them at startup with a legible `unsupported_schema` error instead of an opaque adapter crash. Consumer compatibility across the range is checked by `pnpm zod-compat` (packs the build into throwaway consumers on zod 3.25.76 / 4.0 / 4.1 / latest).
- Always avoid Em-Dash (`—`), use regular dash instead, or ideally avoid altogether (comments, markdown, docs, website)
### Linting and formatting (oxc stack)
`eslint` + `typescript-eslint` + `@stylistic` were replaced by the **oxc** toolchain, which is what unblocked TypeScript 7 (`typescript-eslint` hard-errors on TS 7 - see [typescript-eslint#10940](https://github.com/typescript-eslint/typescript-eslint/issues/10940)).
- **`oxlint`** (`.oxlintrc.json`, one config at the repo root - it is discovered from any package subdirectory, so each package's `"lint": "oxlint"` script needs no path). Categories are `correctness` + `suspicious` only; `pedantic`/`style`/`perf` impose opinions the old eslint config deliberately did not. Rule-level exemptions are commented in the config - notably `no-underscore-dangle` (MCP's `_meta` is a protocol field) and `unicorn/require-post-message-target-origin` (`Worker.postMessage` has no `targetOrigin` parameter).
- **`oxfmt`** (`.oxfmtrc.json`) owns formatting. oxlint has **no** formatting rules at all, so the style contract above lives entirely in the formatter. Scoped to code (`.ts`/`.tsx`/`.mjs`/`.js`); markdown, JSON, CSS and YAML are ignored, since markdown is published content where reflowing can change rendered output.
- Suppression comments use oxlint's own syntax: `// oxlint-disable-next-line <rule>`. `eslint-disable` comments are gone.
- `pnpm format` writes, `pnpm format:check` gates. Formatting is a whole-repo concern (one sub-second pass), so it hangs off the **root** `check` script rather than being duplicated per package the way `lint`/`typecheck` are - and it runs as its own step in both CI workflows.
### TypeScript 7
The workspace is on **TypeScript 7.0.2** (the native Go port). One migration note that is easy to lose an afternoon to:
- **TS 7 no longer auto-includes `@types/*` packages.** Every `tsconfig.json` therefore carries an explicit `"types": [...]` array; without it `console`, `process`, `NodeJS`, `Blob`, `File`, `TextEncoder` and friends all vanish. Reproduced in a clean non-pnpm project, so it is a TS 7 behavior change, not a workspace-layout artifact. When adding a package, list its type providers (`node`, plus e.g. `react`/`vite/client` where relevant).
- **`@swc-node/register` needs a TS < 7 compiler** (it reads the TypeScript JS API, which TS 7 does not ship; crashes with `reading 'Js'`). It resolves `typescript` as a peer, so a pnpm override / `packageExtensions` does not help - `examples/nestjs` pins `"typescript": "^6.0.3"` instead (private example, typechecks fine on 6). NestJS consumers on TS 7 need the same.
- `tsdown` warns that the TS 7 API is experimental. Verified this does **not** affect published output: `packages/core/build/index.d.mts` is byte-identical between TS 5.9.3 and TS 7 apart from quote style.
## Package resolution (dev source vs. published build)
Each publishable package (`packages/*`, except the `silkweave` CLI - bin-only, no library exports) resolves to **build output for everyone except our own tooling**, while in-repo tooling resolves to **TS source** - so cross-package edits need no rebuild, but external installs/`pnpm link`/registry consumers always get `build/`:
```jsonc
"main": "./build/index.mjs",
"types": "./build/index.d.mts", // matches tsdown's emitted .d.mts (NOT .d.ts)
"exports": {
".": {
"@silkweave/source": "./src/index.ts", // custom condition - ONLY our tooling enables it
"types": "./build/index.d.mts",
"default": "./build/index.mjs" // external consumers land here
}
}
```
- The condition is the **custom name `@silkweave/source`**, not the generic `development` - the latter is auto-enabled by Vite/Vitest, which would silently leak our raw `.ts` source to external consumers. A custom name is opt-in only.
- Our tooling enables it explicitly: tsconfig `"customConditions": ["@silkweave/source"]` (every package + example tsconfig); `tsx --conditions=@silkweave/source …` and `node --conditions=@silkweave/source …` in example dev scripts; `resolve.conditions` in the Vite/Astro configs (`examples/ai/vite.config.ts`, `website/astro.config.mjs`).
- There is **no `publishConfig`** - the resolved `main`/`types`/`exports` above are correct as-published; `pnpm pack` ships them verbatim.
- **When adding a new package**, copy this `main`/`types`/`exports` block and add `"customConditions": ["@silkweave/source"]` to its tsconfig.
## Wrapup Config
- check: `pnpm check` - always run from the **repo root** (not from a sub-package). It runs `oxfmt --check` over the whole repo first, then turbo's lint + typecheck across every workspace package. A formatting failure is fixed with `pnpm format`, never by hand.
- test: `pnpm test` - runs `turbo test` (Vitest) across every package that defines a `test` script. New `*.test.ts` files co-locate in a package's `src/` (never shipped - tsdown builds only its explicit entries) and resolve cross-package `@silkweave/*` imports to TS source via the shared `vitest.shared.ts` (the `@silkweave/source` condition). To add Vitest to a package without it: `pnpm -F <pkg> add -D vitest`, add a one-line `vitest.config.ts` re-exporting `sharedConfig`, and a `"test": "vitest run"` script.
- push: yes
- version_bump: yes (aligned across all packages)
+ `pnpm -r exec npm version 1.9.0 --no-git-tag-version --force`
- publish: yes (automated via GitHub Actions - **do not run `pnpm publish` locally**). Pushing a `vX.Y.Z` tag triggers `.github/workflows/publish.yml`, which builds, lints, and publishes every public package to npm via OIDC trusted publishing (`pnpm publish -r`). See [CI/CD](#cicd) below. The local `pnpm publish:all` script remains only as a manual fallback.
- docs: per-package README.md + root CLAUDE.md as index + website docs page
- frontend_smoke: N/A
- co_authored_by: no (global)
- changelog: yes - on every version bump, **prepend a new entry to `website/src/data/changelog.ts`** (the single source of truth; newest first; short user-facing highlights with commit hashes), then run `pnpm sync-releases` after the `vX.Y.Z` tag is pushed to create/update the matching GitHub release. The website `/changelog` page and GitHub releases must stay in sync - both render from that one data file.
- extra: Update our website (landing page and docs) with new or changed features. After a deploy that meaningfully changes public pages, submit the changed URLs to IndexNow (`pnpm indexnow <paths>` - see [SEO: IndexNow](#seo-indexnow)).
## Docs Checklist
When making changes to features, APIs, or architecture, update docs in **all three layers**:
1. **CLAUDE.md** (root) - architecture overview, key utilities, package inventory
2. **Per-package README.md** - API reference, usage examples, options tables
3. **Website docs** (`website/src/pages/docs.astro`) - user-facing documentation including code examples, Action interface, adapter reference, and sidebar nav (`website/src/layouts/DocsLayout.astro`)
## Changelog & Releases
The website `/changelog` page (`website/src/pages/changelog.astro`) and the GitHub releases are both generated from one canonical data file, **`website/src/data/changelog.ts`** (pure data, no imports - so the release-sync script can import it too):
- **On each release**, prepend a `Release` entry (newest first). Keep highlights short and user-facing ("what's new at a glance"); set each change's `type` (`breaking | feature | improvement | fix`) and a short `commit` hash for the GitHub deep-dive link. A pre-tag/POC entry can set `unreleased: true` (rendered without a release link, skipped by the sync script). Major (`X.0.0`) releases render with a highlighted frame automatically.
- **`pnpm sync-releases`** (`scripts/sync-releases.ts`, run via `tsx`) reads that file and creates/updates a GitHub release per `vX.Y.Z` tag (idempotent; notes grouped by change type; the newest entry is marked `--latest`). Requires the `vX.Y.Z` tags to be pushed first; `--dry-run` prints the notes without touching GitHub.
- Release tags follow `vX.Y.Z` and must be pushed (`git push --tags`); every tagged version should have a matching GitHub release.
- Pushing the `vX.Y.Z` tag also triggers the **publish workflow** ([CI/CD](#cicd)) - the tag push is what publishes the packages to npm, so create the tag only on a commit that already contains `.github/workflows/publish.yml`.
## CI/CD
Two GitHub Actions workflows live in `.github/workflows/`:
- **`ci.yml`** - runs on every push to `master` and every pull request. Installs (`--frozen-lockfile`), builds, then runs `pnpm exec turbo lint check` (lint + typecheck across all workspace packages) followed by `pnpm exec turbo test` (the Vitest suites). A `pnpm format:check` step runs before them (the root `check` script's formatting gate, lifted out so CI reports it separately). It deliberately does **not** run the root `pnpm check`'s trailing `pnpm roam` step, since the roam CLI isn't available on CI runners.
- **`publish.yml`** - runs when a `vX.Y.Z` tag is pushed. Builds, lints, tests, then runs `pnpm publish -r --access public --no-git-checks`. Publishing uses **npm trusted publishing (OIDC)** - there is **no `NPM_TOKEN`**; auth comes from the `id-token: write` permission, and npm auto-generates provenance attestations.
Key facts for maintaining the publish flow:
- **Trusted publisher config is per-package on npmjs.com** (one-time, manual): each package's Settings → Trusted Publisher → GitHub Actions points at org `silkweave`, repo `silkweave`, workflow `publish.yml`, allowed action `npm publish`. A newly added package must be configured there (and have an initial version) before the workflow can publish it.
- **pnpm version matters**: pnpm's OIDC publishing regressed in 11.0.8 and was fixed in **11.1.0** (drops the unresolved `${NODE_AUTH_TOKEN}` placeholder that `actions/setup-node` writes). The repo pins `pnpm@11.1.1` via `packageManager`, which `pnpm/action-setup@v4` picks up automatically - do not pin pnpm to <= 11.0.8 or trusted publishing breaks.
- `pnpm publish -r` skips versions already on the registry, so re-running a tag is safe (idempotent).
- A tag must point at a commit that **contains `publish.yml`** - GitHub runs a tag's workflow from the tagged commit. (When first enabling this, the pre-existing `v2.5.0` tag had to be force-moved onto the commit that introduced the workflow.)
## SEO: IndexNow
After every production deploy of the website that meaningfully changes public pages, submit the changed URLs to IndexNow so search engines re-crawl them promptly:
```bash
pnpm indexnow /blog/<slug> /blog # paths are resolved against https://www.silkweave.dev
pnpm indexnow https://www.silkweave.dev/changelog
```
- **When to run** (meaningful changes only): a new or edited blog post (submit the post URL + `/blog`), a new release/changelog entry (`/changelog`), substantive docs updates (`/docs`), or changed landing/feature pages (`/`, `/features`, `/adapters`, ...). Do NOT run for changes with no public-page effect (package code, internal refactors, CI) or trivial tweaks (typo-level edits, style-only changes).
- **How it works**: `scripts/indexnow-submit.ts` (run via `pnpm indexnow`) POSTs the URL list to `api.indexnow.org` with the site key. IndexNow verifies ownership by fetching `https://www.silkweave.dev/<key>.txt`.
- **Key storage**: the key file is committed at `website/static/<key>.txt` (the website's public dir is `static/`, not Astro's default `public/`), served at the default IndexNow key location. IndexNow keys are public by protocol - the key file must be world-readable on the host anyway - so committing it is fine. The submit script auto-discovers the key from that file (a `<key>.txt` whose content equals its basename), or takes `$INDEXNOW_KEY` as an override. The website package is `private: true`, so nothing here can reach npm.
- Submissions are idempotent; re-submitting an unchanged URL is harmless but pointless.